cgroups Explained: Limiting CPU, Memory, and I/O With systemd
Control groups are the kernel mechanism that stops one process taking everything. They are best known as the thing containers use to enforce limits, but there is no requirement to involve a container: any systemd unit can carry the same constraints.
The idea
A cgroup is a set of processes with resource accounting and limits applied to the group as a whole. Controllers handle each resource: memory, CPU, I/O, process count.
The key word is group. Limits apply to everything inside collectively, so a service that spawns twenty workers is constrained in total rather than per process, which is what you almost always want.
v1 and v2
cgroups v1 gave each controller its own hierarchy, so a process could be in one place for memory and somewhere else for CPU. It worked and it was confusing.
v2 uses a single unified hierarchy. Every current distribution defaults to it.
# Check which you are on
stat -fc %T /sys/fs/cgroup/
# cgroup2fs means v2
Seeing the tree
systemd puts every service in its own cgroup automatically, which means you already have a useful structure to work with.
systemd-cgls # the hierarchy as a tree
systemd-cgtop # live resource use per cgroup
systemctl status nginx # includes the cgroup and its processes
systemd-cgtop is the underused one. It is top for cgroups, so instead of a flat list of processes you see resource use aggregated per service, which is far more useful when a service forks heavily.
Applying limits
In a unit file, or better a drop-in so package updates do not overwrite it:
sudo systemctl edit myservice
[Service]
# Memory
MemoryHigh=512M
MemoryMax=1G
# CPU
CPUQuota=50%
CPUWeight=50
# I/O
IOWeight=50
# Processes
TasksMax=100
Then:
sudo systemctl daemon-reload
sudo systemctl restart myservice
Memory
MemoryHigh is a soft limit. Exceeding it triggers throttling and aggressive reclaim, which slows the service down and gives it a chance to recover.
MemoryMax is hard. Exceed it and the kernel kills processes in the group.
Setting MemoryHigh somewhat below MemoryMax is the useful pattern: the service gets back-pressure before it gets killed, and a memory leak becomes a slow service you notice rather than a sudden OOM kill at three in the morning.
CPU
CPUQuota is an absolute ceiling. CPUQuota=50% means half a core, even when the machine is completely idle. That is occasionally what you want and often not.
CPUWeight is proportional and only matters under contention. A service at 50 competing with one at the default 100 gets roughly a third of the contested CPU, and gets everything available when nothing else is running.
For most cases weight is the better tool. Quota is for when you genuinely need a hard cap, such as enforcing a tenant limit.
I/O
IOWeight works the same way proportionally. As with ionice, the effect depends on the I/O scheduler in use.
IOReadBandwidthMax and IOWriteBandwidthMax set absolute byte-per-second caps per device:
IOWriteBandwidthMax=/dev/sda 50M
Task count
TasksMax caps the number of processes and threads. This is genuine fork-bomb protection, and it is worth setting on anything that runs user-supplied code.
Changing limits without editing files
# Immediate, and persists as a drop-in
sudo systemctl set-property nginx MemoryMax=2G CPUQuota=80%
# Immediate, gone at reboot
sudo systemctl set-property --runtime nginx MemoryMax=2G
To launch something under a limit without creating a unit at all:
systemd-run --scope -p MemoryMax=1G -p CPUQuota=25% ./risky-script.sh
This is genuinely handy: run an unfamiliar build or a script you do not trust inside a hard memory cap, so the worst case is that it dies rather than that the machine does.
Limiting a user, not a service
sudo systemctl set-property user-1000.slice MemoryMax=4G CPUQuota=200%
Every session that user opens falls under that slice. On a shared machine this is how you stop one person’s runaway process affecting everyone else.
Find the slice for a user with id -u username.
Reading current usage
# Memory used by a unit right now
systemctl show nginx -p MemoryCurrent
# Straight from the filesystem
cat /sys/fs/cgroup/system.slice/nginx.service/memory.current
cat /sys/fs/cgroup/system.slice/nginx.service/memory.max
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.stat
The memory.events file is worth watching when tuning, because it counts how often the group hit its high and max limits:
cat /sys/fs/cgroup/system.slice/nginx.service/memory.events
A rising high count with a zero max count means the limits are doing their job: pressure without death.
Where to start
Do not apply limits speculatively across a system. Find the service that misbehaves, set MemoryMax somewhat above its observed peak, and set TasksMax on anything that could fork uncontrolled.
The most valuable single setting for most people is a memory ceiling on the one service known to leak, because it converts an outage into a restart.
Frequently Asked Questions
What are cgroups?
Control groups are a kernel feature that groups processes together and applies resource accounting and limits to the group as a whole. They are how containers enforce memory and CPU limits, and they work equally well on ordinary services.
What changed in cgroups v2?
v1 had a separate hierarchy per controller, so a process could sit in different places for memory and CPU, which made the model confusing and inconsistent. v2 uses a single unified hierarchy for all controllers. Every current distribution defaults to v2.
How is MemoryMax different from MemoryHigh?
MemoryHigh applies throttling and aggressive reclaim when exceeded but does not kill anything. MemoryMax is a hard ceiling, and processes that try to exceed it get killed by the OOM handler. Setting High below Max gives you pressure before failure.
Does CPUQuota reserve CPU for a service?
No, it caps it. CPUQuota=50% means the unit can never use more than half a core, even on an idle machine. If you want proportional sharing under contention rather than a ceiling, use CPUWeight instead.
Can I limit a process that is already running?
Yes, with systemctl set-property, which applies immediately and can be made persistent. You can also launch something under a limit on the fly using systemd-run --scope with the properties you want.
Do I need containers to use resource limits?
No. Containers use cgroups, but cgroups are not containers. Any systemd service or scope can carry the same limits with a few lines in a unit file, which is far simpler than containerising something purely to constrain it.