Skip to main content
RunBook Academy

Docker & ContainersII · Linux Internalscgroups

cgroups v2 — the resource side of container isolation

Intermediate⏱ ~28 min

What you'll learn

  • Inspect cgroup hierarchies from the host
  • Explain how Docker memory and CPU limits translate into cgroup constraints
  • Identify the controller(s) responsible for a given class of limit

Prerequisites

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-09

Not yet marked complete on this device.

Namespaces decide what a container can see. cgroups decide what a container can use. Together they are the two halves of container isolation.

This lesson teaches the cgroups v2 hierarchy that every modern Linux distribution ships by default, and shows exactly how Docker translates --memory, --cpus, and other flags into cgroup constraints.

What cgroups v2 actually is

flowchart TB
  Root["/sys/fs/cgroup (root)"] --> Slice["system.slice / user.slice / docker.slice"]
  Slice --> Service["system-docker.service"]
  Slice --> Container["system-docker-<id>.scope"]
  Container --> Cpu["cpu.max"]
  Container --> Mem["memory.max"]
  Container --> Pids["pids.max"]
  Container --> Io["io.max"]

The cgroup v2 hierarchy is a single tree, mounted at /sys/fs/cgroup by default. Every process is in exactly one leaf cgroup. Controllers (cpu, memory, pids, io, etc.) enforce the limits set on the cgroup tree at each node.

A few things that catch operators out:

  • The tree is one tree. There is no cpu,cpuacct or cpu,cpuacct,cpuset split; the cgroup v1 mess is gone.
  • The cgroup that owns a process owns all its children by default. Move a process to a child cgroup and its descendants follow.
  • The container’s cgroup is created by containerd (or dockerd via containerd) at startup. It is removed when the container is removed (assuming no orphaned cgroups — more on that later).

Inspecting cgroups from the host

Read-only / Safefind a container's cgroup
PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
cat /proc/$PID/cgroup
Read-only / Safecgroup contents
CGROUP=$(awk -F: '!/^#/{print $3}' /proc/self/cgroup | head -1)
ls /sys/fs/cgroup/$CGROUP/ 2>/dev/null | head -20
Read-only / Safememory limits
PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
CGROUP=$(cat /proc/$PID/cgroup | awk -F: '!/^0:/{print $3}' | head -1)
cat /sys/fs/cgroup/$CGROUP/memory.max
echo 'current:'
cat /sys/fs/cgroup/$CGROUP/memory.current
Read-only / Safecpu limits
PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
CGROUP=$(cat /proc/$PID/cgroup | awk -F: '!/^0:/{print $3}' | head -1)
cat /sys/fs/cgroup/$CGROUP/cpu.max
Read-only / SafePID limits
PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
CGROUP=$(cat /proc/$PID/cgroup | awk -F: '!/^0:/{print $3}' | head -1)
cat /sys/fs/cgroup/$CGROUP/pids.max
echo 'current:'
cat /sys/fs/cgroup/$CGROUP/pids.current

How Docker flags translate into cgroup limits

From `docker run` flags to cgroup v2 files
$ --memory 512m--memory-reservation 256m--cpus 1.5--cpu-shares 512--pids-limit 200--blkio-weight 500--device-read-bps /dev/sda:1mb--device-read-iops /dev/sda:1000--restart unless-stopped 
  1. 01--memory 512mHard memory limit. The container may not exceed this. Translated into `memory.max = 536870912`.
  2. 02--memory-reservation 256mSoft memory target. The kernel tries to reclaim memory from the container when host pressure rises. Translated into `memory.low = 268435456`.
  3. 03--cpus 1.5Equivalent to `--cpu-quota=150000 --cpu-period=100000`. Translated into `cpu.max = 150000 100000`.
  4. 04--cpu-shares 512Relative weight (default 1024). Only meaningful when CPU is contended. runc converts it with 1 + ((shares - 2) * 9999) / 262142, so 512 becomes `cpu.weight = 20` and the 1024 default becomes 39 (cgroup v2 uses weight 1–10000).
  5. 05--pids-limit 200Caps process/thread count. Translated into `pids.max = 200`. A value of -1 or 0 disables the limit.
  6. 06--blkio-weight 500Block I/O relative weight (default 500). runc converts it with 1 + (weight - 10) * 9999 / 990, so 500 becomes `io.weight = 4950` — not 500. The v1 and v2 numbers are on different scales.
  7. 07--device-read-bps /dev/sda:1mbPer-device read bandwidth cap. Translated into `io.max` for the cgroup.
  8. 08--device-read-iops /dev/sda:1000Per-device read IOPS cap. Same target file.
  9. 09--restart unless-stoppedNot a cgroup limit. This is a restart policy handled by dockerd, not by the kernel.
Show reconstructed command
--memory 512m --memory-reservation 256m --cpus 1.5 --cpu-shares 512 --pids-limit 200 --blkio-weight 500 --device-read-bps /dev/sda:1mb --device-read-iops /dev/sda:1000 --restart unless-stopped

