Union and Overlay Filesystems

A container image for a Node.js app might be 900 MB. You run 50 copies of it. Naively that is 45 GB of disk and a fresh multi-second copy every time you start one. In reality Docker starts all fifty in well under a second and they share almost all of that 900 MB on disk. The magic is a union filesystem, and on Linux today that means overlayfs — a VFS-level filesystem that merges several directory trees into one without copying them.

This is the lesson that explains what a container "image" actually is and why building and shipping them is so fast. The answer is a beautiful application of an old idea — copy-on-write — to filesystem layers: a stack of read-only layers shared by everyone, plus one thin writable layer per container, all fused into a single directory the process sees as its normal root.

Layers, stacked and merged

An image is not a monolithic disk blob; it is an ordered stack of layers, each layer a set of file changes (a tarball of "these files were added/changed/deleted"). Each line in a \texttt{Dockerfile}\texttt{FROM ubuntu}, \texttt{RUN apt install}, \texttt{COPY . /app} — produces one layer. overlayfs's job is to take that stack and present a single merged view. Its vocabulary is four directories:

A lookup for a file walks the stack from the top: if the upperdir has it, you get that; otherwise overlayfs falls through to the first lowerdir that has it. Same-named files in higher layers shadow lower ones. That is the whole "union" semantics.

The overlay stack

Picture the image as a pile of transparent sheets. Look down through them and you see the topmost mark at each position; the writable sheet on top is where this container scribbles. Everyone else's pile rests on the exact same lower sheets — read-only, so sharing them is safe.

Because the lower layers are immutable, the kernel can point a thousand containers at the same on-disk bytes for \texttt{FROM ubuntu}. The image is downloaded, decompressed, and cached once; each container adds only its own tiny upperdir. That is the deduplication that makes the "45 GB" collapse to "900 MB shared plus a few MB each".

Copy-up: the moment a shared file becomes private

Reads are free — they fall through to the shared lower layers. The interesting event is the first write to a file that only exists in a read-only layer. overlayfs cannot modify the lower layer (it is shared and immutable), so it performs a copy-up: it copies the whole file up into the writable upperdir, and the write — and every future access — goes to that private copy. The lower original is untouched and still shared by everyone else.

This is copy-on-write applied at file granularity: pay the copy cost only when, and only for the files, you actually mutate. A container that only reads its image writes nothing to disk at all. One that edits a single config file copies up exactly that one file. Deleting a file that lives in a lower layer is handled with a whiteout — a special marker in the upperdir that hides the lower entry without touching it.

// A toy overlayfs: read falls through RO layers; the FIRST write copies the file UP. // Deletes leave a whiteout marker. Nothing here is a real FS — it just narrates the semantics. const lower: Record<string, string> = { // shared read-only image layers "/etc/hosts": "127.0.0.1 localhost", "/app/config": "mode=prod", "/usr/bin/node": "<binary>", }; const upper: Record<string, string> = {}; // this container's private writable layer const whiteout = new Set<string>(); // files "deleted" from the merged view function read(path: string): string { if (whiteout.has(path)) return `ENOENT (whited-out)`; if (path in upper) return `${upper[path]} [from upperdir]`; if (path in lower) return `${lower[path]} [from lowerdir — shared, no copy]`; return "ENOENT"; } function write(path: string, data: string): void { if (!(path in upper) && path in lower) { console.log(` copy-up: ${path} copied lower → upper before first write`); } upper[path] = data; whiteout.delete(path); } function remove(path: string): void { delete upper[path]; if (path in lower) whiteout.add(path); // hide the lower version } console.log("read /etc/hosts :", read("/etc/hosts")); // served from lower, shared write("/etc/hosts", "127.0.0.1 localhost\n10.0.0.5 db"); // triggers copy-up console.log("read /etc/hosts :", read("/etc/hosts")); // now from upper console.log("read /app/config:", read("/app/config")); // still shared, never copied remove("/usr/bin/node"); console.log("read /usr/bin/node:", read("/usr/bin/node")); // whited out console.log(`\nupperdir now holds ${Object.keys(upper).length} file(s); the rest stays shared on disk.`);

Content addressing — why layers dedup and cache

How does Docker know two images share the \texttt{FROM ubuntu:22.04} layer, so it stores and pulls it once? Each layer is content-addressed: its identity is the cryptographic hash (a SHA-256 \texttt{digest}) of its contents. Two layers with identical bytes have identical digests and are therefore the same object — stored once, pulled once, cached once, whether they appear in one image or a hundred. This is the same content-addressed idea behind Git objects.

Content addressing also powers the build cache. When you rebuild an image, Docker hashes each build step's inputs; if nothing changed, the step's layer digest is unchanged and the cached layer is reused instead of re-run. This is why ordering a \texttt{Dockerfile} from least- to most-frequently-changed (dependencies before your source) makes rebuilds fast: the expensive early layers stay cache-hits.

Because starting it copies almost nothing. The gigabyte of read-only layers is already on disk from the \texttt{docker pull} and is shared — the runtime just mounts an overlay that points the new container's lowerdir at those existing layers and gives it a brand-new, empty upperdir. Creating an empty directory and mounting an overlay is a handful of syscalls, not a gigabyte copy. The container then \texttt{exec}s its process against that merged view. All the "weight" of the image was paid once, at pull time; startup is nearly free. This is the storage half of why containers cold-start in milliseconds — the compute half was the shared kernel.

A classic production mistake: an app writes its database or user uploads into the container's filesystem, the container is later replaced (a deploy, a crash, a reschedule), and the data vanishes. The upperdir lives and dies with the container — remove the container and its writable layer is discarded. overlayfs's copy-on-write is designed for image state, not application state. Anything that must survive the container's lifetime belongs in a volume or bind mount — real storage mounted through the overlay, outside the layer stack. Rule of thumb: treat the container filesystem as scratch, and put durable data somewhere the copy-up machinery never touches. Also beware write-heavy workloads: every first-write triggers a full-file copy-up, so churning large files on the upperdir is slower than on a native filesystem — another reason to use a volume.