A base image is eight megabytes. Run fifty containers from it and you do not want fifty copies — and you certainly do not want one container's rm -rf / to damage the image the other forty-nine are using.
The answer is copy-on-write: every container gets what looks like its own private copy, but blocks are only duplicated at the moment something writes to them. Creating one is instant and costs nothing until it is used.
btrfs has snapshots built in. You don't need a spare disk — a file will do:
sudo apt install -y btrfs-progs
# A sparse 5G file, formatted as a btrfs filesystem
truncate -s 5G ./btrfs-disk.img
mkfs.btrfs -f ./btrfs-disk.img
mkdir -p ./btrfs-mount
sudo mount -o loop ./btrfs-disk.img ./btrfs-mount
truncate -s 5G creates a sparse file: it reports as 5 GB but occupies almost no disk until written to. mount -o loop presents that file to the kernel as a block device, which is what lets a filesystem live inside a regular file.
This one is a recording — the commands are real, but nothing is running here. Type them into your own shell to follow along.
Five gigabytes according to ls, under four megabytes on disk according to du. That gap is the sparse file.
A btrfs subvolume is an independently snapshottable tree inside the filesystem. Make one and unpack Alpine into it:
sudo btrfs subvolume create ./btrfs-mount/base-image
curl -o alpine.tar.gz \
https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
sudo tar -xf alpine.tar.gz -C ./btrfs-mount/base-image
This is the immutable thing. Nothing will ever write to it again.
CONTAINER_ID="my-container"
sudo btrfs subvolume snapshot \
./btrfs-mount/base-image \
./btrfs-mount/$CONTAINER_ID
That completes instantly and consumes essentially no space. The new subvolume references exactly the same blocks as the base image; only when the container modifies a file does btrfs allocate a fresh block for the changed data and repoint that one file. The base image is never touched.
This one is a recording — the commands are real, but nothing is running here. Type them into your own shell to follow along.
Written in the snapshot, absent from the base. That is the whole guarantee, and it is what makes it safe to hand fifty containers the "same" filesystem.
You snapshot a 2 GB base image ten times. How much disk does that consume before any container writes anything?
Docker's layered images are the same idea, generalised. Each instruction in a Dockerfile produces a layer holding only what changed; a running container adds one final writable layer on top and every write lands there. docker commit freezes that writable layer into a new read-only one.
Which explains several behaviours that otherwise look arbitrary:
RUN apt-get install ... && rm -rf /var/lib/apt/lists/* in one instruction works precisely because the removal happens before the layer is sealed.COPY package.json before COPY . . keeps the npm install layer valid across source edits.A Dockerfile installs a 200 MB toolchain in one RUN, then deletes it in a later RUN. What happens to image size?
btrfs is one of several storage drivers Docker supports, and it is used here because snapshots are a single obvious command. The default on nearly every modern installation is overlayfs, which reaches the same result differently.
overlayfs stacks directories. Given a read-only lower and a writable upper, it presents a merged view:
mount -t overlay overlay \
-o lowerdir=./base,upperdir=./upper,workdir=./work \
./merged
Reads come from upper if the file is there and lower otherwise. Writes always go to upper — and writing to a file that exists only in lower triggers a copy-up: the whole file is copied into upper first, then modified.
Two consequences that show up in production. The first write to a large file in a lower layer is slow, because it copies the entire file regardless of how many bytes you changed — which is why database data directories belong on volumes, not in the container filesystem. And deleting a lower-layer file creates a whiteout, a character device with major/minor 0/0 in upper, which is the mechanism behind deletions not reclaiming space.
lowerdir accepts a colon-separated list, and that list is the image's layers.
Snapshots are subvolumes, so they are removed with a btrfs command rather than rm:
sudo btrfs subvolume delete ./btrfs-mount/$CONTAINER_ID
Take a copy-on-write snapshot of ./btrfs-mount/base-image into ./btrfs-mount/c2.
Next up: networking — giving the container its own interface, its own IP, and a wire back to the host.
