Day 148 — Multi-stage Docker builds¶
Month 6 · Week 2 · ⬅ Day 147 · Day 149 ➡ · Journal index
🎯 Learning Objective¶
Package a Go service into a tiny, reproducible container image using a multi-stage Dockerfile, and understand why Go is uniquely suited to FROM scratch.
📚 Topics¶
- Multi-stage builds: a fat
builderstage vs a lean runtime stage - Static linking with
CGO_ENABLED=0;-ldflagsto strip and stamp version - Layer caching: copy
go.mod/go.sumbefore source for fast rebuilds
📖 Reading / Sources¶
- Docker — Multi-stage builds
- Go Wiki — Reducing build artifacts / static binaries (CGO notes)
-
go build&-ldflagsreference -
runtime/debug.ReadBuildInfo
📝 Notes¶
- A multi-stage build uses several
FROMlines. Only the last stage becomes the image; earlier stages (compiler, git, build cache) are discarded. Result: a 900 MB build toolchain produces a ~10 MB image → [[multi-stage-build]]. - Go compiles to a single static binary, so the runtime stage needs almost nothing —
scratch(empty) ordistroless(covered [[day-150]]). This is why Go images are so small compared to interpreted runtimes. CGO_ENABLED=0forces pure-Go (no libc) so the binary is truly static and runs onscratch. With CGO on, the binary dynamically links glibc and breaks onscratch.- Layer cache rule: copy
go.mod+go.sumand rungo mod downloadbefore copying source. Source changes invalidate later layers but the dependency layer stays cached → fast incremental builds → [[docker-layer-cache]]. - Strip debug info and stamp a version with
-ldflags="-s -w -X main.version=$VERSION".-s -wdrop the symbol table and DWARF (smaller binary);-Xinjects a value into avar. - Use BuildKit cache mounts (
--mount=type=cache,target=/root/.cache/go-build) to persist the compiler cache across builds without baking it into a layer. - A non-root
USERand explicitEXPOSE/ENTRYPOINTround out a production image; never run as root by default.
💻 Code Examples¶
# syntax=docker/dockerfile:1
# ---- build stage: full toolchain, thrown away later ----
FROM golang:1.22 AS builder
WORKDIR /src
# Dependency layer first so it caches across source edits.
COPY go.mod go.sum ./
RUN go mod download
# Now the source. Build a static, stripped, version-stamped binary.
COPY . .
ARG VERSION=dev
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/app ./cmd/app
# ---- runtime stage: nothing but the binary ----
FROM scratch
COPY --from=builder /out/app /app
EXPOSE 8080
USER 65534:65534 # nobody:nobody
ENTRYPOINT ["/app"]
The version stamped above is read back at runtime with runtime/debug.ReadBuildInfo and an ldflags var.
Full code:
examples/month-06/buildinfo/main.go· Run:go run ./examples/month-06/buildinfo
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Inspect embedded build info (ReadBuildInfo) |
✅ | examples/month-06/buildinfo |
| Parse 12-factor config (used by the image) | ✅ | exercises/month-06/week-2/envconfig |
🐛 Mistakes Made¶
- Forgot
CGO_ENABLED=0→ thescratchimage crashed with "no such file or directory" (the binary needed glibc). Disabling CGO fixed it. - Copied source before
go.mod, so every code edit re-downloaded all modules. Reordered theCOPYlines.
❓ Open Questions¶
- When is
scratchtoo minimal (no/etc/ssl/certs, no timezone DB, nonobodyuser) and I should jump to distroless? → [[day-150]].
🧠 Active Recall (answer without looking)¶
- Q: Why does a multi-stage build produce a smaller image than a single-stage one?
A
Only the final stage ships. The compiler, git, module cache, and source live in earlier stages that are discarded, so the runtime image contains just the binary (and maybe a few certs).
2. Q: Why is CGO_ENABLED=0 needed for FROM scratch? A
It forces a pure-Go static binary with no dynamic libc dependency. scratch has no shared libraries, so a CGO-linked binary (which needs glibc) would fail to start.
🪶 Feynman Reflection¶
A multi-stage Dockerfile is like cooking in a messy kitchen and then serving only the finished dish on a clean plate — the pots, peelings, and raw ingredients (compiler, source, cache) stay behind. Because Go bakes everything into one static binary, the "plate" can be literally empty (scratch).
🕳️ Knowledge Gaps¶
- BuildKit cache-mount semantics across CI runners — revisit on [[day-151]] (GitHub Actions).
✅ Summary¶
I can write a cache-friendly multi-stage Dockerfile that builds a static, stripped, version-stamped Go binary into a scratch image a few MB in size.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 149: wire the image into a local stack with
docker compose(app + Postgres + Redis).
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: docs(journal): multi-stage docker builds (day 148)