BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

DEVOPS-02-002-P · Primer — Images & Layers

The idea in one sentence

A container image is not one file but a stack of read-only, content-addressed layers — each identified by the SHA-256 hash of what it changed — and identical layers are stored and pulled exactly once no matter how many images reference them.

Why it exists

Before layering, a container image was effectively a single tarball snapshot of a filesystem — the OCI/Docker equivalent of shipping a whole VM disk image every time. Change one line of application code and you'd rebuild and re-push the entire multi-hundred-megabyte blob, and every node in the cluster would re-download all of it from scratch, even the operating-system files and Python interpreter that hadn't changed since last Tuesday. Layers exist to fix that: an image is built as a sequence of diffs (like a chain of git commits), each layer content-addressed by a hash of what it changed, so unchanged layers — the OS base, the language runtime, the dependency set — are built once, cached once, and reused across every image and every node that needs them. Without this, a 50-microservice cluster running the same Python base image would mean 50 separate multi-hundred-MB downloads per node instead of one shared download plus 50 tiny app-code diffs.

How it works

Each instruction in a Dockerfile that touches the filesystem (FROM, RUN, COPY, ADD) produces one new layer: a diff of files added, modified, or deleted relative to the layer below it. The builder computes a content hash (SHA-256) over that diff, and the image's manifest is just an ordered list of these layer digests plus a config blob. At runtime, the container runtime (containerd/CRI-O) mounts all of a image's layers read-only via a union filesystem like OverlayFS, stacking them into one coherent view, and adds a single thin writable layer on top per running container — that writable layer is where your process's own file writes actually land, and it's discarded when the container is removed while the image layers underneath are not.

Concretely: FROM python:3.11-slim contributes a base layer of about 45 MB; a RUN apt-get install -y build-essential && pip install -r requirements.txt instruction might add a 320 MB layer of system and Python packages; a final COPY app.py . adds a 2 MB layer of just your code — one image, 367 MB across three layers. Now build a second microservice FROM python:3.11-slim with the same requirements.txt: the builder (or BuildKit's cache) recognizes that the base and dependency layers hash to digests already present in the registry and on the node, so only the 2 MB app-code layer is genuinely new. A registry stores that 45 MB + 320 MB combination exactly once regardless of how many image tags reference it, and a node that already pulled service A only needs to pull 2 MB to run service B, not another 367 MB.

The math generalizes: if \(N\) images share a common layer set of size \(B\) and each contributes unique bytes \(u_i\), naively summing "size per image" gives \(\sum_{i=1}^{N} (B + u_i)\), but the actual unique bytes ever stored or pulled is \[ B + \sum_{i=1}^{N} u_i \] — the gap of \((N-1)\,B\) is precisely what content-addressed layer sharing saves you, and it grows linearly with fleet size for any shared base.

See it — Same Dockerfile lineage, two services: the base and dependency layers are byte-identical and shared, only app code differs.

flowchart TD
  A["Dockerfile"] --> B["FROM python:slim<br/>(layer: 45 MB)"]
  B --> C["RUN apt/pip<br/>install (320 MB)"]
  C --> D["COPY app.py<br/>(2 MB, unique)"]
  D --> E["Image digest<br/>(sha256)"]
  E --> F["Registry: each<br/>layer stored once"]
  F --> G["Node pulls<br/>service-a pod"]
  F --> H["Node reuses cached<br/>layers for service-b"]

The one gotcha

Layers are immutable and append-only, so deleting a file in a later layer doesn't shrink the image. RUN rm -rf /tmp/build-cache in line 8 of a Dockerfile just writes a "whiteout" marker in layer 8 telling the union filesystem to hide that path — the actual bytes still live in layer 3 underneath, and every node still has to pull them. Teams routinely ship a 900MB image where docker history shows a 600MB layer for "install build tools" immediately followed by a tiny layer that deletes those same tools; the deletion layer costs pull time and disk but recovers nothing. The fix is to never split install-then-clean across layers: do them in one RUN instruction (so the diff computed for that layer already excludes the deleted files), or use a multi-stage build so the final image only COPYs the compiled artifact out of a heavier builder stage.

The one-line takeaway

Shared layers are stored once; deletions in later layers aren't free.

Where you'll meet it

You see this in docker build (BuildKit cache hits/misses), docker history <image> (per-layer sizes), crictl/containerd's content store on each node, kubelet's image-pull step before a pod can start, registries like ECR/GCR/ACR/Docker Hub billing you for layer storage, and Helm/CI pipelines where a slow docker push usually means someone broke layer caching by copying source before installing dependencies.

Named concepts: image layers