cgroups v2 and resource containment

Memory, CPU and PID limits, OOM kills and where to read them.

Advanced14 min · lesson 2 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): cgroups.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/cgroups.tar.gz && tar -xzf cgroups.tar.gz, which creates ~/lab/cgroups/. SHA-256: e991e1a760f219718fe75a9e8872668043db8392e752a97419bbfc225d8671d7

A batch worker has --memory 64m. After a run, docker inspect reports OOMKilled=true and ExitCode=0. The engineer on call files it as a Docker bug, since a container that was killed for memory can't have exited cleanly. Both values are correct. To read them correctly, you need the mechanism underneath: control groups, or cgroups, the kernel feature that counts and limits what a group of processes uses. Namespaces, from the previous lesson, decide what a container can see. cgroups decide how much memory, CPU time and how many processes it can take.

You'll run everything on the main lab VM, and it stays inside lab-* containers. One small image, lab-stress:1, provides a load generator: its Dockerfile is in the lesson files (copy it to ~/lab/cgroups).

Dockerfile
# lab-stress:1, a small load generator for the cgroups lesson (stress-ng from Alpine's community repo)
FROM alpine:3.24
RUN apk add --no-cache stress-ng
ENTRYPOINT ["stress-ng"]

stress-ng is maintained and packaged in Alpine. The progrium/stress image that older tutorials use is stored in the Docker schema 1 manifest format. Releases of containerd from 2.1 on refuse to pull that format, so the image no longer works on Docker 29.

One tree, one scope per container

Build the image in ~/lab/cgroups:

ubuntu@secopslog-docker:~/lab/cgroups · Docker 29.9.2
$ docker build -q -t lab-stress:1 .
sha256:e307630d17a36e9c4f0a592464b9de49f4832392ab2d13d4968ce1a13d0acd45

Then ask Docker and the kernel which cgroup setup this host has:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker info --format 'cgroup v{{.CgroupVersion}}, driver {{.CgroupDriver}}' stat -fc %T /sys/fs/cgroup free -h | grep -E '^(Mem|Swap)'
cgroup v2, driver systemd cgroup2fs Mem: 5.8Gi 692Mi 387Mi 1.2Mi 4.9Gi 5.1Gi Swap: 0B 0B 0B

This track uses cgroup v2 only: one unified hierarchy mounted at /sys/fs/cgroup (filesystem type cgroup2fs). Current Ubuntu, Debian, Fedora and RHEL releases use it by default. driver systemd means Docker asks systemd to create each container's cgroup as a transient unit called docker-<container id>.scope under system.slice. So systemd's tools see containers like any other unit. The VM has no swap (Swap: 0B), which matters for the swap section below.

Limits are files the kernel reads

Start a container with a memory limit, a CPU limit and a process limit, then read its cgroup directory on the host:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run -d --name lab-web --memory 256m --cpus 1.5 --pids-limit 200 nginx:1.31-alpine
bb057c3af172d8926d17d837860467cfef0ea8574e94b6be2bd2c5dfff6d6aa6
$ CG=/sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' lab-web).scope cd "$CG" && grep -H . memory.max memory.swap.max memory.current cpu.max pids.max pids.current
memory.max:268435456 memory.swap.max:268435456 memory.current:5558272 cpu.max:150000 100000 pids.max:200 pids.current:5
$ docker inspect -f 'Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}' lab-web
Memory=268435456 MemorySwap=536870912 NanoCpus=1500000000 PidsLimit=200

