open atlas
↑ Back to track
Docker, containers as a system DOCK · 02 · 02

Cgroups: the resource budget the kernel actually enforces

If namespaces decide what a container sees, cgroups decide what it can consume. cgroup v2 controllers meter CPU, memory, pids, and IO. memory.max is a hard wall — cross it and the kernel OOM-kills inside the cgroup; CPU is a quota, not a wall.

DOCK Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The service had been stable for months, then started dying under no extra traffic — exit code 137, a few times an hour, always mid-request. 137 is 128 + 9: SIGKILL. The dashboards showed the JVM heap nowhere near its -Xmx, so the team chased a phantom leak for a week. The truth was in dmesg: Memory cgroup out of memory: Killed process. The container’s memory.max was 512 MiB. The JVM, told only about the host’s 64 GiB, sized its off-heap buffers, metaspace, and thread stacks for a machine it wasn’t running on — and the sum crossed 512 MiB. The kernel does not negotiate at that line: when a cgroup’s charged memory hits memory.max and reclaim can’t free enough, the in-cgroup OOM killer fires and SIGKILLs a process inside that cgroup, not on the host. The fix wasn’t more memory; it was telling the runtime the truth (-XX:MaxRAMPercentage reading the cgroup limit). The container could always see the host’s RAM. It just wasn’t allowed to use it.

Controllers: one tree, metered resources

Before you set a single limit, ask yourself: what happens when that limit is crossed? The answer is different for CPU and memory — and conflating them is the single most common cgroups mistake in production.

A control group (cgroup) is a node in a tree of processes that the kernel accounts and limits per controller. Modern systems run cgroup v2, a single unified hierarchy (one mount at /sys/fs/cgroup) replacing v1’s separate per-controller trees. The controllers a container runtime cares about:

  • cpucpu.max is "QUOTA PERIOD" in microseconds: "50000 100000" means 50 ms of CPU per 100 ms window = 0.5 cores. cpu.weight (default 100) sets proportional share under contention.
  • memorymemory.max is the hard limit in bytes; memory.high is a soft throttle that triggers reclaim before the wall; memory.low protects a minimum from reclaim.
  • pidspids.max caps the number of processes/threads, the direct guard against a fork bomb or a leaking entrypoint (see the previous lesson).
  • ioio.max rate-limits block IO (BPS and IOPS) per device.

Together, these four controllers cover every resource a noisy neighbor can weaponize: CPU time, memory, process slots, and disk throughput. Miss any one of them and you’ve left that dimension unmetered — a container with no pids.max, for example, can fork-bomb the host as effectively as one with no memory.max.

runc writes the container’s limits from the OCI config.json linux.resources block into these files at startup, then moves the container’s PID 1 into the cgroup so every descendant is accounted together. docker run -m 512m --cpus=0.5 --pids-limit=200 becomes memory.max=536870912, cpu.max="50000 100000", pids.max=200. Reading memory.current or cpu.stat from inside or outside the cgroup is how monitoring sees real consumption — not what free reports, which (pre-cgroup-aware tooling) still shows the host.

/sys/fs/cgroup/system.slice/docker-<id>.scope/
├── cpu.max          50000 100000          # 0.5 core
├── cpu.stat         usage_usec, nr_throttled, throttled_usec
├── memory.max       536870912             # 512 MiB hard wall
├── memory.high      483183820             # ~90%, soft throttle
├── memory.current   421330944             # live charge
├── memory.events    oom 0  oom_kill 0     # increments on each OOM-kill
└── pids.max         200

CPU is a quota; memory is a wall

The two limits fail in opposite ways, and conflating them is the classic mistake. CPU is throttled, never killed: if a container exceeds its cpu.max quota within a period, the kernel simply stops scheduling it until the next period — latency spikes, nr_throttled climbs, but the process lives. Memory is a hard wall: cross memory.max and there is no “throttle harder” — the kernel runs reclaim, and if it can’t free enough, the cgroup-scoped OOM killer SIGKILLs a process within the cgroup. That is the 137 from the Hook. memory.high exists precisely to soften this: set below memory.max, it throttles allocations and forces reclaim early, turning a hard kill into back-pressure — but it is not a guarantee, only memory.max is the wall.

Quiz

Container A is pinned at its cpu.max quota and getting slow; container B just got SIGKILLed with exit 137. Same node. What distinguishes the two failure modes?

Why this works

Why does the JVM (or Go, or Node) misbehave here at all? Historically, language runtimes read /proc/cpuinfo and sysconfig for “how many cores / how much RAM” — which report the host, because that file is not namespaced the way the cgroup limit is. A runtime then sizes thread pools, GC heaps, and buffer caches for a 64-core / 64-GiB box while living in a 0.5-core / 512-MiB cgroup. Modern runtimes are cgroup-aware (Java’s UseContainerSupport, on by default since JDK 10; Go’s GOMAXPROCS auto-tuning in 1.25), but the failure mode persists wherever an old runtime, an unaware library, or a hand-set flag overrides the limit. The cgroup enforces the budget regardless; the runtime just has to be told the budget exists.

Why this is isolation, not virtualization

When you think of “container isolation,” you might picture something like a VM — a private pool of resources that can’t be touched from outside. That mental model will mislead you here.

A cgroup does not give a container its own CPU or own RAM — it gives it a metered slice of the host’s. There is no second pool of memory behind a hypervisor; memory.current charges the same physical pages the host kernel manages, and an unlimited container (memory.max=max) competes for all of host RAM. This is the whole point of containers being cheap: no duplicated kernel, no pre-reserved guest RAM, near-zero overhead — and the whole reason they are not VMs. The kernel is shared, so the budget, not a boundary, is what stands between a noisy neighbor and the rest of the node. Set the budgets, or a single container with no memory.max can drive the host into global OOM and take down every other container on the box.

Quiz

A container runs with no memory limit (memory.max=max) and develops a slow leak. What happens to the OTHER containers on the same node, and why?

Recall before you leave
  1. 01
    Contrast how the cpu and memory controllers respond when a container exceeds its limit, and give the concrete kernel mechanism for each.
  2. 02
    Explain why a cgroup is isolation rather than virtualization, and what goes wrong if a container runs with no memory.max.
Recap

If namespaces decide what a container sees, cgroups decide what it is allowed to consume — and the kernel enforces it. A control group is a node in a process tree accounted and limited per controller; modern systems use cgroup v2, a single unified hierarchy under /sys/fs/cgroup. The controllers that matter for containers are cpu (cpu.max as a quota/period pair, cpu.weight for proportional share), memory (memory.max as the hard byte wall, memory.high as a soft throttle that reclaims early, memory.low to protect a floor), pids (pids.max against fork bombs and zombie leaks), and io (io.max for block IO). runc writes these from the OCI config’s linux.resources and places the container’s PID 1 into the cgroup so all descendants are charged together. The two headline limits fail oppositely: exceed cpu.max and you are throttled — paused until the next period, slow but alive, with nr_throttled climbing; exceed memory.max and the cgroup-scoped OOM killer SIGKILLs a process inside the cgroup, surfacing as exit 137. The classic outage is a runtime that reads the host’s cores and RAM instead of its cgroup limit, sizes itself for a machine it isn’t on, and crosses memory.max — fixed not with more memory but by making the runtime cgroup-aware. And because a cgroup meters a slice of shared host resources rather than carving out a private pool, an uncapped container is a node-wide hazard: a leak with no memory.max can drive the host into global OOM and kill unrelated containers. Now when you see exit 137, your first move is dmesg | grep -i oom and cat memory.events — not heap profiling. When you see latency spikes with no OOM, check nr_throttled in cpu.stat.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.