Session 02 demos – Building images
Demos 1–2 belong to Block A (Dockerfile anatomy, layers and cache), Demos 3–5 to Block B (publishing, multi-platform, attestations). Files live next to this one:
| File | Used in |
|---|---|
naive.Dockerfile |
Demo 1 — the single-stage anti-pattern |
../../src/pinboard-api/Dockerfile |
Demo 1, 2, 4, 5 — the real multi-stage build |
Before the session
~10 minutes, with network. Pre-build the naive image — it downloads the Go toolchain and takes minutes; nobody wants to watch that live.
cd ~/docker-kubernetes-training/demos/session02
docker pull golang:1.25-alpine
docker pull gcr.io/distroless/static-debian12:nonroot
# Warm the caches so the live builds are fast, then throw the tags away
# so that Demo 1 still *shows* the layers being assembled from cache.
docker build -f naive.Dockerfile -t pinboard-api:naive ../../src/pinboard-api
docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api
- Check
docker buildx version(BuildKit is the default builder in Docker 28+; every build you run must print[+] Building …with the=> [stage x/y]lines). - Demo 4 needs a container driver builder; create it up front so the
--bootstrappull does not happen on stage:docker buildx create --name course --driver docker-container --bootstrap - Demo 5 is optional and needs a Docker Hub account. If you will do it:
docker loginbeforehand andexport DOCKER_ID=<your-hub-id>in the demo shell. If the room has no outbound registry access, say so and skip to the Reset. - Terminal: one pane, wide enough that
docker image lsdoes not wrap. - Keep
src/pinboard-api/main.gounmodified at the start (Demo 2 edits and reverts it).
Demo 1: naive vs multi-stage — 412 MB vs 13 MB (≈ 7 min)
What to show / say
The same program, two Dockerfiles. The difference is not “optimisation”, it is what you put on the plane: build tools or the artefact.
Steps
-
Show the two files side by side (or on the slide) and read the shape:
cat naive.Dockerfile cat ../../src/pinboard-api/Dockerfile👉 point at
FROM golang:1.25-alpine AS build…FROM gcr.io/distroless/static-debian12:nonroot AS runtime. “TwoFROMlines. The second one starts from nothing and copies one file out of the first.” -
Build both (both are cached from the pre-session run, so this takes seconds):
docker build -f naive.Dockerfile -t pinboard-api:naive ../../src/pinboard-api docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api👉 point at the
=> CACHEDlines and at BuildKit running the two stages in parallel in the multi-stage build. -
The number that ends the argument:
docker image ls pinboard-apiREPOSITORY TAG IMAGE ID CREATED SIZE pinboard-api 1.0 4d2b8a1c9e07 2 minutes ago 12.8MB pinboard-api naive 9f13c7a6b820 3 minutes ago 412MB👉 “Roughly thirty-five times bigger — and every megabyte is pulled onto every node, scanned by every scanner and stored in every registry.”
-
Why it is bigger — layers, not magic:
docker history pinboard-api:naive | head -6 docker history pinboard-api:1.0IMAGE CREATED CREATED BY SIZE 4d2b8a1c9e07 2 minutes ago ENTRYPOINT ["/pinboard-api"] 0B <missing> 2 minutes ago USER 65532:65532 0B <missing> 2 minutes ago EXPOSE map[8080/tcp:{}] 0B <missing> 2 minutes ago COPY /out/pinboard-api / … 10.4MB <missing> 2 minutes ago ENV PORT=8080 APP_VERSION=1.0 0B <missing> 2 weeks ago …distroless base… 2.4MB👉 “In the small image exactly one layer has a size — our binary. In the naive one the top layer alone is the compiled binary plus the module cache, and under it sit ~270 MB of toolchain that nobody will ever execute.”
-
What is not in the small image — the security half of the argument:
docker run --rm pinboard-api:naive ls /usr/local/go/bin docker run --rm pinboard-api:1.0 ls /go gofmt docker: Error response from daemon: failed to create task for container: exec: "ls": executable file not found in $PATH👉 “The second error is the feature. There is no shell, no
ls, no package manager in a distroless image — and it runs as UID 65532, not root.”
Demo 2: the build cache, --target and --build-arg (≈ 8 min)
What to show / say
Cache invalidation follows one rule: change a layer’s inputs and that layer and everything below it is rebuilt. Instruction order is therefore a design decision.
Steps
-
Rebuild with nothing changed — everything is cached:
docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api=> CACHED [build 3/6] COPY go.mod go.sum ./ => CACHED [build 4/6] RUN go mod download => CACHED [build 5/6] COPY . . => CACHED [build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build … ✔ Building 0.4s -
Touch the source — the dependency layer survives:
echo '// cache demo — reverted at the end of the session' >> ../../src/pinboard-api/main.go docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api=> CACHED [build 3/6] COPY go.mod go.sum ./ => CACHED [build 4/6] RUN go mod download ← still cached! => [build 5/6] COPY . . 0.1s => [build 6/6] RUN … go build … 4.8s👉 point at line 4 being CACHED and line 5 not. “That is the whole reason
COPY go.mod go.sumcomes beforeCOPY . .. Dependencies change monthly, source changes every minute.” -
Now touch the dependency manifest — everything below it goes:
echo '// cache demo' >> ../../src/pinboard-api/go.mod docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api=> [build 3/6] COPY go.mod go.sum ./ 0.0s => [build 4/6] RUN go mod download 6.2s ← rebuilt👉 “One comment in
go.modand we pay for the dependency download again. In the naive Dockerfile,COPY . .came first — so every build paid it.” -
Revert both files (do it now, on screen, so nothing is left dirty):
sed -i '$d' ../../src/pinboard-api/main.go sed -i '$d' ../../src/pinboard-api/go.mod tail -1 ../../src/pinboard-api/main.go -
Build only the first stage — useful for tests and for debugging a build:
docker build --target build -t pinboard-api:builder ../../src/pinboard-api docker run --rm pinboard-api:builder sh -c 'go version && ls -l /out/pinboard-api'go version go1.25.x linux/amd64 -rwxr-xr-x 1 root root 9834496 /out/pinboard-api👉 “
--targetstops at a named stage. In CI this is how you rungo testin the build stage without shipping it.” -
Build arguments are build-time only, and here they end up in a label:
docker build -t pinboard-api:1.1 --build-arg APP_VERSION=1.1 ../../src/pinboard-api docker image inspect --format '' pinboard-api:1.1 docker run --rm pinboard-api:1.1 --help 2>/dev/null; docker run -d --rm --name v11 -p 8081:8080 pinboard-api:1.1 curl -s localhost:8081/api/info; echo docker stop v111.1 {"app":"pinboard-api","greeting":"Welcome to Pinboard","hostname":"6b0e1f2a3c4d","store":"memory","theme":"emerald","uptime":"2s","version":"1.1"}👉 “
ARGis build time,ENVis run time. The version was baked in by the linker and exposed as an env var and a label. Same source, two tags — that is the rolling-update demo in Session 06.”
Demo 3: docker init — the scaffold (≈ 3 min)
What to show / say
You rarely start from a blank Dockerfile any more. But you must be able to read what the wizard writes.
Steps
-
Run the wizard in a throwaway folder:
mkdir -p /tmp/docker-init-demo && cd /tmp/docker-init-demo docker initWelcome to the Docker Init CLI! ? What application platform does your project use? [Use arrows to move] ▸ Go Python Node Rust ASP.NET Core PHP with Apache Java Other👉 arrow down the list so everyone sees it, select Go, accept the Go version, answer
8080for the port — then press Ctrl-C before the last question and say: “I am cancelling on purpose: it would write aDockerfile, acompose.yaml, a.dockerignoreand aREADME.Docker.mdhere. It is a good starting point and it does use multi-stage and a non-root user — but it does not know your app. Ours is 20 lines and we understand every one of them.” -
Back to the demo folder:
cd ~/docker-kubernetes-training/demos/session02
Demo 4: multi-platform images, SBOM and provenance (≈ 7 min)
What to show / say
One tag, many architectures, plus a machine-readable answer to “what is inside this image and who built it”.
Steps
-
Why the default builder is not enough:
docker buildx lsNAME/NODE DRIVER/ENDPOINT STATUS PLATFORMS default * docker running linux/amd64, linux/386 course docker-container running linux/amd64, linux/arm64, linux/arm/v7👉 “The classic image store holds one manifest per tag. Multi-platform needs either the containerd image store (Docker Desktop: Settings → General → Use containerd), or
--pushstraight to a registry, or an OCI tarball — which is what I will do now so this works offline.” -
Build for two architectures with both attestations, into an OCI archive:
docker buildx build --builder course \ --platform linux/amd64,linux/arm64 \ --sbom=true --provenance=true \ --build-arg APP_VERSION=1.0 \ -t pinboard-api:1.0 \ -o type=oci,dest=/tmp/pinboard-api-1.0.oci.tar \ ../../src/pinboard-api=> [linux/amd64 build 6/6] RUN … go build … 5.1s => [linux/arm64 build 6/6] RUN … go build … 7.8s => exporting to oci image format 1.2s => exporting attestation manifest sha256:… 0.1s👉 point at the two parallel stage trees — cross-compiling, not emulating, because the Dockerfile builds a static Go binary with
CGO_ENABLED=0. -
Look inside the archive — the manifest list is real, not a Docker concept:
tar xOf /tmp/pinboard-api-1.0.oci.tar index.json | python3 -m json.tool | head -20"manifests": [ {"mediaType": "application/vnd.oci.image.index.v1+json", …tar tf /tmp/pinboard-api-1.0.oci.tar | head -5👉 “Four manifests:
linux/amd64,linux/arm64, and one attestation manifest for each. The SBOM is an in-toto/SPDX document; the provenance says which builder, which source, which arguments. That is what supply-chain policies check.” -
What it looks like with a registry (say it, or run it if Demo 5 is on):
# docker buildx build --platform linux/amd64,linux/arm64 --sbom=true --provenance=true \ # -t $DOCKER_ID/pinboard-api:1.0 --push ../../src/pinboard-api # docker buildx imagetools inspect $DOCKER_ID/pinboard-api:1.0👉 “
imagetools inspectprints the platform table straight from the registry — that is how you check whether an image will run on an ARM node.”
Demo 5: scan, tag and push (≈ 5 min, optional — needs a Hub account)
What to show / say
Publishing is three commands, and one of them should be a look at what you are about to publish.
Steps
-
Scan (skip silently if
docker scoutis not installed):docker scout quickview pinboard-api:1.0Target │ pinboard-api:1.0 │ 0C 0H 0M 0L digest │ 4d2b8a1c9e07 │ Base image │ distroless/static… │ 0C 0H 0M 0L👉 compare with the naive image if there is time:
docker scout quickview pinboard-api:naive— hundreds of packages, a long CVE row. “Same code. The difference is everything we chose not to ship.” -
Tag for the registry — a tag is just a name, no rebuild:
echo "$DOCKER_ID" docker tag pinboard-api:1.0 "$DOCKER_ID/pinboard-api:1.0" docker image ls --filter reference='*/pinboard-api'👉 point at the identical
IMAGE ID. “Two names, one image.” -
Push and read back the digest:
docker push "$DOCKER_ID/pinboard-api:1.0" docker image inspect --format '' "$DOCKER_ID/pinboard-api:1.0"1.0: digest: sha256:7c9a… size: 856 yourid/pinboard-api@sha256:7c9a…👉 “Now it has a digest in that registry. In Kubernetes, pinning
image: yourid/pinboard-api@sha256:7c9a…is how you guarantee every node runs the same bytes. In this course we skip the registry entirely and usekind load docker-imageinstead.”
Reset
Lab 02 expects: the sources unmodified, golang and the distroless base pulled and
cached, and no pinboard-api images (students build 1.0 and 1.1 themselves).
cd ~/docker-kubernetes-training/demos/session02
# 1. Sources back to their committed state (Demo 2 appended one line to each).
tail -1 ../../src/pinboard-api/main.go # must be the closing brace "}"
tail -1 ../../src/pinboard-api/go.mod # must NOT be "// cache demo"
# 2. Images and scratch files
docker rm -f v11 2>/dev/null
docker image rm pinboard-api:naive pinboard-api:builder pinboard-api:1.0 pinboard-api:1.1 2>/dev/null
docker image rm "$DOCKER_ID/pinboard-api:1.0" 2>/dev/null
rm -f /tmp/pinboard-api-1.0.oci.tar
rm -rf /tmp/docker-init-demo
# 3. Keep the buildx builder for later sessions, or remove it:
# docker buildx rm course
Verify:
docker image ls pinboard-api # empty
docker ps -a # empty