Skip to content
Serverküche
Search

Loading search …

netcup Difficulty: Intermediate Analysis

When is your server too small? Five upgrade signals

Five measurable signals tell you when a VPS is too small – and when cleaning up is cheaper than upgrading. With real numbers from 80 days of uptime.

· Last updated: · 15 min read
Table of contents

“The server is too small” is rarely a measurement and usually a feeling. This article replaces the feeling with five numbers you can read off yourself in two minutes.

The short answer

A bigger server pays off in three cases: when the machine itself runs out of memory (available persistently below roughly 10 percent, CONSTRAINT_NONE in the kernel log), when the 15-minute load stays above the core count, and when the disk is tight even though docker system df shows nothing left to clean up. If the disk is the brake instead (wa high, us low) or a container hits its own mem_limit, an upgrade achieves nothing. Sustained double-digit steal time is a case for a support ticket. Four of the five signals appear in a single vmstat output.

What this is based on

Every value below comes from a netcup VPS 1000 G12 with 4 vCPU and 7.8 GiB of RAM that had been running for 80 days at the time. It was collected under controlled load.

The result is a decision routine. By the end you will know five signals, the command that shows each one, and the threshold at which a bigger server actually solves the problem. The reverse case matters just as much: for two of the five signals an upgrade is money thrown away, because the cause lies somewhere else.

The nice part: four of the five signals appear in a single output, and the tool for it is already installed on every Debian system.

To read them off you need:

  • A Linux server with root access that already runs something – an empty server tells you nothing.
  • vmstat (package procps, preinstalled on Debian 13). That is all you need.
  • Useful but not required: a running Grafana and Prometheus setup. The snapshot below answers “is something tight right now?”; for “since when has it been getting tighter?” you need a time series.
🍳 Recommendation Ad

VPS 1000 G12.5

4 vCores · 8 GB RAM · 128 GB SSD

from €14.50/month

The server every number in this article was measured on.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 1000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

One output, four signals

vmstat shows what the server is stuck on, once per second. The first block is always an average since boot – ignore it and read from the second line onwards:

Terminal
vmstat 1 5
Ausgabe
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 1  0      0 3475108 271400 3831564    0    0    36    68  151    0  0  0 100  0  0  0
 0  0      0 3474844 271400 3831576    0    0     0     0  165  158  0  0 100  0  0  0
 0  0      0 3474844 271400 3831576    0    0     0     4  116  103  0  0 100  0  0  0

That is what a bored server looks like. Six columns carry the message:

ColumnMeaningSignal
rprocesses that want to computequeue in front of the cores
bprocesses waiting on diskdisk maxed out
free / cachefree memory, file cachememory pressure
si / soswapping in and outalways 0 on this VPS – see signal 1
waCPU waiting on I/Odisk, not CPU
ststolen CPU timeoversubscribed host

The fifth number, disk usage, comes with signal 5. df is enough for that.

Signal 1: memory runs out without warning

The best-known rule of thumb says: once the server starts swapping, RAM is too small. On a netcup VPS that rule does not apply, because there is no swap:

Terminal
free -h
Ausgabe
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       721Mi       7.0Gi       664Ki       261Mi       7.1Gi
Swap:             0B          0B          0B

Swap: 0B – and /proc/swaps contains nothing but its header line. That has an awkward consequence: the si and so columns in vmstat stay at 0 forever. The early-warning signal half the advice on the internet points at cannot fire on this machine at all.

We measured what happens instead. A test process allocated memory in 200 MiB steps and actually touched it, while vmstat ran alongside:

Ausgabe
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  0      0 2749328 199352 4218256    0    0   492     0  296  171  0 11 88  0  0
 0  0      0 1513180 199352 4218256    0    0     0     0  222   86  0 13 87  0  0
 0  0      0  684544 199352 4218256    0    0     0   140  190  125  1  6 94  0  0
 0  0      0  200836 199352 4218256    0    0     0     0  175   81  1  6 93  0  0
 0  0      0  149284 152392 3782580    0    0     0     0  324  207  1 13 86  0  0
 0  0      0  141984 152376 3371156    0    0     0     0  309  209  1 11 88  0  0
 0  0      0  140092  49936 2163436    0    0     0     0  511  243  1 18 82  0  0

