Ship the binary, not the distro
A Go service does not need a Linux distribution to run. It needs a kernel, a binary, and — if it makes outbound TLS calls — a CA bundle. Everything else in a FROM alpine image is attack surface you are carrying for no reason.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Under twenty megabytes, most of which is the binary.
The flags
CGO_ENABLED=0 is the one that makes the rest possible. With cgo on, the binary links against the build image's libc and will not run on a base that lacks it — the classic symptom being a no such file or directory error for a binary that plainly exists. That message is the dynamic loader failing to find the interpreter, not the file.
Turning cgo off also switches net and os/user to pure-Go implementations. The DNS resolver behaviour changes slightly as a result, which is worth knowing before you debug it as a networking problem.
-trimpath strips local filesystem paths from the binary, so stack traces do not disclose your home directory. -ldflags="-s -w" drops the symbol table and DWARF data — a few megabytes, at the cost of not being able to run a debugger against the shipped artefact. Keep the unstripped build if you rely on that.
scratch vs distroless
FROM scratch is the smallest thing possible and is missing four things you will discover one at a time:
- CA certificates. Every HTTPS call fails with
x509: certificate signed by unknown authority. /etc/passwd. No non-root user exists to run as.- tzdata.
time.LoadLocationfails for anything but UTC. (import _ "time/tzdata"embeds it in the binary if you need it.) /tmp. Nothing that wants a temporary file works.
distroless/static provides the first three and no shell, no package manager, and no libc. The :nonroot tag runs as UID 65532 by default, which means you do not have to remember USER.
What you give up
There is no shell, so kubectl exec -it pod -- sh does not work. That is the trade, and it is a real one at 3am.
The replacement is ephemeral debug containers:
kubectl debug -it pod/app --image=busybox --target=app
That attaches a container with a shell into the running pod's namespaces, so you can inspect the process, its network, and — with --target — its filesystem via /proc/1/root. The tooling is a shared concern, not something each image must carry.
Two other things to plan for rather than discover:
- Healthchecks cannot shell out.
HEALTHCHECK CMD curl ...has no curl. Use an HTTP probe from the orchestrator, which you wanted anyway. - Signals reach PID 1 directly. There is no shell to swallow them, so your
ENTRYPOINTmust be exec-form (["/app"], not/app) and your program must handleSIGTERMto shut down gracefully instead of being killed after the grace period.
The last point is the one that catches people, and it is not a distroless problem — a shell-form entrypoint makes PID 1 a shell that never forwards SIGTERM, so your service is hard-killed on every deploy. Distroless just removes the shell that was hiding it.