Images & layers — the OCI image format from the inside¶
Module 14’s Docker chapter teaches you to write a Dockerfile and gives you the working rule for layers: each instruction makes one, order them for caching. That’s the driver’s view. This chapter opens the format itself: what an image literally is on disk, why a “layer” is a tarball, how content addressing makes images verifiable and shareable, how the overlay filesystem stacks layers into the single filesystem your process sees, and what the build cache is really keying on. When you finish, docker image inspect output will read like prose, and image-size mysteries (“I deleted the files, why is it still huge?”) will stop being mysteries.
Everything here is standardized by the OCI (Open Container Initiative) image specification — so it applies identically to images built by Docker, Podman, buildah, or Kubernetes tooling. The hands-on lab for this chapter is lab01_container_anatomy.ipynb, where you build all of this machinery yourself in ~100 lines of Python.
1. An image is not a disk image¶
The word “image” misleads. A VM (virtual machine) disk image is one giant opaque blob of bytes — a simulated hard drive. An OCI image is nothing like that. It is a small bundle of JSON (JavaScript Object Notation) documents plus a stack of ordinary compressed tarballs:
an OCI image
├── manifest (JSON) "this image = this config + these layers, in this order"
├── config (JSON) env vars, default command, working dir, layer ordering, history
└── layers (tar.gz) each one a filesystem *diff*: "files added/changed/removed at this step"
That’s the entire format. No magic, no proprietary container — you could unpack an image with tar and a JSON reader (and in the lab notebook, you effectively do).
💡 Intuition: An image is not a photograph of a finished room — it’s a stack of transparent sheets on an overhead projector. The bottom sheet is the base OS’s files; each following sheet adds or covers a few things (“add
/app/requirements.txt”, “add the installed packages”). What the audience sees — the composed picture — is the container’s filesystem. Crucially, the sheets are immutable and reusable: fifty images builtFROM python:3.12-slimall project the same physical bottom sheets, stored once.
Three properties fall out of this design, and the rest of the chapter unpacks each:
Layers are shared and deduplicated — because they’re separate, immutable artifacts (§2, §3).
Everything is verifiable by hash — because each artifact is addressed by its content digest (§4).
Composition happens at runtime via a union filesystem — layers are never physically merged (§5).
2. Layers: filesystem diffs, stacked in order¶
A layer is a tar archive describing a change set against the layers below it:
files added at this step (the file’s full content is in the tar),
files modified at this step (again, the full new content — diffs are per-file, not per-byte),
files removed at this step — recorded as a whiteout: a special marker entry named
.wh.<filename>that means “pretend the file below doesn’t exist.”
Whiteouts are the detail with the biggest practical consequence in this whole chapter:
⚠️ Common mistake: “I added
RUN rm -rf /root/.cacheat the end of my Dockerfile, but the image didn’t get smaller.” Correct — it got slightly bigger. Layers are immutable and additive: the cache files still fully exist in the earlier layer’s tarball; yourrmproduced a new layer containing whiteout markers that hide them. Registry, disk, and download all still pay for the hidden bytes. To actually avoid the weight you must either not create the files in that layer at all (RUN pip install ... && rm -rf ...in one instruction, so the deletion happens before the layer is snapshotted) or use a multi-stage build (dockerfiles-for-data-science.md).
You can watch the layer stack of any image with docker history, which shows one row per layer, its creating instruction, and its size:
docker history python:3.12-slim
# IMAGE CREATED BY SIZE
# <missing> CMD ["python3"] 0B
# <missing> RUN ... apt-get install ... python3.12 ... ~45MB
# <missing> ADD rootfs.tar.xz / ~75MB <- the Debian base
# ...
Rows with 0B are metadata-only layers — instructions like ENV, WORKDIR, CMD, EXPOSE, USER change the image’s config (§6), not its filesystem, so they add no tarball. Only FROM’s base, COPY/ADD, and filesystem-touching RUNs carry weight.
Why diff-stacking is worth the complexity¶
The payoff is massive deduplication at every stage of the lifecycle:
On disk: your twelve project images built
FROM python:3.12-slimstore the ~120 MB of base layers once.On the network:
docker pulldownloads only layers you don’t already have — that’s what the per-layerAlready exists/Pull completelines mean. Pushing after a code-only change uploads kilobytes, not the multi-gigabyte PyTorch layer.In the build: unchanged layers are cache hits (§7) — the entire build-cache mechanism is layer reuse.
For data-science images, where a single dependency layer can be 5 GB, this is not an optimization — it’s the difference between workable and not.
3. Copy-on-write: how containers write on read-only layers¶
Image layers are read-only forever. So how does a running container — which writes logs, temp files, .pyc files — function? When a container starts, the runtime adds one final, writable layer on top of the image’s stack: the container layer. All writes land there; the image below is never touched. This strategy is copy-on-write (CoW):
Read a file → the runtime looks down the stack, top layer first, and returns the topmost version found (respecting whiteouts).
Write a new file → created directly in the writable layer.
Modify an existing file from a lower layer → the file is first copied up, in full, into the writable layer, and the copy is modified. The lower layer’s original is now shadowed, permanently, for this container.
Delete a file from a lower layer → a whiteout is placed in the writable layer.
flowchart TD
W["container layer (writable) — logs, tmp, copied-up files, whiteouts"]
L3["layer 3 (RO): COPY app ./app"]
L2["layer 2 (RO): RUN pip install -r requirements.txt"]
L1["layer 1 (RO): python:3.12-slim base"]
W --> L3 --> L2 --> L1
R((read)) -. "topmost match wins" .-> W
X((write)) -. "always lands here" .-> W
Consequences worth engraving:
Start 100 containers from one image and they share every read-only byte; each costs only its (initially empty) writable layer. This is why containers are cheap.
The writable layer dies with the container (
docker rmdeletes it). Anything that must survive belongs on a volume — the whole subject of volumes-and-networking.md.Copy-up is per-file and pays the full file size. Appending one line to a 2 GB file that lives in a lower layer copies 2 GB into the writable layer first. Databases, model checkpoints being rewritten, and other write-heavy data don’t belong on the overlay at all — mount a volume, which bypasses CoW entirely.
💡 Intuition: Copy-on-write is a library book with sticky notes. The book (image) is shared by every reader and nobody may write in it. Your notes (writable layer) sit on top of the pages; reading, you see page-plus-note; “editing” a page means photocopying it, marking the copy, and clipping it over the original. Return the book (remove the container) and your notes are gone — the book is pristine for the next reader.
5. OverlayFS: the union filesystem that does the stacking¶
§3 said reads search “down the stack.” Something concrete must implement that, and on Linux it’s OverlayFS, a union filesystem built into the kernel (Docker’s default storage driver, overlay2, drives it). A union filesystem presents several directories as if merged into one. OverlayFS takes:
lowerdir— one or more read-only directories: the unpacked image layers, listed top-to-bottom;upperdir— one writable directory: the container layer;workdir— kernel scratch space for atomic operations (you never look inside);merged— the mount point where the union appears: this is the/your container sees.
You can build one by hand in two commands — genuinely worth doing once on any Linux box, it turns this whole chapter concrete:
mkdir lower upper work merged
echo "from the image" > lower/base.txt
echo "old version" > lower/config.txt
sudo mount -t overlay overlay \
-o lowerdir=lower,upperdir=upper,workdir=work merged
cat merged/base.txt # "from the image" (served from lower)
echo "new version" > merged/config.txt # copy-up: config.txt now fully in upper/
rm merged/base.txt # upper/ gains a whiteout: a char device named base.txt
ls merged/ # only config.txt — base.txt is hidden, not gone
ls upper/ # config.txt (the copied-up file) + the whiteout entry
ls lower/ # untouched: base.txt, config.txt — read-only survives it all
The lookup rule is exactly the §3 rule, now with its real implementation: upper wins over lower; among lowers, higher wins; whiteouts terminate the search (OverlayFS represents a whiteout on disk as a character device with device number 0:0 — the tar-level .wh. prefix from §2 is how the same idea is serialized into layer archives). When Docker starts a container, it unpacks any not-yet-unpacked layers under /var/lib/docker/overlay2/, constructs precisely this mount, and hands merged to the container as its root. “Starting from an image” is a mount, not a copy — which is the other half of why containers start in milliseconds.
In lab01_container_anatomy.ipynb you implement this lookup — upper/lower search order, copy-up, whiteouts — in about twenty lines of Python, and it behaves byte-for-byte like the rules above.
6. Reading a real image: manifest, config, docker image inspect¶
Time to look at actual anatomy. Three commands, three documents.
The manifest — the image’s table of contents¶
docker buildx imagetools inspect python:3.12-slim --raw
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": { "mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:19334b…", "size": 3221 },
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:0d9707…", "size": 29125413 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:6f28b1…", "size": 3512 }
]
}
The manifest is tiny: it just names the config blob and the layer blobs by digest, in order. Pulling an image means: fetch manifest → fetch every blob it names that you don’t already have → verify each against its digest. (For popular images this command actually returns a manifest list — an “index” mapping platforms like linux/amd64 and linux/arm64 each to their own manifest; that’s how one tag serves both your Intel server and your M-series Mac. Same format, one level up.)
The config — the runtime metadata¶
The config blob is where all those 0B metadata layers went. It carries the environment (Env), default command (Cmd, Entrypoint), WorkingDir, ExposedPorts, User, plus two structural fields: history (one entry per Dockerfile instruction — what docker history prints) and rootfs.diff_ids (the ordered list of layer digests, this time of the uncompressed tars). docker image inspect shows you the config, locally:
docker image inspect python:3.12-slim
The fields worth knowing cold:
Field |
Meaning |
|---|---|
|
Digest of the config blob — the image’s local identity |
|
The tags, and the pinnable manifest digests, this image is known by |
|
The |
|
The ordered diff-ID stack — §2 made literal |
|
The actual |
Trace one setting end-to-end and the format clicks: ENV PYTHONUNBUFFERED=1 in a Dockerfile → a line in Config.Env in the config blob → an environment variable in every container’s process at docker run. The Dockerfile is source code; the config is the compiled artifact; docker run is the loader.
The blobs on disk¶
Locally, Docker stores every blob under /var/lib/docker/image/.../blobs/sha256/<digest> and unpacked layers under /var/lib/docker/overlay2/ — a plain content-addressed store you now know how to read. (On macOS/Windows these paths live inside Docker Desktop’s Linux VM — see container-internals.md §7.)
7. Build-cache mechanics: what the cache actually keys on¶
You know Module 14’s rule — stable instructions high, volatile instructions low. Here is the machine that rule is exploiting. During docker build, for each instruction the builder computes a cache key and reuses the cached layer on a hit. The key covers:
the parent layer’s identity — a hit requires the whole chain above to have hit; the first miss invalidates everything after it, unconditionally;
the instruction text, verbatim — change
RUN pip install -r requirements.txtto add a single flag and that layer misses;for
COPY/ADD: the content checksum of every copied file — not names, not timestamps. Docker hashes the files;touch requirements.txtdoes not bust the cache, editing one byte in it does. (This is also why a broadCOPY . .is cache-poison: any file changing anywhere in the context misses that layer — and everything below it.)
Two subtleties the simple rule hides:
RUNcommands are assumed deterministic. The key is the instruction text — Docker does not know thatRUN pip install requests(unpinned) orRUN apt-get updateproduces different results on different days. A cached stale layer is served happily forever. That’s an argument for pinning (reproducibility), and the reason “why is my image not picking up the new package version” is a FAQ. Force a full re-run withdocker build --no-cache, or bust from a chosen point down by editing that instruction.BuildKit (the modern build engine, default since Docker 23) extends this model: it builds the instruction graph rather than a straight line (independent multi-stage branches build in parallel and cache independently), can export/import the cache to a registry so CI runners share it (
--cache-to/--cache-from— this is what makes Module 14-style pipelines fast), and adds cache mounts (RUN --mount=type=cache,target=/root/.cache/pip …) — a persistent scratch directory that survives across builds without entering any layer. Cache mounts are the single biggest lever for Python dependency layers, and dockerfiles-for-data-science.md builds on them heavily.
The lab notebook’s centerpiece is a working model of exactly this: you hash instructions + file content into layer keys, chain them through parents, and replay code-edit vs dependency-edit builds to watch the invalidation cascade — the same simulation style as Module 14’s lab01, one level deeper.
8. Exercises¶
Try each before opening the answer.
Exercise 1. docker history on your image shows a 1.2 GB layer for RUN pip install -r requirements.txt and, three lines later, a layer for RUN rm -rf /root/.cache/pip. Roughly how much does that rm layer reduce the image’s download size, and what are correct ways to actually get the saving?
Answer
It reduces it by approximately zero (it adds a few bytes of whiteout entries). The cache files remain, in full, inside the earlier immutable layer; the later layer only hides them. Correct fixes: (1) delete within the same instruction so the files never enter the snapshot — RUN pip install -r requirements.txt && rm -rf /root/.cache/pip — or equivalently pip install --no-cache-dir; (2) a BuildKit cache mount (RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt), which keeps the cache out of every layer and speeds up rebuilds; (3) a multi-stage build that copies only the installed packages forward.
Exercise 2. Your production deploy pulls myapp:latest on two servers, twenty minutes apart, and they end up running different code. Explain precisely why this is possible, and give the two mechanisms (one cryptographic, one conventional) that prevent it.
Answer
A tag is a mutable pointer: CI pushed a new image and re-pointed latest between the two pulls, so each server resolved the same name to a different manifest. Prevention: (1) digest pinning — deploy myapp@sha256:…; identical digest guarantees identical bytes cryptographically; (2) immutable-by-convention tags — Module 14’s approach of tagging every build with the Git commit SHA and deploying that tag (registries.md). Same idea Git users already trust: deploy commits, not branches.
Exercise 3. An OverlayFS mount has lowerdir=l2:l1 (l2 on top of l1) and upperdir=u. Files: l1/a.txt, l1/b.txt, l2/b.txt, l2/c.txt, u/c.txt, and a whiteout for a.txt in u. What does ls merged/ show, and which physical file serves each name?
Answer
merged/ shows b.txt and c.txt. Lookup is top-down (u, then l2, then l1): a.txt — the whiteout in u hides it (search terminates; l1’s copy is invisible). b.txt — served from l2/b.txt (higher lower layer shadows l1/b.txt). c.txt — served from u/c.txt (upper wins over l2/c.txt). Exactly the rule the lab notebook has you implement.
Exercise 4. During a rebuild after editing only app/main.py, which of these instructions hit the cache and why: (1) FROM python:3.12-slim, (2) COPY requirements.txt ., (3) RUN pip install -r requirements.txt, (4) COPY app ./app, (5) CMD ["python","app/main.py"]? Then: does running touch requirements.txt first change anything?
Answer
(1)–(3) hit: parents unchanged, instruction text unchanged, and for (2) the content hash of requirements.txt is unchanged. (4) misses — the copied content changed. (5) rebuilds despite being unchanged — its parent (4) missed, and a cache hit requires an unbroken chain of hits above; it’s a cheap metadata-only layer, so this costs nothing. touch changes nothing at all: COPY’s cache key hashes file content, not timestamps.
Exercise 5. Why does pulling an image you’ve never seen sometimes print Already exists for several layers?
Answer
Layers are stored and transferred by content digest, independently of which image references them. If the new image shares layers with anything you’ve pulled before — most commonly a base like python:3.12-slim or nvidia/cuda — your local content-addressed store already holds those digests, so only the genuinely novel layers are downloaded. Deduplication isn’t an optimization bolted on; it’s a direct corollary of content addressing.