Where Docker keeps data

The containerd image store, snapshotters and the classic graph drivers.

Intermediate30 min · lesson 10 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (4 files, 1 KB): storagedrivers.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/storagedrivers.tar.gz && tar -xzf storagedrivers.tar.gz, which creates ~/lab/storagedrivers/. SHA-256: 1149504eb8ed61913dd19eb55d0c89b6c677cd45bde220bf169fc55759a85b1a
Watch out
The second half of this lesson switches the image store, moves containerd's data directory and mounts a deliberately misformatted XFS filesystem. Run that part only in the SecOpsLog disposable lab VM (secopslog-docker-sec), never on a host anyone depends on. If that VM doesn't exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec. Open a shell in it with multipass shell secopslog-docker-sec (limactl shell secopslog-docker-sec with Lima). If it ends up in a state you don't trust, recreate it with ./setup/create-lab.sh --profile sec --recreate. The sec half restarts the daemons more often than systemd allows by default (three starts a minute). If a start fails with start request repeated too quickly, run sudo systemctl reset-failed docker containerd and repeat it. The first half runs on the main lab VM, secopslog-docker, and changes nothing outside the lab- resources it creates.

Where does your Docker host keep images and container filesystems, and what does each layout cost on disk? On a host installed fresh with Docker 29 the images aren't under /var/lib/docker at all. So a du -sh /var/lib/docker that reports a few hundred megabytes on a full disk proves nothing. Which directory holds them depends on which image store the daemon runs, and that depends on how the host was installed. Start by asking the daemon:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ docker info --format '{{.Driver}} {{json .DriverStatus}} root={{.DockerRootDir}}'
overlayfs [["driver-type","io.containerd.snapshotter.v1"]] root=/var/lib/docker

overlayfs with driver-type io.containerd.snapshotter.v1 is the containerd image store, the default for fresh Docker Engine 29 installs. Image content and container filesystems belong to containerd and live under /var/lib/containerd. A host upgraded from Docker 28 or older reports overlay2 here instead, with lines such as Backing Filesystem and Supports d_type. That's the classic graph driver. It keeps everything under /var/lib/docker and stays in place after the upgrade until someone migrates the host. You'll meet both for years, so the rest of the lesson takes them apart side by side.

Who keeps what on a Docker 29 host
dockerd: /var/lib/docker (data-root)
containers/
container config, logs, hostname and resolv.conf
volumes/
named and anonymous volumes
network/, buildkit/
network records, build cache metadata
rootfs/overlayfs/<id>
only the mount point of each container's root filesystem
containerd: /var/lib/containerd (containerd root)
io.containerd.content.v1.content
compressed blobs: index, manifests, configs, layers
io.containerd.snapshotter.v1.overlayfs
unpacked layers and each container's writable layer
io.containerd.metadata.v1.bolt
which blobs and snapshots belong to which image, per namespace
legacy graph driver (upgraded hosts)
/var/lib/docker/overlay2/<id>/diff
unpacked layers and writable layers
/var/lib/docker/image/overlay2
image and layer metadata
With the containerd store, data-root moves only the first box. The third box exists only on hosts that use, or once used, the legacy store.

A container's filesystem on the containerd store

Build the lab image and start a container from it. Its Dockerfile adds two files of random bytes (100 MB and 50 MB), so you can spot copies easily in sizes and timings:

Dockerfile
FROM alpine:3.24
# two files of random bytes, big enough that a copy-up shows in timings and sizes
RUN head -c 100000000 /dev/urandom > /data.bin \
&& head -c 50000000 /dev/urandom > /meta.bin
CMD ["sleep", "infinity"]
ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ docker build -q --no-cache --label secopslog.lab=true -t lab-cow:1 .
sha256:623c4ce198b764f37c476a840164f85e0d1f3698ccb4e75a5316397de1fa1cd4
$ docker run -d --name lab-cow lab-cow:1
296808529e1a42aff4876d4e1bb700df21f31bde53ea51372d0eb4117f493d4d
$ docker inspect -f '{{json .GraphDriver}} {{json .Storage}}' lab-cow
null {"RootFS":{"Snapshot":{"Name":"overlayfs"}}}

