Skip to content

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 builder stage vs a lean runtime stage
  • Static linking with CGO_ENABLED=0; -ldflags to strip and stamp version
  • Layer caching: copy go.mod/go.sum before source for fast rebuilds

📖 Reading / Sources

📝 Notes

  • A multi-stage build uses several FROM lines. 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) or distroless (covered [[day-150]]). This is why Go images are so small compared to interpreted runtimes.
  • CGO_ENABLED=0 forces pure-Go (no libc) so the binary is truly static and runs on scratch. With CGO on, the binary dynamically links glibc and breaks on scratch.
  • Layer cache rule: copy go.mod+go.sum and run go mod download before 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 -w drop the symbol table and DWARF (smaller binary); -X injects a value into a var.
  • 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 USER and explicit EXPOSE/ENTRYPOINT round 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 → the scratch image 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 the COPY lines.

❓ Open Questions

  • When is scratch too minimal (no /etc/ssl/certs, no timezone DB, no nobody user) and I should jump to distroless? → [[day-150]].

🧠 Active Recall (answer without looking)

  1. 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)