cgroups v2: How the Kernel Enforces Container Resource Limits
Docker's --memory and --cpus flags are just file writes into cgroup v2. A tour of the unified hierarchy: cpu.max, memory.high, PSI, and how limits actually behave under load.
Published on • October 13, 2026
AI Assistant

When you run docker run --memory=512m --cpus=1.5, nothing Docker-specific enforces those numbers. Docker writes a few values into files under /sys/fs/cgroup, and the kernel does the rest. Those files belong to cgroups version 2 — the unified control group hierarchy — and once you can read them, container resource behavior stops being mysterious.
(If you need the isolation primitives that cgroups work with rather than instead of, see Linux namespaces and container isolation.)
One tree to rule them controllers
cgroups v1 had a design problem: each controller (cpu, memory, blkio…) lived in its own hierarchy, so a process’s membership differed per controller, and you could not express cross-resource constraints. v2 collapses everything into a single unified hierarchy: every controller is a subtree of one tree at /sys/fs/cgroup, and a process is in exactly one cgroup for all controllers at once.
$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)
Inside a container, that mount shows the container’s own slice of the tree (thanks to a cgroup namespace), which is why cat /sys/fs/cgroup/memory.max inside a container shows your limit, not the host’s.
The mechanics: subtree_control and delegation
Controllers are enabled downward. The file cgroup.controllers lists what’s available; cgroup.subtree_control lists what your children get:
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset io memory hugetlb pids rdma misc
$ echo '+memory +cpu +pids' > /sys/fs/cgroup/cgroup.subtree_control
$ mkdir /sys/fs/cgroup/experiment
$ cat /sys/fs/cgroup/experiment/cgroup.controllers
cpu io memory pids
The enabling cascades: a controller must be enabled in every ancestor’s subtree_control before a descendant can use it. There is one classic trap — the no-internal-process rule: once a non-root cgroup enables controllers in its subtree_control, it can no longer host its own processes (the root cgroup is exempt). That is why systemd builds an intermediate “leaf” layer instead of running services in the same node that has children.
CPU: weight, quota, and the sharing model
Two knobs govern CPU:
$ cat cpu.max # quota period — "max 100000" = unlimited
200000 100000 # 2 CPUs worth of runtime per 100ms period
$ cat cpu.weight # proportional share, 1–10000, default 100
100
cpu.max is a hard cap: the group gets 200ms of CPU per 100ms period, then is throttled — even if the machine is idle. That surprise (“why is my latency spiking at 50% CPU usage?”) is visible in cpu.stat:
$ cat cpu.stat
usage_usec 842133
nr_periods 1204
nr_throttled 57
throttled_usec 13122
nr_throttled climbing while usage_usec is well under quota means quota throttling, not contention. When you’re not throttling, unused capacity is work-conserving: a group can exceed its proportional share as long as siblings aren’t competing. cpu.weight settles the argument only when there is actual contention.
Memory: the three-level story
Memory is where v2 really shows its design:
memory.max— the hard limit. Exceed it and the OOM killer fires inside that cgroup (memory.eventscountsoomandoom_kill). Setmemory.oom.group=1to kill the whole group atomically instead of sniping one process.memory.high— the throttle line. Crossing it doesn’t kill anything; the kernel aggressively reclaims and stalls the group’s allocations. This is the knob for graceful degradation — puthigha few percent undermaxand your app slows down instead of dying.memory.min/memory.low— protection floors guaranteeing a child a minimum share when the parent is under pressure.
And memory.current tells you the truth your RSS metrics hide: page cache counts. A container “using 400MB” may be 200MB of cache the kernel will reclaim under pressure — which is exactly why pressure-level observability beats point-in-time numbers.
PSI: measure pressure, not usage
v2 exposed Pressure Stall Information through the filesystem — the same signal exposed at /proc/pressure/{cpu,memory,io}:
$ cat memory.pressure
some avg10=0.42 avg60=1.10 avg300=0.87 total=128331
full avg10=0.00 avg60=0.03 avg300=0.01 total=9211
some = time at least one task was stalled on memory; full = all tasks stalled. A container with steady full pressure and modest memory.current is thrashing reclaim — a signal usage-based monitoring never surfaces. Kubernetes does not yet expose PSI natively, but reading it from a node agent is increasingly how SREs catch noisy-neighbor problems.
The container view
Inside a container with a private cgroup namespace, you see a pruned tree rooted at your own cgroup. The runtime translated its flags into file writes:
| Docker flag | cgroup v2 file |
|---|---|
--memory=512m | memory.max |
--memory-reservation=256m | memory.high |
--cpus=1.5 | cpu.max = 150000 100000 |
--cpu-shares=512 | cpu.weight ≈ 512 |
--pids-limit=100 | pids.max |
pids.max deserves special mention: it is your fork-bomb defense. Without it, a runaway os.fork() storm degrades the whole host; with it, the offending group gets EAGAIN and nobody else notices.
Delegating sub-tree management to unprivileged users (for rootless containers or per-service quotas) works by handing over a subtree: chown -R $USER /sys/fs/cgroup/dept plus the nsdelegate mount option on the root — v2’s delegation model is a first-class feature, not the afterthought it was in v1.
Playing with it locally
The safest playground is systemd user scopes:
$ systemd-run --user --scope -p MemoryMax=200M -p CPUQuota=50% \
stress-ng --vm 2 --vm-bytes 300M
Run watch -n1 cat /sys/fs/cgroup/user.slice/.../memory.current in another terminal and watch the reclaim behavior, then the OOM event — with zero containers involved. Understanding cgroup v2 on the host is what makes container resource behavior legible.
Further reading
- Kernel documentation — cgroup v2 — the authoritative spec: hierarchy, resource distribution, threaded mode, delegation
- Linux namespaces and container isolation — the sibling mechanism cgroups pair with
- Containers vs. virtual machines — where resource control sits in the bigger picture
Docker flags are a convenience layer over files. Learn the files — cpu.max, memory.high, pids.max, the pressure files — and you can diagnose a throttled container from its cgroup stats alone, no vendor tooling required.