Old runbooks read .GraphDriver.Data.UpperDir to find a container's files on the host. On the containerd store GraphDriver is null, and the new Storage field only names the snapshotter. The paths come from the mount itself. dockerd mounts each container's root filesystem at /var/lib/docker/rootfs/overlayfs/<id> ("Engine architecture on Docker 29" found it the same way). containerd can describe the snapshot behind it:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ ID=$(docker inspect -f "{{.Id}}" lab-cow) sudo findmnt -no OPTIONS /var/lib/docker/rootfs/overlayfs/$ID | tr , "\n" | grep -E "^(lowerdir|upperdir)=" sudo ctr -n moby snapshots info $ID
lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/859/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/858/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/7/fs upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/860/fs { "Kind": "Active", "Name": "296808529e1a42aff4876d4e1bb700df21f31bde53ea51372d0eb4117f493d4d", "Parent": "296808529e1a42aff4876d4e1bb700df21f31bde53ea51372d0eb4117f493d4d-init", "Created": "2026-10-26T07:31:09.058308848Z", "Updated": "2026-10-26T07:31:09.058308848Z" }

A snapshot is containerd's name for one filesystem layer on disk. The overlayfs snapshotter stores each one as a numbered directory, and an overlay mount stacks them. lowerdir lists the read-only snapshots, top first. 859 is Docker's init layer: placeholder files such as /etc/hosts and /etc/resolv.conf that Docker fills in per container. 858 is the image's RUN layer, and 7 is alpine's base layer, which every image built on this alpine shares. upperdir is the container's writable layer. ctr shows the same chain from containerd's side: an Active (writable) snapshot named after the container ID, whose parent is the -init snapshot. The numbers are allocation order on this VM; yours will differ.

Copy-on-write, and what the first write costs

Lower layers are never modified. The first time your container changes a file that exists in a lower layer, overlayfs copies the whole file into the upper directory and changes the copy. docker ps -s shows the writable layer's size, so you can see the cost without sudo:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ docker ps -s --filter name=lab-cow --format 'table {{.Names}}\t{{.Size}}'
NAMES SIZE lab-cow 4.1kB (virtual 159MB)
$ docker exec lab-cow sh -c 'time sh -c "echo x >> /data.bin"; time sh -c "echo x >> /data.bin"'
real 0m 0.10s user 0m 0.00s sys 0m 0.07s real 0m 0.00s user 0m 0.00s sys 0m 0.00s
$ docker ps -s --filter name=lab-cow --format 'table {{.Names}}\t{{.Size}}'
NAMES SIZE lab-cow 100MB (virtual 259MB)

Appending two bytes to /data.bin took 0.10 s the first time and nothing the second. The writable layer went from 4.1kB to 100MB: the entire file was copied up before one byte changed. Timings depend on the disk; the size jump doesn't. virtual adds the image's size. Each container pays this separately, because each has its own upper directory. So ten containers that touch the same large file store ten copies.

Since Linux 4.19, overlayfs can copy only metadata for changes such as chmod (the metacopy feature). But it's off unless the module parameter or the metacopy=on mount option turns it on, and Docker doesn't set the option. Here's the module parameter on this kernel:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ cat /sys/module/overlay/parameters/metacopy docker exec lab-cow chmod 600 /meta.bin docker ps -s --filter name=lab-cow --format 'table {{.Names}}\t{{.Size}}'
N NAMES SIZE lab-cow 150MB (virtual 309MB)

N means off, and a permission change on the 50 MB meta.bin copied all of it: 150MB now. That's why the rule from "Volumes and bind mounts" in Docker for beginners matters in practice. Database files, logs, caches and anything rewritten often belong on a volume, which bypasses the overlay mount entirely.

What overlayfs does on a write
A container writes a path
every write lands in the upper directory
new path
created in the upper directory
no copy
path in a lower layer, first write
whole file copied up, then changed
cost grows with file size, once per container
path already copied up
written in place in the upper directory
no further copy
path deleted
whiteout created in the upper directory
lower file hidden, its bytes stay

