studyDocker › Union Filesystem Layers

Union Filesystem Layers

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.


Concept

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.


Provenance

Docker Engine docs, Storage drivers — sections Images and layers, Container and layers, Container size on disk, and Sharing promotes smaller images:

"When you create a new container, you add a new writable layer on top of the underlying layers. This layer is often called the 'container layer'."

"Shared image layers are only stored once in /var/lib/docker/ and are also shared when pushing and pulling an image to an image registry."

On docker ps -s: size is "the amount of data (on disk) that's used for the writable layer of each container"; virtual size is "the amount of data used for the read-only image data used by the container plus the container's writable layer 'size'."

Docker Engine docs, OverlayFS storage driver — sections How the overlay2 driver works and How container reads and writes work with overlay2. This is the sentence Parts 2, 4 and 5 exist to make you look at:

"When a file is deleted within a container, a whiteout file is created in the container (upperdir). The version of the file in the image layer (lowerdir) is not deleted (because the lowerdir is read-only). However, the whiteout file prevents it from being available to the container."

And for copy-up, from the same page:

"The first time a container writes to an existing file, that file does not exist in the container (upperdir). The overlay2 driver performs a copy_up operation to copy the file from the image (lowerdir) to the container (upperdir)."

The on-disk form of a whiteout is the kernel's, not Docker's — Linux kernel documentation, Overlay Filesystem, section whiteouts and opaque directories:

"A whiteout is created as a character device with 0/0 device number or as a zero-size regular file with the xattr trusted.overlay.whiteout."

The tar form is the OCI's — OCI Image Layer Specification, section Whiteouts:

"A whiteout file is an empty file with a special filename that signifies a path should be deleted. A whiteout filename consists of the prefix .wh. plus the basename of the path to be deleted."

Part 5's claim about the l/ symlink directory is from the overlay2 page's Image and container layers on-disk: the short identifiers exist "to avoid hitting the page size limitation on arguments to the mount command," and "the overlay2 driver natively supports up to 128 lower OverlayFS layers."


Prerequisites

Four pieces of background, one line each on where they're load-bearing:


Environment

Storage driver overlay2, builder buildkit.dockerfile.v0. Layer digests and overlay2 directory IDs are generated per build and per machine — yours will differ, and you'll substitute your own throughout Part 5.


Mental model: a stack, a union, and a tombstone

Four terms carry the whole exercise:

TermWhat 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.
The consequence that costs real money A later layer can hide a file, but it can never remove it. The bytes stay in the earlier layer forever, and that layer still ships. 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:

container / ← what your process sees (the "merged" view) ═══════════════════════════════════════════ upperdir writable layer [C /etc/app-release] [A /srv/data] [D /etc/motd] ─────────────────────────────────────────── ← container starts here lowerdir 1 RUN rm ... whiteouts only, 0 B lowerdir 2 RUN echo creds /root/.aws-credentials, 21 B lowerdir 3 RUN dd 50MB /opt/build-cache.bin, 52,428,800 B lowerdir 4 RUN echo build /etc/app-release, 11 B lowerdir 5 FROM alpine:3.20 the whole distro, 8,822,697 B

Deleting layer 3's file from layer 1 changes the top row. It does not change row 3.


Setup

One terminal is enough. You need Docker, and roughly 150 MB of free image space (the exercise ends up with four images). 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.

Part 3 assumes you have never built this Dockerfile before If you have, BuildKit already holds several cache entries for these exact RUN lines, and Steps 6 and 7 will pick an arbitrary one. Clear just this exercise's entries first — never a blanket prune, which takes everyone else's cache with it:
for pat in build-id build-cache aws-credentials app-release; do
  docker buildx prune -a --force --filter "description~=$pat"
done
Run that loop twice: freeing a record can make its parent prunable, so the first pass usually leaves a few behind.

Part 1 — one instruction, one layer

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.

