Measuring CPU Steal, Memory Overcommit and I/O Throttling on a VPS

top showing 94% idle while a request takes three times longer than it did yesterday is the signature of CPU steal. The guest’s scheduler sees available time, but the hypervisor preempts it, and the time accounting lands in %st rather than %id—if you look at the right field. Three metrics explain most “the VPS got slow” incidents on shared hosts: steal time, memory overcommit pressure inside the guest versus ballooning from outside, and I/O throttling applied by the hypervisor or the backing storage.

This is a measurement guide: which counters to read, what values indicate a shared-host problem versus a workload problem, and how to distinguish them without access to the host. Every command here is a standard Linux tool that exists on a minimal VPS image.

Steal Time: Where to Read It and What It Means

Steal time is exposed in /proc/stat as the steal field, column eight in the CPU line. mpstat and top surface it as %steal. It is the percentage of time the virtual CPU was runnable but not scheduled by the hypervisor—direct evidence that another tenant consumed the physical core.

grep '^cpu ' /proc/stat
# per-core, 1 s, 10 samples
mpstat -P ALL 1 10
top -b -n 2 -d 1 | grep -i '%Cpu'

# average over a window with sar (sysstat package)
sar -u 1 60 | grep -E 'CPU|all'

Interpretation is nuanced. A steady 0.5–2% steal is normal background noise. Sustained 5–10% during load indicates contention that will show up as latency variance. Above 15% consistently, the instance is sharing a busy core and no guest-side tuning will fix it. Crucially, steal applies to physical CPU time, not virtual: with 2 vCPU on an oversubscribed host, a steal of 30% on one core is a real 15% capacity loss on a 2-core instance.

Correlate steal with wall-clock runtime. Run a fixed CPU-bound loop and time it, then compare against the same loop on a reference instance. If the loop takes 40% longer while %steal is only 10%, the deficit is frequency scaling or cache contention rather than scheduling delay, which is a different diagnosis.

Pressure Stall Information as a Better Signal

Since kernel 4.20, PSI exposes the percentage of time tasks were stalled on CPU, memory or I/O, which is closer to what users experience than per-field counters. Three files matter, and they are cumulative microseconds:

cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

# watch 5-second windows and diff
while true; do
  for f in cpu memory io; do
    awk -v n=$f '/some/{print n, $0}' /proc/pressure/$f
  done
  sleep 5
done

The some line reports time at least one task was stalled; full reports time all runnable tasks were stalled. For memory, a full value above zero means the guest genuinely could not make progress for that period—a much stronger signal than a high page-fault count. PSI can also be enabled for cgroups v2 workloads via cpu.pressure and io.pressure in the cgroup directory, which lets you attribute stalls to one service rather than the whole VM.

Memory Overcommit Inside the Guest

Overcommit inside the guest is your own configuration: vm.overcommit_memory decides whether the kernel refuses allocations heuristically (0), always grants them (1), or applies a strict ratio (2). Setting it to 1 makes memory pressure arrive suddenly as OOM kills rather than early allocation failures, which is rarely what you want on a small instance.

sysctl vm.overcommit_memory vm.overcommit_ratio
cat /proc/meminfo | grep -E 'CommitLimit|Committed_AS|MemAvailable|SwapTotal'
# ratio of committed to limit — above ~90% is tight
awk '/CommitLimit/{l=$2} /Committed_AS/{c=$2} END{printf "commit used: %.1f%%\n", c/l*100}' /proc/meminfo

# per-process resident and swap
ps -eo pid,comm,rss,vsz --sort=-rss | head -15
smem -tk 2>/dev/null | tail -15

Use MemAvailable, not MemFree. Page cache makes MemFree look alarming on any healthy Linux system; MemAvailable estimates what can be reclaimed. If Committed_AS exceeds CommitLimit with overcommit_memory=2, new allocations fail with ENOMEM even though pages are free—a confusing failure mode that looks like a leak.

Ballooning and Host-Side Reclaim

When the host is under memory pressure, it can inflate the virtio balloon driver, reclaiming guest pages without the guest’s cooperation. The guest sees its own free memory decline with no process responsible. Detect it by checking whether the balloon driver is loaded and whether total memory has changed:

lsmod | grep balloon
cat /sys/devices/virtual/misc/virtio_balloon/* 2>/dev/null
free -m
cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapFree|SwapCached'

# page reclaim pressure inside the guest
vmstat 1 10    # si/so non-zero means swapping
sar -B 1 10    # pgscan, pgsteal rates

If MemTotal is materially lower than the plan’s advertised RAM across boots, ballooning is in play. If si/so in vmstat are consistently non-zero with a local swap file, the guest is thrashing—adding RAM is the fix, and tuning vm.swappiness only postpones it. A swappiness of 10 is reasonable for a server; 60 is the default and is often too eager for a 2 GiB instance running a database.

I/O Throttling: Hypervisor versus Filesystem

I/O throttling can be applied by the hypervisor (blkio cgroup or a network volume’s QoS), by the guest’s own cgroup v2 io.max, or by the filesystem. The distinguishing signal is that hypervisor throttling produces latency spikes at a steady period while the device reports idle time.

iostat -xdz 1
cat /sys/block/vda/queue/rotational
cat /proc/pressure/io

# guest-side cgroup limit, if any
cat /sys/fs/cgroup/io.max 2>/dev/null

# device-level stats rather than partition
iostat -x 1 5 /dev/vda
cat /sys/block/vda/stat

Look at aqu-sz, await, and r_await/w_await separately. A high w_await with low %util means the device is waiting outside the guest—either the host is throttling the volume or the physical backend is congested. High %util with high await means your own workload saturated the device. These lead to opposite conclusions: one needs a host change, the other needs a workload change.

Building a Three-Axis Snapshot

A single sample is nearly useless because all three effects are bursty. Capture a short log during an incident and read the axes together. The script below writes a compact per-second line that can be grepped afterwards.

#!/bin/bash
end=$((SECONDS+120))
while [ $SECONDS -lt $end ]; do
  steal=$(awk '/^cpu /{print $9}' /proc/stat)
  mem=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
  io=$(awk '/^io/{printf "%s", $0}' /proc/pressure/io 2>/dev/null | tr -d ',:')
  echo "$(date +%s) steal_ticks=$steal avail_kb=$mem $io"
  sleep 1
done > /root/triage.log

Note that the steal value from /proc/stat is cumulative in USER_HZ ticks; take differences between samples to get a rate. Dividing each delta by the uptime tick delta and multiplying by 100 yields the percentage, which should agree with mpstat. If it does not, user-space sampling is missing bursts shorter than the interval.

What Each Diagnosis Implies

High steal with clean PSI is a host scheduling issue: complain or migrate, and expect to pay for a dedicated-core plan. High memory PSI with non-zero swap-in is a sizing issue: raise RAM or reduce footprint. High I/O PSI with elevated w_await and low guest %util is throttling or backend contention: a guaranteed-IOPS volume or a different class of instance. Only after ruling those out is application tuning the right answer.

Getting the diagnosis right is the difference between changing a tcp_* sysctl that does nothing and moving to an instance class that actually fixes the symptom. If the numbers point at shared-resource limits, our VPS vs dedicated server comparison frames where the floor stops moving. If you would rather have someone else watch these counters continuously, compare managed vs unmanaged in our guide before deciding what you want to own.

Leave a Reply