A 900 MB Image Containing an 8 MB Program
The service binary is 8 MB. The image is 900 MB — a full Go toolchain, git, and the source code shipped to every node, because the Dockerfile used the build image as the runtime. The costs are not theoretical: slower pulls on every deploy, a larger attack surface with compilers and shells inside the container, and a build that fails differently in CI than locally. The multi-stage build fixes all three by separating where the binary is made from where it is run.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Each line earns its place:
CGO_ENABLED=0 produces a pure-Go binary with no libc dependency, which is what lets the runtime image be scratch-class (distroless static, or literally empty). The moment CGO is on — usually by an import of a C-backed library — the binary links against glibc, and running it in a static image fails with a loader error at startup.
The two stages share nothing. The builder has the toolchain; the runtime stage never sees it. What crosses the boundary is one file, so the runtime image contains the binary and nothing else — plus, in distroless/static, CA certificates and time zone data, which are easy to forget and manifest as x509: certificate signed by unknown authority the first time the service makes a TLS call. A true FROM scratch image omits both: add COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ or accept the failure.
-trimpath strips absolute local paths from the binary (they leak build-environment layout and defeat reproducibility); -ldflags="-s -w" removes the symbol table and DWARF data — useful for size, at the cost of panic stack symbolication unless you keep a separate unstripped binary for debugging. Neither is required; both are decisions you make once and document.
ENTRYPOINT ["/server"] is the exec form — no shell wrapper, so the Go binary runs as PID 1 and receives SIGTERM directly. The shell form (ENTRYPOINT /server) puts /bin/sh in between: the orchestrator signals the shell, the shell often does not forward it, and the graceful drain from Graceful Shutdown and Connection Drain never happens — the container hits its kill timeout on every deploy.
USER nonroot:nonroot drops privileges after bind; combined with a read-only root filesystem in the pod spec, it closes the classic container escape paths. The pattern generalizes: the image should contain the artifact and the certificates it needs to do its job — and since Embedding Assets with go:embed already put templates and static files inside the binary, no asset directory ever crosses the stage boundary.
Layer Order: Why Every Deploy Rebuilds From Zero
The Dockerfile is correct but every deploy takes four minutes: COPY . . precedes go mod download, so editing a single Go file invalidates the module layer and re-downloads every dependency on every build. Docker caches each layer by instruction and content — a changed instruction invalidates that layer and every layer after it. Layer order is therefore a caching strategy, written top to bottom from most stable to most volatile.
FROM golang:1.22-alpine AS build
WORKDIR /src
# 1. Dependencies change rarely — cache them separately.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# 2. Source changes constantly — this invalidates only here.
COPY . .
# 3. Build with the module cache mounted, not copied into the layer.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
The go.mod/go.sum copy sits alone because those two files determine the dependency set: unchanged, go mod download is a cache hit; changed, it re-runs — which is exactly the condition that warrants it. The source copy follows, so a code edit skips the download entirely. Two --mount=type=cache directives finish the job: the module cache and the build cache live outside the layer filesystem, shared across builds and across branches. Without them, go build recompiles the entire dependency graph per deploy; with them, incremental builds finish in seconds. The cache mounts need the # syntax=docker/dockerfile:1 front end (or BuildKit, now the default builder).
.dockerignore keeps the invalidation surface small. COPY . . without it drags in .git (which changes on every commit), node_modules, local build artifacts, .env files (a secret leak waiting for review), and — critically — any dist/ directory a previous build left behind. A minimal one:
.git
.env*
node_modules
dist
*.out
Every entry excluded here can no longer invalidate the cache or leak into an image layer, and the second job matters more: COPY . . at the wrong time is how .env ends up readable in an image that reaches a registry.
Cross-platform builds need their own variables. A macOS developer building an image produces an arm64 binary unless told otherwise, and the container dies on amd64 nodes with exec format error. The platform belongs in the build, not in the environment:
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/server ./cmd/server
docker build passes TARGETOS/TARGETARCH automatically, and docker buildx build --platform linux/amd64,linux/arm64 emits both. The general shape — dependencies before source, caches mounted rather than layered, platform pinned explicitly — is what turns a four-minute rebuild into a forty-second one, and it is measured the same way you would measure any performance claim: time ten builds before and after, because caching claims without numbers are folklore.
Running It Right: Signals, Probes, and the Startup Order
The image builds and starts. Whether it behaves in an orchestrator depends on four runtime decisions that are invisible in a local docker run and decisive in production.
Signals reach the process, or shutdown is theater. Covered partly by the exec-form ENTRYPOINT: the Go binary as PID 1 receives SIGTERM and can run the drain sequence. Two additions complete it. If the process spawns children (a CLI that shells out), PID 1 does not reap zombies by default — a tiny init (tini, via ENTRYPOINT ["tini", "--", "/server"]) handles both reaping and signal forwarding. And the pod's terminationGracePeriodSeconds must exceed the drain deadline from your shutdown code, or the platform kills before your drain finishes; the budgets belong together, not in two different files nobody reconciles.
Probes map to the health semantics. The orchestrator's liveness probe hits /healthz, readiness hits /readyz — different endpoints because they mean different things (Metrics, Logs, and Traces). A working configuration:
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
initialDelaySeconds: 3
Note the asymmetry: readiness gets a small initial delay so a cold start does not fail the probe; liveness gets a generous failure threshold so a slow GC pause does not trigger a restart loop. A Dockerfile HEALTHCHECK exists for plain docker run, but in Kubernetes the probes in the pod spec are authoritative — duplicating the logic invites the two to disagree.
Startup order inside the process matters more than the YAML. The tempting sequence — open the listener, then connect the database — accepts traffic before the service can serve it, and readiness passes during a failing boot. The correct sequence inverts it: load and validate config (Configuration and Secrets Handling), connect and ping the database, warm caches, then set ready = true and start accepting. A failure at any step exits nonzero with a clear error, and the orchestrator retries the rollout instead of routing users into a half-initialized process.
Operational hygiene wraps the whole thing: run as the non-root user already declared in the image; mount secrets as files rather than baking them in; set a read-only root filesystem so a compromised process cannot write anywhere except declared emptyDir volumes; and tag images by immutable digest or git sha, never :latest, because :latest makes a rollback indistinguishable from a cache miss. Then verify the claims — docker run the image, send SIGTERM, and watch the drain log appear; curl readiness during shutdown and see the 503; docker inspect the image size. The image is an artifact like any other: it gets built, tested, and measured, and the difference between "it runs" and "it deploys" is exactly the number of these checks that pass before rollout.
Quiz
1. A Dockerfile builds the binary in golang:1.22 (full Debian) and runs it from the same image. What are the two biggest problems?
2. Every source edit re-runs `go mod download`. What Dockerfile change fixes it?
3. ENTRYPOINT is written as `ENTRYPOINT /server` (shell form). What happens on SIGTERM during a deploy?
4. A scratch-based image fails at runtime with `x509: certificate signed by unknown authority` when calling an HTTPS API. Why?
Flashcards
Question
What does CGO_ENABLED=0 change about a Go build, and why does the Dockerfile set it?
Click to reveal answer
Answer
It disables the C toolchain so the binary links no libc and is fully static. That is what makes a scratch or distroless-static runtime possible — with CGO on (often via a C-backed dependency), the binary expects glibc and fails to load in a static image.
Question
Why does `COPY go.mod go.sum` + `RUN go mod download` come before `COPY . .` in a Go Dockerfile?
Click to reveal answer
Answer
Layer caching invalidates from the changed instruction onward. Module files change rarely, so the dependency download stays cached across source edits; copying source first would invalidate the download layer on every commit and re-fetch all modules each build.
Question
What do --mount=type=cache mounts give a Go build, and what do they replace?
Click to reveal answer
Answer
They keep the module cache (/go/pkg/mod) and build cache (~/.cache/go-build) outside the layer filesystem, shared across builds and branches, so go build is incremental instead of recompiling every dependency from scratch. They replace storing caches inside COPY/RUN layers, which would bloat images and miss across builds.
Question
What does .dockerignore prevent beyond image bloat?
Click to reveal answer
Answer
It keeps volatile and sensitive files out of the build context: .git invalidates the COPY . . layer on every commit, and .env files copied into an image layer become readable to anyone with registry access. Excluding them is both a cache and a secrets control.
Question
How do exec-form ENTRYPOINT, a non-root USER, and a pod terminationGracePeriodSeconds relate to graceful shutdown?
Click to reveal answer
Answer
Exec form runs the binary as PID 1 so SIGTERM actually reaches your handler; USER nonroot limits what a compromised process can do after it handles the signal; terminationGracePeriodSeconds must exceed your drain deadline or the platform SIGKILLs before draining completes. Missing any one breaks either delivery, safety, or time budget of the drain.
Revision Notes
Key Takeaways
- 1. Multi-stage builds separate toolchain from runtime: only the binary crosses the boundary
- 2. Layer order is a cache strategy — module files before source, cache mounts for module and build caches
- 3. CGO_ENABLED=0 is what makes static images possible; CA certificates are what make them actually work
- 4. Exec-form ENTRYPOINT is the link between your graceful-shutdown code and the orchestrator's SIGTERM
- 5. A deployable image is measured: image size, rebuild time, signal behavior, and probe responses all get checked
Interview Tips
- • "Show me your Dockerfile for a Go service" — walk multi-stage, layer ordering for cache, CGO_ENABLED=0, distroless/scratch runtime, non-root user, exec ENTRYPOINT
- • "Why is my container getting SIGKILLed on deploy?" — shell-form ENTRYPOINT swallowing SIGTERM or terminationGracePeriodSeconds shorter than the drain budget; give both
- • "How do you make Go Docker builds fast?" — module layer before COPY . ., --mount=type=cache for mod and build caches, and .dockerignore to stop .git invalidating every build
- • "Scratch or distroless?" — tradeoff: smallest possible versus bundled CA certs and nonroot user; name the x509 failure as the classic scratch gotcha
Cheat Sheet
Containerizing Go Services
Multi-stage template:
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Layer order rule: most stable first — go.mod/go.sum → download → source → build.
Build flags: CGO_ENABLED=0 (static, enables scratch); -trimpath (no local paths); -ldflags "-s -w" (strip symbols, smaller, loses panic symbolication).
Runtime checklist: exec-form ENTRYPOINT (signals reach PID 1) · USER nonroot · CA certs present for TLS (distroless static has them, scratch does not) · probes → /healthz + /readyz · config validated before listening · immutable tags, never :latest.
.dockerignore: .git, .env*, node_modules, dist, *.out — cache hygiene and secrets hygiene in one file.
Continue Learning
Before You Start
- No prerequisites listed.