Signal9Signal9 — home
online
all lessons

Building Container Images: Layers, Caching, and Small Images

Intermediate40 min6 steps
What you'll learn
  • Write a Containerfile/Dockerfile and explain how each instruction becomes a layer
  • Order instructions to exploit the build cache instead of fighting it
  • Shrink an image with a smaller base, a .dockerignore, and a multi-stage build
  • Tag an image correctly and push it to a registry
  • Inspect a saved image with tar and jq: read its config and find which layer introduced a file
  1. A Containerfile is a recipe that becomes layers

    Read a Containerfile and map each instruction to an image layer.

    A container image is built from a Containerfile (Docker calls it a Dockerfile — same format, and docker build / podman build read either). Each instruction produces a layer: a read-only filesystem diff stacked on the one before it. FROM picks the base image to start from; RUN executes a command and captures the resulting filesystem change; COPY/ADD bring files in; ENV, WORKDIR, EXPOSE, USER set metadata; CMD/ENTRYPOINT declare what runs when the container starts.

    The layer model is why image builds are fast and images are cheap to ship: layers are content-addressed and shared, so pulling a new image only downloads the layers you don't already have. It's also why the order of instructions matters enormously — which is the next step.

    The format is standardised: images and their layers follow the OCI (Open Container Initiative) spec, which is why an image you build with docker build runs identically under podman, containerd, or Kubernetes, and can live in any OCI registry. An image is really a manifest listing its layers plus a small JSON config (the env, entrypoint, working directory, and the exact layer order). When a container starts, the runtime stacks those read-only layers and adds a thin writable layer on top — changes a running container makes live only in that top layer and vanish when the container is removed, unless you mount a volume. Keeping that mental model — immutable layers below, ephemeral scratch space above — explains both why images are reproducible and why you never store data inside the container filesystem.

    Example — not runnable on your lab box

    cat Containerfile
    FROM python:3.12-slim
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY . .
    EXPOSE 8000
    CMD ["python", "app.py"]
    Seven instructions → seven layers. FROM sets the base; WORKDIR creates and enters /app; the COPY requirements.txt + RUN pip install pair installs dependencies; COPY . . brings the app source; EXPOSE documents the port; CMD is the default command. Note the deliberate split: dependencies are copied and installed before the application source is copied.
  2. Order for the cache: slow-changing first

    Exploit layer caching by putting rarely-changing steps early.

    docker build caches each layer. On a rebuild it reuses a cached layer as long as that instruction and everything before it are unchanged; the first instruction whose inputs changed busts the cache, and every layer after it is rebuilt. So the golden rule is: put the things that change rarely early, and the things that change often late.

    That's exactly why the example copies requirements.txt and runs pip install before COPY . .. Your dependencies change far less often than your source code. Edit a .py file and rebuild: the dependency layers are cache hits (no re-install), and only the cheap COPY . . layer and beyond rebuild.

    Check yourself

    You edit one line of application source and rebuild. In the well-ordered Containerfile above, which is the FIRST instruction that must rebuild?

  3. Make it small: base, .dockerignore, and cleanup

    Cut image size with a leaner base and a build context that excludes junk.

    Smaller images pull faster, start faster, and have less to attack. Three levers:

    1. Choose a lean base. python:3.12 is ~1 GB; python:3.12-slim is a few hundred MB; -alpine or distroless are smaller still (with trade-offs around glibc/musl and debugging tools). Pick the smallest base your app actually works on.

    2. Add a `.dockerignore`. The build context (everything sent to the builder) should exclude .git, node_modules, local venvs, build output, and secrets — otherwise COPY . . bloats the image and busts the cache on irrelevant changes.

    3. Clean up within a layer. A file deleted in a later RUN still exists in the earlier layer, so it doesn't shrink the image. Install and clean in the same RUN.

    Example — not runnable on your lab box

    cat .dockerignore
    .git
    __pycache__
    *.pyc
    .venv
    node_modules
    dist
    .env
    Excludes version control, caches, local virtualenvs, build output, and — critically — .env so secrets never enter the build context or the image. A good .dockerignore both shrinks the image and stops unrelated file changes from invalidating the COPY cache.
  4. Multi-stage builds: build heavy, ship light

    Use a build stage for toolchains and copy only artifacts into a tiny runtime stage.

    The biggest size win is a multi-stage build: use one stage with the full toolchain to compile/assemble, then a second, minimal stage that copies only the finished artifact. The compilers, headers, and package caches stay in the discarded build stage and never reach the shipped image.

    Example — not runnable on your lab box

    cat Containerfile.multistage
    FROM golang:1.22 AS build
    WORKDIR /src
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN CGO_ENABLED=0 go build -o /app/server .
    
    FROM gcr.io/distroless/static
    COPY --from=build /app/server /server
    ENTRYPOINT ["/server"]
    The build stage carries the ~800 MB Go toolchain and compiles a static binary. The final stage starts from distroless/static (a few MB, no shell, no package manager) and COPY --from=build pulls in just the binary. The result is a tiny, hard-to-attack image — the toolchain is gone.
  5. Tag it and push it

    Give the image a meaningful reference and push it to a registry.

    An image reference is registry/namespace/name:tag. With no registry prefix, tools assume Docker Hub; a full reference like ghcr.io/acme/api:1.4.2 names the registry (GitHub Container Registry), the namespace (acme), the image (api), and the tag (1.4.2). Tags are mutable pointers — :latest is just a conventional default, not a guarantee of freshness — so production should pin an immutable tag (a version or the image digest @sha256:…), never rely on :latest.

    Every image also has a content-addressed digest — a sha256 hash of its manifest — and that digest is the only truly immutable identifier. A tag like 1.4.2 can be re-pushed to point at different bytes; a digest cannot, by definition. That's why hardened deployments and supply-chain tooling reference images by digest (api@sha256:…) and often sign and generate an SBOM (software bill of materials) for each build, so what runs in production is provably the exact artifact CI produced. You don't need all of that on day one, but knowing that tag ≠ identity and digest = identity is what keeps 'it works on my machine' from turning into 'we can't tell which image is in prod.'

    Example — not runnable on your lab box

    docker build -t ghcr.io/acme/api:1.4.2 .
    docker push ghcr.io/acme/api:1.4.2
    -t tags the built image with a full reference. docker push uploads it to the registry named in the reference (you must docker login ghcr.io first). CI typically also tags the same build with the git SHA so every image traces back to an exact commit — a common pattern is to push two tags for one build, the human-friendly version and the immutable commit SHA, then have deployments reference the SHA. The push only uploads layers the registry doesn't already have, so a rebuild that changed one late layer ships fast.
  6. Read an image without a runtime: it's tarballs and JSON

    Unpack a saved image and read its config and layers with jq and tar.

    Everything above is the build side. The same layer model is what lets you inspect an image with no container runtime at all, which is how you audit an image someone hands you or pulled from a compromised registry. docker save -o app.tar app:1.0 (or podman save) writes the image as one tar file. Unpack it with tar -xf app.tar -C img and you find:

    • `manifest.json`: which file is the config, and the list of layer tarballs in order, base first, top layer last.
    • The config JSON: Entrypoint, Cmd, Env, WorkingDir, the runtime metadata the Containerfile set.
    • One `layer.tar` per layer (newer Docker writes them under blobs/sha256/, still listed in manifest.json): each is the filesystem diff that one instruction produced.

    The config is plain JSON, so jq reads it; the layers are plain tarballs, so tar -tf lists them without extracting.

    printf '%s\n' '{"config":{"Entrypoint":["/usr/local/bin/api"],"Env":["PATH=/usr/bin:/bin","API_PORT=8080"]}}' > config.json
    jq -r '.config.Entrypoint[0]' config.json
    jq -r '.config.Env[] | select(startswith("API_PORT="))' config.json
    /usr/local/bin/api
    API_PORT=8080
    jq -r
    Raw output: print strings without JSON quotes.
    select(startswith("API_PORT="))
    Keep only array elements that begin with the given prefix.
    A trimmed config of the shape docker save produces. The entrypoint is an array (the exec form), so [0] takes its first element, the program the container runs. Env is an array of KEY=VALUE strings rather than an object, so you pick one with select(startswith("KEY=")) instead of .KEY; add | cut -d= -f2 to keep just the value. In a real unpacked image, jq -r .[0].Config manifest.json tells you which file this is.
    mkdir -p rootfs/app l1 l2
    printf 'key\n' > rootfs/app/id_rsa && tar -cf l1/layer.tar -C rootfs app/id_rsa
    printf 'v2\n' > rootfs/app/VERSION && tar -cf l2/layer.tar -C rootfs app/VERSION
    for t in l*/layer.tar; do echo "== $t"; tar -tf "$t"; done
    == l1/layer.tar
    app/id_rsa
    == l2/layer.tar
    app/VERSION
    tar -tf FILE
    List an archive's contents without extracting anything.
    tar -cf FILE -C DIR PATH
    Create an archive containing PATH, read relative to DIR.
    Two stand-in layer tarballs, built the way a two-step image would be: the first layer added app/id_rsa, the second added only app/VERSION. Listing each one shows which layer introduced each file. A file belongs to the layer that added it, not to the newest layer, so to find where a secret came from, loop over the layers and tar -tf "$t" | grep -q <path> each one, printing the layer that matches.

    Check yourself

    An image's top layer contains a whiteout for app/id_rsa, and docker run shows no such file. Is the key still recoverable from the saved image?

Recap
  • Each Containerfile instruction is a layer; the first instruction whose inputs change busts the cache for itself and everything after it.
  • Order slow-changing steps first: copy the dependency manifest and install BEFORE copying the app source, so code edits don't re-run installs.
  • Shrink images with a lean base, a .dockerignore that excludes junk and secrets, same-layer cleanup, and multi-stage builds that ship only the artifact.
  • Reference images as registry/namespace/name:tag; pin an immutable version or digest in production instead of :latest.
  • A saved image is a tar of JSON and layer tarballs: jq reads the config (Entrypoint, Env), tar -tf shows which layer introduced a file, and a secret deleted in a later layer is still in the earlier one.