Skip to main content
RunBook Academy

Docker & ContainersXV Β· Resource ControlsMemory

Memory limits and OOM behaviour

Intermediate⏱ ~30 min

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

Not yet marked complete on this device.

--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

Configuration changehard limit
docker run -d --name api --memory 512m myorg/api:1.0.0
Configuration changesoft target
docker run -d --name api --memory 512m --memory-reservation 256m myorg/api:1.0.0
Configuration changememory plus swap
docker run -d --name api --memory 512m --memory-swap 1g myorg/api:1.0.0

The --memory-swap semantics catch people out often enough to be worth a table:

--memory--memory-swapEffective swap allowance
512munset512m β€” the container may swap as much as its memory limit, giving 1 GB total
512m1g512m
512m512mnone β€” swap is disabled for this container
512m-1unlimited, 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.

Read-only / Safethe symptom
$ docker ps -a --filter name=api --format 'table {{.Names}}\t{{.Status}}'
NAMES     STATUS
api       Exited (137) 4 minutes ago

Illustrative 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.

Read-only / Safeask the daemon
$ 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.

Read-only / Safe1. the cgroup's own counters
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
cat /sys/fs/cgroup"$CG"/memory.events
low 0
high 0
max 4128
oom 3
oom_kill 3
oom_group_kill 0

Read it precisely β€” each field means something different:

FieldMeaning
lowtimes the cgroup was reclaimed despite being under memory.low
hightimes processes were throttled into direct reclaim by memory.high
maxtimes usage was about to exceed memory.max, so the kernel reclaimed instead
oomtimes reclaim failed and an allocation was about to fail
oom_killprocesses killed by any OOM killer in this cgroup
oom_group_killtimes 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.

Read-only / Safe2. 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.

Read-only / Safe3. the breakdown of what was using the memory
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.stat

The 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:

Configuration changeset memory.high for an investigation
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.events

The 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

  1. 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?

  2. Q2. A sidecar with a runaway buffer pushes a cgroup to memory.max, and the kernel kills the main application server instead. Why?

  3. 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.

  4. Q4. `docker inspect --format "{{.State.Error}}"` returns a message explaining the OOM kill.

  5. Q5. You run `--memory 512m --memory-swap 512m`. What does that configure?

  6. 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.