Chapter 10Lesson 04~120 minutes

Multi-Stage Builds, Minimal Runtime Images, Distroless Patterns, Debug Stages, and Build Separation: Diagnostics, Failure Modes, Security, and Performance

When a minimal image fails, adding a shell or copying the whole build tree is rarely the right first move. This lesson localizes failures caused by missing interpreters, dynamic loaders, CA certificates, timezone/native libraries, ownership, and accidental toolchain leakage while preserving image/build evidence and keeping debugging outside the immutable release artifact.

DiagnosticsMissing runtime dependencyImmutable repairFirst-failure evidenceSecurity

Learning objectives

  • Preserve first-failure build metadata, image IDs/digests, config, history, runtime error text, and dependency assumptions before changing the image.
  • Diagnose an intentionally broken scratch image whose script requires /bin/sh even though scratch contains no shell.
  • Separate missing interpreter/loader/library/certificate/timezone/user-permission failures from Docker daemon, network, or registry failures.
  • Avoid “fixing” incidents by hot-patching release containers, switching to root, or copying entire build workspaces into production images.
  • Apply the smallest declared Dockerfile/runtime dependency correction and rebuild a new image identity.
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. Preserve the image that failed before “making it bigger”

A minimal-runtime failure often tempts engineers to add a shell, package manager, compiler, or root access immediately. That destroys the very evidence needed to distinguish a missing runtime dependency from a build problem. Record the exact image ID/digest, Dockerfile/source revision, target, build metadata, container exit/error text, configured user/entrypoint, and any external dependency failure first.

docker image inspect devops-academy-ch10:runtime \
  > evidence/runtime-inspect-before.json
docker image history --no-trunc devops-academy-ch10:runtime \
  > evidence/runtime-history-before.txt

2. Evidence-first diagnostic sequence

  1. Confirm host/platform, active Docker context, Engine/CLI, Buildx, selected builder, target platform, and frontend assumptions.
  2. Confirm source/Dockerfile/base identities and the exact target/output that produced the image.
  3. Inspect image config: user, entrypoint/CMD, environment, workdir, architecture, size, and labels.
  4. Preserve the original create/start/run error, exit code, and events before rebuilding.
  5. Ask whether the executable itself exists and whether its interpreter/loader/libraries/data files exist.
  6. Use the dedicated debug target or offline extraction to inspect artifacts; do not hot-patch the release container.
  7. Change one declared dependency or configuration choice, rebuild to a new image identity, and verify the smallest failing scope.

3. Intentionally broken example: a script in scratch

This Dockerfile copies a script whose shebang requires /bin/sh, but the scratch filesystem contains no shell:

# syntax=docker/dockerfile:1
FROM busybox:1.37.0 AS build
RUN printf '#!/bin/sh\necho hello\n' > /start.sh \
    && chmod 755 /start.sh

FROM scratch
COPY --from=build /start.sh /start.sh
ENTRYPOINT ["/start.sh"]

Build it under a disposable tag, then preserve the expected start failure:

docker buildx build --load \
  -t devops-academy-ch10:broken-script \
  -f Dockerfile.broken .

set +e
docker run --rm devops-academy-ch10:broken-script \
  > evidence/broken.stdout 2> evidence/broken.stderr
printf 'exit=%s\n' "$?" > evidence/broken-exit.txt
set -e

The file exists, but its interpreter does not. The repair is not “Docker needs privileged mode” or “disable seccomp.” Either use an exec-able static binary, or choose/copy a runtime that deliberately supplies the interpreter. The cause is the runtime filesystem contract.

4. Missing dynamic loader can look like a missing executable

A dynamically linked ELF binary records an interpreter such as a glibc or musl loader. If you copy only the binary into scratch, startup can fail even though /app is present. Diagnose linkage in a build/debug environment with tools such as file, readelf, or ldd where appropriate, and record the output. Then either produce a truly static artifact or choose/copy the exact runtime libraries and loader.

Do not copy random /lib trees until the error disappears. That creates an undocumented runtime and can introduce incompatible or unpatched libraries.

5. TLS failure: certificate data is a runtime dependency

An application can start perfectly in scratch yet fail outbound HTTPS with an unknown-authority error because no CA roots exist. Preserve the application error and endpoint identity first. Then decide whether to copy a reviewed CA bundle into the runtime stage or choose a base whose documented contract includes CA certificates, such as an appropriate current Distroless family.

Do not “fix” certificate failures by disabling TLS verification. That changes the trust model rather than supplying the missing dependency.

6. Timezone, locale, and name-service assumptions are easy to miss

Programs that convert named time zones may need timezone data. Some language/runtime features expect locale files or /etc/nsswitch.conf. User-name lookup may expect /etc/passwd. DNS resolution itself is provided through container runtime configuration and resolver files, but library behavior can still vary with libc/runtime choices. Record the exact failing operation and required file/library instead of assuming “scratch cannot network.”

7. Non-root failure: inspect ownership before escalating privilege

If UID 65532 cannot read an executable or write a required directory, inspect mode/ownership and mounts. The Dockerfile may need COPY --chown, an explicitly prepared writable directory, or a correctly owned volume. Switching the final stage to root changes the security boundary and can mask the packaging defect.

docker image inspect devops-academy-ch10:runtime \
  --format 'User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}}'

docker create --name devops-academy-ch10-perm devops-academy-ch10:runtime
docker cp devops-academy-ch10-perm:/app ./evidence/app-for-permission-check
ls -ln ./evidence/app-for-permission-check
docker rm devops-academy-ch10-perm

8. Toolchain leakage: prove what crossed instead of guessing

If the release unexpectedly contains source, package managers, or compilers, inspect the Dockerfile's copy boundary and image history. For a shell-equipped debug/comparison target, list expected paths. For a shell-less release, create a stopped container and use docker cp only for known paths, or export to an isolated analysis directory if your process permits. Do not run untrusted tools from the image on the host.

Common causes include FROM build AS runtime when a fresh runtime base was intended, broad COPY --from=build / /, or copying a workspace rather than a build artifact directory.

9. Performance: measure transfer and rebuild cost, not only compressed size

Smaller runtimes generally transfer faster and consume less registry/disk bandwidth, but startup time also depends on decompression, page faults, application initialization, storage driver, host cache, and network. Multi-stage builds can also improve build clarity without changing runtime performance at all. Measure image sizes and startup/build timings separately and avoid claiming causality from one metric.

10. Immutable repair pattern

After identifying a missing dependency, change the Dockerfile or artifact build, rebuild to a new image identity, rerun the failing test, and compare evidence. Do not docker exec a hot fix into the production-like container and then treat that modified writable layer as the solution. Chapter 06 established why runtime mutation is not image engineering.

11. Diagnostic challenge

A scratch image returns an HTTPS certificate error but runs as UID 65532 and has the correct binary hash. Which layer is already exonerated, which evidence should you preserve, and why are “run as root” and “disable TLS verification” both invalid first fixes?

Next lesson

Next: Checkpoint Lab

Produce an evidence packet for build/debug/runtime targets and prove diagnosis without mutating the release identity.

Knowledge check

Why can a script file exist in scratch yet fail to start?

What is the safest first response to a missing-CA error?

Why can a dynamically linked binary fail in scratch with a confusing “not found” style error?

What should you inspect before changing a non-root runtime back to root?

Why is adding a shell to a production image a poor generic debugging fix?

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.