Most cloud waste does not sit in the big database or the GPU node. It sits in the staging box, the Slack bot and the queue worker that each run on a dedicated-CPU plan all month while using a few percent of it. Moving those services to shared CPU is the cheapest fix you have. Shared vs dedicated CPU is still a performance question before it is a pricing one, and your own CPU percentiles answer it better than any spec sheet.
You will set up sysstat for a proper observation window, turn the samples into p95 and p99 figures with a small script, check steal time, and cap a real service at a 50% or 25% CPU share before moving it. We then map workload types onto that data and compare what an always-on 2 GB shared VM costs at seven providers. I ran all of it on an Ubuntu 26.04 VM in my homelab in October 2026, capped the way e2-small and e2-micro are, and every performance number below comes from that box.
Shared vs dedicated CPU: what you actually get
A dedicated vCPU is a CPU thread that is yours for every second you pay for it. A shared vCPU is a thread the host also hands to other tenants, so the plan promises you a fraction of it as a sustained baseline and lets you burst above that for a while. The vCPU count on the plan page tells you how many threads the guest sees, not how much CPU time you are entitled to, and that gap is where most bad migrations start.
Each provider expresses the fraction differently, which is why a two-vCPU shared plan at one provider is not the same product as a two-vCPU plan at another:
- Google E2 shared-core: e2-micro gets 25% of total CPU time, e2-small 50%, e2-medium 100%, all shown as 2 vCPUs
- AWS T3 and T4g: 20% baseline per vCPU on t3.small and t4g.small (10% on micro, 5% on nano), CPU credits for bursts
- Azure B-series v2: 20% to 40% baseline depending on size, credits for bursts
- DigitalOcean, Akamai, Hetzner and Fluence: a shared thread with no fixed published fraction
The credit and token models matter for planning. Google lets each E2 vCPU burst to 100% for short periods (60 seconds on e2-small) at no extra charge, per the general-purpose machine family docs. AWS T3 and T4g start in Unlimited mode, so a box that stays above baseline keeps running fast and bills surplus credits instead ($0.05 per vCPU-hour on T3, $0.04 on T4g). Azure B-series v2 throttles back to the baseline once the credits are gone. Akamai is the most direct of the flat-rate providers: its docs say shared cores can run at 100% for short bursts but should stay below 80% sustained on average.
Step 1: Collect CPU history with sysstat
You need at least two weeks of samples per VM before deciding anything, because weekly jobs, backups and release days are exactly the peaks a shared plan has to absorb. AWS Compute Optimizer looks back 14, 32 or 93 days for the same reason, while the Google Cloud machine-type recommendations use only 8 days and its docs warn that this window misses monthly spikes. The sysstat defaults on Ubuntu work against you here: samples every 10 minutes, which averages away a 90-second spike, and only 7 days of history.
Install sysstat, keep 35 days of data uncompressed, and sample every 2 minutes. The COMPRESSAFTER change matters because sysstat compresses files older than 10 days by default and sadf cannot read the compressed ones:
sudo apt install -y sysstat
sudo sed -i 's/^ENABLED=.*/ENABLED="true"/' /etc/default/sysstat
sudo sed -i -e 's/^HISTORY=.*/HISTORY=35/' -e 's/^COMPRESSAFTER=.*/COMPRESSAFTER=36/' /etc/sysstat/sysstat
sudo mkdir -p /etc/systemd/system/sysstat-collect.timer.d
printf '[Timer]\nOnCalendar=\nOnCalendar=*:00/2\n' | sudo tee /etc/systemd/system/sysstat-collect.timer.d/interval.conf
sudo systemctl daemon-reload
sudo systemctl enable --now sysstat sysstat-collect.timer
sudo systemctl restart sysstat-collect.timer
The empty OnCalendar= line clears the packaged 10-minute schedule before the new one is added; without it systemd keeps both. Confirm the settings and the next run time:
grep -E '^(HISTORY|COMPRESSAFTER)' /etc/sysstat/sysstat
systemctl list-timers sysstat-collect.timer --no-pager
The timer should now fire on even minutes:
HISTORY=35
COMPRESSAFTER=36
NEXT LEFT LAST PASSED UNIT ACTIVATES
Fri 2026-10-23 12:30:00 UTC 1min 12s Fri 2026-10-23 12:28:07 UTC 39s ago sysstat-collect.timer sysstat-collect.service
One side effect we only noticed when listing the log directory: once HISTORY is above 28, sysstat switches to saYYYYMMDD file names (the HISTORY comment in /etc/sysstat/sysstat points to the -D option in sadc(8)), so new data lands in sa20261023 and not sa23. Any script that globs sa?? will quietly read nothing. We tested these paths on Ubuntu; RHEL-family packages keep the settings in a different file, so run rpm -qc sysstat before copying the sed lines. If you already run Prometheus with the node exporter, you can skip sysstat and pull the same percentiles from node_cpu_seconds_total instead.
Step 2: Turn samples into percentiles and a fit class
Average CPU is the number that misleads people most, so the script below ignores it for the decision. It counts busy time as everything that is not idle, steal included, because a vCPU waiting on the host still wanted to run. It then sorts the VM into one of five classes from its p95 and p99. The thresholds are ours, picked to line up with the 20% to 50% baselines above; adjust them to your provider.
sudo vim /usr/local/bin/cpu-profile.sh
Paste the script:
#!/usr/bin/env bash
# Summarise sysstat CPU samples into percentiles and a shared-CPU fit class.
# Usage: cpu-profile.sh /var/log/sysstat/sa20?????? (one or more sa files)
set -euo pipefail
for f in "${@:-}"; do
sadf -d ${f:+"$f"} -- -u
done | awk -F';' '$4 == "-1" { printf "%.2f %.2f\n", 100 - $10, $9 }' | sort -n | awk '
{ n++; busy[n] = $1; sum += $1; if ($2 > st) st = $2 }
function pct(p, i) { i = int(n * p); if (i < 1) i = 1; return busy[i] }
END {
if (n == 0) { print "no samples"; exit 1 }
p50 = pct(0.50); p95 = pct(0.95); p99 = pct(0.99)
if (p95 < 5 && p99 < 25) class = "idle"
else if (p95 < 25 && p99 < 50) class = "steady-low"
else if (p95 < 25) class = "short-burst"
else if (p50 < 40) class = "long-burst"
else class = "sustained-high"
printf "samples=%d avg=%.1f p50=%.1f p95=%.1f p99=%.1f max=%.1f max_steal=%.1f class=%s\n",
n, sum / n, p50, p95, p99, busy[n], st, class
}'
Make it executable, then point it at every daily file in the window:
sudo chmod +x /usr/local/bin/cpu-profile.sh
cpu-profile.sh /var/log/sysstat/sa20??????
To test the script on something with a known shape, we recorded 240 one-second sar samples of a small API answering one request a second, with an 8-second stress-ng burst on both vCPUs in the middle. That is the profile of a webhook receiver that runs a report once a day:
samples=240 avg=4.8 p50=1.5 p95=3.0 p99=100.0 max=100.0 max_steal=2.0 class=short-burst
Would you move this VM to shared CPU? The average of 4.8% and the p95 of 3.0% both say it is nearly idle, and a dashboard showing either would mark it as an easy win. The p99 of 100% is the burst, and it is the only reason the class is short-burst and not idle. Short-burst VMs fit E2 and credit-based plans well, because a burst of seconds fits inside the 60-second E2 window and is usually covered by a T-family credit balance. On a flat shared plan the same burst runs at whatever share is left once neighbours are busy.
Use the class to decide what you do with each VM:
| Class | Rule in the script | Shared CPU verdict |
|---|---|---|
| idle | p95 under 5% and p99 under 25% | Schedule it off first, then move what must stay up |
| steady-low | p95 under 25%, p99 under 50% | Move it; the best candidates |
| short-burst | p95 under 25%, p99 50% or higher | Move to a plan with burst credits or tokens |
| long-burst | p95 25% or higher, p50 under 40% | Pilot under a cap before moving |
| sustained-high | p50 40% or higher | Keep on dedicated CPU |
Run it on each VM you are considering and keep the output with the hostname in a spreadsheet, because that list becomes your migration queue. Our CPU usage commands reference covers the other tools if you want to look at a single VM interactively before trusting the summary.
Step 3: Check steal time on VMs already on shared CPU
Steal is the time a vCPU was ready to run but the hypervisor gave the physical CPU to something else. The kernel exposes it in /proc/stat, and the usual tools show it as %steal or st. On a dedicated plan it stays near zero. On a shared plan it is the clearest sign that you have hit your share, and it is how you tell a slow application apart from a starved one. Watch it during a busy period:
mpstat 2 5
Our lab VM was capped at half a CPU while stress-ng tried to use both vCPUs. The guest asked for 100% and got a quarter of its two threads; the rest shows up as steal:
Linux 7.0.0-15-generic (homelab-api) 10/23/26 _x86_64_ (2 CPU)
12:30:46 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
12:30:48 all 24.37 0.00 0.25 0.00 0.00 0.25 74.87 0.00 0.00 0.25
12:30:50 all 24.56 0.00 0.00 0.00 0.00 0.00 75.44 0.00 0.00 0.00
12:30:52 all 24.69 0.00 0.00 0.00 0.00 0.50 74.81 0.00 0.00 0.00
12:30:54 all 24.75 0.00 0.00 0.00 0.00 0.25 75.00 0.00 0.00 0.00
12:30:56 all 24.63 0.00 0.50 0.00 0.00 0.00 74.88 0.00 0.00 0.00
Average: all 24.60 0.00 0.15 0.00 0.00 0.20 75.00 0.00 0.00 0.05
Note that %idle is zero. A VM with high steal and no idle is CPU-bound against its share, and only a bigger share fixes it. High steal with plenty of idle in a short mpstat 1 run is a different problem: the host is oversubscribed and your neighbours are the cause, which is a ticket to the provider or a reason to change plan family. Do not make that call from sysstat data alone. A 2-minute sample that mixes a capped burst with idle time looks the same, and our lab VM logged one at 31% steal and 63% idle while it was only hitting its own cap. Use the max_steal field from Step 2 to find the VMs worth watching, then confirm with mpstat 1 during their busy period.

