Advanced Docker & Container Security

Distroless & Minimal Images

18 min Lesson 3 of 28

Distroless & Minimal Images

Every megabyte you ship in a container image is attack surface, startup latency, and bandwidth cost. At Google scale — where hundreds of thousands of container instances launch every minute — the choice of base image is a first-class security and reliability decision, not an afterthought. This lesson covers the three dominant minimal-image strategies: scratch, Alpine Linux, and Distroless, when each makes sense, and how static binaries enable the most extreme reduction.

The Cost of a Full Base Image

A standard ubuntu:22.04 base image weighs ~77 MB compressed and ships with a shell, a package manager, coreutils, and hundreds of libraries. None of those are needed at runtime for most server processes. They exist purely to make the image author's life easier during debugging — and they make an attacker's life easier too. Every CVE in bash, curl, openssl, or apt is now your CVE, even if your application never invokes those binaries.

Rule of thumb: the ideal production image contains exactly what your process needs to run — nothing more. Every tool you add beyond that is a liability that needs patching, scanning, and justifying.

Strategy 1 — FROM scratch

scratch is Docker's empty base image: no filesystem, no shell, no libc. A container built FROM scratch contains only what you COPY into it. This is the absolute minimum.

It works perfectly for statically compiled binaries — programs linked with all their dependencies baked in, requiring no shared libraries from the OS. Go is the canonical example: go build with CGO_ENABLED=0 produces a single self-contained ELF binary that runs from scratch with no other files needed except perhaps CA certificates and timezone data.

# Build a fully static Go binary, then drop it into scratch FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -trimpath -ldflags="-s -w" -o /app/server ./cmd/server # Final image: nothing but the binary + TLS roots FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /app/server /server ENTRYPOINT ["/server"]

The resulting image is typically 5–15 MB. There is no shell, no ps, no wget — a successful attacker who escapes your application logic has nowhere to go. The -ldflags="-s -w" flag strips the symbol table and DWARF debug info, shrinking the binary further.

Scratch pitfall — no /etc/passwd: Many applications call getpwnam() or rely on a non-root UID being resolvable. A scratch image has no /etc/passwd or /etc/group. Either run as numeric UID (USER 65532 in the Dockerfile) or COPY in minimal passwd/group files from the builder. Kubernetes runAsNonRoot: true checks the numeric UID, so this is safe.

Strategy 2 — Alpine Linux

Alpine is a 5 MB musl-libc-based Linux distribution. Unlike scratch, it has a shell (ash), a package manager (apk), and just enough of a filesystem to make debugging practical. It is the pragmatic choice when your language runtime requires dynamic linking — Python, Node.js, Ruby, Java all need shared libraries that scratch cannot provide without manual surgery.

FROM python:3.12-alpine AS builder WORKDIR /app RUN apk add --no-cache gcc musl-dev libffi-dev COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt FROM python:3.12-alpine WORKDIR /app COPY --from=builder /install /usr/local COPY src/ ./src/ RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser ENTRYPOINT ["python", "-m", "src.main"]

Key Alpine discipline: always use --no-cache with apk add to avoid writing the package index to the layer, pin versions in requirements.txt precisely, and never leave build-only packages (gcc, musl-dev) in the final stage. Alpine images still carry a shell and apk, which is why they score worse than Distroless in security scans despite being tiny.

Strategy 3 — Distroless

Distroless images, maintained by Google, are the sweet spot for most production workloads. They contain only the language runtime and its dependencies — no shell, no package manager, no coreutils. Available for Go, Python, Java, Node.js, .NET, and a static variant. They are built from Debian packages using Bazel's rules_pkg, so they receive Debian security fixes on the same cadence as Debian's security team.

Base Image Comparison: Attack Surface vs Usability Attack Surface / Image Size Runtime Support & Debugging Ease scratch ~5–15 MB Static binaries only Distroless ~20–60 MB No shell, runtime only Alpine ~50–120 MB Shell + apk ubuntu:22.04 ~77 MB+ Full toolchain
Trade-off matrix: smaller images have less attack surface but require more build discipline.
# Java service with Distroless JRE FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app COPY . . RUN ./gradlew bootJar --no-daemon FROM gcr.io/distroless/java21-debian12:nonroot WORKDIR /app COPY --from=builder /app/build/libs/myservice.jar app.jar EXPOSE 8080 CMD ["/app/app.jar"]

The :nonroot tag ensures the image runs as UID 65532 by default — no USER instruction required, but compatible with Kubernetes runAsNonRoot. A :debug tag variant exists and includes BusyBox; use it only in emergency debugging scenarios, never in scheduled production builds.

Production decision matrix: Use scratch for Go/Rust/C with CGO disabled. Use Distroless for Python, Java, Node.js, or any dynamically-linked language — it is the sweet spot of security and practicality. Use Alpine when you need apk to install native system libs at build time but keep it in the build stage only; use Alpine as a final stage only when Distroless has a coverage gap you cannot work around.

Static Binaries in Depth

A statically linked binary embeds all library code at compile time. No ld-linux.so interpreter, no libc.so, no libssl.so — the binary is a self-contained executable. Rust's default linker produces static binaries when targeting x86_64-unknown-linux-musl. For C/C++, pass -static to GCC. For Go, CGO_ENABLED=0 is usually sufficient; when CGO is required (e.g., SQLite via mattn/go-sqlite3), use musl-cross to produce a musl-static binary.

# Rust service: statically linked against musl FROM rust:1.79-alpine AS builder RUN apk add --no-cache musl-dev WORKDIR /src COPY . . RUN cargo build --release --target x86_64-unknown-linux-musl FROM scratch COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/myservice /myservice COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ USER 65532:65532 ENTRYPOINT ["/myservice"]

Debugging Distroless in Production

The most common objection to Distroless and scratch is: "how do I debug?" The answer is ephemeral debug containers — a Kubernetes feature that attaches a fully-equipped container to a running pod without modifying the pod spec:

# Attach a debug sidecar to inspect a running distroless pod kubectl debug -it <pod-name> \ --image=gcr.io/distroless/base:debug \ --target=<container-name> \ --copy-to=debug-pod # Or with a full toolbox image kubectl debug -it <pod-name> \ --image=ubuntu:22.04 \ --target=app \ -- bash

This pattern means you never need a shell in your production image. The debug container shares the process namespace of the target container, giving you access to /proc/<pid>/fd, network sockets, and environment variables without altering the image or the running pod's security posture.

Image size is not the only metric. Distroless images may be larger than Alpine for the same language runtime because they pull in full Debian library packages. The primary advantage is the number of CVEs in the image, not the raw size. Always measure both dimensions with your image scanner (Trivy, Grype, Snyk) when making the final call.

Layer Hygiene and Final Checks

Whatever base you choose, apply these final-stage rules: never install build tools in the runtime stage, always set USER to a non-root UID, remove any credentials or secrets copied in during build, and use --no-cache or --mount=type=cache in build stages to avoid leaking package indices into layers. Use docker image inspect --format '{{json .RootFS.Layers}}' to audit layer count, and docker history --no-trunc to spot accidental large layers before pushing to the registry.