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: d565a246d95c3e4db6e79f3642f045df5ed7aaa001187a9404858432cf781517
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 does not exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec, and 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 do not 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.

This lesson answers an operational question: where a Docker host keeps images and container filesystems, and what each layout costs on disk. On a host installed fresh with Docker 29 the images are not 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.8.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 is the classic graph driver, which keeps everything under /var/lib/docker and stays in place after the upgrade until someone migrates the host. You will meet both for years, so the rest of this 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, whose Dockerfile adds two files of random bytes (100 MB and 50 MB) so that copies are easy to see in sizes and timings, and start a container from it:

Dockerfile
FROM alpine:3.22
# 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.8.2
$ docker build -q --no-cache --label secopslog.lab=true -t lab-cow:1 .
sha256:67e64f15444771e7ed6b3a2f86910e544276cbcc9e8c219eca4836fbec43aabe
$ docker run -d --name lab-cow lab-cow:1
7b36dd97805f827a7b316bcadc81c2de2c1663a1da050061e723e4be93f1dc05
$ 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), and containerd can describe the snapshot behind it:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.8.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/1741/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1738/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1742/fs { "Kind": "Active", "Name": "7b36dd97805f827a7b316bcadc81c2de2c1663a1da050061e723e4be93f1dc05", "Parent": "7b36dd97805f827a7b316bcadc81c2de2c1663a1da050061e723e4be93f1dc05-init", "Created": "2026-10-07T18:55:53.14995077Z", "Updated": "2026-10-07T18:55:53.14995077Z" }

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: 1741 is Docker's init layer (placeholder files such as /etc/hosts and /etc/resolv.conf that Docker fills in per container), 1738 is the image's RUN layer and 1 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 differ.

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

Lower layers are never modified. The first time a 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 the cost is visible without sudo:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.8.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.17s user 0m 0.00s sys 0m 0.12s 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.17 s the first time and nothing the second, and 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 does not. 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 is off unless the module parameter or the metacopy=on mount option enables it, and Docker does not set the option. The module parameter on this kernel:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.8.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. This is 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.8.2
$ docker exec lab-cow sh -c 'echo hello > /note.txt; rm /etc/issue' docker diff lab-cow
C /etc D /etc/issue C /meta.bin C /data.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/1742/fs: total 146500 -rw-r--r--+ 1 root root 100000004 Oct 8 00:25 data.bin drwxr-xr-x+ 2 root root 4096 Oct 8 00:25 etc -rw-------+ 1 root root 50000000 Oct 8 00:25 meta.bin -rw-r--r-- 1 root root 6 Oct 8 00:25 note.txt /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1742/fs/etc: total 0 c--------- 2 root root 0, 0 Oct 8 00:25 issue

docker diff reports the change set: A added, C changed, D deleted. On disk, the upper directory holds the two copied-up files, the new note.txt, and etc/issue as a character device with device number 0, 0. That is 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 does not 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, and 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.8.2
$ df -h /var/lib/containerd df -i /var/lib/containerd
Filesystem Size Used Avail Use% Mounted on /dev/sda1 38G 12G 27G 30% / Filesystem Inodes IUsed IFree IUse% Mounted on /dev/sda1 5111808 280972 4830836 6% /

Here both are far from full. On a host that runs many images with tens of thousands of small files (language runtimes, node_modules), IUse% is the number to alert on. Clean up the main VM part:

ubuntu@secopslog-docker:~/lab/storagedrivers · Docker 29.8.2
$ docker rm -f lab-cow docker image rm lab-cow:1
lab-cow Untagged: lab-cow:1 Deleted: sha256:67e64f15444771e7ed6b3a2f86910e544276cbcc9e8c219eca4836fbec43aabe

Inside /var/lib/containerd

The rest of the lesson runs on secopslog-docker-sec, where your login is not 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 is configured:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.8.2
$ sudo docker pull -q alpine:3.22
docker.io/library/alpine:3.22
$ sudo containerd config dump | grep -E "^(root|state) =" grep -nE "^#?(root|state) =" /etc/containerd/config.toml
time="2026-10-08T03:12:20+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="5.167µ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.22 | 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 8 00:19 /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 amd64 sha256:3e9b4b680bfc9fb5269227cffbd6d42be39fbf7c0b908123913864aa4447e764 unknown sha256:136a7e91b81ee0a601b537ae15392eca6a3c8f6e8496f1f256acf29160bc1fe1 arm sha256:450c744b1ef46c709ee72b733c54813f149999273fffb17f2097f79160aba27a

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, which is 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.22 is such a digest, and 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: compressed in the content store, as pulled or built, and unpacked in a snapshot that containers can use. Measure what one 50 MB layer of random bytes (which does not compress) costs:

blob/Dockerfile
FROM alpine:3.22
# 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.8.2
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
1415 /var/lib/containerd/io.containerd.content.v1.content 4323 /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:45b6da471ed550a9b8428580ab351e5aa6b662a42d2ecd1ceb134e70acc393a6
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
1463 /var/lib/containerd/io.containerd.content.v1.content 4428 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs 1 /var/lib/docker
$ sudo docker buildx du
ID RECLAIMABLE SIZE LAST ACCESSED 7nxubk50oo2o3ae19c3he3nuz* true 4.096kB 5 seconds ago yk4z7kwrwi3flssn2qt1fgoc6* true 8.192kB 5 seconds ago m2mxqguwwr06o1zfbmtuypkya true 13.33MB* 5 seconds ago jzc2bjxog0hnfpqsatampuvh5 true 100MB* 5 seconds ago Shared: 113.4MB Private: 12.29kB Reclaimable: 113.4MB Total: 113.4MB

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 105 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, meaning the cache and the image use the same snapshots, so docker builder prune cannot free them while lab-blob:1 exists. That is 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 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 is 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 do not 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.8.2
$ sudo docker save alpine:3.22 > alpine-3.22.tar ls -l alpine-3.22.tar
-rw-r--r-- 1 ubuntu ubuntu 4232192 Oct 8 03:12 alpine-3.22.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.22.tar
Loaded image: alpine:3.22

