Docker & ContainersXXXII Β· Docker InternalsStorage driver
The storage driver on disk β overlay2, and what replaced it
What you'll learn
- Map a running container to its lowerdir, upperdir, workdir and merged directories
- Explain the l/ symlink farm and the mount argument limit it works around
- Translate between layer digests and on-disk cache IDs
- Recognise the containerd snapshotter and what it breaks
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
docker-overlayfs covers the kernel mechanism: lower, upper, work,
merged, and copy-up. This lesson covers the layer above it β how
dockerd decides which directories to pass to that mount, where it
keeps them, and what happens to all of it when the storage driver
changes underneath you.
The mount, from the daemonβs side
When a container starts, the daemon assembles one mount -t overlay
call. Everything under /var/lib/docker/overlay2/ exists to make
that call possible.
$ docker inspect web --format '{{json .GraphDriver}}' | python3 -m json.tool{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/9c1f...e4a-init/diff:/var/lib/docker/overlay2/7b30...c19/diff:/var/lib/docker/overlay2/2ad8...f70/diff",
"MergedDir": "/var/lib/docker/overlay2/9c1f...e4a/merged",
"UpperDir": "/var/lib/docker/overlay2/9c1f...e4a/diff",
"WorkDir": "/var/lib/docker/overlay2/9c1f...e4a/work"
},
"Name": "overlay2"
}Illustrative output
Three details in that output surprise people:
LowerDir is ordered, highest first. The leftmost path is the
layer closest to the containerβs root; the rightmost is the base
image layer. Reading it right to left reconstructs the Dockerfile.
UpperDir ends in diff. The containerβs writable layer lives
in a directory called diff β the same name image layers use for
their content. There is no directory named upper in overlay2; that
was the older overlay driver. Scripts written against the old
layout silently find nothing.
The first LowerDir entry ends in -init/diff. That is not an
image layer.
The l/ symlink farm
/var/lib/docker/overlay2/l/ is full of short symlinks with names
like 6Y5IM2XC7TSNIJZZFLJCS6I4I4, each pointing at
../<64-hex-cache-id>/diff.
Upstream states the reason directly: the shortened identifiers are
βused to avoid hitting the page size limitation on arguments to the
mount commandβ.
Each layer directory also contains a link file holding its own
short name, and β for every layer above the base β a lower file
listing its parents, in the same short form.
Digests are not directory names
docker image inspect reports layer diff IDs as sha256: digests.
None of those digests appear as a directory name under overlay2/.
The directories are named with cache IDs: random identifiers the
daemon allocates when it unpacks a layer. The mapping from
content-addressed digest to cache ID lives in the daemonβs layer
database under /var/lib/docker/image/overlay2/layerdb/.
This is a deliberate separation. The digest identifies content and must be stable across every host in the world; the cache ID identifies one local unpacked copy and is meaningless anywhere else.
The practical consequence: you cannot go from a digest to a
directory by string matching, and a directory copied from another
host means nothing without that hostβs layer database. It is the
mechanical reason a /var/lib/docker tar is not portable β
docker-what-is-actually-stateful covers the operational side.
The driver changed
Docker 29 moved the default to the containerd image store. The symptom on a modern host:
$ docker info --format 'driver={{.Driver}}'
docker info | grep -A1 'Storage Driver'
docker inspect web --format '{{json .GraphDriver}}'driver=overlayfs
Storage Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
nullRead that last line carefully. .GraphDriver is null β not
empty, not a different shape. Every script, dashboard and monitoring
check that does docker inspect --format '{{.GraphDriver.Data.UpperDir}}'
fails on this host, and it fails with a Go template error rather
than with anything that explains why.
Reading disk usage correctly
du on /var/lib/docker double-counts nothing, but it also does
not tell you what is reclaimable. Ask the daemon:
$ docker system df -v | head -12Images space usage:
REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS
app dev 0ef9d88b4b73 5 days ago 1.05GB 384.8MB 669.4MB 0
app cimatch bea532850da8 10 days ago 964MB 424.8MB 539.3MB 0
debian bookworm-slim abd67ffcfa54 8 days ago 116MB 0B 115.8MB 0
redis 7-alpine e7723ff73d96 2 weeks ago 57.8MB 12.15MB 45.67MB 0Illustrative output
SHARED SIZE is why removing a 630 MB image often frees far less
than 630 MB: most of it is base-image layers other images are still
using. Capacity planning that sums image sizes overestimates,
sometimes by a large factor.
Sanity check
Knowledge check Β· 4 questions
Q1. In an overlay2 GraphDriver listing, which directory is the container writable layer?
Q2. Why does /var/lib/docker/overlay2/l/ exist?
Q3. A host reports Storage Driver overlayfs with driver-type io.containerd.snapshotter.v1. Which statements follow? Select all that apply.
Q4. A layer directory name under /var/lib/docker/overlay2 is a locally allocated cache ID rather than the layer sha256 digest.
Passing score: 75%. Answers are checked in this browser.