memory.current is what the group uses now (about 5 MiB for an idle nginx; yours will differ) and pids.current counts its tasks, the master and four workers. The swap line needs care. cgroup v2 has separate files for memory and swap. Docker's API still takes one combined number, MemorySwap, which defaults to twice --memory when you don't set it (536870912 above). The runtime writes the difference into memory.swap.max. So a plain --memory 256m allows up to another 256 MiB of swap on a host that has swap. The same values also appear on the systemd unit, because systemd created the scope:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ ID=$(docker inspect -f '{{.Id}}' lab-web) cat /proc/$(docker inspect -f '{{.State.Pid}}' lab-web)/cgroup systemctl show docker-$ID.scope -p Slice -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax
0::/system.slice/docker-bb057c3af172d8926d17d837860467cfef0ea8574e94b6be2bd2c5dfff6d6aa6.scope Slice=system.slice CPUQuotaPerSecUSec=1.500000s MemoryMax=268435456 TasksMax=200
$ systemd-cgls --no-pager -u docker-$(docker inspect -f '{{.Id}}' lab-web).scope
Unit docker-bb057c3af172d8926d17d837860467cfef0ea8574e94b6be2bd2c5dfff6d6aa6.scope (/system.slice/docker-bb057c3af172d8926d17d837860467cfef0ea8574e94b6be2bd2c5dfff6d6aa6.scope): ├─152197 nginx: master process nginx -g daemon off; ├─152262 nginx: worker process ├─152263 nginx: worker process ├─152265 nginx: worker process └─152266 nginx: worker process

/proc/<pid>/cgroup holds a single 0:: line on cgroup v2: the process's path in the unified tree. systemd-cgls -u prints the processes in one unit. The full tree (systemd-cgls without arguments) also shows containerd and the shims inside containerd.service and dockerd inside docker.service, separate from the container scopes.

What the container sees, and what it does not

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker exec lab-web sh -c 'cat /sys/fs/cgroup/memory.max; grep MemTotal /proc/meminfo; nproc'
268435456 MemTotal: 6039664 kB 4

From inside the container, you read the container's own limit from /sys/fs/cgroup/memory.max, because its cgroup namespace shows its own cgroup as the root. /proc/meminfo and nproc aren't cgroup-aware: they report the VM's 6 GB and 4 CPUs. A program that sizes a heap, a cache or a thread pool from those numbers plans for resources it'll never get. It's then killed or throttled when it grows into them. Current Java (container support is on by default since JDK 10) and Go (GOMAXPROCS follows the CPU limit since Go 1.25) read the cgroup files. Check what your runtime and your own scripts read.

Setting --memory-swap equal to --memory gives the container no swap at all:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --rm --memory 256m --memory-swap 256m alpine:3.24 cat /sys/fs/cgroup/memory.swap.max
0

On a host with swap, a container that reaches memory.max without that setting starts swapping instead of being killed. memory.current never goes above memory.max; the swapped pages appear in memory.swap.current. So docker stats keeps showing usage at or under the limit while the container slows down. The lab VM has no swap, so the swap allowance here is a number in a file with no effect, and you won't see swapping in this lab.

A container with no limits has no memory or CPU cap, but it does have a process cap:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --rm alpine:3.24 sh -c 'cd /sys/fs/cgroup && grep -H . memory.max cpu.max pids.max' systemctl show -p DefaultTasksMax
memory.max:max cpu.max:max 100000 pids.max:6326 DefaultTasksMax=6326

max means no limit and cpu.max reads max 100000 (no quota, 100 ms period). pids.max is 6326 because the systemd driver gives every scope systemd's DefaultTasksMax, 15 percent of the kernel's task limit on this VM. A runaway process tree in an "unlimited" container therefore stops at a few thousand tasks instead of exhausting the host. But a few thousand per container is still enough to hurt a host running many of them, so set --pids-limit explicitly. Choosing values for real services is part of "Production best practices" in Docker in depth.

Out of memory

Give a container 64 MiB and make it hold 200 MiB. head writes 200 MiB of zeros into a pipe, and tail must keep all of it in memory because the input has no line break:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --name lab-oom --memory 64m alpine:3.24 sh -c 'head -c 200m /dev/zero | tail'
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' lab-oom
OOMKilled=true ExitCode=137
$ sleep 1 docker events --since 5m --until "$(date +%s)" --filter container=$(docker inspect -f '{{.Id}}' lab-oom) --filter event=oom --filter event=die --format '{{.Action}} exitCode={{.Actor.Attributes.exitCode}}' docker rm lab-oom
oom exitCode=<no value> die exitCode=137 lab-oom

Once the kernel can't reclaim enough memory to keep the group under memory.max, the OOM killer picks a process inside that group. It sends it SIGKILL, which can't be caught. The likely victim is tail, the process holding the memory. The container exited 137 (128 plus signal 9) and printed nothing. An earlier run of the same lab also showed busybox sh printing Killed. The daemon saw the kernel's OOM notification and set State.OOMKilled. docker events replays the oom event followed by die with exit code 137. In production you'd watch the same stream live (docker events --filter event=oom) or alert on the counter below.

