Distroless Container Images: Smaller, Safer, and More Annoying to Debug
No shell, no package manager, no CVEs you didn't put there yourself

The first time you run a vulnerability scanner against a node:20 image and it reports two hundred CVEs, you have a small crisis of faith. None of those flaws are in your code. They’re in bash, curl, apt, coreutils, the Debian base — an entire operating system you shipped just to run one process. Distroless images are the reaction to that absurdity: a container image containing your application, its runtime dependencies, and nothing else. No shell. No package manager. No ls. The CVE count drops because the attack surface drops, and that’s not a coincidence — it’s the whole point.
1 What “distroless” actually means
Google’s distroless images are minimal base images that include only what a language runtime needs: glibc, CA certificates, /etc/passwd, timezone data, and for the language-specific variants, the runtime itself. There’s no /bin/sh. You cannot docker exec -it into one and poke around, because there is nothing to poke around with. That sounds hostile, and it is — deliberately. An attacker who lands a remote code execution in your app finds no shell to spawn, no wget to pull a second-stage payload, no package manager to install tools. They’re stuck in a near-empty box with one running process.
The natural way to use them is a multi-stage build: do all your messy compilation in a fat builder image, then copy only the artefact into the distroless final stage.
# --- build stage: has the whole toolchain ---
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# --- final stage: just the binary ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]That final image is a handful of megabytes, runs as non-root by default, and a scanner finds essentially nothing to complain about because there’s essentially nothing there. For a statically-linked Go or Rust binary you can go further still with gcr.io/distroless/static or even scratch.
2 The debugging tax is real
Here’s the part nobody puts on the marketing slide. The day your container is crash-looping in production and you reach for the usual reflexes:
$ docker exec -it web sh
OCI runtime exec failed: exec: "sh": executable file not found in $PATH
There is no shell. There never was. Your muscle memory is useless. You can’t cat a config file, can’t curl localhost:8080/health, can’t ps. Everything you’d normally do to inspect a misbehaving container is gone along with the attack surface you were so pleased to remove. This is the trade, stated honestly: you have made the image safer and you have made it much harder to inspect by hand.
The workarounds exist and you should know them before you need them, not during an incident.
The cleanest is an ephemeral debug container that shares the broken container’s namespaces. On Docker:
# Attach a full toolbox to the running container's PID/network namespace
$ docker run -it --rm \
--pid=container:web \
--network=container:web \
nicolaka/netshoot
On Kubernetes the equivalent is first-class:
$ kubectl debug -it web-7c9f --image=busybox --target=app -- sh
Both give you a shell next to the application — sharing its network and process view — without baking any of that into the shipped image. There’s also :debug tags of the distroless images themselves, which include a minimal BusyBox shell for non-production builds. Use those in staging if you must, never in prod.
3 When the trade is worth it
I run distroless for anything that compiles to a static binary, because the cost is almost nil: Go and Rust don’t need a shell at runtime, the multi-stage build is barely more code, and the security and size wins are free money. For interpreted runtimes — Python, Node — distroless still works but the calculus is closer, because those ecosystems lean on shelling out more than people admit, and the occasional library that calls subprocess or expects /bin/sh will fail in confusing ways.
So, is it worth it? For production services, yes, with eyes open. A smaller image pulls faster, a smaller attack surface genuinely reduces risk, and “an attacker can’t get a shell” is a real mitigation, not security theatre. But adopt it as a team decision, not a unilateral one — the first colleague who tries to exec into a crashing prod pod at 3am and finds nothing there will not thank you for the surprise. Document the kubectl debug workflow, put it in the runbook, and practise it once before you need it. Distroless isn’t harder, exactly; it’s just differently hard, and the difficulty lands at the worst possible moment if you haven’t prepared. Prepared, it’s one of the cleanest security wins available.




