Building Container Images: Layers, Caching, and Small Images
- 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
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, anddocker build/podman buildread either). Each instruction produces a layer: a read-only filesystem diff stacked on the one before it.FROMpicks the base image to start from;RUNexecutes a command and captures the resulting filesystem change;COPY/ADDbring files in;ENV,WORKDIR,EXPOSE,USERset metadata;CMD/ENTRYPOINTdeclare 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 buildruns identically underpodman,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 ContainerfileFROM 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. FROMsets the base;WORKDIRcreates and enters /app; theCOPY requirements.txt+RUN pip installpair installs dependencies;COPY . .brings the app source;EXPOSEdocuments the port;CMDis the default command. Note the deliberate split: dependencies are copied and installed before the application source is copied.Order for the cache: slow-changing first
Exploit layer caching by putting rarely-changing steps early.
docker buildcaches 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.txtand runspip installbeforeCOPY . .. Your dependencies change far less often than your source code. Edit a.pyfile and rebuild: the dependency layers are cache hits (no re-install), and only the cheapCOPY . .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?
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.12is ~1 GB;python:3.12-slimis a few hundred MB;-alpineordistrolessare 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 — otherwiseCOPY . .bloats the image and busts the cache on irrelevant changes.3. Clean up within a layer. A file deleted in a later
RUNstill exists in the earlier layer, so it doesn't shrink the image. Install and clean in the sameRUN.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 — .envso secrets never enter the build context or the image. A good.dockerignoreboth shrinks the image and stops unrelated file changes from invalidating theCOPYcache.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.multistageFROM 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 buildstage carries the ~800 MB Go toolchain and compiles a static binary. The final stage starts fromdistroless/static(a few MB, no shell, no package manager) andCOPY --from=buildpulls in just the binary. The result is a tiny, hard-to-attack image — the toolchain is gone.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 likeghcr.io/acme/api:1.4.2names the registry (GitHub Container Registry), the namespace (acme), the image (api), and the tag (1.4.2). Tags are mutable pointers —:latestis 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
sha256hash of its manifest — and that digest is the only truly immutable identifier. A tag like1.4.2can 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-ttags the built image with a full reference.docker pushuploads it to the registry named in the reference (you mustdocker login ghcr.iofirst). 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.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(orpodman save) writes the image as one tar file. Unpack it withtar -xf app.tar -C imgand 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 inmanifest.json): each is the filesystem diff that one instruction produced.
The config is plain JSON, so
jqreads it; the layers are plain tarballs, sotar -tflists 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 saveproduces. The entrypoint is an array (the exec form), so[0]takes its first element, the program the container runs.Envis an array ofKEY=VALUEstrings rather than an object, so you pick one withselect(startswith("KEY="))instead of.KEY; add| cut -d= -f2to keep just the value. In a real unpacked image,jq -r .[0].Config manifest.jsontells 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 onlyapp/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 andtar -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, anddocker runshows no such file. Is the key still recoverable from the saved image?
- 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.