Step 1 · build it and read the stack
docker build -t layers-demo:leaky .
docker history layers-demo:leaky
Predict: the Dockerfile has five instructions after FROM. How many rows will you get, and what size will the last RUN — the rm — report?
IMAGE CREATED CREATED BY SIZE COMMENT ad1aa751238f 7 seconds ago CMD ["sleep" "3600"] 0B buildkit.dockerfile.v0 <missing> 7 seconds ago RUN /bin/sh -c rm -f /opt/build-cache.bin /r… 0B buildkit.dockerfile.v0 <missing> 8 seconds ago RUN /bin/sh -c echo "AKIAEXAMPLESECRETKEY" >… 21B buildkit.dockerfile.v0 <missing> 8 seconds ago RUN /bin/sh -c dd if=/dev/urandom of=/opt/bu… 52.4MB buildkit.dockerfile.v0 <missing> 9 seconds ago RUN /bin/sh -c echo "build-id=1" > /etc/app-… 11B buildkit.dockerfile.v0 <missing> 3 months ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0 <missing> 3 months ago ADD alpine-minirootfs-3.20.10-aarch64.tar.gz… 8.82MB buildkit.dockerfile.v0

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.

Step 2 · check the arithmetic
docker image inspect layers-demo:leaky --format '{{.Size}}'
docker image inspect alpine:3.20       --format '{{.Size}}'
61251529 8822697

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.

Step 3 · the union view
docker run --rm layers-demo:leaky sh -c 'ls -la /opt; cat /root/.aws-credentials'
Predict: the last RUN deleted both files. From inside a container, is /opt/build-cache.bin there?
total 8 drwxr-xr-x 1 root root 4096 Jul 25 16:14 . drwxr-xr-x 1 root root 4096 Jul 25 16:14 .. cat: can't open '/root/.aws-credentials': No such file or directory

Both gone, exactly as a filesystem should behave. The image is still 61 MB.


Part 2 — the file you deleted is still in the image

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.

Step 4 · grep the shipped layers
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
Predict: docker save writes the image exactly as it would be pushed to a registry. Can you get AKIAEXAMPLESECRETKEY out of it?
FOUND IN: blobs/sha256/38df108fbea373e8ed78e4e686c365fd7bf0bb15950d2a51b410865f95c26824 FOUND IN: blobs/sha256/fe77f750c34353660716bbcb0ad108ecc8c1300457a00b7e44a487a50cde1a1f AKIAEXAMPLESECRETKEY

