cgroups v2 on a VPS: Capping CPU, Memory and I/O per Service with systemd

A single runaway service — a rebuild, a backup, a memory leak — can starve everything else on a small VPS. cgroups v2 lets you bound each service’s CPU, memory and I/O so one process cannot take the whole box down. This is a practical setup using systemd units and slices, with verification at every step.

Confirm you are on cgroup v2

stat -fc %T /sys/fs/cgroup
# cgroup2fs  => v2 (unified)
# tmpfs      => v1 (legacy, hybrid)
systemctl --version
cat /sys/fs/cgroup/cgroup.controllers

If cgroup.controllers lists cpu memory io pids, the controllers are available. On v1, the syntax below does not apply and you would use the legacy CPUQuota/MemoryMax mapping which is less complete.

The three knobs you actually need

Resourcesystemd directivecgroup v2 file
CPU bandwidthCPUQuota=50%cpu.max
Memory ceilingMemoryMax=512Mmemory.max
Memory throttle (soft)MemoryHigh=384Mmemory.high
I/O bandwidthIOReadBandwidthMax=io.max
Process countTasksMax=256pids.max

MemoryHigh is the underused one: it throttles and reclaims before MemoryMax triggers the OOM killer. Setting only MemoryMax lets a service balloon right up to the hard wall and then get killed. Set MemoryHigh at ~75% of MemoryMax for graceful behaviour.

Cap a service with a drop-in override

Never edit unit files from the package manager — use a drop-in so upgrades do not clobber your changes:

systemctl edit myservice.service

# --- contents ---
[Service]
CPUQuota=75%
MemoryHigh=384M
MemoryMax=512M
IOWeight=50
TasksMax=512
systemctl daemon-reload
systemctl restart myservice
systemctl show myservice -p CPUQuota -p MemoryMax -p MemoryHigh

Group related services into a slice

Apply one limit to a whole tier — say all web-adjacent services — by placing them in a custom slice:

# /etc/systemd/system/webtier.slice
[Slice]
CPUQuota=150%
MemoryMax=1G

# in each unit:
[Service]
Slice=webtier.slice

Slices nest, so a parent slice’s limit applies collectively to its children. This is the clean way to give a workload group a budget without hunting down every individual daemon.

Verify the limits are real

# inspect live cgroup values for a running unit
systemctl status myservice
cat /sys/fs/cgroup/system.slice/myservice.service/memory.max
cat /sys/fs/cgroup/system.slice/myservice.service/cpu.max

# watch memory pressure inside the cgroup
cat /sys/fs/cgroup/system.slice/myservice.service/memory.events
#  low / high / max / oom_kill counters tell you which wall you hit

The memory.events counters are the diagnostic gold: high incremented means the soft limit throttled it; max means it hit the hard ceiling; oom_kill means it died. That single file tells you whether to raise the limit or fix the leak.

  • Start with MemoryHigh slightly below MemoryMax so services throttle before they die.
  • Set CPUQuota as a percentage of one core: 200% = two cores.
  • Use TasksMax to stop fork bombs from a compromised or buggy service.
  • Re-verify after every restart; a failed drop-in is silent.

Testing that the cap actually bites

A limit you have not stress-tested is a limit you do not have. Reproduce load inside the cgroup and watch the accounting counters move:

# in one terminal, spin CPU inside the unit's cgroup
systemd-run --slice=webtier.slice -p CPUQuota=50% \
  stress-ng --cpu 4 --timeout 30s

# in another, watch the throttle happen
systemctl status <unit>
cat /sys/fs/cgroup/webtier.slice/cpu.stat
#  nr_throttled and throttled_usec rising => the quota is enforced

For memory, allocate past the limit and confirm the throttle-then-kill ladder behaves as configured. If you only see oom_kill and never high events, your MemoryHigh is too close to MemoryMax or unset.

Failure modes and how to spot them

SymptomCauseFix
Service silently unthrottledDrop-in not loaded (typo/daemon-reload missing)systemctl show –property to confirm
OOM kills despite a MemoryMaxcgroup accounting vs host overcommitcheck memory.events + dmesg
I/O limits ignoredio controller not delegated or device mismatchverify with io.stat and block device
CPUQuota set but no effectservice forked outside the unituse Type=notify / KillMode=control-group

The second row is the one people hit on small VPS instances: a cgroup memory limit is not a reservation, and the host’s own overcommit policy still governs total memory. If the box is oversubscribed at the host level, your container or service can still be selected by the host kernel even while respecting its own limit. cgroup limits contain blast radius; they do not conjure memory that the host does not have.

Applied consistently, per-service caps convert a class of ‘the whole VPS went down’ incidents into ‘this one service was throttled and logged’. Start with the noisiest, most variable service — backups, builds, and anything that caches — and expand from there.

Two operational habits make the whole scheme durable. First, keep the limits in version control: a drop-in committed alongside the service definition is reviewable and reproducible, while one typed directly onto a host disappears at rebuild. Second, emit the cgroup counters — memory.events, cpu.stat, io.stat — into your monitoring so a throttle is visible as a trend rather than discovered during an incident. A cap that silently fires without a signal just moves the surprise later.

Layered with the host-level memory policy on a small instance, this gives you a predictable failure mode: the constrained service degrades, everything else keeps serving, and you get a log line telling you exactly which wall was hit. That is a far better day than the alternative.

Resource caps are the difference between one slow service and a full outage. Combined with sensible swap and OOM policy on a small instance, they turn unpredictable host failures into contained, logged events. If you would rather not hand-tune this on every host, a cloud VPS tuned for consistent resource isolation is the better starting point.

Leave a Reply