Using systemd Slices to Cap Runaway Processes on a VPS

You’ve seen it: a runaway process eats all available CPU on your VPS, the load average spikes to 40, SSH becomes unresponsive, and the only fix is a hard reboot from the provider panel. The usual advice is to install a userspace daemon to watch for high load. That’s the wrong layer. systemd already has a complete resource-control framework built in, and on any modern VPS with cgroup v2 you can cap CPU, memory, I/O, and process counts so that a runaway service degrades itself instead of the whole machine. This guide walks through the units, the directives, and how to verify the limits are actually enforced. If your provider’s images don’t support cgroup v2, see our full VPS comparison.

Confirm cgroup v2 is active

Everything below assumes the unified cgroup v2 hierarchy. Check it first — many older VPS images still boot with the legacy v1 layout, where the specific directives differ.

stat -fc %T /sys/fs/cgroup/
# 'cgroup2fs' = v2 (good).  'tmpfs' = v1 (legacy).

systemctl --version | head -1   # systemd 243+ for most directives below

# See live per-slice resource usage
systemd-cgtop -m

If you’re on cgroup v1, add systemd.unified_cgroup_hierarchy=1 to the kernel command line and reboot. On a VPS with GRUB, edit /etc/default/grub and run update-grub. Some providers use a custom bootloader, so confirm your changes persist across a reboot before relying on them.

The four slices you should have

systemd ships with system.slice, user.slice, and machine.slice. The trick is to put backup jobs, web services, and anything untrusted into their own slices so their resource consumption is bounded independently.

SlicePurposeTypical cap on a 2 vCPU / 4 GB VPS
system.sliceCritical daemons: sshd, networking, journaldNever capped — protect it
web.slicenginx, PHP-FPM, app serversCPUQuota=150%, MemoryMax=2G
batch.sliceBackups, log rotation, cron jobsCPUQuota=50%, IOWeight=50
user.sliceInteractive SSH sessionsUntouched

Protecting system.slice is the key insight. When a runaway process belongs to web.slice, the kernel will throttle it there, and sshd — living in system.slice — keeps responding. You can still log in and investigate instead of reaching for the provider’s reboot button.

Defining the slices

Slices are just unit files with a .slice suffix. Create them under /etc/systemd/system/:

sudo tee /etc/systemd/system/web.slice >/dev/null <<'EOF'
[Unit]
Description=Web services slice

[Slice]
CPUAccounting=yes
CPUQuota=150%
MemoryAccounting=yes
MemoryHigh=1600M
MemoryMax=2G
MemorySwapMax=512M
TasksMax=512
IOAccounting=yes
IOWeight=200
EOF

sudo tee /etc/systemd/system/batch.slice >/dev/null <<'EOF'
[Unit]
Description=Background batch jobs slice

[Slice]
CPUAccounting=yes
CPUQuota=50%
MemoryAccounting=yes
MemoryMax=768M
TasksMax=256
IOWeight=50
EOF

sudo systemctl daemon-reload

A few semantics worth internalising:

  • MemoryHigh vs MemoryMax. MemoryHigh is a soft limit — the kernel reclaims aggressively and throttles the cgroup rather than killing it. MemoryMax is a hard limit; exceeding it triggers the OOM killer within that cgroup only, not globally. Set High below Max to get graceful degradation before a kill.
  • CPUQuota is a ceiling, not a reservation. CPUQuota=150% means the slice can consume at most one and a half cores’ worth of time. It can be idle; the machine just won’t let it exceed the cap.
  • IOWeight is a relative share (1–10000, default 100). Under contention, a process with weight 200 gets roughly twice the bandwidth of one at weight 100. It only matters when the device is saturated.
  • TasksMax caps the number of processes/threads, which prevents fork bombs from filling the PID table.

Assigning services to slices

Use a drop-in override rather than editing the shipped unit file — package updates won’t overwrite it.

# Put nginx into web.slice
sudo systemctl edit nginx
# In the editor, add:
#   [Service]
#   Slice=web.slice

# Per-service hard stop on top of the slice cap
sudo mkdir -p /etc/systemd/system/nginx.service.d
sudo tee /etc/systemd/system/nginx.service.d/limits.conf >/dev/null <<'EOF'
[Service]
Slice=web.slice
MemoryMax=1G
TasksMax=256
EOF

# Batch jobs: a backup unit
sudo mkdir -p /etc/systemd/system/backup.service.d
sudo tee /etc/systemd/system/backup.service.d/limits.conf >/dev/null <<'EOF'
[Service]
Slice=batch.slice
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF

sudo systemctl daemon-reload
sudo systemctl restart nginx

For cron-driven work, the cleanest pattern is to stop using cron entirely for heavy jobs and define transient units instead, so every job inherits slice limits. A timer plus a service gives you the same schedule with accounting, logging, and resource control for free.

Verifying the caps are enforced

Directives that aren’t enforced are worse than none, because they give false confidence. Test each one deliberately.

# Inspect the effective cgroup limits for a running service
systemctl show nginx -p Slice -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax
cat /sys/fs/cgroup/web.slice/memory.max
cat /sys/fs/cgroup/web.slice/cpu.max       # format: "quota period"

# Prove the memory cap works: allocate past MemoryMax inside the slice
systemd-run --slice=batch.slice --unit=memtest -p MemoryMax=64M \
  /bin/sh -c 'python3 -c "a=bytearray(200*1024*1024); print(len(a))"'
systemctl status memtest   # expect a SIGKILL / OOM message, not a system-wide stall

# Prove the CPU cap works: burn cycles and watch the usage
systemd-run --slice=batch.slice --unit=cputest -p CPUQuota=25% \
  /bin/sh -c 'while :; do :; done' &
sleep 10
systemd-cgtop -m --iterations=1   # batch.slice should sit near 25%
systemctl stop cputest

When cputest shows roughly a quarter of one core and memtest gets killed while the rest of the system stays responsive, you’ve confirmed the framework is doing its job.

Common mistakes

  • Setting MemoryMax without MemoryHigh. The service gets OOM-killed abruptly instead of being throttled first.
  • Capping system.slice. Never do this. If sshd and networking compete for a capped budget you’ve created a lockout scenario.
  • Forgetting daemon-reload. Changes to slice files do nothing until systemd rereads them, and existing cgroups are only re-parented on service restart.
  • Ignoring swap. Without MemorySwapMax, a memory-hungry service can push the whole VPS into swap thrash before hitting MemoryMax.
  • Setting TasksMax too low on a threaded app. A web server with a thread-per-connection model will hit a low cap and start refusing work. Watch systemd-cgtop task counts under load first.

Complementing this with kernel-level tuning

Slices control who gets what. You still want the kernel’s own reclaim behaviour tuned so that memory pressure is handled gracefully — lower vm.swappiness for database hosts, sane dirty_ratio values for slow virtual disks. Slices and sysctls solve different halves of the same problem, and together they turn a fragile instance into one that survives its own mistakes.

Resource control is one of the strongest arguments for picking a provider that gives you a current systemd and a cgroup v2 kernel. Providers running 4.15 kernels with cgroup v1 can’t offer any of this. See our full VPS comparison for hosts that ship modern images with full root access.

Want to stop rebooting your VPS to fix runaway processes? Compare VPS plans with full systemd and cgroup v2 support and get an instance where resource limits actually protect you.

Leave a Reply