Chapter 10Lesson 02~130 minutes

Multi-Stage Builds, Minimal Runtime Images, Distroless Patterns, Debug Stages, and Build Separation: Guided Hands-On Workflow and Core Operations

Turn a deliberately toolchain-heavy Go image into named build, single-stage, debug, and scratch runtime targets. The workflow records base references, Buildx/BuildKit state, target-specific image size and user configuration, static-binary evidence, metadata-file digests, and a safe debugging path that never modifies the release image.

Hands-onNamed stages--targetNon-rootImage evidence

Learning objectives

  • Capture Docker, Buildx, builder, frontend, source, and base-image evidence before building any target.
  • Build one Dockerfile as single-stage, debug, and scratch runtime targets and explain exactly which stages BuildKit needs for each target.
  • Verify the release runs as a numeric non-root user and that the scratch target has no shell/package manager by design.
  • Compare image size, history/configuration, binary hashes, and metadata-file output without treating size as a security verdict.
  • Use a separate debug target to inspect the same application artifact while leaving the release image immutable.
Chapter 10 evidence baseline — verified 2026-09-21. Mandatory exercises use only a synthetic Go source file, public base images, local Docker/Buildx, exact chapter-owned tags/containers, and no real credentials, production daemons, registries, or privileged host access. At verification time Docker Engine 29.8.1, Buildx 0.37.1, and BuildKit 0.33.0 (built-in Dockerfile frontend 1.27.0) are the current course baselines, but every lab records the actual installed versions and builder state. Docker documents named multi-stage builds, COPY --from, and --target as current core semantics; scratch remains the empty reserved base. Current Distroless Debian 13 images intentionally omit ordinary shells/package managers and publish separate nonroot/debug/debug-nonroot variants. Base-image tags are readable selectors, not immutable release identity: record or substitute reviewed digests when reproducibility matters.

1. Lab scope: one source tree, four named targets

This lab uses a tiny Go program and one Dockerfile with build, single, debug, and runtime stages. The mandatory path is local and free. It does not publish an image, change daemon configuration, require a registry credential, or require a paid scanner. Base images are public; record the exact digests your builder resolves before treating the result as reproducible.

2. Preflight: capture builder and base assumptions

mkdir -p ch10-lab/evidence
cd ch10-lab

docker version | tee evidence/00-docker-version.txt
docker context show | tee evidence/01-context.txt
docker buildx version | tee evidence/02-buildx-version.txt
docker buildx inspect --bootstrap | tee evidence/03-builder.txt

docker buildx imagetools inspect golang:1.26 \
  | tee evidence/04-golang-base.txt
docker buildx imagetools inspect busybox:1.37.0 \
  | tee evidence/05-debug-base.txt

The tag is human-readable input; the resolved manifest/index and platform-specific digest are immutable identity. For release engineering, substitute reviewed name@sha256:… references after recording the correct platform digest. The examples keep readable tags so the lab remains copyable across architectures, but the evidence packet must explicitly state that limitation if you do not substitute digests.

3. Create the synthetic application

package main

import (
    "fmt"
    "os"
    "runtime"
)

func main() {
    fmt.Printf("chapter10 uid=%d gid=%d platform=%s/%s\\n",
        os.Getuid(), os.Getgid(), runtime.GOOS, runtime.GOARCH)
}
sha256sum main.go | tee evidence/06-main-go-sha256.txt

The program deliberately needs no network, secrets, writable data, shell, CA store, or timezone database. That makes a scratch runtime valid for this exercise. A real application must enumerate its own runtime dependencies rather than copying this base choice blindly.

4. One Dockerfile, explicit responsibilities

# syntax=docker/dockerfile:1
ARG GO_BASE=golang:1.26
ARG DEBUG_BASE=busybox:1.37.0

FROM ${GO_BASE} AS build
WORKDIR /src
COPY main.go .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build -trimpath -ldflags='-s -w -buildid=' -o /out/app ./main.go
RUN sha256sum /out/app > /out/app.sha256

# Deliberately heavy comparison target: it inherits the complete build stage.
FROM build AS single
ENTRYPOINT ["/out/app"]

# Separate diagnostic target with a shell and basic BusyBox tools.
FROM ${DEBUG_BASE} AS debug
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

# Production exercise target: no shell, package manager, or extra files.
FROM scratch AS runtime
COPY --from=build --chown=65532:65532 /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
sha256sum Dockerfile | tee evidence/07-dockerfile-sha256.txt

The single target intentionally demonstrates what happens when the build environment itself becomes the runtime. The debug and runtime targets copy the same application artifact but offer different operational surfaces.

5. Build the toolchain-heavy comparison target

docker buildx build \
  --progress=plain \
  --target single \
  --load \
  --metadata-file evidence/08-single-metadata.json \
  --label devops-academy.lab=ch10 \
  -t devops-academy-ch10:single \
  . 2>&1 | tee evidence/08-single-build.txt

docker image inspect devops-academy-ch10:single \
  --format 'ID={{.Id}} Size={{.Size}} User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}}' \
  | tee evidence/09-single-inspect.txt

