17.1.1 Small Image Practices
A focused guide to Small Image Practices, connecting core concepts with practical Docker and container operations.
Small image practices are the specific techniques used to minimize an image's final size, distinct from broader image lifecycle concerns like tagging and scanning, focused entirely on reducing how much actually needs to be stored, transferred, and pulled every time the image moves between a registry and a runtime environment.
Multi-stage builds as the primary technique
Separating build-time tooling from the final runtime image through multi-stage builds is the single most impactful technique available, since it excludes compilers, build dependencies, and intermediate artifacts entirely from what actually ships:
FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN go build -o server .
FROM alpine:3
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
The final image here contains only the compiled binary and a minimal Alpine base, with the entire Go toolchain, several hundred megabytes, excluded entirely, since it was only ever needed during the build stage and never copied into the final one.
Choosing progressively smaller base images
Base image choice spans a wide size range, and selecting deliberately along this spectrum, rather than defaulting to the largest, most full-featured option out of habit, meaningfully affects final image size:
FROM node:20
FROM node:20-slim
FROM node:20-alpine
FROM gcr.io/distroless/nodejs20-debian12
Each step down this list typically represents a substantial size reduction, the full node:20 image is commonly several hundred megabytes larger than its Alpine equivalent, but each also removes tooling and compatibility that might be needed; the right choice depends on verifying the application actually runs correctly against the smaller option, not just confirming size reduction.
Removing package manager caches within the same layer
Package manager metadata and cache files add measurable size if left in the image, and removing them must happen within the same RUN instruction that created them, since a later, separate cleanup instruction does not actually reduce the size of the earlier layer it cannot retroactively modify:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag additionally avoids pulling in commonly bundled but often unnecessary recommended packages that many package managers install by default alongside an explicitly requested one.
Avoiding unnecessary packages and tools
Each additional installed package, even a small one, contributes to final image size and expands the attack surface; reviewing what is actually used by the running application, rather than installing a generally familiar but broader toolkit out of habit, keeps the final image lean:
RUN apk add --no-cache curl
RUN apk add --no-cache curl vim git wget
The second example installs several tools that are convenient for interactive debugging but entirely unnecessary for the application's actual runtime operation; reserving such tools for a separate, debug-specific image variant rather than including them in the production image by default keeps the production image's footprint focused strictly on what is actually needed.
Static linking to eliminate runtime dependencies
For compiled languages, producing a statically linked binary removes the need for shared runtime libraries entirely, which allows the final stage to be an extremely minimal image, or even scratch, an entirely empty base, since the binary carries everything it needs within itself:
FROM golang:1.22 AS build
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
This produces one of the smallest possible final images, since there is no base operating system layer at all, only the application binary itself; this approach is not available for every language or every dependency (some native libraries cannot be statically linked at all), but where feasible, it represents the extreme end of the size-minimization spectrum.
Combining instructions to reduce layer overhead
Beyond the cache-cleanup-in-the-same-layer principle, combining several related, sequential operations into fewer, larger RUN instructions reduces the small but real per-layer metadata overhead that accumulates across a Dockerfile with many separate, granular instructions:
RUN mkdir -p /app/data && \
chown appuser:appuser /app/data && \
chmod 750 /app/data
This is a more modest optimization compared to multi-stage builds or base image selection, but it contributes incrementally, particularly for a Dockerfile with many small, separate filesystem operations that could reasonably be consolidated.
Measuring the actual impact of each change
Rather than applying these techniques blindly, measuring the actual size impact of each change directly confirms it produced the intended benefit and helps prioritize which techniques matter most for a specific image:
docker images my-api --format "{{.Tag}}: {{.Size}}"
docker history my-api:1.4.2
docker history specifically breaks down size contribution by individual layer, which is useful for identifying exactly which instruction in a Dockerfile is responsible for the largest portion of the final image's size, directing optimization effort toward the layers that actually matter most rather than guessing.
Balancing size reduction against maintainability and debuggability
An extremely minimal image, particularly one built from scratch with no shell or package manager at all, sacrifices the ability to use docker exec for interactive debugging, which is a real, practical trade-off worth weighing against the size benefit for any image that might need that kind of investigation during a production incident:
docker exec -it my-api sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
For services where this debugging capability genuinely matters, a slightly larger but still minimal image that retains a basic shell, such as Alpine rather than scratch or distroless, is often a more practical balance than pursuing the absolute smallest possible image at the cost of meaningfully reduced operability during an investigation.
Common mistakes
- Defaulting to the largest, most full-featured base image out of habit without considering whether a meaningfully smaller alternative would work equally well.
- Cleaning up package manager caches in a separate instruction from where they were created, failing to actually reduce the image size despite the cleanup appearing to happen.
- Installing convenience debugging tools directly into the production image rather than reserving them for a separate, debug-specific variant.
- Pursuing extreme size minimization, such as building from
scratch, for a service where the resulting loss of interactive debugging capability is a meaningful operational cost. - Applying size-reduction techniques without measuring their actual impact, missing the opportunity to prioritize the changes that matter most for a specific image.
Small image practices, multi-stage builds, deliberate base image selection, same-layer cleanup, avoiding unnecessary packages, and static linking where feasible, compound meaningfully when applied together, but the right balance for a given image depends on weighing the genuine size and security benefits against the practical cost to debuggability and maintainability that the most extreme minimization techniques can introduce.