The controllers and what they limit

Controllercgroup fileDocker flagNotes
cpucpu.max--cpusQuota per period (default 100 ms).
cpucpu.weight--cpu-sharesRelative weight when contended.
cpucpu.idle(none)Whether the cgroup may use idle CPU.
cpusetcpuset.cpus--cpuset-cpusPin to specific CPUs.
cpusetcpuset.mems--cpuset-memsPin to specific NUMA nodes.
memorymemory.max--memoryHard cap.
memorymemory.low--memory-reservationSoft target.
memorymemory.swap.max--memory-swapTotal memory + swap.
memorymemory.high(none)Throttle before OOM.
pidspids.max--pids-limitProcess/thread count.
ioio.max--device-*-bps/--device-*-iopsPer-device bandwidth and IOPS.
ioio.weight--blkio-weightRelative weight.

The OOM killer in depth

When a container exceeds memory.max, the OOM-killer picks a process to kill. The choice is influenced by:

  • oom_score_adj — each process has a score adjustment in /proc/<pid>/oom_score_adj. Higher = killed first.
  • The cgroup’s memory.oom.group — when set, the OOM-killer kills the entire cgroup at once instead of picking individual processes. This is what you want for batch jobs but not for services.

Docker sets neither of these by default, and the consequence is the one that surprises people at 03:00. On a default Engine 28/29 host, memory.oom.group reads 0 for every container scope and the container’s PID 1 has oom_score_adj of 0 — runc’s cgroup v2 memory path writes only memory.max, memory.low and memory.swap.max. Check it on your own host rather than taking it on trust:

CID=$(docker ps -q | head -1)
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
cat "/sys/fs/cgroup$CG/memory.oom.group"   # 0
cat "/proc/$PID/oom_score_adj"             # 0

Because memory.oom.group is 0, the kernel kills one process — the largest resident one in the cgroup, which is frequently a worker rather than PID 1. If PID 1 survives, the container stays running with a worker missing. docker ps shows it up, the health check may still pass, and throughput has quietly dropped. That partial OOM is far more common than the clean exit-137 case, and it is the reason memory.events is worth alerting on rather than container restarts.

--oom-score-adj is exposed on docker run if you want to bias the choice; there is no Docker flag that sets memory.oom.group.

Read-only / Safedid this container lose a process without anyone noticing?
CONTAINER=worker
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
docker inspect --format 'started={{.State.StartedAt}} restarts={{.RestartCount}} oomkilled={{.State.OOMKilled}}' "$CONTAINER"
cat "/sys/fs/cgroup$CG/memory.events"
echo "peak: $(cat "/sys/fs/cgroup$CG/memory.peak")  max: $(cat "/sys/fs/cgroup$CG/memory.max")"
started=2026-08-08T02:11:47.9Z restarts=0 oomkilled=false
low 0
high 0
max 1184
oom 6
oom_kill 6
oom_group_kill 0
sock_throttled 0
peak: 536870912  max: 536870912

Illustrative output

Every line in that output is doing work:

  • oom_kill 6 with restarts=0 — six processes were killed and the container never restarted. This is the partial OOM.
  • oom_group_kill 0 — confirms memory.oom.group is off; had it been on, the whole cgroup would have gone at once and the container would have exited 137.
  • max 1184 — the number of times allocation hit memory.max. This one climbs long before anything is killed, which makes it the early warning oom_kill is not.
  • peak equal to max — the cgroup has been pinned at its ceiling, not merely brushing it.

A healthy container reads oom 0, oom_kill 0, and a peak comfortably below max. Note that all of these counters live in the cgroup, so a container restart creates a new cgroup and resets them to zero — which is another reason to scrape them into a time series rather than read them after the fact.

Orphan cgroups

A container is deleted but its cgroup is not. This happens when containerd crashes mid-deletion, or when an operator deletes a container with docker rm -f while containerd is down.

You will notice this when docker ps -a shows nothing but /sys/fs/cgroup/system.slice/docker-<id>.scope/ still exists. The processes are gone (the kernel reaped them) but the cgroup directory is still there with cgroup.procs containing stale PIDs.

The fix is usually to clean them up with systemd-cgtop and manual deletion, or to bounce containerd. In production, monitor for orphan cgroups — a slow leak here eats host memory.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A container has `--cpus 1.5`. Which cgroup file contains this constraint?

  2. Q2. Setting `--memory 512m` on a container means Docker reserves 512 MB of host RAM for it.

  3. Q3. Which kernel subsystem performs the kill when a container exceeds its memory.max?

  4. Q4. A container shows RestartCount 0 and State.OOMKilled false, but /sys/fs/cgroup/.../memory.events reports oom_kill 6. What has happened?

  5. Q5. Which signals can detect a partial OOM inside a still-running container? Select all that apply.

Passing score: 75%. Answers are checked in this browser.

Where next

OverlayFS — how Docker builds container filesystems from image layers. After that, capabilities, then the rest of the internals chapter.