Read the lines top to bottom: free drops from 2.7 GB to 140 MB. Then the kernel starts sacrificing the file cache – cache falls from 4.2 GB to 2.1 GB. si and so sit at 0 the whole time. And then it is over:

Terminal
journalctl -k --since "-5min" --grep "oom-kill|Out of memory"
Ausgabe
python3 invoked oom-killer: gfp_mask=0x140dca(GFP_HIGHUSER_MOVABLE|__GFP_COMP|__GFP_ZERO), order=0, oom_score_adj=1000
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=user.slice,mems_allowed=0,global_oom,task_memcg=/user.slice/user-0.slice/session-6076.scope,task=python3,pid=3065035,uid=0
Out of memory: Killed process 3065035 (python3) total-vm:6571320kB, anon-rss:6459404kB, file-rss:116kB, shmem-rss:0kB, UID:0 pgtables:12712kB oom_score_adj:1000

Seven seconds passed between “2.7 GB still free” and a killed process. Without swap there is no sluggish phase in which the server tells you things are getting tight. It runs normally – and then a service is missing.

The number to watch is called available

Not free. The kernel uses every unused block as file cache, which is why free is always small on a server that has been up for a while and tells you nothing. available estimates how much can still be obtained without swapping. If that value stays below roughly 10 percent of total memory, you are one unlucky request away from the next kill.

Who got killed decides the bill

The same kernel line contains the field your purchase decision hangs on. This server has both variants in its log. Above, just now: constraint=CONSTRAINT_NONE together with global_oom. And on September 9:

Ausgabe
oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=docker-c7122b11…scope,oom_memcg=/system.slice/docker-c7122b11…scope,task=tail,pid=589430,uid=0
Memory cgroup out of memory: Killed process 589430 (tail) total-vm:132096kB,anon-rss:130432kB,file-rss:772kB,shmem-rss:0kB,UID:0 pgtables:304kB oom_score_adj:0

The difference is the entire diagnosis:

  • CONSTRAINT_MEMCG means a container hit its own limit – 130 MB against a mem_limit of 128 MB. The server had gigabytes free at that moment. A bigger server changes nothing here; the limit needs fixing, see Harden Docker containers.
  • CONSTRAINT_NONE with global_oom means the machine itself ran out of memory. Only that is an upgrade signal.

Skip this distinction and you will order twice the server, then watch the container die again the next day. An exit 137 alone does not tell you which of the two cases you are looking at – both end the process the same way. Only the kernel log has the answer.

Signal 2: the queue in front of the cores

Load average on its own says little, because nobody writes down how many cores the machine has. It becomes interesting only when divided by nproc:

Terminal
nproc; cat /proc/loadavg

To measure it we built a reference task that always costs the same: the SHA-256 hash of a one-gigabyte file that is already cached – pure compute, no disk. Idle, three times in a row:

Ausgabe
0.88 s
0.88 s
0.89 s

Then eight busy loops on four vCPU, so more than double subscription:

Ausgabe
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 8  0      0 2406468 271400 4882092    0    0     0     0  980  905 98  1  0  0  1
 8  0      0 2406468 271400 4882092    0    0     0     0  985  920 99  0  0  0  1

Load average 6.15 on four cores, r sits at 8, the CPU is 99 percent in application code. The same task now takes:

Ausgabe
2.16 s
2.20 s

Two and a half times as long. With the load switched off it is immediately back at 0.91 s – no after-effects, no broken machine. That is exactly how a server short on compute behaves: everything gets slower, nothing breaks.

That yields a usable rule of thumb. Divide r by the number of cores and you get roughly the factor by which everything is currently slower. Nine runnable processes on four cores predict a factor of 2.25; we measured 2.45.

