Safeguard
Container Security

Minimal container images with ko: evaluating distroless Go builds

ko builds Go containers straight from source onto a shell-less distroless base with no Dockerfile — cutting attack surface, and debugging tools, at once.

Safeguard Research Team
Research
7 min read

A typical golang:1.22 builder image ships a full Debian userland — a shell, apt, curl, and hundreds of OS packages an attacker can use for post-exploitation, none of which a compiled Go binary needs at runtime. ko, the build tool originally released as google/ko and now maintained under the dedicated ko-build GitHub organization, takes a different approach: it invokes the Go compiler directly on the host, skips the Dockerfile entirely, and places the resulting binary onto gcr.io/distroless/static:nonroot by default — a base image with no shell, no package manager, and a non-root user already configured. No docker build, no local daemon, and by default a generated SBOM (SPDX by default, with a CycloneDX option) attached to every image, with reproducible, multi-architecture output from a single command. This isn't a marginal packaging convenience. For teams running Go microservices, it changes what a compromised container can actually do, and it changes what a vulnerability scanner finds when it looks. This post evaluates ko-built minimal images against traditional multi-stage Dockerfiles across attack surface, CVE exposure, SBOM coverage, and the operational tradeoffs — namely debugging and scanning tooling — that minimalism doesn't get for free.

What does ko actually remove from a Go container image?

ko removes the entire OS build toolchain and userland that a traditional Dockerfile stage carries into the final image unless someone deliberately strips it back out. A naive single-stage FROM golang:1.22 Dockerfile ships the Go compiler, git, gcc, and a full Debian or Alpine package set in production — none of which the running service needs, all of which are attack surface. ko sidesteps this by compiling on the host (or in CI) and copying only the resulting static binary onto gcr.io/distroless/static:nonroot, a Google-maintained base that contains libc, ca-certificates, tzdata, and nothing else: no shell (/bin/sh doesn't exist), no package manager, no interactive user session. The practical effect is that even if an attacker achieves remote code execution in the Go binary itself, there's no sh, curl, wget, or bash present to pivot into a second-stage payload download or an interactive foothold — the exploit primitive stops at whatever the Go process itself can do.

How does this compare to a well-written multi-stage Dockerfile?

A carefully authored multi-stage Dockerfile can reach a materially similar end state — a COPY --from=builder step that lands only the compiled binary onto FROM scratch or FROM gcr.io/distroless/static is a well-known pattern, and plenty of security-conscious Go teams already do this by hand. The difference ko introduces is automation and defaults, not a capability that's otherwise unreachable. Writing that multi-stage pattern correctly requires remembering to set CGO_ENABLED=0, statically link, choose a distroless or scratch final stage, and configure a non-root user — and it's easy to get one of those wrong under deadline pressure, silently leaving a shell or a dynamically-linked binary that fails at runtime against a FROM scratch target. ko encodes those decisions as defaults: static-by-default builds, nonroot base, no Dockerfile to drift out of sync with the codebase. The tradeoff is scope — ko is Go-specific and won't help a polyglot service with a Python sidecar, where a hand-authored Dockerfile remains the only option.

Does removing OS packages actually reduce CVE exposure, or just hide it?

It genuinely reduces exposure, because most container CVE counts come from the OS package layer, not the application binary. A standard Debian or Ubuntu base image carries hundreds of installed packages — openssl, libc6, zlib1g, and dozens more — each a separate line item a scanner can flag against the National Vulnerability Database, regardless of whether the running application ever calls into that package. Distroless images cut that surface down to essentially libc, ca-certificates, and tzdata, which is why distroless and scratch-based images consistently show far fewer CVE findings in vulnerability scan reports than their full-OS equivalents for the identical application code. This is a real reduction in exploitable surface, not just a reduction in noise: a shell that isn't present can't be exec'd into regardless of what a scanner reports. It is worth being precise about what it doesn't do — a CVE in the Go standard library itself, or in a vendored dependency compiled into the binary, is still present and still needs patching; distroless removes the OS layer's CVEs, not the application layer's.

What breaks when you remove the shell — and how do teams compensate?

The most immediate operational cost is incident response: kubectl exec -it <pod> -- /bin/sh simply fails against a distroless container, because there's no shell binary to exec into. Teams accustomed to live-debugging a running pod by attaching a shell and inspecting file descriptors, environment variables, or network connections lose that workflow entirely. The standard compensating control is Kubernetes' ephemeral containers feature (stable since Kubernetes 1.25), which lets an operator attach a separate debug container — one that does carry a shell and diagnostic tools — into the target pod's namespaces without modifying the running distroless container itself. Some teams also keep a -debug variant image tagged alongside the production distroless build, built from the same source but on a base with a shell, pulled only when an incident requires it. Either approach adds a step to the debugging workflow that a traditional Dockerfile with a shell baked in doesn't require — a real cost that should be budgeted for, not waved away.

Do traditional vulnerability scanners still work against distroless images?

Mostly, but with a gap worth knowing about. Traditional container scanners built around OS package managers — reading dpkg, rpm, or apk databases to enumerate installed packages and match them against CVE feeds — find little or nothing useful to enumerate in a distroless image, because there is no package database to read. That doesn't mean the image is unscannable; it means scanning has to shift from package-database enumeration to binary-level analysis: identifying the Go runtime version and vendored module versions compiled into the binary itself, which is exactly what an SBOM generated at build time (as ko does by default) is designed to capture. Without that SBOM, a team can end up in a worse position than before — assuming "no CVEs found" means "no CVEs present," when it actually means "the scanner had nothing to enumerate." The fix is treating the build-time SBOM, not a runtime package scan, as the source of truth for what's actually inside a minimal image.

How Safeguard Helps

Safeguard's SBOM generation covers exactly the gap that minimal images create: it ingests container images from Amazon ECR, Docker Hub, GCP Cloud Source, and generic OCI registries and generates a CycloneDX or SPDX SBOM from the binary layer itself, not from a package-manager database — so a FROM scratch or ko-built distroless image gets the same continuous vulnerability visibility as a traditional Debian-based one. Self-healing containers extends that visibility into remediation: when a new CVE lands against a component baked into a distroless image, Safeguard tracks which binaries are present and rebuilds only the affected layer, rather than requiring a full Dockerfile rewrite, and Griffin can plan an upstream bump or a Gold-hardened substitution automatically. For teams adopting ko or hand-authored distroless builds specifically to shrink attack surface, that combination means the SBOM gap doesn't reopen the visibility hole that minimal images were built to close in the first place.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.