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.controllersIf 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
| Resource | systemd directive | cgroup v2 file |
|---|---|---|
| CPU bandwidth | CPUQuota=50% | cpu.max |
| Memory ceiling | MemoryMax=512M | memory.max |
| Memory throttle (soft) | MemoryHigh=384M | memory.high |
| I/O bandwidth | IOReadBandwidthMax= | io.max |
| Process count | TasksMax=256 | pids.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 MemoryHighGroup 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.sliceSlices 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 hitThe 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
MemoryHighslightly belowMemoryMaxso services throttle before they die. - Set
CPUQuotaas a percentage of one core: 200% = two cores. - Use
TasksMaxto 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 enforcedFor 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
| Symptom | Cause | Fix |
|---|---|---|
| Service silently unthrottled | Drop-in not loaded (typo/daemon-reload missing) | systemctl show –property to confirm |
| OOM kills despite a MemoryMax | cgroup accounting vs host overcommit | check memory.events + dmesg |
| I/O limits ignored | io controller not delegated or device mismatch | verify with io.stat and block device |
| CPUQuota set but no effect | service forked outside the unit | use 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
You must be logged in to post a comment.