The threshold: if the 15-minute value stays above the core count, the server is too small for what you are asking of it. Short spikes during the nightly backup or a docker compose pull are perfectly normal and no reason for anything.

Signal 3: when the disk is the brake, a bigger server will not help

Now the case that gets misread most often. We turned four parallel writers loose on the system disk, bypassing the file cache:

Terminal
for i in 1 2 3 4; do dd if=/dev/zero of=/var/tmp/p$i.bin bs=1M count=8192 oflag=direct & done
Ausgabe
after 15 s: 2.07 1.95 1.27 | r=0 b=4 us=0% wa=72%
after 30 s: 2.50 2.05 1.31 | r=0 b=4 us=0% wa=97%
after 45 s: 2.83 2.15 1.35 | r=0 b=4 us=0% wa=97%

A load average of 2.8 – at zero percent CPU usage. That is not a measurement error: Linux counts processes in uninterruptible disk wait towards the load average. Anyone looking only at load will order more cores here and get exactly nothing.

The numbers behind it: a single stream writes 678 MB/s and reads 686 MB/s. Four parallel streams together also write only about 700 MB/s – the NVMe is at its ceiling, and the three extra processes are merely parked in column b.

What this signal means is uncomfortable: a bigger VPS package gives you more vCPU and more RAM, but not more disk throughput. If wa is your bottleneck, what helps is reshaping the work – database indexes, fewer concurrent jobs, a separate volume for the write-heavy application – or a product with different storage, not the next size of the same one.

Tip

Sustained high wa with low throughput (bi/bo small) is a different problem from wa at full throughput. Full bo means “device at its limit”. Low bo with high wa usually means many tiny writes, typically a database without suitable indexes or a logger flushing every line synchronously.

Signal 4: steal time, and why it is mostly a legend

Every forum will tell you shared vCPU are useless because the neighbours take the compute time away. The st column measures exactly that. For this server across its full 80 days of uptime:

Terminal
awk '/^cpu /{t=$2+$3+$4+$5+$6+$7+$8+$9; printf "%.3f %%\n", 100*$9/t}' /proc/stat
Ausgabe
0.001 %

One thousandth of a percent. Even during our full-load measurement st showed 1. The legend does not hold up on this machine, and the VPS 1000 benchmark found the same picture.

The column still belongs in the routine – because when it does spike, it is the one signal you cannot fix yourself. Sustained double-digit values mean the host is oversubscribed. No amount of cleaning up and no bigger package on the same host will help; a support ticket with your measurements will.

Signal 5: disk usage lies the loudest

The last signal is the only one where cleaning up is almost always cheaper than upgrading. On our server it looks relaxed at first:

Terminal
df -h /
Ausgabe
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda4       251G   73G  169G  31% /

31 percent, nothing to worry about. Until you ask who is holding those 73 GB:

Terminal
docker system df
Ausgabe
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          107       1         74.58GB   68.43GB (91%)
Containers      1         1         8.192kB   0B (0%)
Local Volumes   0         0         0B        0B
Build Cache     33        0         209.5MB   4.096kB

107 images, exactly one of them in use. 68 GB are leftovers from tests that were torn down long ago – the containers were, but not the images. That is not an upgrade case, that is a docker image prune -a preceded by a look at whether any of it is genuinely needed.

On a current VPS 1000 of the G12.5 generation, the same heap would already be more than half the disk: it has 128 GB instead of the 251 GB of our G12. Why the generation matters here is covered in the 100-day report.

Under Docker 29 the images live elsewhere

The obvious search misleads: du -sh /var/lib/docker reports 188 MB on this server. The 70 GB sit in /var/lib/containerd, because since version 29 Docker uses the containerd image store by default (docker info shows driver-type: io.containerd.snapshotter.v1). Follow the old recipe and you find nothing, then go looking for the problem in the wrong place.

