Docker & ContainersX Β· Production ArchitectureCapacity planning
Capacity planning β CPU, RAM, disk, network
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
Capacity planning for Docker is capacity planning for any Linux server, plus two concerns that have no equivalent on a traditional host:
- 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.
- Every limit is a cgroup ceiling, not a reservation. Setting
memory: 1gon 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
| Resource | Bounded by | Failure mode | Where it shows first |
|---|---|---|---|
| Memory | Host RAM | Container OOM kill, or host-wide if unlimited | Exit code 137 |
| CPU | Host cores | Throttling; latency grows, throughput does not | cpu.stat throttled counters |
| Disk space | Filesystem size | Writes fail with ENOSPC across every container at once | df, not docker system df |
| Disk IOPS | Device capability | Everything slows; looks like a CPU or network problem | iostat await |
| Network | Link and NIC | Latency and drops | ss -s, interface counters |
| PIDs | pids_limit | fork() fails; the container cannot even run a shell | pids.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.
# 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
doneA 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.
$ cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format '{{.Id}}' api).scope/cpu.statusage_usec 48213904
user_usec 39120441
system_usec 9093463
nr_periods 182400
nr_throttled 21877
throttled_usec 194330012Illustrative 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.
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.tsvTwo 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.
- Measure peak working set per container over at least a week; do not use a single
docker statssnapshot. - Set memory limits above measured peak with margin, and alert on
memory.eventsrather than only on kills. - Set
cpusfrom measured utilisation, then watchnr_throttledovernr_periods. - Set
pids_limiton every service; a few hundred is generous. - Model disk as layers + writable + volumes + build cache + logs + 30% headroom.
- Bound the log driver, and ship the logs somewhere before you cap them.
- Put image and build-cache pruning on a schedule; a retention policy is cheaper than disk.
- Record a daily usage datapoint so you can compute the date you run out, not just the percentage full.
- Alert at 80% of disk and re-plan quarterly.
Knowledge check
Knowledge check Β· 5 questions
Q1. Capacity planning for a Docker host should account for:
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.
Q3. A container shows 3.8 GB in `memory.current` but its heap is around 40 MB. What is the most likely explanation?
Q4. A Docker filesystem is full but `docker system df` totals well under its size. Which could explain the gap? Select all that apply.
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.