Docker & ContainersXV Β· Resource ControlsVerification
Auditing resource limits β proving the limit you set is the limit in force
What you'll learn
- Compare declared, configured and enforced limits for a running container
- Read the cgroup counters that prove a limit was reached
- Recognise limits that are silently absent or wider than intended
- Produce a fleet-wide audit of containers with no effective 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-11
The previous lessons in this part covered how to set CPU, memory, PID and I/O limits. This one starts from an uncomfortable observation: on most hosts, a meaningful fraction of the limits somebody wrote down are not in force, and nothing anywhere reports that.
There is no warning, no log line and no error. The container starts, runs, and is unlimited.
Four layers, and a limit can be lost between any two
flowchart LR
A[Compose file<br/>declared] --> B[Container config<br/>HostConfig]
B --> C[cgroup files<br/>enforced]
C --> D[Application<br/>observed]
- Declared β what the Compose file or run script says.
- Configured β what the daemon recorded when the container was
created, visible in
HostConfig. - Enforced β what the kernel has in the cgroup files.
- Observed β what the workload actually did, and whether it ever met the ceiling.
An audit walks all four. Checking only the first is how the gap survives; checking only the last is how you conclude a limit works because nothing has broken yet.
Layer 2: what the daemon recorded
$ docker inspect api --format 'mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}} nanocpus={{.HostConfig.NanoCpus}} shares={{.HostConfig.CpuShares}} pids={{.HostConfig.PidsLimit}}'mem=536870912 swap=-1 nanocpus=500000000 shares=0 pids=0Illustrative output
Read the zeroes. 0 means unset, not zero. So this container has
a 512 MiB memory limit and half a CPU, and no PID limit at all β
a fork bomb inside it can exhaust the hostβs process table, as the
PIDs lesson in this part describes.
Across the whole host, in one command:
$ docker ps -q | xargs -r docker inspect --format '{{.Name}} {{.HostConfig.Memory}}' | awk '$2 == 0 {print $1}'/legacy-cron
/adminer
/redisIllustrative output
That list is the actual finding on most hosts, and it is usually longer than anyone expects.
Layer 3: what the kernel is enforcing
HostConfig is the daemonβs record of intent. The cgroup files are
the enforcement. They should agree, and checking is cheap:
$ cat /proc/$(docker inspect api --format '{{.State.Pid}}')/cgroup0::/system.slice/docker-c3f81a04b7d29e6a5f0c8b3d1e7a92f4068c5b1d3a7e9f2b4c6d8e0a2f4b6c8d.scopeIllustrative output
$ cd /sys/fs/cgroup/system.slice/docker-c3f81a04b7d2*.scope && grep . memory.max memory.swap.max cpu.max pids.maxmemory.max:536870912
memory.swap.max:max
cpu.max:50000 100000
pids.max:maxIllustrative output
memory.max matches. cpu.max of 50000 100000 is half a CPU, as
configured. pids.max: max confirms the missing PID limit. And
memory.swap.max: max is the one worth stopping on.
Layer 4: did it ever actually hit the limit?
This is the part that turns an audit into a decision, and it is where cgroup v2 is genuinely good: the kernel counts.
$ cd /sys/fs/cgroup/system.slice/docker-c3f81a04b7d2*.scope && cat memory.events memory.peaklow 0
high 0
max 1284
oom 3
oom_kill 1
oom_group_kill 0
1073741824Illustrative output
Every line is a fact you cannot get any other way:
max 1284β the memory limit was reached 1284 times and the kernel reclaimed to stay under it. The container did not die, and it spent real time doing reclaim instead of work. This is the number that identifies a limit set too tight, and nothing indocker statsshows it.oom 3β three times reclaim was not enough.oom_kill 1β one process was killed. This is the counter that answers βwas my container OOM-killedβ without relying on the exit code surviving a restart.memory.peakβ the high-water mark in bytes. 1 GiB here, against a 512 MiB limit, is only possible because swap was unlimited.
The CPU equivalent:
$ cd /sys/fs/cgroup/system.slice/docker-c3f81a04b7d2*.scope && grep -E 'nr_periods|nr_throttled|throttled_usec' cpu.statnr_periods 184204
nr_throttled 21847
throttled_usec 43918204Illustrative output
nr_throttled / nr_periods is the throttled fraction: 21847/184204 is
about 12%, meaning that in one period in eight the container exhausted
its quota and was stopped until the next period began. Above a few
per cent, the --cpus value is the cause of your latency, and no
amount of application profiling will show it β from inside the
container the process simply was not scheduled.
$ cd /sys/fs/cgroup/system.slice/docker-c3f81a04b7d2*.scope && grep . pids.peak pids.maxpids.peak:534
pids.max:maxIllustrative output
pids.peak is how you choose a --pids-limit from evidence rather
than from a guess: observe the real peak over a busy week, then set
the limit at a comfortable multiple of it.
Where limits get lost
There is a fifth that no cgroup file will reveal, because the limit is in force and the application is ignoring it:
An audit you can run on a whole host
#!/usr/bin/env bash
# audit-limits.sh - declared vs enforced vs observed, per container.
set -euo pipefail
printf '%-22s %12s %10s %10s %8s %10s\n' \
CONTAINER MEM_MAX CPU_MAX PIDS_MAX OOM_KILL THROTTLED
for id in $(docker ps -q); do
name=$(docker inspect "$id" --format '{{.Name}}' | tr -d /)
pid=$(docker inspect "$id" --format '{{.State.Pid}}')
rel=$(sed 's/^0:://' "/proc/$pid/cgroup")
cg="/sys/fs/cgroup$rel"
[ -d "$cg" ] || continue
mem=$(cat "$cg/memory.max")
cpu=$(awk '{print $1}' "$cg/cpu.max")
pids=$(cat "$cg/pids.max")
oom=$(awk '/^oom_kill/ {print $2}' "$cg/memory.events")
thr=$(awk '/^nr_throttled/ {print $2}' "$cg/cpu.stat")
printf '%-22s %12s %10s %10s %8s %10s\n' \
"$name" "$mem" "$cpu" "$pids" "$oom" "$thr"
done
$ ./audit-limits.shCONTAINER MEM_MAX CPU_MAX PIDS_MAX OOM_KILL THROTTLED
api 536870912 50000 max 1 21847
postgres max max max 0 0
redis 268435456 max 4096 0 0
legacy-cron max max max 0 0Illustrative output
Three findings in four rows. postgres and legacy-cron have no
limits of any kind, so either can take the host down. api is being
throttled heavily and has been OOM-killed once, which means its
limits are set below what it needs. redis is the only correctly
configured row, and it too has no CPU cap.
- List containers with
HostConfig.Memoryof 0 β these have no memory limit. - For each container that does have one, confirm
memory.swap.maxis notmax, or the ceiling is soft. - Read
memory.eventsforoom_killabove zero, and formaxclimbing β the second is a tight limit nobody has noticed. - Read
cpu.statfor a throttled fraction above a few per cent. - Read
pids.peakand set--pids-limitfrom it rather than from a guess. - Re-run after any deploy. A recreated container is a new cgroup and the counters reset to zero.
Knowledge check
Knowledge check Β· 4 questions
Q1. `docker inspect` reports `HostConfig.PidsLimit` as 0 for a container. What does that mean?
Q2. You set `--memory 512m` and nothing else. A leaking process grows past 512 MiB. What happens?
Q3. Which observations indicate a limit that is set too tight rather than one that is absent? Select all that apply.
Q4. An `oom_kill` counter of 0 proves the container has never been OOM-killed.
Passing score: 75%. Answers are checked in this browser.
Where next
That closes the resource-controls part. The performance part takes these same counters and asks the next question: given that the limits are correct, where is the time actually going?