This target is expected to be large because it inherits the Go toolchain, source tree, and build-stage filesystem. That is a controlled comparison, not the recommended release design.

6. Build the separate debug target

docker buildx build \
  --progress=plain \
  --target debug \
  --load \
  --metadata-file evidence/10-debug-metadata.json \
  --label devops-academy.lab=ch10 \
  -t devops-academy-ch10:debug \
  . 2>&1 | tee evidence/10-debug-build.txt

docker run --rm devops-academy-ch10:debug \
  | tee evidence/11-debug-run.txt

docker run --rm --entrypoint /bin/sh devops-academy-ch10:debug \
  -c 'echo "debug-shell uid=$(id -u) gid=$(id -g)"; ls -ln /app; sha256sum /app' \
  | tee evidence/12-debug-shell.txt

The debug target remains non-root by default. It has a shell because BusyBox supplies one, but that shell belongs to the debug image identity, not the release image.

7. Build and prove the scratch runtime target

docker buildx build \
  --progress=plain \
  --target runtime \
  --load \
  --metadata-file evidence/13-runtime-metadata.json \
  --label devops-academy.lab=ch10 \
  -t devops-academy-ch10:runtime \
  . 2>&1 | tee evidence/13-runtime-build.txt

docker image inspect devops-academy-ch10:runtime \
  --format 'ID={{.Id}} Size={{.Size}} User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}}' \
  | tee evidence/14-runtime-inspect.txt

docker run --rm devops-academy-ch10:runtime \
  | tee evidence/15-runtime-run.txt

The program should report UID/GID 65532. The numeric identity works even though scratch has no /etc/passwd. If the application requires username lookup, home-directory data, certificates, or other OS files, those become explicit runtime dependencies to add or a reason to select a different base.

8. Prove shell absence without “fixing” it

set +e
docker run --rm --entrypoint /bin/sh devops-academy-ch10:runtime \
  > evidence/16-runtime-shell.stdout \
  2> evidence/16-runtime-shell.stderr
printf 'exit=%s\n' "$?" | tee evidence/16-runtime-shell-exit.txt
set -e

The failure is expected evidence: scratch contains no /bin/sh. Do not add a shell to the release target merely to make this diagnostic command succeed. Use the separate debug target when a shell is required.

9. Compare content, size direction, user, and history

for image in \
  devops-academy-ch10:single \
  devops-academy-ch10:debug \
  devops-academy-ch10:runtime
do
  docker image inspect "$image" \
    --format '{{join .RepoTags ","}} size={{.Size}} user={{.Config.User}} entrypoint={{json .Config.Entrypoint}}'
done | tee evidence/17-target-comparison.txt

docker image history --no-trunc devops-academy-ch10:runtime \
  | tee evidence/18-runtime-history.txt

Expect single to be much larger than runtime; exact byte counts vary by architecture and base updates. The comparison is operational evidence, not a vulnerability score.

10. Extract and hash the release binary without starting it

CID="devops-academy-ch10-evidence"
docker create --name "$CID" devops-academy-ch10:runtime \
  > evidence/19-container-id.txt
docker cp "$CID":/app ./evidence/runtime-app
sha256sum ./evidence/runtime-app \
  | tee evidence/20-runtime-app-sha256.txt
docker rm "$CID" | tee evidence/21-evidence-container-cleanup.txt

docker cp works against a stopped container, so there is no need to mutate or exec into the release. Compare this checksum with the hash printed from the debug target. Both targets should contain the same compiled artifact if the Dockerfile and inputs are unchanged.

11. Challenge: application suddenly needs HTTPS

The current scratch image works because the sample program has no TLS client. Now imagine the program must call an HTTPS endpoint that uses a public CA. Which layer owns the failure if certificate verification fails? Name two defensible designs: explicitly copy a reviewed CA bundle into scratch, or choose a runtime base such as an appropriate Distroless image that already carries CA roots. Explain the evidence you would capture before choosing.

Next lesson

Next: Configuration, Design Choices, and Tradeoffs

Choose among scratch, Distroless, minimal/full distributions, linkage models, and debug strategies according to the actual runtime contract.

Knowledge check

Why does building --target runtime not require shipping the Go compiler?

What proves the scratch image is intentionally shell-less?

Why use --metadata-file in addition to docker image inspect?

If the runtime binary hash differs from the debug binary hash, what should you do first?

Why is numeric USER 65532:65532 useful in scratch?

Official references and version notes

Version and compatibility note

Version-sensitive statements were rechecked against primary sources on 2026-09-21. Docker Engine 29.8.1 is the current Engine 29 patch baseline (released 2026-09-15); Buildx 0.37.1 is the current upstream Buildx release (2026-09-11); BuildKit 0.33.0 is the current upstream BuildKit release (2026-09-02) and ships built-in Dockerfile frontend 1.27.0. Current Distroless documentation lists Debian 13 families with latest, nonroot, debug, and debug-nonroot variants and warns that images intentionally lack a shell. Always record the actual local versions, builder/frontend, base-image digests, target platform, and external-image identity because installation bundles and image tags evolve independently.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.