View on GitHub

Containers & Kubernetes Tutorial

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

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

  1. 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. “Two FROM lines. The second one starts from nothing and copies one file out of the first.”

  2. 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 => CACHED lines and at BuildKit running the two stages in parallel in the multi-stage build.

  3. The number that ends the argument:

    docker image ls pinboard-api
    
    REPOSITORY     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.”

  4. Why it is bigger — layers, not magic:

    docker history pinboard-api:naive | head -6
    docker history pinboard-api:1.0
    
    IMAGE          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.”

  5. 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

  1. 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
    
  2. 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.sum comes before COPY . .. Dependencies change monthly, source changes every minute.”

  3. 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.mod and we pay for the dependency download again. In the naive Dockerfile, COPY . . came first — so every build paid it.”

  4. 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
    
  5. 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
    

    👉 “--target stops at a named stage. In CI this is how you run go test in the build stage without shipping it.”

  6. 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 v11
    
    1.1
    {"app":"pinboard-api","greeting":"Welcome to Pinboard","hostname":"6b0e1f2a3c4d","store":"memory","theme":"emerald","uptime":"2s","version":"1.1"}
    

    👉 “ARG is build time, ENV is 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

  1. Run the wizard in a throwaway folder:

    mkdir -p /tmp/docker-init-demo && cd /tmp/docker-init-demo
    docker init
    
    Welcome 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 8080 for the port — then press Ctrl-C before the last question and say: “I am cancelling on purpose: it would write a Dockerfile, a compose.yaml, a .dockerignore and a README.Docker.md here. 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.”

  2. 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

  1. Why the default builder is not enough:

    docker buildx ls
    
    NAME/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 --push straight to a registry, or an OCI tarball — which is what I will do now so this works offline.”

  2. 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.

  3. 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.”

  4. 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 inspect prints 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

  1. Scan (skip silently if docker scout is not installed):

    docker scout quickview pinboard-api:1.0
    
    Target             │ 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.”

  2. 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.”

  3. 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 use kind load docker-image instead.”


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