Docker & ContainersXV Β· Resource ControlsMemory
Memory limits and OOM behaviour
What you'll learn
- Configure memory limits correctly
- Predict and recognise OOM kills
- Find the evidence for an OOM when the application log is empty
- Distinguish page cache from a memory leak in memory.current
Prerequisites
None β start here.
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
--memory 512m writes 536870912 to the containerβs cgroup file
memory.max. Everything else in this lesson follows from that one
sentence: the limit is enforced by the kernel, not by Docker, and
when it is exceeded the kernel takes an action that Docker only
learns about afterwards.
The reason this deserves thirty minutes rather than three is that
the evidence of an OOM kill is almost never where people look for
it. The application log is empty. docker logs shows a clean
shutdown or nothing at all. The exit code is a number nobody
recognises. And the process the kernel killed is frequently not the
process that caused the problem.
Setting the limits
docker run -d --name api --memory 512m myorg/api:1.0.0docker run -d --name api --memory 512m --memory-reservation 256m myorg/api:1.0.0docker run -d --name api --memory 512m --memory-swap 1g myorg/api:1.0.0The --memory-swap semantics catch people out often enough to be
worth a table:
--memory | --memory-swap | Effective swap allowance |
|---|---|---|
512m | unset | 512m β the container may swap as much as its memory limit, giving 1 GB total |
512m | 1g | 512m |
512m | 512m | none β swap is disabled for this container |
512m | -1 | unlimited, up to what the host has |
Setting --memory-swap equal to --memory is the useful trick: it
turns swap off for that container, so a memory leak fails fast and
loudly instead of degrading into a host that is technically alive
and answering nothing.
The exit that explains nothing
Here is the shape of the 03:00 page.
$ docker ps -a --filter name=api --format 'table {{.Names}}\t{{.Status}}'NAMES STATUS
api Exited (137) 4 minutes agoIllustrative output
137 is 128 + 9. The process was killed by signal 9, SIGKILL.
SIGKILL cannot be caught, blocked, or handled β so the application
had no opportunity to write a shutdown message, flush a log buffer,
or run an exit hook. That is why docker logs api ends mid-sentence
on an ordinary request. The absence of a log line is the
evidence, not the absence of evidence.
137 does not by itself mean OOM. Anything that sends SIGKILL
produces it: docker kill, a docker stop whose grace period
expired, a health-check-driven orchestrator, an operator. So the
next command is not optional.
$ docker inspect api --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}} err={{printf "%q" .State.Error}}'oom=true exit=137 err=""Illustrative output
Note err="". .State.Error is empty on a normal OOM kill β
there is no message, because nothing generated one. Any runbook
that tells you to look for a string in .State.Error is wrong, and
following it wastes the first ten minutes of the incident.
Where the evidence actually is
Three sources, in the order you should read them.
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
cat /sys/fs/cgroup"$CG"/memory.eventslow 0
high 0
max 4128
oom 3
oom_kill 3
oom_group_kill 0
Read it precisely β each field means something different:
| Field | Meaning |
|---|---|
low | times the cgroup was reclaimed despite being under memory.low |
high | times processes were throttled into direct reclaim by memory.high |
max | times usage was about to exceed memory.max, so the kernel reclaimed instead |
oom | times reclaim failed and an allocation was about to fail |
oom_kill | processes killed by any OOM killer in this cgroup |
oom_group_kill | times the whole cgroup was killed together |
max 4128 with oom_kill 0 is the healthy-but-tight case: the
container repeatedly bumped its ceiling and the kernel reclaimed
page cache each time. Nothing died, but it is doing reclaim work on
every request. oom_kill 3 is three dead processes.
This cgroup path disappears when the container is removed. On a container that has already exited and been cleaned up, skip to the kernel log.
sudo dmesg -T | grep -iE 'oom-kill|Killed process|Memory cgroup out of memory' | tail -20
sudo journalctl -k --since '1 hour ago' | grep -i 'out of memory'[Sun Aug 9 03:14:02 2026] python invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Sun Aug 9 03:14:02 2026] memory: usage 524288kB, limit 524288kB, failcnt 0
[Sun Aug 9 03:14:02 2026] Memory cgroup stats for /system.slice/docker-9f2c1a....scope:
[Sun Aug 9 03:14:02 2026] Tasks state (memory values in pages):
[Sun Aug 9 03:14:02 2026] [ pid ] uid tgid total_vm rss ... name
[Sun Aug 9 03:14:02 2026] [ 31840] 0 31840 12043 812 ... tini
[Sun Aug 9 03:14:02 2026] [ 31871] 1001 31871 198442 94210 ... gunicorn
[Sun Aug 9 03:14:02 2026] [ 31904] 1001 31904 41220 8102 ... celery-beat
[Sun Aug 9 03:14:02 2026] Memory cgroup out of memory: Killed process 31871 (gunicorn) total-vm:793768kB, anon-rss:376840kB
That block is the single most useful artefact in a memory incident, and almost nobody reads it. It names the cgroup, the limit, every task with its RSS, and which one was chosen. Paste it into the ticket verbatim.
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
cat /sys/fs/cgroup"$CG"/memory.current
grep -E '^(anon|file|slab|sock|shmem) ' /sys/fs/cgroup"$CG"/memory.statThe two wrong diagnoses
memory.high: throttle instead of kill
memory.max is a wall. memory.high is a brake: when usage crosses
it, the kernel puts allocating processes into direct reclaim and
throttles them, but never kills. A container that overshoots
memory.high gets slow; a container that overshoots memory.max
gets SIGKILL.
Docker has no flag for memory.high. runc writes memory.max
from --memory, memory.low from --memory-reservation, and
memory.swap.max from --memory-swap, and nothing writes
memory.high at all.
You can set it by hand for a diagnosis:
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
echo 400M | sudo tee /sys/fs/cgroup"$CG"/memory.high
# watch the throttle counter climb instead of an oom_kill
watch -n2 grep high /sys/fs/cgroup"$CG"/memory.eventsThe restart caveat is the whole reason this is a diagnostic tool and
not a production configuration. If you need it permanently, the
supported route is a systemd drop-in on the slice, or lowering
--memory and accepting the kills.
Knowledge check
Knowledge check Β· 6 questions
Q1. A container exits with code 137 and `docker logs` ends mid-request with no error. What is the most likely explanation for the missing log line?
Q2. A sidecar with a runaway buffer pushes a cgroup to memory.max, and the kernel kills the main application server instead. Why?
Q3. A container sits at 99% of its memory limit. Which observations would indicate a genuine leak rather than page cache? Select all that apply.
Q4. `docker inspect --format "{{.State.Error}}"` returns a message explaining the OOM kill.
Q5. You run `--memory 512m --memory-swap 512m`. What does that configure?
Q6. Which cgroup v2 file lets you throttle a container before it is killed, and why can Docker not configure it?
Passing score: 75%. Answers are checked in this browser.