Skip to content
LinuxHow-To July 11, 2026 4 min read

Limiting a Service's CPU and Memory with cgroups v2

Using systemd's resource-control unit directives to cap CPU, memory, and IO for a specific service, and how to confirm the limits are actually being enforced by the kernel.

Control groups (cgroups) are the kernel mechanism that lets you cap how much CPU, memory, and IO bandwidth a process (and its children) can consume, and on any modern systemd-based distribution, the practical way to use them for a specific service is almost never talking to the cgroup filesystem directly — it’s setting a handful of directives in that service’s unit file and letting systemd manage the cgroup on your behalf.

Why this matters even on a single-service machine

A single misbehaving service — a memory leak, a runaway process spawned by a bug, a batch job that should be throttled rather than allowed to saturate the whole machine — can degrade or take down everything else running alongside it if nothing limits its resource consumption. cgroups turn “one service’s bug” into “one service hits its own ceiling and gets throttled or killed,” rather than “the whole system runs out of memory.”

Setting memory limits

For a systemd-managed service, memory limits go directly in the unit file:

# /etc/systemd/system/myservice.service.d/override.conf
[Service]
MemoryMax=512M
MemoryHigh=400M

MemoryMax is the hard ceiling — the kernel’s OOM killer will act against processes in this cgroup specifically if they try to exceed it, rather than the system-wide OOM killer picking an arbitrary victim. MemoryHigh is a softer throttling point: the kernel will start pushing back (throttling, reclaiming memory more aggressively) on the cgroup as it approaches this value, before the hard MemoryMax ceiling is ever reached, which in practice means a service under memory pressure degrades gracefully rather than getting killed outright the moment it crosses one line.

Setting CPU limits

[Service]
CPUQuota=150%
CPUWeight=50

CPUQuota=150% caps this service at the equivalent of one and a half CPU cores’ worth of processing time, regardless of how many cores the machine actually has or how idle they are — a hard ceiling, not a share. CPUWeight is different: it’s a relative priority (default 100) used only when the CPU is actually under contention from multiple cgroups competing for it; a service with CPUWeight=50 gets proportionally less CPU time than one at the default weight during contention, but faces no artificial ceiling at all when the CPU isn’t contended.

Using both together is common: CPUWeight to deprioritize a background batch service relative to interactive ones during contention, and CPUQuota as an absolute backstop so that even on an otherwise-idle machine, the batch job can’t monopolize every core.

Setting IO limits

[Service]
IOReadBandwidthMax=/dev/sda 50M
IOWriteBandwidthMax=/dev/sda 20M

This caps a service’s read and write throughput against a specific block device, which matters most for batch or backup jobs that would otherwise saturate disk IO badly enough to cause latency spikes for every other service sharing that same disk.

Applying and verifying

After editing an override file under a .service.d/ drop-in directory, reload systemd’s configuration and restart the affected service:

systemctl daemon-reload
systemctl restart myservice

Confirming the limits actually took effect — rather than assuming the unit file syntax was correct — means checking the live cgroup state directly:

systemctl show myservice -p MemoryMax -p CPUQuotaPerSecUSec
cat /sys/fs/cgroup/system.slice/myservice.service/memory.max

The systemctl show output confirms systemd’s own view of the configured limits; reading the actual memory.max/cpu.max files under /sys/fs/cgroup/ confirms the kernel received and applied them, which is the real source of truth — a typo or a systemd version that ignores an unsupported directive silently would show up as a mismatch between what you configured and what’s actually enforced.

Watching a limit get enforced

For memory in particular, it’s worth confirming behavior under actual pressure rather than trusting the configuration alone:

systemd-cgtop

systemd-cgtop shows live, per-cgroup CPU, memory, and IO consumption, updated continuously — watching a service approach its configured MemoryHigh and visibly get throttled (rather than immediately OOM-killed) is a useful, concrete confirmation that the soft/hard limit distinction is actually working as intended, not just correctly configured on paper.

Why this beats ulimit for service-level control

Older-style ulimit process limits are per-process and don’t correctly account for a service that forks multiple children — a memory limit meant to cap a service’s total footprint is trivially defeated if that service spawns several worker processes, each individually under the ulimit ceiling but collectively consuming far more. cgroup-based limits, by contrast, apply to the whole cgroup — the service and everything it spawns — which is almost always the actually intended scope when someone reaches for a resource limit on a systemd service in the first place.