The cgroup itself keeps the count in memory.events, which only exists while the container does. This time stress-ng asks for 200 MiB inside a 64 MiB limit and is left running:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run -d --name lab-hog --memory 64m --memory-swap 64m lab-stress:1 --vm 1 --vm-bytes 200M --timeout 15s
c5ccf7f795f71293daee7b7ce3bfdc561dd7f714f1e9800c3caf786ccfef55cd
$ sleep 5 cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' lab-hog).scope/memory.events
low 0 high 0 max 10211 oom 251 oom_kill 249 oom_group_kill 0 sock_throttled 0
$ docker wait lab-hog docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' lab-hog docker logs lab-hog 2>&1 | tail -3
0 OOMKilled=true ExitCode=0 stress-ng: info: [1] failed: 0 stress-ng: info: [1] metrics untrustworthy: 0 stress-ng: info: [1] successful run completed in 15.01 secs

In memory.events, max counts how often usage hit the limit, oom how often the OOM killer was invoked, and oom_kill how many processes it killed; your counts will differ. The kills landed on stress-ng's worker processes. stress-ng started a new worker after each one and finished its 15-second run normally. The container exited 0, and OOMKilled is still true, because it means "an OOM kill happened in this container's cgroup", not "the main process died of it". That's the worker from the opening. Read OOMKilled together with the exit code. For a long-running container, alert on oom_kill increasing: a web server that loses a worker process to the OOM killer can keep running and never set a non-zero exit code.

A container reaches memory.max
Usage reaches memory.max
the kernel first tries to reclaim memory in the group
enough reclaimed
Allocation succeeds
memory.events max increases; the process sees nothing
victim is PID 1
Container exits
exit code 137, OOMKilled=true, oom and die events
victim is another process
Container keeps running
OOMKilled=true, oom_kill increases, exit code decided later by PID 1
The OOM killer only picks inside the group that hit its limit. A host-wide out-of-memory condition is a different event and can kill processes in any group.

CPU: throttled, not killed

A CPU limit never kills anything. When the group's used its quota for the current period, its threads wait until the next one. Two busy stress-ng workers under --cpus 0.5:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run -d --name lab-cpu --cpus 0.5 lab-stress:1 --cpu 2 --timeout 60s
ea0c0a1c4e3039d9fb8ffa46fe0d7895e4be827d55fb7e4ba3b4817bfc6e7c22
$ sleep 5 CG=/sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' lab-cpu).scope cat "$CG/cpu.max" grep -E '^(usage_usec|nr_periods|nr_throttled|throttled_usec) ' "$CG/cpu.stat"
50000 100000 usage_usec 2611181 nr_periods 51 nr_throttled 51 throttled_usec 7572340

cpu.max is 50000 100000: 50 ms per 100 ms period. In all 51 of the first 51 periods the group used its quota and was throttled. throttled_usec adds up the waiting: about 8 seconds across both workers in 5 seconds of wall time. If you see a latency problem with clean logs and a climbing nr_throttled, that's a CPU limit doing its job. systemd-cgtop gives the per-cgroup view the way top gives the per-process one; in batch mode, the columns are path, tasks, CPU percent, memory, and input and output per second:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ systemd-cgtop -b -n 2 -d 2 system.slice/docker-$(docker inspect -f '{{.Id}}' lab-cpu).scope
system.slice/docker-ea0c0a1c4e3039d9fb8ffa46fe0d7895e4be827d55fb7e4ba3b4817bfc6e7c22.scope 3 - 4.7M - - system.slice/docker-ea0c0a1c4e3039d9fb8ffa46fe0d7895e4be827d55fb7e4ba3b4817bfc6e7c22.scope 3 49.4 4.7M - -
$ docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} mem={{.MemUsage}} pids={{.PIDs}}' lab-cpu lab-web
lab-cpu cpu=55.10% mem=4.75MiB / 5.76GiB pids=3 lab-web cpu=0.00% mem=4.504MiB / 256MiB pids=5