Deletes are whiteouts

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ docker exec lab-cow sh -c 'echo hello > /note.txt; rm /etc/issue' docker diff lab-cow
C /data.bin C /etc D /etc/issue C /meta.bin A /note.txt
$ ID=$(docker inspect -f "{{.Id}}" lab-cow) UP=$(sudo findmnt -no OPTIONS /var/lib/docker/rootfs/overlayfs/$ID | tr , "\n" | sed -n "s/^upperdir=//p") sudo ls -l $UP $UP/etc
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/860/fs: total 146500 -rw-r--r--+ 1 root root 100000004 Oct 26 13:01 data.bin drwxr-xr-x+ 2 root root 4096 Oct 26 13:01 etc -rw-------+ 1 root root 50000000 Oct 26 13:01 meta.bin -rw-r--r-- 1 root root 6 Oct 26 13:01 note.txt /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/860/fs/etc: total 0 c--------- 2 root root 0, 0 Oct 26 13:01 issue

docker diff reports the change set: A added, C changed, D deleted. On disk, the upper directory holds the two copied-up files and the new note.txt. It also holds etc/issue as a character device with device number 0, 0. That's an overlayfs whiteout. It tells the merged view to hide /etc/issue from the lower layer, whose bytes are untouched. The same mechanism in an image build is why deleting a file in a later RUN doesn't shrink the image; "Slimming images and layer hygiene" in Advanced container security deals with that. Files in a writable layer also count as inodes, not only bytes. A filesystem can run out of inodes with gigabytes free, so watch both on the filesystem that holds /var/lib/containerd:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ df -h /var/lib/containerd df -i /var/lib/containerd
Filesystem Size Used Avail Use% Mounted on /dev/sda1 38G 13G 26G 34% / Filesystem Inodes IUsed IFree IUse% Mounted on /dev/sda1 5111808 271454 4840354 6% /

Here both are far from full. Some hosts run many images with tens of thousands of small files (language runtimes, node_modules). On those, IUse% is the number you alert on. Clean up the main VM part:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.9.2
$ docker rm -f lab-cow docker image rm lab-cow:1
lab-cow Untagged: lab-cow:1 Deleted: sha256:623c4ce198b764f37c476a840164f85e0d1f3698ccb4e75a5316397de1fa1cd4

Inside /var/lib/containerd

The rest of the lesson runs on secopslog-docker-sec, where your login isn't in the docker group, so every command uses sudo. Fetch the lesson files into ~/lab/storagedrivers on that VM too (the lesson files box shows the command) and work there. A recreated sec VM may not have alpine yet, so pull it first. Then look at where containerd keeps its data and how that's configured:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker pull -q alpine:3.24
docker.io/library/alpine:3.24
$ sudo containerd config dump | grep -E "^(root|state) =" grep -nE "^#?(root|state) =" /etc/containerd/config.toml
time="2026-10-26T13:04:53+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="4.75µs" root = '/var/lib/containerd' state = '/run/containerd' 17:#root = "/var/lib/containerd" 18:#state = "/run/containerd"
$ D=$(sudo docker image inspect -f "{{.Id}}" alpine:3.24 | cut -d: -f2) sudo ls -l /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/$D sudo jq -r ".manifests[] | .platform.architecture + \" \" + .digest" /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/$D | head -3
-r--r--r-- 1 root root 9218 Oct 26 12:23 /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a amd64 sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d unknown sha256:964745728d174226819257152a79b34561fe7321472eb9c8e32f461cbc7969c2 arm sha256:2b8ed0bd9e82c247a3817b867718404f020ab8f701bda09642ddfaa7b4f1578d

containerd config dump prints the effective configuration. root holds persistent data; state (under /run) holds sockets and runtime state that disappear at reboot. The file Docker's packages install, /etc/containerd/config.toml, has both lines commented out, so the defaults apply. It also has no version line. That's why containerd 2.x warns that it migrated the config from version 0 in memory. The warning is harmless, and containerd config migrate prints an upgraded file if you want to silence it.

The content store is content-addressed: every blob is a file named after its own SHA-256 digest. The image ID that Docker 29 prints for alpine:3.24 is such a digest. The file with that name is the image index, a small JSON document listing one manifest per platform ("Image anatomy: index, manifest, config and layers" reads it in detail). unknown entries are attestation manifests, not a platform.

Two copies of every layer

