Skip to main content
RunBook Academy

Docker & ContainersX Β· Production ArchitectureCapacity planning

Capacity planning β€” CPU, RAM, disk, network

Intermediate⏱ ~26 mindocker

What you'll learn

  • Size memory from measured working set rather than from the sum of limits
  • Model disk growth including shared layers, build cache and logs
  • Read cgroup v2 counters without mistaking page cache for a leak
  • Set an alert threshold that leaves time to act

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

Not yet marked complete on this device.

Capacity planning for Docker is capacity planning for any Linux server, plus two concerns that have no equivalent on a traditional host:

  1. Disk grows from things nobody is watching. Image layers accumulate with every deploy, build cache grows without bound, and container logs are unbounded by default. None of these are the application’s data and all of them are on the same filesystem as it.
  2. Every limit is a cgroup ceiling, not a reservation. Setting memory: 1g on ten containers does not reserve 10 GB. It permits 10 GB. Whether the host can honour all ten at once is a question you have to answer yourself, because nothing checks it for you.

Miss the first and the host fills up. Miss the second and the OOM killer resolves it for you, badly.

What you are sizing

ResourceBounded byFailure modeWhere it shows first
MemoryHost RAMContainer OOM kill, or host-wide if unlimitedExit code 137
CPUHost coresThrottling; latency grows, throughput does notcpu.stat throttled counters
Disk spaceFilesystem sizeWrites fail with ENOSPC across every container at oncedf, not docker system df
Disk IOPSDevice capabilityEverything slows; looks like a CPU or network problemiostat await
NetworkLink and NICLatency and dropsss -s, interface counters
PIDspids_limitfork() fails; the container cannot even run a shellpids.events

Memory: measure, do not sum

The sum of every container’s memory limit is an upper bound on what the host could be asked for. It is not what the host needs, because containers do not peak simultaneously. Sizing from that sum buys hardware you will never use; sizing from average usage buys an outage. The number you want is the peak working set, measured.

Read-only / Safeworking set
# Sample every 30s for an hour; keep the peak per container
for i in $(seq 1 120); do
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}'
sleep 30
done | tee /tmp/usage.log

# The counter that says a limit is too tight, per container
for c in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$c")
echo "== $name"
cat "/sys/fs/cgroup/system.slice/docker-$(docker inspect --format '{{.Id}}' "$c").scope/memory.events" 2>/dev/null
done

A container whose oom_kill counter is above zero has already been killed at least once, whatever its uptime says. That is a fact docker ps will not tell you, because the restart policy brought it straight back.

CPU: throttling is the signal, not utilisation

cpus: 2.0 sets cpu.max to a quota-and-period pair β€” 200000 microseconds of CPU per 100000 microsecond period. When a container exhausts its quota inside a period, every one of its threads is stopped until the period rolls over.

This matters because CPU throttling does not look like a CPU problem. Throughput stays flat and latency develops a long tail, because requests that happen to land near the end of a period wait for the next one. The p99 goes bad while the average and the CPU graph look fine.

Read-only / Safethrottling
$ cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format '{{.Id}}' api).scope/cpu.stat
usage_usec 48213904
user_usec 39120441
system_usec 9093463
nr_periods 182400
nr_throttled 21877
throttled_usec 194330012

Illustrative output

nr_throttled as a fraction of nr_periods is the number to watch. Twelve percent, as above, is a service that is being stopped regularly. Either its limit is too low or its concurrency is too high; either way, no amount of tuning inside the application will fix it.

Size CPU from measured utilisation with headroom for burst. Unlike memory, overcommitting CPU is safe in the sense that nothing gets killed β€” it just gets slower, and slower is a decision you can make deliberately.

Disk: the term everybody forgets

This is where Docker capacity planning genuinely differs, and where hosts actually die. Four things grow on the Docker filesystem, and only one of them is your data.

