Skip to main content
RunBook Academy

Docker & ContainersXVI Β· PerformanceImage size

Image size and the cost of layers

Intermediate⏱ ~26 mindocker

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

Not yet marked complete on this device.

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 drivesIt does not drive
Cold pull time on a host that lacks the imageRuntime CPU or memory of the running process
Decompression and extraction time on that hostRequest latency
Disk consumed under /var/lib/dockerApplication startup time once the image is present
Registry storage and egress costAnything at all on a warm host
Number of packages a scanner reports as vulnerableWhether 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”

Read-only / Safethe three sizes
$ docker image ls python:3.12-slim && docker system df -v | grep python
REPOSITORY   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       0

Illustrative 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 SIZE column of docker image ls β€” is every layer uncompressed, added together. It double-counts layers shared with other images, which is why summing the column exceeds du on /var/lib/docker.
  • Unique size is what deleting this image would actually free. python:3.12-slim above has 179 MB of logical size and 2.2 MB of unique size, because everything else is shared with other images on the host.
Read-only / Safeget all three
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

  1. Base image. One line, an order of magnitude.
  2. Build toolchain left in the runtime image. Compilers, headers, build-essential, the Go SDK. Hundreds of megabytes, entirely avoidable with multi-stage.
  3. Package manager caches. /var/lib/apt/lists, /root/.cache/pip, /root/.npm, /go/pkg/mod. Frequently 100–400 MB.
  4. Application artefacts. Usually the smallest part, and the only part anybody wanted.
  5. Layer metadata. Negligible.
Read-only / Safemeasure your own base images
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)"
done

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

Read-only / Safefind the fat layers
IMAGE=myapp:1.4.2

docker history --no-trunc --format 'table {{.Size}}\t{{.CreatedBy}}' "$IMAGE" \
| head -25
Read-only / Safea history worth reading
$ docker history --format 'table {{.Size}}\t{{.CreatedBy}}' myapp:1.4.2
SIZE      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

Read-only / Safeimage size gate
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:

Read-only / Safefind files hidden by whiteouts
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

  1. Q1. A Dockerfile installs build-essential in one RUN and purges it in a later RUN. What is the effect on the final image?

  2. Q2. Which column of `docker system df -v` tells you how much disk deleting a specific image would free?

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

  4. Q4. Which are genuine costs of a large image? Select all that apply.

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