Skip to main content
RunBook Academy

Docker & ContainersXXXIV Β· Capacity PlanningCapacity

Capacity planning for Docker hosts

Intermediate⏱ ~28 min

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

Not yet marked complete on this device.

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.

Read-only / Safethe measurement set
# 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/dev

Memory: 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.

Read-only / Safehow much cache does this host actually have
$ free -m
               total        used        free      shared  buff/cache   available
Mem:           32109       18442        1204         312       12463       13051
Swap:              0           0           0

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

ComponentAmountHow it was obtained
Total RAM32,109 MBfree -m
Kernel and slab1,500 MBtotal minus used minus buff/cache minus free
Host services (sshd, journald, dockerd, exporters)1,200 MBsystemd-cgtop on the non-container slices
Sum of container working sets at peak14,800 MB7 days of container_memory_working_set_bytes, peak
Page cache reservation6,400 MB20% of total β€” justified below
Committed23,900 MB
Uncommitted8,209 MB25.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.

Read-only / Safethe CPU number that matters
$ cat /proc/pressure/cpu
some avg10=4.28 avg60=3.91 avg300=2.55 total=1842771991

Illustrative 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

Read-only / Safethe baseline measurement
$ docker system df
TYPE            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.6GB

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

Configuration change/etc/docker/daemon.json
{
"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:

Destructivebounded cache prune
docker builder prune --keep-storage 10GB --filter 'until=168h' --force

Logs

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.

Read-only / Safevolume growth rate
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.log

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

  1. Measure for a full week minimum, covering a weekly peak. A Tuesday afternoon sample plans for Tuesday afternoons.
  2. Take peaks, not averages, for memory, and averages plus pressure for CPU. Memory has no graceful degradation; CPU does.
  3. Reserve page cache explicitly as a line item, so nobody later reads it as spare capacity and allocates it.
  4. Cap what can be capped β€” build cache in daemon.json, logs in log-opts, images by retention policy. Three settings remove three growth curves.
  5. Extrapolate only what remains, which is almost always volumes.
  6. Publish a date and the inputs that produced it, so the plan can be recomputed when an input changes rather than redone.
  7. Re-measure quarterly, and immediately after any change to logging verbosity, retention, or the number of services.

Knowledge check

Knowledge check Β· 6 questions

  1. Q1. Why should a capacity plan reserve memory for page cache rather than allocating it to containers?

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

  3. Q3. Which statements about measuring Docker disk usage are true? Select all that apply.

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

  5. Q5. Docker prevents you from starting containers whose memory limits sum to more than the host RAM.

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