docker info now reports overlay2 and the classic graph-driver details, and 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.8.2
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
1463 /var/lib/containerd/io.containerd.content.v1.content 4428 /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:4dbf8e90b33dee1280e2c8f24a5f6921f9acc7457f58db4952cbb65aa8bd7f37
$ sudo du -sm /var/lib/containerd/io.containerd.content.v1.content /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs /var/lib/docker
1463 /var/lib/containerd/io.containerd.content.v1.content 4428 /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 did not move. The legacy store keeps one unpacked copy and no compressed blob, which is 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.8.2
$ sudo docker run -d --name lab-legacy alpine:3.22 sleep infinity sudo docker inspect -f '{{json .GraphDriver.Data}}' lab-legacy | jq -r 'to_entries[] | .key + "=" + .value' | cut -c1-140
2aa48418f6d9b6b9072c81b72411c759c9c3d5801445285ea4e3e0e17226a235 ID=2aa48418f6d9b6b9072c81b72411c759c9c3d5801445285ea4e3e0e17226a235 LowerDir=/var/lib/docker/overlay2/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df-init/diff:/var/lib/docker/overlay2/829a33 MergedDir=/var/lib/docker/overlay2/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df/merged UpperDir=/var/lib/docker/overlay2/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df/diff WorkDir=/var/lib/docker/overlay2/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df/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/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df/diff: total 8 drwxr-xr-x+ 2 root root 4096 Oct 8 03:12 etc -rw-r--r-- 1 root root 6 Oct 8 03:12 note.txt /var/lib/docker/overlay2/b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df/diff/etc: total 0 c--------- 2 root root 0, 0 Oct 8 03:12 issue diff link lower merged work PIMH4Y3FFSYXCYXXHIP3O7ZA7G total 12 lrwxrwxrwx+ 1 root root 77 Oct 8 03:12 4T25NQ3LCIVRJAQL5CCNO4FNAD -> ../b0765bea8beb028f0878d805ec838a38db30ce390ec75ecd6268f150569ee0df-init/diff lrwxrwxrwx+ 1 root root 33 Oct 8 03:12 DBQYGPU7GLEIH4RWSRIFZDZEPP -> ../99goxi5n5wa1v4u6ae7nd18v3/diff lrwxrwxrwx 1 root root 33 Oct 8 03:12 E3WEIED734KWNRMTLPNA56CTXC -> ../ux34mp1g2fvzq26b9wfzgdkip/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 that 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.8.2
$ sudo docker rm -f lab-legacy sudo docker image rm lab-blob:1 alpine:3.22
lab-legacy Untagged: lab-blob:1 Deleted: sha256:4dbf8e90b33dee1280e2c8f24a5f6921f9acc7457f58db4952cbb65aa8bd7f37 Untagged: alpine:3.22 Deleted: sha256:e09dd31eab4f4aeaade69891673134f31880ef51c6ee514b0cea072df06d7547

Switching back, and the data left behind

Restoring the backup, which has no features key, does not 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.8.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.22 /var/lib/docker/image: identity-cache.db overlay2 /var/lib/docker/overlay2: 829a33ad7d689b1b7fe59cf1dbf689793aa69ddd09c4697d0e06ca024ada9b30 99goxi5n5wa1v4u6ae7nd18v3 cqpec0lgyau3gx1z3q2eym8ei l ux34mp1g2fvzq26b9wfzgdkip
# 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:45b6da471ed550a9b8428580ab351e5aa6b662a42d2ecd1ceb134e70acc393a6

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, 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) that switches only when no containers exist and the images are below a size threshold; 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.8.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, and 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, 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 both daemons must be stopped, and 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.8.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 5.8G /var/lib/containerd 5.8G /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-08T03:13:08+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="3.75µ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.22 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.22 b126b59a229d89cee5c065e3e0ed1ba9f125e12bb7845cb21105692b7f67a711 upperdir=/srv/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/116/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, with 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, or it creates a fresh, empty root on / and fills the root filesystem; a systemd drop-in does 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. This lab moves back:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.8.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-08T03:13:11+05:30" level=warning msg="Configuration migrated from version 0, use `containerd config migrate` to avoid migration" t="7µ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 (it needs the deprecated V4 format to allow that) and points each store at it:

ubuntu@secopslog-docker-sec:~/lab/storagedrivers · Docker 29.8.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-08T03:13:11.697476509+05:30" level=error msg="exec: \"fuse-overlayfs\": executable file not found in $PATH" storage-driver=fuse-overlayfs time="2026-10-08T03:13:12.020819507+05:30" level=info msg="Docker daemon" commit=8af9fe3 containerd-snapshotter=false storage-driver=vfs version=29.8.2

Nothing failed, and that is the problem. With ftype=0 under data-root, dockerd did not select overlay2, tried fuse-overlayfs (not installed, the one error line) and 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, so the place to 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.8.2
$ sudo systemctl stop docker docker.socket sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json

Now 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.8.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-08T03:13:12.486094121+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-08T03:13:12.490985607+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 [ 60.279368] 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), and the line that ends the start is an unrelated-looking buildkit error about a missing leases service, which is what you would 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 putting Docker or containerd data on XFS, and reformat with ftype=1 (the default in current mkfs.xfs) if it says 0. 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.8.2
$ rm -f alpine-3.22.tar
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.

Try this

Work through “d_type and XFS” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from where docker keeps data, keep “d_type and XFS”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related