34.4 Distroless and Scratch Base Images
Alright, let’s talk about the promised land of containerized Go deployments: the tiny, hyper-secure base image. You’ve built a static binary. It’s a glorious, self-contained chunk of machine code. So why on earth would you slap it into a full-blown Ubuntu or Alpine image, complete with a package manager and a shell you’ll never use? You wouldn’t. That’s like using a cargo ship to deliver a single, perfect diamond. Enter scratch and its more sophisticated cousin, distroless.
The scratch base image isn’t a thing you download. It’s a concept. It’s the empty canvas of the Docker world. It contains absolutely nothing. No libc, no shell, not even /bin or /tmp. It is the digital void. This is terrifying and brilliant in equal measure. Your binary, and only your binary, gets dropped into this void. If your binary is truly static, it will run. If it’s not, it will fail immediately with a cryptic error you can’t debug because you have no shell to docker exec into. More on that nightmare later.
distroless images, a Google project, are the thoughtful middle ground. They provide the absolute bare minimum: typically just the CA certificates (so your app can make TLS connections without erroring out) and a basic root filesystem structure (/tmp, /home, etc.). They are meticulously maintained, patched for security, and crucially, they also contain no shell, package managers, or other unnecessary cruft. They are what scratch aspires to be when it grows up and gets a security audit.
The Bare Metal: Using scratch
Using scratch is the ultimate flex. Your Dockerfile is a masterpiece of minimalism.
# Build stage: get the binary right
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o my-app .
# Final stage: the void awaits
FROM scratch
COPY --from=builder /app/my-app /my-app
# Copy CA certs if your app needs to talk to the outside world (e.g., HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/my-app"]
See that CGO_ENABLED=0? It’s non-negotiable. It tells the compiler to link all dependencies statically. Forget this, and your binary will look for libc in the container, find nothing but empty space, and panic. The COPY for the CA certificates is also crucial for any network calls. You’re manually provisioning the one thing from the host OS your app actually needs. Forget it, and your HTTPS calls will fail with x509: certificate signed by unknown authority.
The Smarter Default: Using distroless
For most people, distroless is the smarter play. It handles the CA certs and basic OS structure for you. There’s a specific image for static binaries: gcr.io/distroless/static.
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o my-app .
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app/my-app /my-app
CMD ["/my-app"]
Notice the :nonroot tag. This is a best practice. The container won’t run as the root user by default, which is a fantastic, easy win for security. The image includes a non-root user called nonroot with a pre-configured home directory.
The Debugging Pitfall (And How to Escape)
You’ve built your image. You run it. It immediately exits with code 1. What now? You can’t docker exec -it my-broken-container /bin/sh because there is no sh. This is the price of admission.
Your debugging toolkit changes. You become a forensic log analyst. You must ensure your application logs everything of importance to stdout or stderr, because that’s all you’ll get from docker logs. For truly stubborn cases, you have two options:
- Temporary Alpine Interrogation: Quickly swap your final stage to
FROM alpineto get a shell and test if the binary even runs. Is it the binary, or the environment? - The Copy-Paste Debugger: Use a multi-stage build to copy your binary into a debug image after the main build. This is my preferred method.
# ... your normal builder stage above ...
# Final stage for production
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app/my-app /my-app
CMD ["/my-app"]
# A separate stage you can build explicitly for debugging
FROM alpine:latest AS debug
WORKDIR /
COPY --from=builder /app/my-app /my-app
# Install any tools you might need (curl, ca-certificates, strace, etc.)
RUN apk add --no-cache strace
CMD ["/my-app"]
Build the debug image with docker build --target debug -t my-app-debug .. Now you can docker run -it my-app-debug /bin/sh and poke around to your heart’s content. It keeps your production image clean and gives you a surgical tool for diagnosis. It acknowledges that sometimes, even the most brilliant of us need to peek under the hood with a blunt instrument.