Docker & ContainersXVI Β· PerformanceImage size
Image size and the cost of layers
What you'll learn
- State precisely which costs image size drives and which it does not
- Distinguish compressed size, logical size and unique on-disk size
- Explain why removing a file in a later RUN does not shrink the image
- Apply multi-stage builds and cache mounts to the layers that actually dominate
- Choose a base image on runtime compatibility rather than on megabytes
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
Image size is the most quoted number in container operations and the most loosely reasoned about. It is a genuine cost. It is a cost on specific things, and being precise about which ones is the difference between an optimisation that pays and an afternoon spent shaving 40 MB off an image that is pulled once a month and then never again.
What image size costs, and what it does not
| Image size drives | It does not drive |
|---|---|
| Cold pull time on a host that lacks the image | Runtime CPU or memory of the running process |
| Decompression and extraction time on that host | Request latency |
Disk consumed under /var/lib/docker | Application startup time once the image is present |
| Registry storage and egress cost | Anything at all on a warm host |
| Number of packages a scanner reports as vulnerable | Whether the application is actually exploitable |
The right-hand column deserves emphasis because it is where the folk belief lives. A 2 GB image and a 20 MB image, both already present on the host, run at identical speed.
Three numbers, all called βsizeβ
$ docker image ls python:3.12-slim && docker system df -v | grep pythonREPOSITORY TAG IMAGE ID CREATED SIZE
python 3.12-slim 6c4dd321d176 2 weeks ago 179MB
REPOSITORY TAG IMAGE ID SIZE SHARED SIZE UNIQUE SIZE CONTAINERS
python 3.12-slim 6c4dd321d176 179MB 176.9MB 2.179MB 0Illustrative output
- Compressed size is what crosses the network. It is not in either command above; it is in the registry manifest. Roughly one third of the logical size for a typical Debian-based image.
- Logical size β the
SIZEcolumn ofdocker image lsβ is every layer uncompressed, added together. It double-counts layers shared with other images, which is why summing the column exceedsduon/var/lib/docker. - Unique size is what deleting this image would actually free.
python:3.12-slimabove has 179 MB of logical size and 2.2 MB of unique size, because everything else is shared with other images on the host.
IMAGE=python:3.12-slim
docker manifest inspect "$IMAGE" \
| awk '/"size"/ {gsub(/[^0-9]/,"",$2); s+=$2} END {printf "compressed: %.1f MB\n", s/1048576}'
docker image inspect "$IMAGE" --format 'logical: {{.Size}}' | numfmt --field=2 --to=iec
docker system df -v | awk '/^REPOSITORY/{p=1} p && /python/ {print "unique: " $6}'Quote the right one for the question. Cold pull time is governed by
compressed size. Host disk pressure is governed by unique size. The SIZE
column that everyone quotes governs neither on its own.
What dominates, in order
- Base image. One line, an order of magnitude.
- Build toolchain left in the runtime image. Compilers, headers,
build-essential, the Go SDK. Hundreds of megabytes, entirely avoidable with multi-stage. - Package manager caches.
/var/lib/apt/lists,/root/.cache/pip,/root/.npm,/go/pkg/mod. Frequently 100β400 MB. - Application artefacts. Usually the smallest part, and the only part anybody wanted.
- Layer metadata. Negligible.
for I in ubuntu:24.04 debian:bookworm-slim alpine:3.20 gcr.io/distroless/static-debian12; do
docker pull -q "$I" > /dev/null 2>&1 || { echo "$I: pull failed"; continue; }
printf '%-42s %s\n' "$I" "$(docker image inspect "$I" --format '{{.Size}}' | numfmt --to=iec)"
doneExpect roughly 80 MB, 75 MB, 8 MB and 2 MB of logical size, with compressed sizes near a third of each. The precise figures move with every base image release, which is exactly why the command matters more than the numbers.
Multi-stage: the fix that actually works
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Only what COPY --from names ends up in the final image. The 800 MB Go
toolchain in the build stage is not a hidden layer of the result; it is a
separate image that never leaves the build host.
The cache mounts are the second half of the technique. --mount=type=cache
gives the build a persistent directory that is not part of any layer, so
the module cache survives between builds without ever being committed. That is
strictly better than the old rm -rf dance: fast rebuilds and nothing in the
image.
IMAGE=myapp:1.4.2
docker history --no-trunc --format 'table {{.Size}}\t{{.CreatedBy}}' "$IMAGE" \
| head -25$ docker history --format 'table {{.Size}}\t{{.CreatedBy}}' myapp:1.4.2SIZE CREATED BY
12.4MB COPY /out/app /app # buildkit
0B ENV PYTHONUNBUFFERED=1
684MB RUN /bin/sh -c pip install -r requirements.txt # buildkit
41.2MB RUN /bin/sh -c apt-get update && apt-get install -y gcc g++ # buildkit
0B CMD ["python" "-m" "app"]
77.8MB /bin/sh -c #(nop) ADD file:... in /Illustrative output
684 MB in one pip install, in an image whose application is 12 MB. That is
almost certainly build dependencies compiled at install time and left behind.
The fix is a build stage that produces wheels, and a runtime stage that
installs only the wheels.
Choosing a base image on compatibility, not megabytes
The smallest base that runs the workload β not the smallest that exists.
Distroless images have the opposite trade: they contain the runtime and
nothing else β no shell, no package manager, no ps. That is a real security
improvement and a real operability cost, because docker exec gives you
nothing. The mitigation is the technique from the performance-tools lesson:
nsenter from the host, or a debug container sharing the targetβs namespaces.
Adopt distroless and establish the debugging path, in the same change.
Verification that can fail
IMAGE=myapp:1.4.2
BUDGET_MB=150
SIZE_MB=$(( $(docker image inspect "$IMAGE" --format '{{.Size}}') / 1048576 ))
echo "logical size: $SIZE_MB MB (budget $BUDGET_MB MB)"
if [ "$SIZE_MB" -gt "$BUDGET_MB" ]; then
echo 'FAIL: image exceeds budget. Largest layers:'
docker history --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" | head -5
exit 1
fi
echo 'PASS'And the check that catches the deleted-but-still-present problem, which no size number reveals:
IMAGE=myapp:1.4.2
WORK=$(mktemp -d)
docker save "$IMAGE" -o "$WORK/img.tar"
tar -tf "$WORK/img.tar" > "$WORK/entries.txt"
# Layer tars containing whiteout markers indicate content deleted in a later
# layer but still present in an earlier one.
grep -c '\.wh\.' "$WORK/entries.txt" \
&& echo 'whiteouts present: something is deleted in a later layer and still shipped' \
|| echo 'PASS: no whiteout entries at the top level'
rm -rf "$WORK"Knowledge check
Knowledge check Β· 5 questions
Q1. A Dockerfile installs build-essential in one RUN and purges it in a later RUN. What is the effect on the final image?
Q2. Which column of `docker system df -v` tells you how much disk deleting a specific image would free?
Q3. You halve the size of an image. On a host that already has the previous version cached with the same base layers, what improves?
Q4. Which are genuine costs of a large image? Select all that apply.
Q5. Switching a Python service from debian:bookworm-slim to alpine reliably produces a smaller image.
Passing score: 75%. Answers are checked in this browser.