The containerd store keeps each layer twice. One copy sits compressed in the content store, as pulled or built. The other is unpacked in a snapshot that containers can use. Measure what one 50 MB layer of random bytes (which doesn't compress) costs:

blob/Dockerfile
FROM alpine:3.24
# 50 MB of random bytes: compression cannot shrink it, so disk growth is easy to read
RUN head -c 50000000 /dev/urandom > /blob.bin
ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
364 /var/lib/containerd/io.containerd.content.v1.content 1132 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs 1 /var/lib/docker
$ sudo docker build -q --no-cache --label secopslog.lab=true -t lab-blob:1 blob
sha256:7b32529ce628e0238f0645d5a0c9c4c42c67a43526be94f9cba2eab4694b5734
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
412 /var/lib/containerd/io.containerd.content.v1.content 1236 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs 1 /var/lib/docker
$ sudo docker buildx du
ID RECLAIMABLE SIZE LAST ACCESSED 1p4qgelfj82odcv0ldstegonx* true 4.096kB 5 seconds ago j3revonx2c2f8ffltxshkvq3b* true 8.192kB 5 seconds ago qq3sqduki4r1eepnb94joh9du true 13.5MB* 5 seconds ago r385qbdfshntcnbmtnkuzm5v3 true 100MB* 5 seconds ago Shared: 113.5MB Private: 12.29kB Reclaimable: 113.5MB Total: 113.5MB

The content store grew by 48 MB: the layer as a compressed blob, which for random bytes is as large as the data. The snapshot directory grew by 104 MB, about twice the layer, because the build left more than one snapshot of it. docker buildx du reports the build-cache record for the RUN step at 100MB and counts everything under Shared. That means the cache and the image use the same snapshots, so docker builder prune can't free them while lab-blob:1 exists. That's about 150 MB of disk for a 50 MB layer. An image you pull rather than build costs the compressed blob plus one unpacked snapshot. Docker's documentation gives that double storage as the reason the containerd store needs more disk than the legacy drivers. Size your disks for it, and expect different absolute numbers on your VM, which holds other images.

The legacy graph driver, side by side

Switch this VM to the legacy store with the procedure from "Configuring the daemon safely". Back up the current daemon.json (an empty {} stands in when there's none, as on this VM). Merge the new key into the backup with jq, validate, restart, check. Merging instead of overwriting keeps whatever the host already had in the file, such as the MTU settings the lab kit writes on VPN machines. Images don't move between the stores, so save one first to load on the other side:

legacy-store.json
{
"features": { "containerd-snapshotter": false }
}
ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker save alpine:3.24 > alpine-3.24.tar ls -l alpine-3.24.tar
-rw-r--r-- 1 ubuntu ubuntu 4299264 Oct 26 13:05 alpine-3.24.tar
$ sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak 2>/dev/null || echo '{}' | sudo tee /etc/docker/daemon.json.bak >/dev/null sudo cat /etc/docker/daemon.json.bak
{}
$ jq -s '.[0] * .[1]' /etc/docker/daemon.json.bak legacy-store.json | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker sudo docker info --format '{{.Driver}} {{json .DriverStatus}}' sudo docker image ls --format '{{.Repository}}:{{.Tag}}'
{ "features": { "containerd-snapshotter": false } } configuration OK overlay2 [["Backing Filesystem","extfs"],["Supports d_type","true"],["Using metacopy","false"],["Native Overlay Diff","true"],["userxattr","false"]]
$ sudo docker load -i alpine-3.24.tar
Loaded image: alpine:3.24

docker info now reports overlay2 and the classic graph-driver details. The image list was empty until docker load put alpine into the legacy store. Build the same 50 MB image here:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
412 /var/lib/containerd/io.containerd.content.v1.content 1236 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs 10 /var/lib/docker
$ sudo docker build -q --no-cache --label secopslog.lab=true -t lab-blob:1 blob
sha256:ff472ff7d7ce64c9302533bd2f3f6b3e7c2bd41be283dc0f00e5981bbbe010ad
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
412 /var/lib/containerd/io.containerd.content.v1.content 1236 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs 58 /var/lib/docker

/var/lib/docker grew from 10 to 58 MB, 48 MB for the same layer, and containerd's directories didn't move. The legacy store keeps one unpacked copy and no compressed blob. That's also why it reported CONTENT SIZE 0B in "Inspecting, exporting and cleaning up images". Now look at a container on this store:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker run -d --name lab-legacy alpine:3.24 sleep infinity sudo docker inspect -f '{{json .GraphDriver.Data}}' lab-legacy | jq -r 'to_entries[] | .key + "=" + .value' | cut -c1-140
ab81828d9dd7c6f364b676d23b32001d0f5a51c59bf960728bf729391e50517c ID=ab81828d9dd7c6f364b676d23b32001d0f5a51c59bf960728bf729391e50517c LowerDir=/var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576-init/diff:/var/lib/docker/overlay2/360537 MergedDir=/var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/merged UpperDir=/var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/diff WorkDir=/var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/work
$ sudo docker exec lab-legacy sh -c 'echo hello > /note.txt; rm /etc/issue' UP=$(sudo docker inspect -f '{{.GraphDriver.Data.UpperDir}}' lab-legacy) sudo ls -l $UP $UP/etc sudo ls $(dirname $UP) sudo cat $(dirname $UP)/link; echo sudo ls -l /var/lib/docker/overlay2/l | head -4
/var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/diff: total 8 drwxr-xr-x+ 2 root root 4096 Oct 26 13:05 etc -rw-r--r-- 1 root root 6 Oct 26 13:05 note.txt /var/lib/docker/overlay2/847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/diff/etc: total 0 c--------- 2 root root 0, 0 Oct 26 13:05 issue diff link lower merged work LFBE76I77SCO2ZLTKA73UDHL3V total 12 lrwxrwxrwx+ 1 root root 33 Oct 26 13:05 4D2GGQCDYOG3GYUY3TZFTOLZWK -> ../abyzu9s1z2zr78nvlgboefqlz/diff lrwxrwxrwx 1 root root 33 Oct 26 13:05 DBVSK6FPU7PZ34VCXXUYCAJ3IN -> ../zrkrrdzut4a7jverqy1k057nv/diff lrwxrwxrwx+ 1 root root 72 Oct 26 13:05 LFBE76I77SCO2ZLTKA73UDHL3V -> ../847db4a29f03dcee8236e4c3ff18798ce118d3d3905fe66e12cae4815c8fd576/diff

On the legacy store .GraphDriver.Data names every directory: LowerDir (here the -init layer and alpine), UpperDir, MergedDir and WorkDir, all under /var/lib/docker/overlay2/<id>/. The whiteout looks exactly as before, because the kernel's overlayfs does the work on both stores. Each layer directory holds diff (the layer's files), link (a short name), lower (the short names of its parents), and for containers merged and work. The short names under l/ are symlinks. They keep the overlay mount options under the kernel's page-size limit when an image has many layers. Remove what you created on this store before you leave it:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker rm -f lab-legacy sudo docker image rm lab-blob:1 alpine:3.24
lab-legacy Untagged: lab-blob:1 Deleted: sha256:ff472ff7d7ce64c9302533bd2f3f6b3e7c2bd41be283dc0f00e5981bbbe010ad Untagged: alpine:3.24 Deleted: sha256:f7bbaec1bd0330a8dba26f03e05f421bb3c8b807bb41873df3c3b0c3d1083d8f

Switching back, and the data left behind

Restoring the backup, which has no features key, doesn't bring the containerd store back once legacy data exists. The daemon finds /var/lib/docker/overlay2 and treats the host as an upgraded one ("Configuring the daemon safely" and "Inspecting, exporting and cleaning up images" both show that trap). Ask for the containerd store explicitly:

containerd-store.json
{
"features": { "containerd-snapshotter": true }
}
ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ jq -s '.[0] * .[1]' /etc/docker/daemon.json.bak containerd-store.json | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker sudo docker info --format '{{.Driver}} {{json .DriverStatus}}' sudo docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -E '^(alpine|lab-)' sudo ls /var/lib/docker/image /var/lib/docker/overlay2 | head
{ "features": { "containerd-snapshotter": true } } configuration OK overlayfs [["driver-type","io.containerd.snapshotter.v1"]] lab-blob:1 alpine:3.24 /var/lib/docker/image: identity-cache.db overlay2 /var/lib/docker/overlay2: 3605375a0522e869c1de6476593f51754aba4c587369754b90cb0f41a87a2789 abyzu9s1z2zr78nvlgboefqlz b72wex6ooveoctdgbiras58qi l zrkrrdzut4a7jverqy1k057nv
# The legacy store's directories are still on disk after the switch.
$ sudo docker image rm lab-blob:1
Untagged: lab-blob:1 Deleted: sha256:7b32529ce628e0238f0645d5a0c9c4c42c67a43526be94f9cba2eab4694b5734

The containerd store's images are visible again, including the lab-blob:1 built earlier, and /var/lib/docker/overlay2 is still there. Switching stores hides the other store's data; it never deletes it. On a real host upgraded from Docker 28, this is the disk-usage trap of the migration. After "containerd-snapshotter": true, every old image and container still occupies /var/lib/docker/overlay2 and /var/lib/docker/image/overlay2, invisible to docker system df. Migrate in this order. List what the host needs, switch, then pull or rebuild those images (or docker save them before and docker load after). Recreate the containers, and only then remove the old directories with Docker stopped. Docker also has an experimental automatic migration ("containerd-migration": true in features). It switches only when no containers exist and the images are below a size threshold, and the docs recommend starting fresh instead. This lab VM never held anything important in the legacy store, so it removes the directories at once and restores the backup:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo systemctl stop docker docker.socket sudo rm -rf /var/lib/docker/overlay2 /var/lib/docker/image/overlay2 sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json sudo systemctl start docker sudo docker info --format '{{.Driver}} {{json .DriverStatus}}'
overlayfs [["driver-type","io.containerd.snapshotter.v1"]]
# Lab only: deletes the legacy store's data. On a real host, only after the migration is verified.

One daemon option rules the containerd store out entirely: userns-remap disables it. A host that needs user-namespace remapping runs the legacy store ("User-namespace remapping" in Advanced container security covers the trade-off). Rootless Docker keeps its own data under the user's home directory; "Rootless Docker" in Advanced container security covers it.

Moving containerd's root

data-root in daemon.json moves only dockerd's directory, as "Configuring the daemon safely" showed. To put images and container filesystems on another disk, you move containerd's root as well. Stop or remove every container first, and check that docker ps -q prints nothing. With live-restore on, stopping docker.service leaves containers running on their shims, and the copy would race their writes to the upper directories. Then you stop both daemons. The copy must keep hard links, ACLs and extended attributes, which overlay layers use (rsync -aHAX). Plan it as a maintenance window. In production /srv/containerd would be the mount point of the new disk:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker ps -q | wc -l sudo systemctl stop docker docker.socket containerd sudo rsync -aHAX /var/lib/containerd/ /srv/containerd/ sudo du -sh /var/lib/containerd /srv/containerd
0 1.6G /var/lib/containerd 1.6G /srv/containerd
$ sudo cp -a /etc/containerd/config.toml /etc/containerd/config.toml.bak sudo sed -i "s|^#root = \"/var/lib/containerd\"|root = \"/srv/containerd\"|" /etc/containerd/config.toml sudo containerd config dump | grep -E "^root ="
time="2026-10-26T13:05:23+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="8.875µs" root = '/srv/containerd'
$ sudo systemctl start containerd docker sudo docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -E '^alpine:' sudo docker run -d --name lab-moved alpine:3.24 sleep infinity ID=$(sudo docker inspect -f '{{.Id}}' lab-moved) sudo findmnt -no OPTIONS /var/lib/docker/rootfs/overlayfs/$ID | tr , '\n' | grep '^upperdir='
alpine:3.24 065d5bc6e34677227e1e52e5d489d7a0f135a79a568d54e07d72c98b708e3303 upperdir=/srv/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/52/fs

After the restart the images are still listed, and the new container's upperdir is under /srv/containerd. Until you delete it, the old /var/lib/containerd is your rollback. It has one cost: switching back loses every image pulled and every container change made since the move. Once the new location has run for a while, remove it to free the space. On a real disk, make sure containerd never starts before the disk is mounted. Otherwise it creates a fresh, empty root on / and fills the root filesystem. A systemd drop-in prevents that:

/etc/systemd/system/containerd.service.d/data-disk.conf
[Unit]
RequiresMountsFor=/srv/containerd

Each daemon needs its own root: never point two containerd instances (or two hosts over NFS) at the same directory. The lab moves back:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo docker rm -f lab-moved sudo systemctl stop docker docker.socket containerd sudo cp -a /etc/containerd/config.toml.bak /etc/containerd/config.toml sudo systemctl start containerd docker sudo rm -rf /srv/containerd sudo containerd config dump | grep -E "^root ="
lab-moved time="2026-10-26T13:05:27+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="7.333µs" root = '/var/lib/containerd'

d_type and XFS

Overlayfs needs the backing filesystem to report each directory entry's file type (d_type); it uses it to recognise whiteouts. ext4 always supports it. XFS supports it only when formatted with ftype=1, which mkfs.xfs has defaulted to for years. But old RHEL and CentOS 7 era hosts were often formatted with ftype=0. The classic overlay2 check is what prints Supports d_type: true in docker info. To see what happens without it, the lab formats an XFS image with ftype=0 and points each store at it. (It needs the deprecated V4 format to allow that.)

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo truncate -s 2G /srv/xfs-noftype.img sudo mkfs.xfs -q -m crc=0 -n ftype=0 /srv/xfs-noftype.img sudo mkdir -p /srv/noftype && sudo mount -o loop /srv/xfs-noftype.img /srv/noftype sudo xfs_info /srv/noftype | grep -o "ftype=[01]"
V4 filesystems are deprecated and will not be supported by future versions. ftype=0
$ jq '. + {"features": {"containerd-snapshotter": false}, "data-root": "/srv/noftype/docker"}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker sudo docker info --format "{{.Driver}} {{json .DriverStatus}} root={{.DockerRootDir}}"
{ "features": { "containerd-snapshotter": false }, "data-root": "/srv/noftype/docker" } configuration OK vfs null root=/srv/noftype/docker
$ sudo journalctl _SYSTEMD_INVOCATION_ID=$(systemctl show -p InvocationID --value docker) -o cat | grep -iE "storage-driver|d_type"
time="2026-10-26T13:05:27.532846562+05:30" level=error msg="exec: \"fuse-overlayfs\": executable file not found in $PATH" storage-driver=fuse-overlayfs time="2026-10-26T13:05:27.941639562+05:30" level=info msg="Docker daemon" commit=0367cbe containerd-snapshotter=false storage-driver=vfs version=29.9.2

Nothing failed, and that's the problem. With ftype=0 under data-root, dockerd didn't select overlay2. It tried fuse-overlayfs (not installed, the one error line). Then it settled on vfs, which logs at info level only. vfs has no copy-on-write. Every layer and every container is a full copy of everything below it. So disk use multiplies and container starts slow down. On this store the daemon gives up on overlay2 without a d_type message in its log. The place you catch it is docker info after provisioning: alert on any driver other than the one you expect. Docker's deprecation notes add the other half. Since Engine 23, a daemon told explicitly to use overlay2 (storage-driver in daemon.json) on such a filesystem refuses to start; automatic selection, as here, skips it. Stop the daemon and restore the backup before the next test, so dockerd starts on its defaults again:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo systemctl stop docker docker.socket sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json

Now try the containerd store on the same filesystem. Point containerd's root at the XFS mount, restart containerd, and start dockerd:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ sudo sed -i "s|^#root = \"/var/lib/containerd\"|root = \"/srv/noftype/containerd\"|" /etc/containerd/config.toml sudo systemctl restart containerd sudo ctr plugins ls | grep -E "TYPE|snapshotter.*overlayfs"
TYPE ID PLATFORMS STATUS io.containerd.snapshotter.v1 overlayfs linux/arm64/v8 error
$ sudo systemctl start docker
Job for docker.service failed because the control process exited with error code. See "systemctl status docker.service" and "journalctl -xeu docker.service" for details.
$ sudo journalctl _SYSTEMD_INVOCATION_ID=$(systemctl show -p InvocationID --value docker) -o cat | grep -vE "level=(info|debug)" | tail -6 sudo dmesg | grep -m1 "d_type"
time="2026-10-26T13:05:28.506176313+05:30" level=warning msg="failed check for fsverity support" error="enable fsverity failed: operation not supported" path=/var/lib/docker/plugins/storage time="2026-10-26T13:05:28.516037396+05:30" level=warning msg="Preferred snapshotter not available in containerd" message="/srv/noftype/containerd/io.containerd.snapshotter.v1.overlayfs does not support d_type. If the backing filesystem is xfs, please reformat with ftype=1 to enable d_type support" error initializing buildkit: error creating buildkit instance: unknown service containerd.services.leases.v1.Leases [ 44.112984] overlayfs: upper fs needs to support d_type.
$ sudo systemctl stop docker docker.socket containerd sudo cp -a /etc/containerd/config.toml.bak /etc/containerd/config.toml sudo umount /srv/noftype; sudo rm -rf /srv/noftype /srv/xfs-noftype.img sudo systemctl reset-failed docker docker.socket containerd sudo systemctl start containerd docker sudo docker info --format "{{.Driver}}"
overlayfs

containerd itself starts, but its overlayfs snapshotter plugin is in state error, and the kernel log says why: upper fs needs to support d_type. dockerd then refuses to start. Its journal names the cause in a warning (does not support d_type ... reformat with ftype=1). The line that ends the start is an unrelated-looking buildkit error about a missing leases service, and that's what you'd see first in systemctl status. So the containerd store fails loudly where the legacy store degrades quietly. The fix is the same for both. Check xfs_info <mountpoint> | grep ftype before you put Docker or containerd data on XFS. If it says 0, reformat with ftype=1 (the default in current mkfs.xfs). The restore step puts containerd's original config back, removes the test filesystem and starts both daemons.

Clean up the lab directory on the sec VM:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.9.2
$ rm -f alpine-3.24.tar

Try this

On the main lab VM, rebuild the lab image and start two containers from it under different names. Append to /data.bin in only one of them, then run docker ps -s again. Predict both writable-layer sizes first, using the 4.1kB and 100MB values above, then check whether the untouched container paid anything.

Bottom line

Keep write-heavy files on volumes, and size your disks for the store you actually run. Two appended bytes grew a writable layer from 4.1kB to 100MB, and one 50 MB layer cost about 150 MB on the containerd store. The quiet failure on real hosts is the old XFS volume with ftype=0, where the legacy store fell back to vfs with only an info-level log line. Read the driver from docker info after every provisioning run; don't trust the install docs for it.

Quick check
01A host installed fresh with Docker 29 alerts at 91% disk use. du -sh /var/lib/docker reports 300M and docker system df reports 40GB of images. Where is the space?
Correct — On the containerd image store containerd owns image content and container filesystems; data-root holds only dockerd's part.
Incorrect — That directory exists only on hosts that use or once used the legacy store, and it is inside the 300M du already counted.
Incorrect — That is containerd's state directory on a tmpfs: sockets and runtime state, not image data.
Incorrect — Possible on a busy host, but it would not show up as 40GB of images in docker system df.
02A container appends one line to a 2 GB file that ships in its image. The first append takes seconds, later ones are instant, and docker ps -s jumps to about 2GB. What explains this, and what is the fix?
Incorrect — A restart keeps the writable layer and its copy; nothing is out of sync.
Incorrect — metacopy only affects metadata changes, and the lab showed a chmod copying the whole file too. O_DIRECT does not avoid copy-up.
Correct — The first write to a lower-layer file copies all of it into the upper directory. A volume bypasses the overlay mount.
Incorrect — Snapshots are not compressed, and a tmpfs would put the 2 GB in memory.
03An operator migrates a host from Docker 28 by setting "containerd-snapshotter": true, pulls the needed images again and recreates the containers. docker system df looks small, yet df shows the disk as full as before. What is using it?
Incorrect — Each blob is stored once, compressed; the second copy is the unpacked snapshot, which docker system df counts.
Incorrect — docker system df has a Build Cache row; it would show there.
Incorrect — Daemon logs are small and live in the journal, not in Docker's data.
Correct — Switching stores hides the other store's images and containers without deleting them, so /var/lib/docker/overlay2 still holds them.

Related