The Container Diet Nobody Asked For
You’ve probably seen the benchmarks. Someone posts a Docker image comparison and the distroless version is 15 MB while the Ubuntu base is 180 MB and everyone in the comments acts like they’ve found religion. “Distroless! Supply chain security! Minimal attack surface!” The security team starts putting it in every RFC. Your Kubernetes cluster becomes a monastery of silent, shell-less containers.
Then something breaks in production.
You kubectl exec -it <pod> -- /bin/sh and get back: OCI runtime exec failed: exec failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directory. No shell. No curl. No ls. No cat. No ps. Nothing. Just your application binary and existential dread.
This is the distroless experience in full. It’s genuinely useful — just not for everything, and definitely not the way most teams reach for it.
What Distroless Actually Is
Google’s distroless images (gcr.io/distroless/...) are container base images that contain only your application and its runtime dependencies. No package manager. No shell. No system utilities. No /bin, no /usr/bin, no anything you’d recognize from a normal Linux system.
Compare that to the other common minimal options:
scratch — literally nothing. An empty filesystem. You ship your statically compiled binary and that’s it. Zero OS overhead. Ideal for Go binaries that don’t need libc. Also ideal for making your teammates hate you when they have to figure out why TLS isn’t working.
Alpine — a full musl-libc based Linux distro, but tiny. About 7 MB base. Has ash shell, apk package manager, busybox utilities. The classic “small but functional” choice.
Distroless — sits between Alpine and scratch. Has glibc (so your Java, Python, Node, or Go apps that need it will work), has CA certificates, has timezone data, has a nonroot user. Has absolutely nothing else.
Here’s a rough size comparison for a Node.js app:
| Base Image | Compressed Size |
|---|---|
node:20 | ~340 MB |
node:20-alpine | ~60 MB |
gcr.io/distroless/nodejs20-debian12 | ~115 MB |
| Custom scratch (static binary) | 5–20 MB |
Wait — distroless is bigger than Alpine for Node? Yep. Because distroless bundles glibc and the full Node runtime. The size advantage evaporates for interpreted runtimes. The security story is still there, but size alone isn’t the selling point unless you’re working with compiled languages.
The Real Security Win (and It’s Not the Size)
Here’s the thing people get wrong about distroless: the security value isn’t primarily the smaller image size. It’s the reduction in attack surface and exploitability.
When an attacker gets code execution inside an Alpine container, they have wget, curl, nc, sh, a package manager, and a full environment to work with. They can download tools, exfiltrate data, pivot to other services. It’s uncomfortable how capable a compromised Alpine container is.
In a distroless container, they have your application binary. That’s it. They can’t run shell commands, can’t install anything, can’t easily pivot. The container becomes a much harder place to operate from even after initial compromise.
The other genuine win is supply chain. Every package in your image is a potential CVE. Alpine ships hundreds of packages. Distroless ships almost none. Smaller dependency footprint means fewer CVEs, faster Trivy scans, happier security teams, and less time spent triaging false positives at 11 PM.
The third win is the forced discipline. When you can’t apt-get install curl at container build time as a lazy fix, you write better Dockerfiles. You actually think about what your application needs.
Multi-Stage Builds: The Only Sane Way to Use Distroless
You’d never build directly on a distroless image — there’s nothing to build with. Multi-stage builds are how this actually works in practice.
Here’s a Go example that goes from full builder to distroless final image:
# Stage 1: BuildFROM golang:1.22-alpine AS builder
WORKDIR /appCOPY go.mod go.sum ./RUN go mod download
COPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Stage 2: Distroless final imageFROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]The static-debian12 variant is for statically compiled binaries (no glibc needed). For Go with CGO_ENABLED=0, this is perfect. The final image is your binary plus essentially nothing.
For a Python app, you’d use gcr.io/distroless/python3-debian12, and the multi-stage gets more involved because you need to copy your virtualenv and application code:
FROM python:3.12-slim AS builder
WORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt
COPY src/ ./src/
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /app/deps /app/depsCOPY --from=builder /app/src /app/src
ENV PYTHONPATH=/app/deps
WORKDIR /appCMD ["/app/src/main.py"]Less clean than the Go version. More footguns. If your dependency tree has any compiled extensions with dynamic linking assumptions, you’ll spend quality time figuring out why libgomp.so.1 doesn’t exist.
The 2 AM Problem: Debugging Without a Shell
Everything above sounds great until something is wrong and you need to look inside the container.
Standard exec fails immediately:
$ kubectl exec -it my-distroless-pod -- /bin/sherror: Internal error occurred: error executing command in container:failed to exec in container: failed to start exec "...":OCI runtime exec failed: exec failed: unable to start container process:exec: "/bin/sh": stat /bin/sh: no such file or directory: unknownGoogle anticipated this and ships :debug variants for every distroless image. The debug variant adds busybox, giving you a minimal shell and basic utilities:
# In dev/staging only — never ship :debug to productionFROM gcr.io/distroless/static-debian12:debug
# Then at runtime, exec works:$ kubectl exec -it my-pod -- /busybox/shBut you can’t swap base images in running production containers. This is where Kubernetes ephemeral containers come in, and honestly this feature is underused.
# Attach a debug sidecar to a running distroless pod without restarting it$ kubectl debug -it my-distroless-pod \ --image=busybox:latest \ --target=my-container \ --share-processes
# Now you have a shell, and can inspect /proc/<pid>/root to see the target container's filesystem$ ls /proc/$(pgrep -f myapp)/root/The --share-processes flag gives the ephemeral container visibility into the target container’s process namespace. You can inspect open files, environment variables, network connections — everything you need to debug without modifying the original image. It’s like attaching a diagnostic sidecar to a running car engine without stopping the car, which sounds insane until you’ve tried to reproduce a production-only bug in staging for three days.
There’s also ko for Go specifically — a tool from Google that builds and pushes minimal Go container images without requiring a Dockerfile at all. It handles the multi-stage build pattern automatically and defaults to distroless base images:
# Build and push a Go binary as a distroless image$ ko build ./cmd/server --base-import-paths
# With a KO_DOCKER_REPO set, it pushes directly to your registry$ KO_DOCKER_REPO=your-registry.io/your-org ko build ./cmd/serverko is excellent for Go microservices shops that want distroless without writing and maintaining Dockerfiles for every service.
When Distroless Is Genuinely Worth It
Let’s be concrete about the cases where distroless pays for the pain it introduces.
Production workloads with a threat model. If you’re running internet-facing services and have thought about what an attacker can do with container code execution, distroless is a legitimate defense layer. The shell removal matters when the threat is real.
Security-sensitive services. Auth services, secret managers, payment processors, anything that touches credentials or PII. The reduced blast radius from a compromised container is worth the operational overhead.
Supply chain compliance. If your organization does SBOM generation, signs images with Sigstore/Cosign, or has compliance requirements around provenance, distroless images have dramatically fewer components to account for. Your attestation story becomes much cleaner.
Go and Rust services. Statically compiled binaries in distroless are genuinely elegant. The multi-stage Dockerfile is clean, the image is tiny, and the runtime is essentially just your binary. This is the use case distroless was designed for.
Regulated industries. Healthcare, finance, government. When auditors ask about container security posture, “we don’t ship shells into production” is a satisfying answer.
When to Skip It Entirely
Development environments. Please don’t. You will hate yourself. Use node:20 or python:3.12 locally, iterate fast, and save the distroless discipline for CI/prod.
Internal tools and admin services. Your internal metrics aggregator, the script that runs database migrations, the webhook handler for your internal chat integration — these don’t face the internet. The security ROI from distroless here is near zero and the debugging overhead is 100% real.
Anything you exec into regularly. If your operational runbook has kubectl exec steps in it, you’re signing yourself up for pain. Either fix the runbook (build proper health endpoints, structured logging, metrics) or use Alpine.
Interpreted language services under heavy development. Python and Ruby distroless setups are fiddly. Dynamic linking, native extensions, and dependency isolation issues will cost you hours you don’t want to spend. Get the architecture right with a normal base image first, then harden later if the workload warrants it.
Small teams without platform/ops dedicated bandwidth. The ephemeral container trick is great, but it requires your team to know about it, practice it, and have the right RBAC configured. If you’re a three-person team that needs to be able to debug things quickly, Alpine is the pragmatic choice.
The Decision Rule
If you’re trying to figure out whether your next service should use distroless, here’s the honest version:
Use distroless when:
- Compiled binary (Go, Rust, C/C++) — the fit is natural
- Internet-facing and you’ve done a threat model
- Compliance, SBOM, or supply chain attestation requirements exist
- You have Kubernetes ephemeral containers configured and your team knows how to use them
Skip distroless when:
- Interpreted runtime (Python, Node, Ruby) and you’re still in active development
- Internal service with no external exposure
- Team is small and fast debugging matters more than hardening
- Your operational tooling assumes shell access
Use Alpine when you’re in between — it’s the right default for most workloads. The attack surface is bigger than distroless, but it’s manageable, and the operational experience is dramatically better. Add RUN apk --no-cache add restrictions, use a nonroot user, pin your package versions, and you’ll have a container that’s secure enough for most threat models while still being debuggable when you need to be.
The container security community sometimes makes this feel like a binary choice between “irresponsible Alpine user” and “enlightened distroless practitioner.” It’s not. Distroless is a production hardening tool with real costs. Use it where those costs are justified.
Your 2 AM self will thank you for being honest about which category your service actually falls into.