Docker & ContainersXXXIV Β· Capacity PlanningCapacity
Memory capacity β sizing limits against what the cgroup actually counts
What you'll learn
- Separate anonymous memory, page cache and kernel memory in memory.stat
- Derive a memory limit from measured working set rather than from a guess
- Budget the host so the sum of container limits cannot OOM the host
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 overview lesson gave you a formula with a safety margin in it.
This lesson gives you the number to put into that formula, because
the obvious source β βwhat docker stats saysβ β is a derived
figure, and the raw figure underneath it counts things that are not
your applicationβs memory at all.
Get this wrong in the generous direction and you waste a third of a host. Get it wrong in the tight direction and the OOM killer takes the container down at 03:00 under exactly the load you sized it for.
The three things inside memory.current
memory.current is the cgroupβs total charged memory. It is the sum
of several distinct populations with completely different behaviour
under pressure:
| Population | memory.stat key | Reclaimable? | Counts toward the limit? |
|---|---|---|---|
| Anonymous (heap, stack) | anon | Only by swapping | Yes |
| Page cache (file-backed) | file | Yes, cheaply | Yes |
| Shared memory / tmpfs | shmem | Only by swapping | Yes |
| Kernel structures | kernel, slab, pagetables | Partly | Yes |
| Socket buffers | sock | Under pressure | Yes |
Everything in that table is charged to the limit. That is the part
people are surprised by: reading a 4 GB file inside a container with
a 512 MB limit does not OOM the container, because the kernel
reclaims clean page cache instead β but it does push memory.current
right up against memory.max while it happens, and every dashboard
that plots memory.current / memory.max will show 99%.
Reading the real numbers
CID=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
echo "/sys/fs/cgroup$CG"CID=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
D="/sys/fs/cgroup$CG"
cat "$D/memory.current" "$D/memory.max" "$D/memory.peak"
grep -E '^(anon|file|shmem|slab|inactive_file|active_file) ' "$D/memory.stat"264437760
536870912
300359680
anon 31805440
file 230162432
shmem 0
slab 1034832
inactive_file 117424128
active_file 112738304Read that capture carefully, because every number in it matters:
memory.maxis 536870912 β a 512 MiB limit.memory.currentis 264437760 β 252.2 MiB, or 49% of the limit.- Of that 252.2 MiB, only
anon31805440 (30.3 MiB) is the applicationβs own heap and stack. file230162432 (219.5 MiB) is page cache the container happened to touch. It is 87% of the reported usage and it is almost all disposable.memory.peakis 300359680 (286.4 MiB) β the high-water mark since the container started, which is the number a five-minute polling interval will miss.
Why docker stats disagrees with memory.current
docker stats for the same container reports 140.2 MiB, not 252.2 MiB:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' webweb 140.2MiB / 512MiB 27.38%The difference is exactly inactive_file:
memory.current 264437760 = 252.2 MiB
minus inactive_file 117424128 = 112.0 MiB
---------
docker stats MEM USAGE = 140.2 MiB
On cgroup v2 Docker subtracts inactive_file β the cold end of the
page cache LRU β before reporting. It is a reasonable approximation
of βmemory that is actually being usedβ, and it is not the number
the kernel compares against memory.max. The kernel compares
memory.current.
The sizing formula
The limit has to cover the parts that cannot be reclaimed on demand, plus enough cache for the workload not to thrash, plus margin:
working_set = peak(anon) + peak(shmem) + peak(slab + pagetables)
cache_floor = the file cache the workload re-reads (its hot set)
limit = (working_set + cache_floor) x safety
safety of 1.3 is the working default for a service with a stable
allocator. Use 1.5 for a JVM or any runtime with a generational
collector, because collection is bursty and the peak between
collections is not the average.
Applying it to the captured container: anon peaked around 34 MB
across the sample window, shmem is 0, kernel structures are about
1.7 MB, and active_file sits near 107 MiB, which is the cache it
keeps re-reading.
working_set = 34 + 0 + 2 = 36 MB
cache_floor = 107 = 107 MB
limit = (36 + 107) x 1.3 = 186 MB -> round up to 256 MiB
The 512 MiB limit that was configured is twice what the evidence supports. On a host running twenty such containers that is 5 GB of RAM budgeted against nothing.
Budgeting the host
Per-container limits are only half of it. The host budget is the constraint that stops one containerβs limit from being cashed at the same moment as everyone elseβs:
sum(limits) <= RAM - host_reserve
host_reserve = 1 GiB + 1% of RAM (dockerd, containerd, systemd,
journald, kernel slab, SSH)
TOTAL=$(docker ps -q | xargs -r docker inspect --format '{{.HostConfig.Memory}}' | awk '{s+=$1} END {print s+0}')
RAM=$(awk '/MemTotal/ {print $2 * 1024}' /proc/meminfo)
awk -v t="$TOTAL" -v r="$RAM" 'BEGIN {
printf "limits: %.1f GiB\n", t/1073741824
printf "host RAM: %.1f GiB\n", r/1073741824
printf "reserve: %.1f GiB\n", (1073741824 + r*0.01)/1073741824
printf "committed: %.0f%%\n", 100*t/(r - 1073741824 - r*0.01)
}'limits: 22.5 GiB
host RAM: 46.0 GiB
reserve: 1.5 GiB
committed: 51%Illustrative output
A container with no --memory reports 0, which the sum silently
treats as zero. That is the dangerous case: an unlimited container is
not 0 GB of commitment, it is all of it.
docker ps -q | xargs -r docker inspect \
--format '{{.HostConfig.Memory}} {{.Name}}' | awk '$1 == 0 {print "UNLIMITED:", $2}'UNLIMITED: /legacy-batchIllustrative output
Overcommit: when it is defensible
Committing more than 100% is defensible only when you can show the peaks do not coincide. A batch job that runs at 02:00 and an API that peaks at 14:00 can share a memory budget; two API replicas behind the same load balancer cannot, because they peak together by definition.
If you overcommit, the mitigation is --memory-reservation, which
sets memory.low. Under host pressure the kernel reclaims from
cgroups above their memory.low first, so the reservation decides
who gets squeezed rather than leaving it to chance.
docker run -d --name api \
--memory 512m --memory-reservation 256m \
--memory-swap 512m \
registry.example.com/api:1.4.0Setting --memory-swap equal to --memory disables swap for the
container. Do that for latency-sensitive services: a service that
swaps is a service whose p99 has already left the building, and you
would rather have a clean OOM you can see in memory.events than a
slow service nobody can explain.
Proving the limit was right
A sizing decision is a hypothesis. These two counters are the test:
CID=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
cat "/sys/fs/cgroup$CG/memory.events"
cat "/sys/fs/cgroup$CG/memory.pressure"low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0Read it as a verdict:
oom_kill 0andmax 0β the limit was never reached. Ifmemory.peakis also far belowmemory.max, the limit is loose and you can reclaim the difference.maxclimbing withoom_kill 0β the container is repeatedly hitting the ceiling and the kernel is reclaiming its way out. It is surviving on cache eviction. Raise the limit before it stops working.oom_killabove 0 β the limit is wrong, full stop. Do not restart and hope.memory.pressurefull avg300above zero β everything in the cgroup stalled waiting for memory. That is a latency problem the application will report as slow database calls.
Sanity check
Knowledge check Β· 4 questions
Q1. A container shows `memory.current` at 252 MiB against a 512 MiB limit, with `anon` at 30 MiB and `file` at 220 MiB. What should you conclude?
Q2. Why does `docker stats` report a smaller number than `memory.current` on cgroup v2?
Q3. Which observations indicate a memory limit that is set too low? Select all that apply.
Q4. Summing `HostConfig.Memory` across running containers understates the host memory exposure whenever a container was started without `--memory`.
Passing score: 75%. Answers are checked in this browser.