Docker & ContainersXXXIV Β· Capacity PlanningCapacity
Capacity planning for Docker hosts
What you'll learn
- Measure real consumption per resource with the correct tool for each
- Reserve memory headroom for page cache and explain why the reservation is not slack
- Compute image, build-cache and log growth from build and traffic rates
- Produce a dated answer to "when does this host run out"
- Recognise where oversubscription is safe and where it is not
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-12
Capacity planning has a bad reputation because most of it is a peak multiplied by 1.5. That is not planning; it is a guess with a decimal point in it.
Real capacity planning produces a date. βThis host runs out of disk in approximately nine months, driven by volume growth of 1.4 GB per weekβ is an answer you can act on. βWe should probably get a bigger boxβ is not.
Measure with the right tool
Each resource has one tool that answers the question and several that look like they do.
# Per-container CPU, memory, network and block I/O, one sample.
# CPU % here is a fraction of ONE core, so 250% means two and a half cores.
docker stats --no-stream \
--format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'
# Host memory. The number that matters is "available", not "free".
free -m
# Disk BY SUBTREE. Do not use du on /var/lib/docker - see below.
docker system df -v
# Filesystem and inodes. Both fill, independently.
df -h /var/lib/docker
df -i /var/lib/docker
# Real network throughput over 10 seconds, per interface.
# ss shows sockets, not bytes; it cannot answer a throughput question.
sar -n DEV 10 1 2>/dev/null || cat /proc/net/devMemory: the headroom that is not slack
This is where capacity plans are wrong most often, and it is one idea.
Linux uses all free memory as page cache, holding recently read file contents so the next read does not touch the disk. Page cache is reclaimable β the kernel evicts it instantly under pressure β which makes it look like memory you can allocate away. It is not free in the sense that matters: evicting it turns a memory read into a disk read, and the two differ by roughly four orders of magnitude.
A host with no page cache does not report a problem. It reports normal memory usage and mysteriously bad I/O latency.
$ free -m total used free shared buff/cache available
Mem: 32109 18442 1204 312 12463 13051
Swap: 0 0 0Illustrative output
Read available, not free. free is memory doing nothing at all, which on
a healthy Linux host is always small and is not a problem. available is the
kernelβs own estimate of what a new allocation could obtain, including the
cache it would be willing to evict β 13,051 MB here.
The worked memory plan
A host with 32 GB, running twelve containers.
| Component | Amount | How it was obtained |
|---|---|---|
| Total RAM | 32,109 MB | free -m |
| Kernel and slab | 1,500 MB | total minus used minus buff/cache minus free |
| Host services (sshd, journald, dockerd, exporters) | 1,200 MB | systemd-cgtop on the non-container slices |
| Sum of container working sets at peak | 14,800 MB | 7 days of container_memory_working_set_bytes, peak |
| Page cache reservation | 6,400 MB | 20% of total β justified below |
| Committed | 23,900 MB | |
| Uncommitted | 8,209 MB | 25.6% of total |
The 20% page-cache reservation is a floor, not a rule of nature. Justify it per workload:
- A service whose data fits in RAM β a 4 GB Postgres dataset on a 32 GB host β wants enough cache to hold the whole working set, so reserve for the dataset, not a percentage.
- A service that streams data it never re-reads β a video transcoder, a log shipper β gains nothing from cache and can run with far less.
- A build host genuinely wants a lot: BuildKit reads and writes layer files constantly, and a build host with no cache spends its time in I/O wait.
Twenty percent is the number to use when you have not measured. Measure by
watching major page faults: node_vmstat_pgmajfault climbing means the
kernel is going to disk for pages it used to hold, which is the direct
symptom of insufficient cache.
CPU: quota is not the saturation signal
CPU is the resource where oversubscription is normal and safe, because CPU starvation is recoverable and memory starvation is not. A container waiting for CPU is slow; a container denied memory is dead.
So the plan is different in shape: the sum of --cpus values across
containers routinely exceeds the core count, and that is fine. What you plan
against is the run queue, not the sum of the quotas.
$ cat /proc/pressure/cpusome avg10=4.28 avg60=3.91 avg300=2.55 total=1842771991Illustrative output
Pressure Stall Information reports the percentage of time some task was
runnable but waiting for CPU. avg10=4.28 means 4.28% of the last ten
seconds had something waiting. Below roughly 10% is comfortable; sustained
above 20% is a host that needs cores, and it says so long before load
average does β because load average counts uninterruptible sleep too and
conflates I/O waits with CPU contention.
For the per-container view, the throttle ratio from the monitoring part is the equivalent: a container throttled more than a quarter of its scheduling periods needs more quota regardless of what the host has spare.
Disk: four things growing at four different rates
$ docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 22 9 24.8GB 11.2GB (45%)
Containers 14 12 1.9GB 0.3GB (15%)
Local Volumes 9 8 61.4GB 2.1GB (3%)
Build Cache 184 0 18.6GB 18.6GBIllustrative output
Images
Image growth is driven by build rate multiplied by unique layer size, not by image size, because base layers are shared.
Take a CI host building six times a day:
Distinct base images on the host: 3
Base layers, total on disk: 2.4 GB (shared by everything)
Unique application layer per build: 180 MB (docker system df -v, UNIQUE SIZE)
Builds per day: 6
Tags retained: 30 days
Steady-state image store
= base layers + (builds/day x retention x unique size)
= 2.4 GB + (6 x 30 x 0.180 GB)
= 2.4 GB + 32.4 GB
= 34.8 GB
Two things fall out of that arithmetic that are not obvious from the numbers going in:
- Retention dominates. Halving retention to 15 days saves 16.2 GB; halving the image size saves 16.2 GB too, and is far more work. Retention policy is the cheapest capacity lever you have.
- Adding a fourth base image costs its full size once, not once per build. A host standardised on one base image and a host using four differ by a couple of gigabytes, not by a factor of four. Standardising base images is worth doing for security and reproducibility; do not sell it as a disk saving.
Build cache
Build cache is the fastest-growing thing on a build host and the only one with a supported cap. BuildKitβs garbage collection is on by default with a 20 GB target, and you should set it explicitly rather than inherit it:
{
"builder": {
"gc": {
"enabled": true,
"defaultKeepStorage": "20GB"
}
}
}Setting this is strictly better than a nightly docker builder prune,
because garbage collection is continuous. A prune job scheduled for 02:00
does nothing about a build storm at 14:00 that fills the disk by 16:00.
If you do prune, bound it rather than emptying it:
docker builder prune --keep-storage 10GB --filter 'until=168h' --forceLogs
This is where the arithmetic surprises people, so do it properly.
Request rate: 500 req/s
Log lines per request: 1
Bytes per JSON log line: 400 B (structured logging is verbose)
Per second: 500 x 400 B = 200 KB/s
Per day: 200 KB/s x 86,400 = 17.3 GB/day
Per month: = 518 GB/month
Seventeen gigabytes a day, from one container, written to the root
filesystem β not to the volume you sized for data. The default json-file
driver does not rotate. This container fills a 200 GB disk in under twelve
days and takes the daemon down with it.
With rotation configured:
max-size: 50m, max-file: 3 -> 150 MB per container, hard cap
12 containers -> 1.8 GB total, permanently
The same workload, bounded, costs 1.8 GB forever instead of 518 GB a month. This single setting is the highest-leverage line in a Docker capacity plan.
Volumes
Volumes are the only row you cannot manage with a retention policy, because the contents are the point. Measure the trend and extrapolate; there is no formula.
STAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker system df --format '{{.Type}} {{.Size}} {{.Reclaimable}}' \
| sed "s|^|$STAMP |" >> /var/log/docker-capacity.log
tail -5 /var/log/docker-capacity.logPutting it together: the date
The output of a capacity plan is a date. Here is the full calculation for the 200 GB host measured above.
Filesystem total 200.0 GB
OS, packages, Docker binaries 8.0 GB
Images (steady state, 30d retention) 34.8 GB
Build cache (capped by GC) 20.0 GB
Container logs (capped by log-opts) 1.8 GB
Volumes (today) 61.4 GB
------------------------------------------------
Currently committed 126.0 GB
Act-by threshold at 85% of the filesystem
170.0 GB
Headroom before the threshold 44.0 GB
Volume growth, measured over 8 weeks 1.4 GB/week
Weeks of headroom = 44.0 / 1.4 = 31 weeks
~ 7 months
Seven months. That is the deliverable. It is a number you can put in a budget, and it is a number that changes when an input changes β so when someone proposes doubling log verbosity, you can price it in weeks rather than arguing about it.
The 85% threshold rather than 100% is deliberate: the last 15% is consumed
during the emergency, by the image pull needed to deploy the fix, and by the
fact that filesystems degrade as they approach full. Plan to act at 85% and
you are ordering hardware; plan to act at 98% and you are running
docker system prune -a on a production host at 03:00 and deleting the
rollback image.
- Measure for a full week minimum, covering a weekly peak. A Tuesday afternoon sample plans for Tuesday afternoons.
- Take peaks, not averages, for memory, and averages plus pressure for CPU. Memory has no graceful degradation; CPU does.
- Reserve page cache explicitly as a line item, so nobody later reads it as spare capacity and allocates it.
- Cap what can be capped β build cache in daemon.json, logs in log-opts, images by retention policy. Three settings remove three growth curves.
- Extrapolate only what remains, which is almost always volumes.
- Publish a date and the inputs that produced it, so the plan can be recomputed when an input changes rather than redone.
- Re-measure quarterly, and immediately after any change to logging verbosity, retention, or the number of services.
Knowledge check
Knowledge check Β· 6 questions
Q1. Why should a capacity plan reserve memory for page cache rather than allocating it to containers?
Q2. One container logs 500 lines per second at 400 bytes per line, with the default json-file driver and no log options. Roughly how much disk does it consume per day?
Q3. Which statements about measuring Docker disk usage are true? Select all that apply.
Q4. You set `log-opts` with max-size and max-file in daemon.json and restarted the daemon. What happens to the container that has been writing a 200 GB log file for six months?
Q5. Docker prevents you from starting containers whose memory limits sum to more than the host RAM.
Q6. Which single daemon.json block bounds build cache growth continuously, instead of relying on a scheduled prune?
Passing score: 75%. Answers are checked in this browser.