Delete a 50 MB file and a credentials file in the last RUN of your Dockerfile — then read the credentials back out of the shipped image, and find the 50 MB still sitting on your disk. An image is a stack of immutable diffs; a container is one thin layer on top.
A Docker image is not a disk image. It's a stack of tarballs — one per build step — and the filesystem your container sees is a union mount that overlays them, topmost wins. Every layer is immutable and content-addressed, so unrelated images share the ones they have in common. A running container adds exactly one thin writable layer on top, which is why docker diff can tell you what changed and why two containers from the same image cost almost nothing extra.
This exercise makes all of that literal. You'll build an image whose last RUN deletes a 50 MB file and a credentials file — and then read that credentials file back out of the shipped image. You'll watch a two-byte append cost 4.6 MB. And you'll end up looking at the actual kernel overlay mount, with its six lowerdir= paths and a character device standing in for a deleted file.
Four terms carry the whole exercise:
| Term | What it actually is |
|---|---|
| Layer | A tar archive of the filesystem changes one build step made — files added or modified, plus tombstones for files removed. Immutable once written, and named by the SHA-256 of its contents (its diff ID), which is what makes sharing possible. |
| Union / overlay mount | The kernel stacking those layers into one directory tree. In overlay2's vocabulary: the read-only image layers are lowerdirs, the container's writable layer is the upperdir, and the combined view is the merged directory — which is what becomes the container's /. |
| Whiteout | How you delete a file you don't own. Since lower layers are read-only, an upper layer records a marker at that path — a character device with major/minor 0,0 on disk, or a .wh.<name> entry inside a layer tarball — and the union hides everything below it. |
| Copy-up | How you modify a file you don't own. On first write, overlayfs copies the entire file from the lower layer into the upper layer, then edits the copy. Costs are per-file, not per-byte-changed. |
RUN rm in its own step reduces your image size by zero and reduces the recoverability of your secret by zero.
The shape you're about to see on disk:
Deleting layer 3's file from layer 1 changes the top row. It does not change row 3.
One terminal is enough. You need Docker, and roughly 70 MB of free image space. Everything here is tagged layers-demo:* and named layers-* so you can clean up precisely at the end.
Work in a scratch directory, not in a repo:
mkdir -p /tmp/layers && cd /tmp/layers
Create Dockerfile. Note that each RUN is deliberately its own step, and the last one is the "cleanup" line you've seen in a hundred real Dockerfiles:
FROM alpine:3.20
RUN echo "build-id=1" > /etc/app-release
RUN dd if=/dev/urandom of=/opt/build-cache.bin bs=1M count=50 2>/dev/null
RUN echo "AKIAEXAMPLESECRETKEY" > /root/.aws-credentials
RUN rm -f /opt/build-cache.bin /root/.aws-credentials
CMD ["sleep", "3600"]
/dev/urandom rather than zeros on purpose: incompressible bytes, so nothing downstream can quietly make the problem look smaller than it is.
linux/arm64, storage driver overlay2, cgroup v2. Digests and layer directory IDs are per-machine — yours will differ, and you'll substitute your own throughout Part 5.
What this part tests: the claim that the image is a stack of per-step diffs, not a single blob. If that's true, docker history should show one row per instruction with a size attached, and those sizes should add up.
docker build -t layers-demo:leaky .
docker history layers-demo:leaky
FROM. How many rows will you get, and what size will the last RUN — the rm — report? Click to check.Seven rows: your five plus the two the base image contributed — history is inherited, so you're reading alpine's own build steps at the bottom. CMD rows are 0B because they change metadata, not files. And the rm step is 0B too, which is the first hint of the whole lesson.
docker image inspect layers-demo:leaky --format '{{.Size}}'
The layer sizes are 8,822,697 + 11 + 52,428,800 + 21 + 0, which is 61,251,529 exactly. The image's size is the sum of its layers' diffs; there is nothing else in there.
docker run --rm layers-demo:leaky sh -c 'ls -la /opt; cat /root/.aws-credentials'
RUN deleted both files. From inside a container, is /opt/build-cache.bin there? Click to check.Both gone, exactly as a filesystem should behave. The image is still 61 MB.
What this part tests: the mental model's headline — a whiteout hides, it doesn't remove. The union view above is honest; it just isn't the whole artifact. docker save gives you the artifact, exactly as a registry would receive it.
mkdir -p save && docker save layers-demo:leaky -o save/leaky.tar
cd save && tar -xf leaky.tar
for f in blobs/sha256/*; do
if tar -tf "$f" 2>/dev/null | grep -q 'aws-credentials'; then
echo "FOUND IN: $f"
tar -xOf "$f" root/.aws-credentials 2>/dev/null
fi
done
docker save writes the image exactly as it would be pushed to a registry. Can you get AKIAEXAMPLESECRETKEY out of it? Click to check.Two layers matched the name; only one has the file. The credential is plaintext in a layer that ships with the image, retrievable by anyone who can pull it — no exploit, no privilege, just tar.
tar -tvf blobs/sha256/33b9bea4... # the layer that created the files
tar -tvf blobs/sha256/8c147ab8... # the layer that "deleted" them
0B in docker history. Is it empty? Click to check.There it is, in plain sight: the rm layer contains two zero-byte files whose names begin with .wh.. That is what a delete is in an image. rm didn't produce a smaller image, it produced a slightly larger one — two extra tombstones stacked on top of everything they hide.
root/.wh..aws-credentials has two dots because the original filename already started with one: prefix .wh. plus .aws-credentials.
Write Dockerfile.fixed, moving the create-and-delete into a single RUN:
FROM alpine:3.20
RUN echo "build-id=1" > /etc/app-release
RUN dd if=/dev/urandom of=/opt/build-cache.bin bs=1M count=50 2>/dev/null \
&& echo "AKIAEXAMPLESECRETKEY" > /root/.aws-credentials \
&& rm -f /opt/build-cache.bin /root/.aws-credentials
CMD ["sleep", "3600"]
docker build -f Dockerfile.fixed -t layers-demo:squashed .
docker image inspect layers-demo:squashed --format '{{.Size}}'
:leaky — rm in its own layer | :squashed — one RUN | |
|---|---|---|
| Image size | 61,251,529 B (61.3 MB) | 8,822,708 B (8.82 MB) |
| Layers | 5 | 3 |
| 50 MB cache file on disk | yes, in layer 3 | no |
| Secret recoverable from image | yes | no |
Byte-identical container filesystems, 7× the size. The only difference is where the layer boundaries fell, and layer boundaries are the unit at which content becomes permanent.
Its docker history shows the combined step as 0B — because that step's net diff really is nothing: the file it created never outlived the step, so no tombstone was needed either.
What this part tests: the claim that a layer is named by its contents. If so, two independently-built images that happen to produce identical layers should list the same digest, and Docker should store one copy.
docker image inspect layers-demo:leaky --format '{{json .RootFS.Layers}}'
docker image inspect layers-demo:squashed --format '{{json .RootFS.Layers}}'
:leaky | :squashed | |
|---|---|---|
| layer 1 | 88b4fba61c4c714a2fc173ddf7e9324a… — the same layer, not a copy | |
| layer 2 | 891abf759b795a947180c4a402e06377… — the same layer, not a copy | |
| layer 3 | 8c35f8546e65b2ec544623a065bfac1b… | 7dc425ddeb23c628fc015249884decf6… |
| layer 4 | 33b9bea4ef890bff4fcf2cd6fcd24261… | — |
| layer 5 | 8c147ab8f86c88e8d6420070a4ee8b9f… | — |
The first two are identical strings: the alpine rootfs, and the echo "build-id=1" layer. Nothing coordinated that — the two builds produced byte-identical diffs, so they hash to the same name, so they are the same layer. Pull a hundred alpine-based images and you download 88b4fba… once.
docker build -t layers-demo:leaky . # unchanged, for contrast
sed -i '' 's/build-id=1/build-id=2/' Dockerfile
docker build --progress=plain -t layers-demo:rebuilt .
All three re-ran. The digests confirm it — only the base layer survives:
A layer's cache key includes the layer it was built on. Change one, and every layer above it is a different stack, so none of them can be reused. That's the reason COPY package.json comes before COPY . . in every well-written Node Dockerfile: put the thing that changes every commit as high in the stack as you can.
What this part tests: that a running container adds only a diff. Per the mental model, the upperdir starts empty and gains one entry per file you touch — which is exactly the information docker diff reports.
docker run -d --name layers-box layers-demo:leaky sleep 3600
docker diff layers-box
docker diff report? Click to check.A container that has done no writes has an empty writable layer — the process is running on a stack of read-only directories and a mount point.
docker exec layers-box sh -c '
echo "build-id=2" > /etc/app-release
mkdir -p /srv/data && echo hello > /srv/data/new.txt
rm /etc/motd'
docker diff layers-box
docker diff say about the directories containing them? Click to check.Added, Changed, Deleted. Note /srv and /etc are C, not A — they already existed in the image; the writable layer had to materialize its own copy of each directory to hold the new entries. That's copy-up applied to directories.
docker ps -s --filter name=layers-box --format 'table {{.Names}}\t{{.Size}}'
17B is the writable layer — 11 for app-release plus 6 for new.txt; the tombstone for /etc/motd is free. virtual is that plus the shared image.
Start a second container and it costs 0B:
docker run -d --name layers-box2 layers-demo:leaky sleep 3600
docker exec layers-box2 cat /etc/app-release
docker ps -s --filter name=layers-box --format '{{.Names}} {{.Size}}'
Two containers reporting 61.3 MB each; 17 bytes of actual new disk between them. And layers-box2 still sees build-id=1 — the first container's edit lives in its upperdir, invisible to anyone else, because the layer they share is read-only for both.
docker exec layers-box2 sh -c 'ls -l /lib/libcrypto.so.3'
docker exec layers-box2 sh -c 'echo x >> /lib/libcrypto.so.3'
docker ps -s --filter name=layers-box2 --format '{{.Names}} {{.Size}}'
docker diff layers-box2
layers-box2 is currently at 0B. You are about to append two bytes to a 4.6 MB library that came from an image layer. What does it weigh afterwards? Click to check.Two bytes written, 4.6 MB consumed. Copy-up has no notion of a partial file: to make one byte writable, overlayfs copies all 4,597,896 of them into the upper layer first.
This is the mechanism behind "why did my container balloon overnight" — a log rotation or an in-place edit of large image files, not new data. It's also why a database in a container wants a volume: a volume bypasses the union entirely.
What this part tests: everything above, at the level the kernel sees it. docker inspect will hand you the real paths.
docker inspect layers-box --format '{{json .GraphDriver}}'
LowerDir, given the image has five layers? Click to check.Six lower directories, topmost first: a per-container -init layer (where Docker stages /etc/hosts and friends), then your five image layers in reverse order. One upper. That colon-separated string is passed straight to mount -t overlay.
Now compare with the second container:
docker inspect layers-box2 --format '{{.GraphDriver.Data.LowerDir}}' | tr ':' '\n'
The five image directories are character-for-character the same paths; only the -init and upper differ. "Sharing a layer" means literally mounting the same directory into two containers.
ls /var/lib/docker from your own shell will fail — there is no such directory on the host. On native Linux, just sudo ls them and skip this wrapper. Everywhere else, reach into the VM's mount namespace:
docker run --rm --privileged --pid=host alpine \
nsenter -t 1 -m -- <command>
--pid=host puts you in the VM's PID namespace so PID 1 is its init; nsenter -t 1 -m then enters that process's mount namespace, which is where /var/lib/docker lives. Substitute your own directory IDs from the docker inspect output — they are randomly generated per machine.
docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- \
find /var/lib/docker/overlay2/5cd5505b…/diff -exec ls -ld {} +
docker diff reported six paths for layers-box, one of them D /etc/motd. What is physically there — and what stands in for the deleted file? Click to check.Six entries for a 61 MB filesystem. And look at /etc/motd: leading c, size 0, 0. That is a character device with major and minor both zero — the on-disk whiteout convention. Deleting a file added something. The D in docker diff is Docker translating this back into a word.
docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- \
sh -c 'grep 5cd5505b /proc/mounts'
A plain overlay mount in /proc/mounts, indistinguishable from one you'd make by hand. Docker's whole "container filesystem" is this single line.
The l/ paths are short symlinks Docker keeps so the option string stays under the kernel's page-sized mount-data limit — a real constraint you hit somewhere around 120 layers.
docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- sh -c \
'for d in p3nn7a2jvcy7m982o8hidm1bx 1y7kex3p13fatta38d9kgg45w \
1dp70kjleo19gjnqbcpoz793k 3srqfagieyzqzsxt0pcvln6vc; do
echo "=== $d ==="
find /var/lib/docker/overlay2/$d/diff -exec ls -ld {} +
done'
1dp70kjleo… is the dd layer, p3nn7a2jvc… the rm layer. Is the 50 MB file on your disk right now? Click to check.(Directory lines trimmed — every layer also carries the parent directories of the files it touched.)
All 52,428,800 bytes, sitting in a directory on your disk, under a path no container can see. And one command finishes the story:
docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- \
cat /var/lib/docker/overlay2/1y7kex3p13fatta38d9kgg45w/diff/root/.aws-credentials
The same secret you pulled out of the tarball in Part 2, now read straight off the host filesystem, from an image whose every container reports the file as missing.
A docker history whose per-layer sizes sum to exactly the image size (8,822,697 + 11 + 52,428,800 + 21 + 0 = 61,251,529), with the RUN rm step contributing 0B. A container that agrees the deleted files are gone, and a docker save tarball that hands you AKIAEXAMPLESECRETKEY anyway, alongside two zero-byte .wh. tombstones. The same build with one && instead of three RUNs at 8,822,708 bytes — 7× smaller for identical behavior. Two images sharing their first two layer digests character-for-character, and a one-character edit invalidating every layer above it. A running container whose writable layer is 17B (virtual 61.3MB), a second one at 0B, and a two-byte append that costs 4.6 MB. And finally a real overlay line in /proc/mounts with six lowerdirs, a character device at …/diff/etc/motd, and a 52,428,800-byte file alive in a layer directory that nothing can reach.
Because the layer is the unit of transfer, and immutability is what makes transfer cheap. If a layer could be edited, its digest would change, every image referencing it would need updating, and the "already have that one" check that makes docker pull fast would have nothing stable to compare. So a layer is frozen the moment it's written, named by its contents, and shared by anyone whose stack happens to contain the same bytes. Everything else follows from refusing to break that.
Deletion is where the cost shows up. You cannot remove a file from a read-only layer, so the only honest thing an upper layer can do is record "stop looking below this path" — a whiteout. The union honors it, ls honors it, your application honors it. The registry does not, because the registry ships layers, not the union. RUN rm on a later line therefore buys you nothing but two tombstones, and this is not a Docker bug to be fixed; it is the direct price of the property that makes images shareable at all. The fix is always to arrange for the bytes never to be committed to a layer in the first place — one RUN, a multi-stage build, or a build mount.
Copy-up is the same trade in the other direction. Making an image layer writable per-container would mean copying it per-container, which would undo the sharing. So the layer stays read-only and the copy happens lazily, per file, on first write — near-zero cost for a container that mostly reads, and a full file copy the instant it isn't. 17B (virtual 61.3MB) and 4.6MB for two bytes are the two ends of that one mechanism.
:leaky as a multi-stage build — do the dd and the credentials work in a FROM alpine AS build stage, then FROM alpine and COPY --from=build only what you want. Check RootFS.Layers: the discarded stage contributes no layers at all, because it was never part of the final image's stack. This is the actual answer to Part 2's problem.RUN --mount=type=secret,id=creds cat /run/secrets/creds with docker build --secret id=creds,src=./creds. The file is present during the step and absent from the layer — verify with the Part 2 tarball scan and confirm nothing matches.WORKDIR, ENV, LABEL and EXPOSE lines and re-read docker history. All 0B: they're recorded in the image config (docker inspect --format '{{json .Config}}'), which is itself a blob in the tarball. The config plus the ordered layer list is the entire image — there is no other file.-v volume from inside layers-box and re-run docker diff. It doesn't appear, because a volume is a bind mount over the union, not part of it — which is the whole reason volume data survives docker rm.docker system df reports images TOTAL and SIZE, and the gap between the sum of docker images sizes and that SIZE is layer sharing, in bytes.Sources: Linux kernel: Overlay Filesystem — its "whiteouts and opaque directories" section defines the 0,0 character device you found · OCI Image Layer Specification for the .wh. tar convention · Docker Docs: the overlay2 storage driver
docker system prune and friends would take other people's images and containers with them. Remove only what this exercise created.
docker rm -f layers-box layers-box2
docker rmi layers-demo:leaky layers-demo:squashed layers-demo:rebuilt
rm -rf /tmp/layers