Two layers matched the name; only one has the file, and the loop walks blobs/sha256/* in digest order, so which of the two prints first is luck — here the empty one did, which is why the secret shows up under the second FOUND IN line. 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.

Step 5 · what a "delete" looks like inside a layer

Substitute your own digests:

tar -tvf blobs/sha256/fe77f750...   # the layer that created the files
tar -tvf blobs/sha256/38df108f...   # the layer that "deleted" them
Predict: the second layer reported 0B in docker history. Is it empty?
drwxr-xr-x 0 0 0 0 Jul 26 00:14 etc/ drwx------ 0 0 0 0 Jul 26 00:14 root/ -rw-r--r-- 0 0 0 21 Jul 26 00:14 root/.aws-credentials
drwxr-xr-x 0 0 0 0 Jul 26 00:14 etc/ drwxr-xr-x 0 0 0 0 Jul 26 00:14 opt/ -rw------- 0 0 0 0 Jul 26 00:14 opt/.wh.build-cache.bin drwx------ 0 0 0 0 Jul 26 00:14 root/ -rw------- 0 0 0 0 Jul 26 00:14 root/.wh..aws-credentials

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.

Step 6 · the same work, one layer boundary later

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}}'
Predict: the exact same commands run, in the exact same order, producing the exact same container filesystem. Does the image get smaller?
#4 [1/3] FROM docker.io/library/alpine:3.20 #4 DONE 0.0s #5 [2/3] RUN echo "build-id=1" > /etc/app-release #5 CACHED #6 [3/3] 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 #6 DONE 0.4s #7 writing image sha256:0e915405e51483e6b245c21d98ba46ac1915f887456ef00fabb1e15c57d4678e done #7 naming to docker.io/library/layers-demo:squashed done #7 DONE 0.0s

#5 CACHED — the echo step is character-for-character the one :leaky already ran, so BuildKit did not run it again. Remember this; Part 3 turns on it.

8822708
:leaky — rm in its own layer:squashed — one RUN
Image size61,251,529 B (61.3 MB)8,822,708 B (8.82 MB)
Layers53
50 MB cache file on diskyes, in layer 3no
Secret recoverable from imageyesno

Byte-identical container filesystems, 6.9× the size. The only difference is where the layer boundaries fell, and layer boundaries are the unit at which content becomes permanent.

8,822,708 is 8,822,697 + 11 + 0: the alpine rootfs, the app-release line, and a combined step whose net diff is nothing. Its docker history row reads 0B — the file it created never outlived the step, so not even a tombstone was needed.


Part 3 — layers are content-addressed, and therefore shared

What this part tests: the claim that a layer is named by its contents, so Docker stores one copy of a layer no matter how many images list it.

Step 7 · compare two images' layer digests
docker image inspect layers-demo:leaky    --format '{{json .RootFS.Layers}}'
docker image inspect layers-demo:squashed --format '{{json .RootFS.Layers}}'
Predict: these are separate images built from separate Dockerfiles. How many of their layer digests match?
leaky: squashed: 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f ← same 7b511a713a666939376b9940ccab0b4b119de3f31be772c94d0d976ad95808d4 7b511a713a666939376b9940ccab0b4b119de3f31be772c94d0d976ad95808d4 ← same 8d85c35474bef3ae31f1d5fc960b33c66db7d3971ac674d51dc63d24453d1b19 06a204555e48d10c594ef7fa321f5725f0f696b5d013667550ce7722611bd66b fe77f750c34353660716bbcb0ad108ecc8c1300457a00b7e44a487a50cde1a1f 38df108fbea373e8ed78e4e686c365fd7bf0bb15950d2a51b410865f95c26824

(One digest per line for readability — the command prints each image's list as a JSON array.)

Two identical strings. 88b4fba6… is the alpine rootfs, and you can prove that without leaving the shell — it is the only layer alpine:3.20 has:

docker image inspect alpine:3.20 --format '{{json .RootFS.Layers}}'
["sha256:88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f"]

7b511a71… is the echo "build-id=1" layer. Hold on to the question of why it matches — Step 9 answers it, and the obvious answer is wrong.

Step 8 · change one character near the bottom
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 .
Predict: steps 3, 4 and 5 have identical text and take no input from the edited line. Do they re-run?
rebuild with no change
#4 [1/5] FROM docker.io/library/alpine:3.20 #4 DONE 0.0s #5 [2/5] RUN echo "build-id=1" > /etc/app-release #5 CACHED #6 [3/5] RUN dd if=/dev/urandom of=/opt/build-cache.bin bs=1M count=50 2>/dev/null #6 CACHED #7 [4/5] RUN echo "AKIAEXAMPLESECRETKEY" > /root/.aws-credentials #7 CACHED #8 [5/5] RUN rm -f /opt/build-cache.bin /root/.aws-credentials #8 CACHED #9 exporting to image #9 exporting layers done #9 writing image sha256:ad1aa751238fca192bfa4464394ff93b4cf0a823d18bf1dad0271ac81f4d338f done #9 naming to docker.io/library/layers-demo:leaky done #9 DONE 0.0s
after editing line 3 only
#4 [1/5] FROM docker.io/library/alpine:3.20 #4 CACHED #5 [2/5] RUN echo "build-id=2" > /etc/app-release #5 DONE 0.2s #6 [3/5] RUN dd if=/dev/urandom of=/opt/build-cache.bin bs=1M count=50 2>/dev/null #6 DONE 0.6s #7 [4/5] RUN echo "AKIAEXAMPLESECRETKEY" > /root/.aws-credentials #7 DONE 0.2s #8 [5/5] RUN rm -f /opt/build-cache.bin /root/.aws-credentials #8 DONE 0.2s

All three re-ran. The digests confirm it — only the base layer survives:

leaky: rebuilt: 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f ← same 7b511a713a666939376b9940ccab0b4b119de3f31be772c94d0d976ad95808d4 f68171ac3205a7036dfbc43d4b87b4b59919a1ae7b3dc46cc28f8e609ed9f921 8d85c35474bef3ae31f1d5fc960b33c66db7d3971ac674d51dc63d24453d1b19 05c17317ef39ef2da843a47892ab23c5bb3ad9a42216afa9941c55e2a7cb7218 fe77f750c34353660716bbcb0ad108ecc8c1300457a00b7e44a487a50cde1a1f 1f6aea84e50d5ccb7dfed81c1b8ce2ee4cad1970d97a3f2c095cc6e7189eb145 38df108fbea373e8ed78e4e686c365fd7bf0bb15950d2a51b410865f95c26824 0dc8bdf9228ba5eab256c19809b558383e9d1e0044820e910602f8c05f132d93

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.

Step 9 · make that shared layer actually rebuild

Back to Step 7: :leaky and :squashed agreed on 7b511a71…, the echo "build-id=1" layer. Force that step to run again — same base, same command, same resulting 11-byte file:

docker build --no-cache -f Dockerfile.fixed -t layers-demo:nocache .
docker image inspect layers-demo:squashed --format '{{json .RootFS.Layers}}'
docker image inspect layers-demo:nocache  --format '{{json .RootFS.Layers}}'
Predict: was Step 7's match because the two builds independently produced the same bytes? If so, this rebuild should produce the same digest again.
squashed: nocache: 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f 88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f ← same 7b511a713a666939376b9940ccab0b4b119de3f31be772c94d0d976ad95808d4 edfaed2daef14a6326f9f453bc611e49d370ffd76d1f87d194b35bfb4aae5fff 06a204555e48d10c594ef7fa321f5725f0f696b5d013667550ce7722611bd66b 7b712b080d4b55f47b7e6ef69d6f833041ee7bc015b9c5a01cb573a6319a7c79

The echo "build-id=1" layer is now a different layer. Same Dockerfile line, same base image, same 11 bytes of file content — different digest. docker save both images and list that one layer out of each to see the entire difference:

drwxr-xr-x 0 0 0 0 Jul 26 00:14 etc/ -rw-r--r-- 0 0 0 11 Jul 26 00:14 etc/app-release
drwxr-xr-x 0 0 0 0 Jul 26 00:16 etc/ -rw-r--r-- 0 0 0 11 Jul 26 00:16 etc/app-release

Two minutes of mtime, and tar -xO returns build-id=1 from both. A layer's digest is over its tar bytes, and a tar member carries its modification time, so re-running a step produces a layer that is content-identical and digest-different.

So Step 7's match was not two builds converging on the same bytes: it was BuildKit recognising (same parent, same command string) and reusing the already-committed layer without running anything — look back at Step 6's build, where BuildKit printed #5 … CACHED for exactly that line. That distinction is the whole practical difference between "Docker builds are reproducible" (they are not) and "Docker builds are cacheable" (they are). What is content-addressed all the way down, with no cache involved, is 88b4fba6…: every alpine-based image on your machine points at the one rootfs layer you pulled once.

Step 10 · put a number on the sharing

You now have four images that between them advertise a lot of megabytes. How many of those bytes exist? Scope the question to your own images — a machine-wide total says nothing about this exercise:

docker system df -v | grep -E 'SHARED SIZE|^layers-demo'
Predict: :squashed reports 8.82 MB. How much disk does it actually occupy, given :leaky is already there?
REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS layers-demo nocache 19726d67590a 3 minutes ago 8.82MB 8.823MB 11B 0 layers-demo rebuilt fb1f8c3e4021 3 minutes ago 61.3MB 8.823MB 52.43MB 0 layers-demo squashed 0e915405e514 4 minutes ago 8.82MB 8.823MB 0B 0 layers-demo leaky ad1aa751238f 5 minutes ago 61.3MB 8.823MB 52.43MB 2

Read the :squashed row again: 8.82 MB of size, 0 B of unique size. Every byte that image needs was already on disk for :leaky. Building it cost nothing at all.

In exact bytes (from docker image inspect and the daemon's /system/df endpoint — the one-liner is under Go deeper):

reported SIZESHARED SIZEUNIQUE SIZE
:leaky61,251,5298,822,70852,428,821
:squashed8,822,7088,822,7080
:rebuilt61,251,5298,822,69752,428,832
:nocache8,822,7088,822,69711
sum140,148,47435,290,810104,857,664

The four images advertise 140,148,474 bytes. 35,290,810 of that is shared bytes, counted once into every image that names them — and only 8,822,708 distinct shared bytes exist on disk (the 8,822,697-byte alpine rootfs plus the 11-byte app-release layer that :leaky and :squashed agree on). Real occupancy is 104,857,664 + 8,822,708 = 113,680,372: 26,468,102 bytes of the headline number were never stored. Scale that to a fleet of fifty services on one base image and the sharing is the entire reason a registry is affordable.


Part 4 — the writable layer is genuinely thin

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.

Step 11 · diff a container that has done nothing
docker run -d --name layers-box layers-demo:leaky sleep 3600
docker diff layers-box
Predict: the container is up and running a process. What does docker diff report?
(no output at all)

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.

Step 12 · touch three files
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
Predict: one modify, one create, one delete. What three letters come back — and what does docker diff say about the directories containing them?
C /etc C /etc/app-release D /etc/motd C /srv A /srv/data A /srv/data/new.txt

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.

Step 13 · what did that cost?
docker ps -s --filter name=layers-box --format 'table {{.Names}}\t{{.Size}}'
Predict: you wrote 17 bytes of content into a 61.3 MB image. What size does Docker report for the container?
NAMES SIZE layers-box 17B (virtual 61.3MB)

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}}'
build-id=1 layers-box2 0B (virtual 61.3MB) layers-box 17B (virtual 61.3MB)

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.

Step 14 · append two bytes to a big file from the image
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
Predict: 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?
-rwxr-xr-x 1 root root 4597896 Apr 9 22:19 /lib/libcrypto.so.3 layers-box2 4.6MB (virtual 65.8MB) C /lib C /lib/libcrypto.so.3

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.


Part 5 — look at the actual mount

What this part tests: everything above, at the level the kernel sees it. docker inspect will hand you the real paths.

Step 15 · the paths Docker hands the kernel
docker inspect layers-box --format '{{json .GraphDriver}}'
Predict: how many directories are in LowerDir, given the image has five layers?
{ "Data": { "ID": "9330aac54137b4451f2c9253c753e25b07e9b7cc5230a01a89f5eb9ec0966e52", "LowerDir": "/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec-init/diff:/var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff:/var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff:/var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff:/var/lib/docker/overlay2/bsf07qsb707txc2nz2x55y0h2/diff:/var/lib/docker/overlay2/f9488352a1dfcbea7d209c9877e72d5cc48dd1a4baa75af80225bc9be0349fb5/diff", "MergedDir": "/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/merged", "UpperDir": "/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff", "WorkDir": "/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/work" }, "Name": "overlay2" }

(Pretty-printed — the command emits one line.) 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'
/var/lib/docker/overlay2/5bf4aaa9e7ebe7fa12a1d5495ffd24c168fa4ac949801f04199d871c39583a83-init/diff /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff /var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff /var/lib/docker/overlay2/bsf07qsb707txc2nz2x55y0h2/diff /var/lib/docker/overlay2/f9488352a1dfcbea7d209c9877e72d5cc48dd1a4baa75af80225bc9be0349fb5/diff

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.

Those paths live inside the Docker VM on macOS and Windows 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:3.20 \
  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.
Step 16 · read the writable layer off disk
docker run --rm --privileged --pid=host alpine:3.20 nsenter -t 1 -m -- \
  find /var/lib/docker/overlay2/1d5c8c44b19a8b…/diff -exec ls -ld {} +
Predict: 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?
drwxr-xr-x 4 root root 4096 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff drwxr-xr-x 2 root root 4096 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/etc -rw-r--r-- 1 root root 11 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/etc/app-release c--------- 2 root root 0, 0 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/etc/motd drwxr-xr-x 3 root root 4096 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/srv drwxr-xr-x 2 root root 4096 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/srv/data -rw-r--r-- 1 root root 6 Jul 25 16:16 /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff/srv/data/new.txt

Seven entries — the upper root, two directories it had to materialize, and four leaves — 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 — exactly the on-disk whiteout the kernel documentation defines. Deleting a file added something. The D in docker diff is Docker translating this back into a word.

Step 17 · the kernel's own view
docker run --rm --privileged --pid=host alpine:3.20 nsenter -t 1 -m -- \
  sh -c 'grep 1d5c8c44 /proc/mounts'
overlay /var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/merged overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/5UFK4BZSC23VMAIAAC2IZICORX:/var/lib/docker/overlay2/l/CTHEBUFJOM6N2DI4GPOPG3CEWG:/var/lib/docker/overlay2/l/DHPCB4AFVBFGTV7W4IMCMGOMPJ:/var/lib/docker/overlay2/l/K3ONCNKGRH423U672XBR2RNF6U:/var/lib/docker/overlay2/l/NHHLV26VO4SQJ4LW4BBNG33IDC:/var/lib/docker/overlay2/l/7XATOXVVNXECXOSKERCYNBHVWW,upperdir=/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/diff,workdir=/var/lib/docker/overlay2/1d5c8c44b19a8b1d9d5b9106966f12a44b29fff1931d5586191f25386e2180ec/work 0 0

A plain overlay mount in /proc/mounts, indistinguishable from one you'd make by hand. Docker's whole "container filesystem" is this single line: six lowerdir= entries, one upperdir=, one workdir=.

The l/ paths are short symlinks Docker keeps "to avoid hitting the page size limitation on arguments to the mount command" — the same limit behind overlay2's documented ceiling of 128 lower layers.

Step 18 · walk the image layers
docker run --rm --privileged --pid=host alpine:3.20 nsenter -t 1 -m -- sh -c \
  'for d in s0boitxkt0lcv2ltxa1p6108u td38xshhvgkyrnryq0z1fhq8r \
            uqvao75hp68davd9jea3taw7m bsf07qsb707txc2nz2x55y0h2; do
     echo "=== $d ==="
     find /var/lib/docker/overlay2/$d/diff -exec ls -ld {} +
   done'
Predict: uqvao75hp6… is the dd layer, s0boitxkt0… the rm layer. Is the 50 MB file on your disk right now?
=== s0boitxkt0lcv2ltxa1p6108u === drwxr-xr-x 5 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff/etc drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff/opt c--------- 3 root root 0, 0 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff/opt/build-cache.bin drwx------ 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff/root c--------- 3 root root 0, 0 Jul 25 16:14 /var/lib/docker/overlay2/s0boitxkt0lcv2ltxa1p6108u/diff/root/.aws-credentials === td38xshhvgkyrnryq0z1fhq8r === drwxr-xr-x 4 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff/etc drwx------ 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff/root -rw-r--r-- 1 root root 21 Jul 25 16:14 /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff/root/.aws-credentials === uqvao75hp68davd9jea3taw7m === drwxr-xr-x 4 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff/etc drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff/opt -rw-r--r-- 1 root root 52428800 Jul 25 16:14 /var/lib/docker/overlay2/uqvao75hp68davd9jea3taw7m/diff/opt/build-cache.bin === bsf07qsb707txc2nz2x55y0h2 === drwxr-xr-x 3 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/bsf07qsb707txc2nz2x55y0h2/diff drwxr-xr-x 2 root root 4096 Jul 25 16:14 /var/lib/docker/overlay2/bsf07qsb707txc2nz2x55y0h2/diff/etc -rw-r--r-- 1 root root 11 Jul 25 16:14 /var/lib/docker/overlay2/bsf07qsb707txc2nz2x55y0h2/diff/etc/app-release

(Every layer also carries the parent directories of the files it touched — that's why each one has a bare etc/.)

The rm layer is two character devices. The dd layer is 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:3.20 nsenter -t 1 -m -- \
  cat /var/lib/docker/overlay2/td38xshhvgkyrnryq0z1fhq8r/diff/root/.aws-credentials
AKIAEXAMPLESECRETKEY

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.


What you should see

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 — 6.9× smaller for identical behavior. Two images agreeing on their first two layer digests character-for-character, docker system df -v reporting :squashed at 8.82MB size and 0B unique size, and a one-character edit invalidating every layer above it. A --no-cache rebuild of that same RUN producing a different digest for an identical 11-byte file, which is how you learn the match was cache reuse rather than reproducibility. 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.

Why

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.

Two kinds of "shared", and only one is content addressing 88b4fba6… is shared for the strong reason: it is the layer you pulled, and every alpine-based image on the machine names it. 7b511a71… is shared for a weaker reason: BuildKit saw a step it had already run on the same parent and reused the committed layer. Make that step actually execute again and the digest changes, because the tar records mtimes and two runs are never at the same instant. Content addressing gives you deduplication of bytes you already have; it does not give you reproducible builds, and Step 9 is the two-line proof.

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.

Where the effect vanishes. All of this is a property of the union, not of containers. Mount a volume or a bind mount over a path and that subtree is no longer part of the overlay: no copy-up, no whiteouts, no contribution to docker diff, and no growth in the writable layer no matter how much you write. That is why the copy-up surprise never bites a database that keeps its data directory on a volume — and always bites one that doesn't.


Go deeper

Sources: Docker Docs: Storage drivers · Docker Docs: OverlayFS storage driver · 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


Cleanup

By exact name and tag — never a prune 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 layers-demo:nocache
rm -rf /tmp/layers

The build cache outlives the images. Drop just this exercise's entries — the description filter keeps everyone else's cache intact:

for pat in build-id build-cache aws-credentials app-release; do
  docker buildx prune -a --force --filter "description~=$pat"
done

Run it twice, for the same reason as in Setup, then confirm nothing of yours is left and everyone else's cache still is:

docker buildx du --verbose --filter 'description~=build-cache' | grep -c '^ID:'   # → 0
docker buildx du | tail -2