The first sample has no CPU figure, because cgtop needs two readings to compute a rate. Both tools take a short sample, and here they read 49 and 55 percent for lab-cpu, either side of its 50 percent cap. A sample that lines up badly with the 100 ms periods reads high or low. For a precise figure, use cpu.stat: usage_usec above is 2.6 seconds of CPU in about 5 seconds, close to the cap. docker stats also prints each container's memory limit: 256 MiB for lab-web, and the VM's whole memory for lab-cpu, which has none.

A fork bomb against pids.max

b(){ b|b& };b is a fork bomb written for busybox sh: each call starts two more in the background, forever. With --pids-limit 100 it fills its own group and nothing else:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run -d --name lab-bomb --pids-limit 100 --cpus 0.5 alpine:3.24 sh -c 'b(){ b|b& };b; sleep 600'
b412c8124f4a7ceb11af709c7a200e70f5a6e8f8a0c9b81d15755bae04781213
$ sleep 5 CG=/sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' lab-bomb).scope cat "$CG/pids.max" "$CG/pids.current" "$CG/pids.events"
100 100 max 54

pids.current sits at the limit and the max line in pids.events counts the forks the kernel refused (54 in five seconds here). Other containers and the host keep their process slots. The --cpus 0.5 on the bomb matters too: the failing fork loop still burns CPU, and without a CPU limit it would take every core it could get. docker rm -f stops it in one step: it kills PID 1, and when the first process of a PID namespace dies the kernel kills every other process in it:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker rm -f lab-web lab-hog lab-cpu lab-bomb docker rmi lab-stress:1
lab-web lab-hog lab-cpu lab-bomb Untagged: lab-stress:1 Deleted: sha256:e307630d17a36e9c4f0a592464b9de49f4832392ab2d13d4968ce1a13d0acd45

Limits like these contain a noisy or broken workload. They aren't a security boundary. A container that stays under every limit can still attack the kernel through system calls and capabilities, which "Capabilities, cap-drop and no-new-privileges" takes up next.

Try this

On the main lab VM, rerun the lab-cpu stress step with --cpus 1.5 instead of --cpus 0.5, keeping the two workers. Predict the cpu.max line and whether nr_throttled still climbs, then compare cpu.stat with the 51-of-51 reading above. After that, rerun the head -c 200m /dev/zero | tail pipeline with --memory 256m in place of 64m, and check OOMKilled and ExitCode with the same docker inspect format. Remove both containers when you're done.

Bottom line

Give every container explicit --memory, --cpus and --pids-limit values, and read OOMKilled together with the exit code. The stress-ng run showed OOMKilled=true with exit code 0, because the kills hit workers, not PID 1, and an alert on non-zero exits alone misses that. A container started with no limits isn't unbounded on pids either: it inherits the systemd default of 6326 tasks, a number nobody on your team chose.

Quick check
01A service started with --cpus 0.5 has slow responses, clean logs and no restarts. Which reading on the host confirms that the CPU limit is the cause?
Incorrect — CPU limits never kill. oom_kill counts memory OOM kills only.
Correct — Each throttled period means the group used its quota and had to wait.
Incorrect — OOMKilled is set only by memory OOM events.
Incorrect — nproc is not cgroup-aware; the lab container under a CPU limit still reports all 4 CPUs.
02A worker container with --memory 64m has stopped. docker inspect shows OOMKilled=true and ExitCode=0. Which explanation fits?
Incorrect — Reaching the limit increments the max counter in memory.events. OOMKilled needs an actual OOM event.
Incorrect — A killed main process would not exit 0, and the limit here is the container's own.
Incorrect — The flag records a real OOM kill in the container's cgroup, just not of PID 1.
Correct — As with stress-ng in the lab, the kill hit a worker and PID 1 finished with status 0.
03You start a container with --memory 256m and no other memory options. What does its memory.swap.max file contain?
Correct — MemorySwap defaults to twice --memory, and the runtime writes the difference, 256 MiB, as the swap cap.
Incorrect — Docker sets a finite default; max would need --memory-swap -1.
Incorrect — 0 is what you get with --memory-swap equal to --memory.
Incorrect — Combined memory plus swap accounting was cgroup v1. In v2 the files are separate; 536870912 is the API's MemorySwap value, not the file.

Related