Two more candidates that pile up quietly: the journal (journalctl --disk-usage, 279 MB here – cap it with SystemMaxUse in /etc/systemd/journald.conf) and the old images your update run leaves behind; how to automate that is in Keep your Docker stack updated.

On Debian 13, /tmp is a RAM disk

findmnt /tmp shows tmpfs 3.9G. Whatever you put there consumes memory, not disk – it appears as shared in free -h and is gone after a reboot. A 4 GiB file written to /tmp therefore aborts halfway while df -h / still reports an unchanged 31 percent – the disk was never the problem. Large scratch files belong in /var/tmp.

The decision

SignalHow you see itAnd then?
Memoryavailable persistently < 10 %, CONSTRAINT_NONE in the kernel logupgrade
Container OOMCONSTRAINT_MEMCG in the kernel logfix mem_limit
Compute15-minute load persistently > core count, us highupgrade
I/Owa high, us low, b > 0reshape the work or change storage
Stealst persistently double-digitsupport ticket
Diskdf tight and docker system df cleanupgrade – otherwise clean up

Three of the six rows do not end at “bigger server”. That is the real value of the routine: it prevents an upgrade that achieves nothing just as reliably as it justifies one that does.

And if the decision does land on “bigger” after all: the plans with prices are in the netcup overview, and a snapshot before the move costs nothing but a few minutes.

When things go wrong

vmstat shows a steady 100 percent id even though the site feels slow. Then the bottleneck is not on this server. Check the response time at the service directly instead of through the domain – if the container answers fast and the browser still waits, it is the path there: DNS, the TLS handshake or a congested network, not CPU or RAM.

Nothing at all in the kernel log, even though a container disappeared. journalctl -k only shows genuine OOM kills. If a service exits on its own, the reason is in its own log: docker compose logs --tail 50 YOUR_SERVICE. An exit 137 without a matching kernel line is usually a docker stop whose grace period ran out, not a memory problem.

Load average is high but r and b are both 0. You are reading the first vmstat line. That one is an average since boot and practically meaningless on a server with 80 days of uptime. Use vmstat 1 5 and evaluate from the second line onwards.

free -h reports almost no free memory, yet the server runs perfectly. That is the normal state. The kernel fills unused memory with file cache and hands it straight back when somebody needs it. The column that matters is available; free may happily sit near zero.

Lots of reclaimable space, but docker image prune -a frees almost nothing. Then the images are still attached to stopped containers. Run docker container prune first, then the images – or do both at once with docker system prune -a, after checking that every stack you want is actually running.

Your problem is not listed? Search all error messages →

Limits of this analysis

Every number comes from a single netcup VPS 1000 of generation G12 with 4 vCPU, 7.8 GiB of RAM and Debian 13.6, measured on 2026-10-04 under controlled load. That steal time stayed at one thousandth of a percent over 80 days holds for this machine; on another host it can look different. The memory behaviour in signal 1 applies to a server without swap, which is how netcup ships its VPS. A current VPS 1000 of generation G12.5 has 128 GB of disk instead of 251 GB, so the same image heap would already take up more than half of it.

The thresholds are rules of thumb: r divided by the core count predicted a factor of 2.25, we measured 2.45. And the routine is a snapshot. Whether things have been getting tighter for weeks only shows up in a time series from your monitoring.

Maintenance & backups

This routine is not something you run once. Two habits are enough:

Once a month, two minutes: vmstat 1 5, free -h, df -h / and docker system df. Write the four values down – the trend across half a year says more than any snapshot, and it shows you an upgrade weeks in advance.

Set up once, running permanently: an alert on available and on disk usage. Both fit into the existing Grafana and Prometheus setup; if you prefer something leaner, use a push service as in ntfy push notifications. Set the memory threshold deliberately low: without swap there is no window between “getting tight” and “service gone” in which an alert firing at 2 percent would still reach you in time.

A backup belongs before any consequence you draw from this measurement – whether you clean up or move. The routine in Backups with restic covers both; before a product change, add a snapshot in the netcup customer area.

You might also like