Skip to main content
RunBook Academy

Docker & ContainersXXXII Β· Docker InternalsStorage driver

The storage driver on disk β€” overlay2, and what replaced it

Expert⏱ ~24 min

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

Not yet marked complete on this device.

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.

Read-only / Safethe four directories
$ 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.

/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:

Read-only / Safecaptured on Docker 29.7.1
$ 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
null

Read 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:

Read-only / Safeshared vs unique
$ docker system df -v | head -12
Images 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       0

Illustrative 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

  1. Q1. In an overlay2 GraphDriver listing, which directory is the container writable layer?

  2. Q2. Why does /var/lib/docker/overlay2/l/ exist?

  3. Q3. A host reports Storage Driver overlayfs with driver-type io.containerd.snapshotter.v1. Which statements follow? Select all that apply.

  4. 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.