disk required =
    unique image layers          (NOT the sum of image sizes)
  + container writable layers    (small, unless someone writes outside a volume)
  + volumes                      (your actual data, growing at its own rate)
  + build cache                  (unbounded; only exists if you build here)
  + container logs               (unbounded by default; NOT in docker system df)
  + headroom                     (30%)

Modelling the layer term honestly

docker images shows each image’s full logical size, so adding them up double-counts every shared base layer. docker system df reports what is actually on disk. Model growth from deploy rate instead:

image growth per month =
    (deploys per month) Γ— (size of the layers that change per deploy)

For a typical application image, the layers that change are the application layer and its dependency layer β€” often 50–200 MB β€” not the whole image. So 60 deploys a month at 150 MB is about 9 GB a month if nothing is ever pruned, and close to zero if a weekly docker image prune runs. That retention policy is worth more than the disk you would otherwise buy.

Modelling logs

log bytes per day = (lines/sec) Γ— (bytes/line) Γ— 86400 Γ— (containers)

A service emitting 50 structured JSON lines a second at 400 bytes each is 1.7 GB per day, per container. With max-size: 10m and max-file: 3 it is capped at 30 MB and the rest is discarded β€” which is why the cap has to be paired with actually shipping the logs somewhere, or you have solved a disk problem by throwing away your evidence.

Read-only / Safegrowth baseline
STAMP=$(date +%F)
USED=$(df --output=used -B1 /var/lib/docker | tail -1)
LOGS=$(du -sb /var/lib/docker/containers 2>/dev/null | cut -f1)
printf '%s\t%s\t%s\n' "$STAMP" "$USED" "$LOGS" >> /var/log/docker-capacity.tsv
tail -5 /var/log/docker-capacity.tsv

Two weeks of that file tells you the growth rate, and the growth rate tells you the date you run out. That is a far more useful number than a percentage full, because it is the one that says whether you have a month to act or a weekend.

Headroom, and what it is for

Run production hosts at 60–70% of capacity. The remaining 30–40% is not waste; each part of it is doing a job:

  • Burst. Real traffic is not flat. Plan for peak, not mean.
  • Failover. If this host takes another’s load, it needs room for it.
  • Operations. An upgrade pulls new images before removing old ones, so disk usage goes up during a deploy. A forensic capture needs somewhere to write. A database restore needs room for both copies.

That last point is the one people find out about during an incident: you cannot restore a 24 GB database onto a filesystem with 12 GB free, and the moment you need to is exactly the moment there is no time to make room.

Alert at 80%, not 95%. At 80% you have a change window. At 95% you have an incident, and at 100% you may not be able to log in to fix it.

  1. Measure peak working set per container over at least a week; do not use a single docker stats snapshot.
  2. Set memory limits above measured peak with margin, and alert on memory.events rather than only on kills.
  3. Set cpus from measured utilisation, then watch nr_throttled over nr_periods.
  4. Set pids_limit on every service; a few hundred is generous.
  5. Model disk as layers + writable + volumes + build cache + logs + 30% headroom.
  6. Bound the log driver, and ship the logs somewhere before you cap them.
  7. Put image and build-cache pruning on a schedule; a retention policy is cheaper than disk.
  8. Record a daily usage datapoint so you can compute the date you run out, not just the percentage full.
  9. Alert at 80% of disk and re-plan quarterly.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. Capacity planning for a Docker host should account for:

  2. Q2. A memory limit is a ceiling rather than a reservation, so the sum of every limit on a host may legitimately exceed physical RAM.

  3. Q3. A container shows 3.8 GB in `memory.current` but its heap is around 40 MB. What is the most likely explanation?

  4. Q4. A Docker filesystem is full but `docker system df` totals well under its size. Which could explain the gap? Select all that apply.

  5. Q5. Which counter tells you a CPU limit is too tight, when average utilisation still looks acceptable?

Passing score: 75%. Answers are checked in this browser.