The same capture in the terminal, with the profile script run against the burst test file underneath it.
Step 4: Test the service under a CPU cap before you move it
Percentiles tell you how much CPU a service uses, not how it behaves when it cannot get more. The cheapest way to find out is to cap the VM you already have at the share the target plan guarantees and replay normal traffic against it. On Proxmox that is the cpulimit option, where 1.0 means one full host CPU across all of the VM’s vCPUs:
qm set 4005 --cpulimit 0.5
On our two-vCPU VM, cpulimit 1, 0.5 and 0.25 match the sustained share of e2-medium, e2-small and e2-micro. The change applies to a running VM, and qm set 4005 --delete cpulimit removes it. The test service was a small Python HTTP server that hashes a value 20,000 times per request, about 12 ms of CPU, which is close to a small JSON API doing real work:
vim ~/app.py
The whole service:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import hashlib
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
digest = b"x"
for _ in range(20000):
digest = hashlib.sha256(digest).digest()
self.send_response(200)
self.end_headers()
self.wfile.write(digest.hex().encode())
def log_message(self, *args):
pass
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
Install the load tools and start the service in the background:
sudo apt install -y stress-ng apache2-utils
nohup python3 ~/app.py >/dev/null 2>&1 &
At each cap we ran three loads. stress-ng on both vCPUs measured raw throughput, and ApacheBench with one and eight clients stood in for a busy service:
stress-ng --cpu 2 --cpu-method matrixprod --timeout 20s --metrics-brief
ab -n 300 -c 1 http://127.0.0.1:8080/
ab -n 300 -c 8 http://127.0.0.1:8080/
The quiet-service load sends one request a second for a minute and prints the latency percentiles:
for i in $(seq 1 60); do
curl -s -o /dev/null -w "%{time_total}\n" http://127.0.0.1:8080/
sleep 1
done | sort -n | awk '{a[NR]=$1*1000} END {printf "requests=%d p50=%.1fms p95=%.1fms max=%.1fms\n", NR, a[int(NR*0.5)], a[int(NR*0.95)], a[NR]}'
You can see what each cap did to the same service on the same host:
| Cap (same share as) | stress-ng bogo ops/s | Steal under load | 1 req/s, p95 (two runs) | 1 client | 8 clients |
|---|---|---|---|---|---|
| None (dedicated 2 vCPU) | 3,275 | 0.1% | 14.0 ms | 82.2 req/s, p95 14 ms | 79.7 req/s, p50 74 ms |
| 1.0 (e2-medium) | 1,671 | 50.0% | not run | 79.6 req/s, p95 17 ms | 77.1 req/s, p50 80 ms |
| 0.5 (e2-small) | 724 | 75.0% | 14.5 / 21.3 ms | 35.0 req/s, p95 67 ms | 32.6 req/s, p50 202 ms |
| 0.25 (e2-micro) | 364 | 87.7% | 14.2 / 14.2 ms | 16.9 req/s, p95 97 ms | 17.5 req/s, p50 399 ms |
The quiet column is the whole case for shared CPU. At one request per second the service used about 1% of the VM, and the p95 stayed between 14 and 21 ms even at a quarter of a CPU, which no user would notice. The busy columns are the case against it. The Python server is effectively single-threaded because of the GIL (eight clients got no more throughput than one), so it never needed more than one CPU, which is why the 1.0 cap barely moved it. At 0.5 the eight-client median went from 74 ms to 202 ms, and at 0.25 the p95 went past 1.3 seconds. Raw CPU throughput tracked the cap almost exactly (3,275 to 1,671 to 724 to 364 bogo ops/s).
The one-client column at 0.5 shows how the cap is enforced. The p50 stayed at 13 ms but the p95 jumped to 67 ms, because the cgroup CPU quota behind cpulimit is enforced in 100 ms periods by default. A run of back-to-back requests spends the 50 ms allowed in a period, then waits for the next period to start. That is the latency shape to look for in your own pilot, a normal median with a tail stretched toward the period length. Also remember that cpulimit is a hard cap with no burst budget, so these numbers are what an E2 VM looks like, and roughly what a T-family VM in Standard mode looks like, after its burst allowance is spent, not before. In Unlimited mode a T-family VM keeps bursting and bills surplus credits instead.
Which workloads can move to shared CPU
Workloads that are idle most of the time and tolerate a slower burst are the ones to move, and the class from Step 2 decides it more reliably than the workload type. These usually land in the steady-low or short-burst classes and move without drama:
- Staging, QA and preview environments that must stay up
- Chat bots, webhook receivers and small internal APIs
- Queue-backed background workers without deadlines
- Admin dashboards, wikis and code mirrors for a small team
- Bastion hosts, VPN endpoints and DNS forwarders
Before you rightsize staging, check whether it needs to run at night at all. Running a dev VM 10 hours a day on weekdays only cuts its compute hours from 168 to 50 a week, about 70% off, which beats any plan change. Queue workers are the most forgiving because a slower worker only means a deeper queue; what you watch after the move is queue age, not CPU. For workloads on Kubernetes the same thinking applies per node pool, and Kubecost gives you the per-namespace numbers to decide with.
Workloads not suitable for shared CPU
Keep dedicated CPU for anything in the sustained-high class and for anything where a 60 ms tail breaks a contract. In practice that means:
- Customer-facing APIs with a latency SLO
- Production databases beyond a small side store
- CI runners and build agents that peg every core
- Batch jobs with a hard completion deadline
- Monitoring and alerting stacks that must stay fast during an incident
The monitoring item surprises people, because Prometheus or Zabbix on a small fleet shows a low CPU average. The problem is timing. During an incident every scrape, query and alert rule runs at once, and that is the moment a shared plan is most likely to be throttled. A slow alert costs more than the VM saves.
What an always-on shared CPU VM costs
For an always-on 2 GB VM, the lowest list price you could get in October 2026 came from the Fluence shared CPU plans. Azure’s smallest B-series v2 size came next with less memory, then a cluster of plans at about $12 a month. That ranking only holds once you add what each price includes, so the table carries the CPU entitlement and the extras next to the price. Monthly figures for hourly-billed plans are the published hourly rate times 730 hours:
| Provider and plan | vCPU / RAM | CPU entitlement | Per month | Included |
|---|---|---|---|---|
| Fluence Shared 2GB | 2 / 2 GB | Burstable, no published baseline | $2.54 (calculator, 25 GB storage) | Unlimited bandwidth, no egress fees, daily billing |
| Azure B2ats_v2 (East US) | 2 / 1 GiB | 20% baseline, credits | $6.86 | Disk billed separately |
| Google e2-small (us-central1) | 2 / 2 GB | 50% of CPU time, 60 s bursts | $12.23 | Disk billed separately |
| AWS t4g.small (us-east-1) | 2 / 2 GiB | 20% per vCPU, Unlimited mode | $12.26 | EBS billed separately |
| DigitalOcean Basic 2 GiB | 1 / 2 GiB | Shared thread, not guaranteed | $12.00 | 50 GiB SSD, 2,000 GiB transfer |
| Akamai Linode 2 GB | 1 / 2 GB | Below 80% sustained | $12.00 | 50 GB disk, 2 TB transfer |
| Hetzner CPX22 (EU) | 2 / 4 GB | Shared, not for sustained load | €19.99 excl. VAT | 80 GB NVMe, 20 TB traffic |
Two rows need a footnote. AWS currently gives t4g.small free for up to 750 hours a month until December 31, 2026, so the instance hours of a single Graviton VM cost nothing until then (EBS, public IPv4 and surplus credits are still billed). After that, an Unlimited-mode instance that runs above baseline all month adds surplus credits on top of the $12.26. Hetzner also sells a Cost-Optimized shared line on older hardware generations from €5.99 a month (CX23), which would sit next to Azure in the ranking. Its product pages marked both CX23 and CPX22 as currently unavailable when we checked, so confirm stock before you plan a migration around either. Disk, public IPv4 and egress are where the hyperscaler rows grow, and heavy egress alone can pass the instance price, which is the gap the AWS cost breakdown walks through line by line.
Where Fluence Shared CPU Cloud enters the shortlist
Fluence Shared CPU Cloud is the option to look at when the fleet is many small always-on services and egress is part of the bill. Its shared plans run from 2 GB to 32 GB of RAM (2 to 16 vCPUs), and you can bill daily with a maximum daily price you set. Bandwidth is unlimited with no egress fees, which removes the line that grows fastest in a hyperscaler estimate. Instances, disks and public IPs can be managed through the Fluence Cloud API, so a staging or worker fleet can be created from the same pipeline that deploys it. When you tear one down, delete the boot disk and public IP as well, because terminating a VM through the API leaves both behind.
Fluence does not publish a sustained CPU fraction for these plans, so treat it the way you would treat DigitalOcean or Akamai. Run the Step 4 cap test at a conservative share, move the steady-low class first, and watch steal for the first two weeks. If part of the fleet is on Azure, our notes on Azure cost optimization are worth reading alongside, because Advisor flags the same underused VMs from the other direction.
Step 5: Pilot the move and set rollback signals
Move one VM from each class you plan to migrate, not your whole list, and keep the old VM stopped but not deleted until the new one has run through a full week including its busiest day. Write the rollback triggers down before the move, using numbers you already have from Steps 2 to 4, so nobody argues about them during an incident:
- Record the baseline:
cpu-profile.shoutput, p95 latency and error rate from your monitoring - Move the VM and leave sysstat running on the new plan
- Check
max_stealand p99 daily for the first week - Roll back if p95 latency breaks the service objective or queue age keeps growing
- After a clean week, compare the real invoice with the estimate, then move the next batch
The invoice check in the last step is where credit-based plans surprise people. A T-family instance that looked cheap in the estimate and then spent every night in surplus credits can cost more than the dedicated plan it replaced, and the only place that shows up is the bill.
What broke in the lab
The first version of cpu-profile.sh classified the burst test as idle. It checked only p95 for the idle class, and with 8 busy seconds out of 240 the burst sat entirely above the 95th percentile, so the script reported an idle VM with a p99 of 100%. That is the same mistake a dashboard of averages makes, just one percentile higher, and it is why the idle rule now needs the p99 under 25% as well. If you change the thresholds, feed the script a capture with a known burst like ours before you trust it on a fleet.
The second surprise was noise. The very first one-client run at the 1.0 cap returned 35.3 req/s with a p95 of 60 ms; three repeats a few minutes later all landed between 77.9 and 80.2 req/s with a p95 of 16 to 17 ms. We could not tie the bad run to anything on the guest, and the host also runs VMs for other people, which is the shared-CPU experience in miniature. Run every pilot measurement at least three times, and treat a single bad run as a reason to repeat it rather than a verdict.
Common misconceptions about shared CPU
“Low average CPU means it is safe to move.” Our burst test averaged 4.8% with a p99 of 100%. Classify on p95 and p99 over at least two weeks, sampled every few minutes.
“Two vCPUs means two cores.” An e2-small shows two vCPUs and guarantees half of one CPU in total. Our capped VM still reported two CPUs in mpstat while running at a quarter of their throughput.
“Bursting is free.” It is on E2, within its token window. On AWS T-family in Unlimited mode it is billed as surplus credits once the 24-hour average stays above baseline, and on Azure B-series v2 the VM drops back to baseline when credits run out.
“Shared CPU means worse uptime.” Availability and CPU consistency are separate properties. A shared VM can have the same uptime commitment as a dedicated one and still show a 67 ms tail on a busy minute, as ours did at the 0.5 cap.
“The cheapest plan gives the cheapest workload.” Disk, IPv4, egress and surplus credits decide the final number. Compare total monthly cost per service after the pilot week, which is also the figure to put in front of whoever asked for the savings in the first place.