← All runbooks in Docker & Containers
Runbook: Migrate to the containerd image store
1 · Prerequisites
Confirm every item is in place before any state change.
- A maintenance window exists, because the daemon must be restarted
- Every image the host needs is either present in a registry you can pull from, or exported to a tar archive you have verified
- You know which containers run on this host and can recreate them from compose files in version control
- Free disk space is at least the total image size again, because containerd holds compressed and uncompressed layers
- You know whether the daemon uses userns-remap, which is not compatible with the containerd image store
- You have console or out-of-band access, so a daemon that fails to start does not lock you out
- The current daemon.json is in version control and you can restore the exact previous file
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · docker info -f "{{.ServerVersion}}" records the engine version before the change
- · docker info -f "{{.DriverStatus}}" shows the active backend; the containerd image store reports driver-type io.containerd.snapshotter.v1
- · docker info -f "{{.Driver}} {{.DockerRootDir}}" records the current storage driver and data root
- · docker info -f "{{json .SecurityOptions}}" shows whether userns-remap is in effect
- · docker image ls --digests --format "{{.Repository}}:{{.Tag}} {{.Digest}}" captures the full image inventory, saved to a file outside the Docker data root
- · docker ps -a --format "{{.Names}} {{.Image}} {{.State}}" captures every container and the image it runs
- · docker system df -v records image, container and volume space before the change
- · df -h / and df -h /var/lib/docker confirm free space on BOTH the root partition and the Docker data root
- · cat /etc/docker/daemon.json and dockerd --validate --config-file /etc/docker/daemon.json exits 0 before you edit anything
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Announce the maintenance window; the daemon restart stops every container that is not configured to restart
- 2Capture the inventory: write docker image ls --digests and docker ps -a output to a file stored OUTSIDE the Docker data root
- 3Confirm every image you need is pullable from a registry, or export it: docker save -o /srv/export/app-1.4.2.tar registry.example.com/team/app:1.4.2
- 4Verify each export before you rely on it: tar -tf /srv/export/app-1.4.2.tar lists a manifest and layer entries
- 5Confirm free space on the root partition as well as the data root, because containerd storage does not follow a custom data-root setting
- 6Back up the current daemon configuration: cp /etc/docker/daemon.json /etc/docker/daemon.json.pre-containerd
- 7Stop the containers cleanly rather than letting the daemon restart kill them: docker compose stop for each stack
- 8Add the feature flag to /etc/docker/daemon.json: a features object containing containerd-snapshotter set to true
- 9Validate before restarting: dockerd --validate --config-file /etc/docker/daemon.json exits 0
- 10Restart the daemon: sudo systemctl restart docker
- 11Confirm the store actually changed: docker info -f "{{.DriverStatus}}" reports driver-type io.containerd.snapshotter.v1
- 12Expect docker image ls to be nearly empty; the old images are still on disk but are not visible to the new store
- 13Repopulate the new store by pulling from the registry: docker compose pull for each stack, or docker load -i for each verified export
- 14Compare the repopulated inventory against the file captured before the change; every entry must be accounted for
- 15Start the stacks in dependency order: docker compose up -d, data stores first
- 16Re-check disk usage with docker system df and df -h, because both stores are now consuming space
- 17Once the host is verified healthy and the window for rollback has passed, reclaim space from the store you are no longer using
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓docker info -f "{{.DriverStatus}}" prints driver-type io.containerd.snapshotter.v1, which is the only positive proof the switch took effect
- ✓docker info -f "{{.ServerVersion}}" matches the version recorded in the pre-checks, confirming nothing else changed
- ✓systemctl is-active docker prints active and journalctl -u docker --since "10 min ago" contains no error at daemon start
- ✓Every image in the pre-change inventory file is present in docker image ls --digests after repopulation; a diff of the two lists is empty
- ✓docker compose ps shows every expected service running, with no restart loop
- ✓Each container with a healthcheck reports healthy: docker inspect -f "{{.Name}} {{.State.Health.Status}}" prints healthy, not starting
- ✓A fresh pull works on the new store: docker pull registry.example.com/team/app:1.4.2 completes and the digest matches the recorded value
- ✓A build works on the new store: docker build -t localhost/smoke:1 - reading a trivial Dockerfile from stdin completes and the image appears in docker image ls
- ✓df -h on BOTH the root partition and the Docker data root shows free space above your alert threshold after repopulation
- ✓Reboot the host once and confirm the daemon starts on the containerd store and every service returns
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Stop the containers cleanly: docker compose stop for each stack
- ↶Restore the previous daemon configuration: cp /etc/docker/daemon.json.pre-containerd /etc/docker/daemon.json
- ↶Validate before restarting: dockerd --validate --config-file /etc/docker/daemon.json exits 0
- ↶Restart the daemon: sudo systemctl restart docker
- ↶Confirm the store reverted: docker info -f "{{.DriverStatus}}" no longer reports io.containerd.snapshotter.v1
- ↶The images that were hidden when you switched reappear on switching back, because the old store was never deleted
- ↶Start the stacks again and verify health as before
- ↶This rollback holds ONLY while you have not deleted the old store. Once you reclaim that space the rollback is gone, which is why space reclamation is the last step and not part of the change.
- ↶Images pulled or built while on the containerd store do not appear in the classic store; pull or load them again after reverting
6 · Escalation
When the runbook isn't enough, contact:
- · The daemon fails to start after the change: capture journalctl -u docker output, roll back to the saved daemon.json, and escalate to the platform team with the log
- · The daemon uses userns-remap: stop. The containerd image store is documented as unavailable with user-namespace remapping. Escalate rather than removing the remapping, which is a security control.
- · The root partition fills during repopulation: escalate to the platform team, since containerd storage does not follow a custom data-root and may need its own directory configured
- · An image cannot be repopulated because it exists nowhere but the old store: escalate to the service owner before proceeding; roll back first so the image is visible again
- · A build behaves differently on the new store: escalate to the build owner with the image digests from both stores rather than adjusting the Dockerfile under pressure
- · You are considering the experimental automatic migration feature on a production host: escalate for a decision, since the upstream documentation describes starting fresh as the recommended approach
What this change actually is
Docker Engine can store images in one of two places: the classic
store that sits alongside a graph driver such as overlay2, or the
containerd image store, which uses a containerd snapshotter.
Upstream states it plainly: “The containerd image store is the
default storage backend for Docker Engine 29.0 and later on fresh
installations.” The Engine 29.0 release notes add the qualifier
that this “doesn’t apply to daemons configured with userns-remap”.
Two words in that sentence do the work.
- 29.0. Not 28.x. If you are reading a note that attributes this default to a 28 release, that note is wrong.
- Fresh installations. An existing host that you upgrade to 29 keeps the store it already had. Moving it is this runbook, and it is a deliberate change with a maintenance window — not something that happens to you during an upgrade.
Step 1: Capture the inventory, outside the data root
sudo install -d -m 0755 /srv/migration
docker info -f '{{.ServerVersion}}' | tee /srv/migration/version.txt
docker info -f '{{.Driver}} {{.DockerRootDir}}' | tee /srv/migration/driver.txt
docker info -f '{{.DriverStatus}}' | tee /srv/migration/driverstatus-before.txt
docker info -f '{{json .SecurityOptions}}' | tee /srv/migration/security.txt
docker image ls --digests \
--format '{{.Repository}}:{{.Tag}} {{.Digest}}' \
| sort | tee /srv/migration/images-before.txt
docker ps -a --format '{{.Names}} {{.Image}} {{.State}}' \
| tee /srv/migration/containers-before.txt
docker system df -v | tee /srv/migration/df-before.txt
df -h / /var/lib/docker | tee /srv/migration/disk-before.txtWrite these outside /var/lib/docker. A file recording what you are
about to make invisible should not live inside the thing you are
changing.
Check security.txt for user-namespace remapping now, not later.
Step 2: Make sure every image can be repopulated
# Everything the stacks reference
cd /srv/infra/stacks/app
docker compose config --images | sort | tee /srv/migration/images-needed.txt
# For anything NOT in a registry, export it and CHECK the export
sudo install -d -m 0755 /srv/export
docker save -o /srv/export/app-1.4.2.tar registry.example.com/team/app:1.4.2
tar -tf /srv/export/app-1.4.2.tar | head
ls -lh /srv/export/app-1.4.2.tarAn image that exists only on this host, built locally and never
pushed, is the item that turns this migration into an incident. Find
those now. docker save produces an archive docker load can read
back; verify it with tar -tf before you rely on it, exactly as you
would verify a backup.
Step 3: Check disk space on both filesystems
Step 4: Make the change
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.pre-containerd
# Stop containers cleanly rather than having the daemon restart kill them
cd /srv/infra/stacks/app && docker compose stop
# Add to /etc/docker/daemon.json:
# {
# "features": {
# "containerd-snapshotter": true
# }
# }
# Merge it into any existing "features" object rather than replacing the file.
sudo dockerd --validate --config-file /etc/docker/daemon.json
echo "validate exit: $?"
sudo systemctl restart docker
systemctl is-active docker
sudo journalctl -u docker --since '5 min ago' --no-pager | tail -30Validate before you restart, every time. dockerd --validate checks
the configuration file without starting the daemon and returns
non-zero for an invalid one, which converts “the daemon will not come
back and I am on a maintenance window” into a one-line answer.
Step 5: Confirm which store is actually active
docker info -f '{{.DriverStatus}}'
# containerd image store reports:
# [[driver-type io.containerd.snapshotter.v1]]
docker info -f '{{.Driver}}'
docker image ls # expect this to be nearly empty
docker system dfDriverStatus reporting driver-type io.containerd.snapshotter.v1 is the documented signal and the only
one worth trusting. Do not infer the answer from the fact that
docker image ls is empty — an empty image list has several causes,
and only one of them is the one you intended.
Step 6: Repopulate
cd /srv/infra/stacks/app
# From the registry, which is the preferred route
docker compose pull
# From a verified export, for anything that exists nowhere else
docker load -i /srv/export/app-1.4.2.tar
# Compare against what you had
docker image ls --digests \
--format '{{.Repository}}:{{.Tag}} {{.Digest}}' \
| sort > /srv/migration/images-after.txt
diff /srv/migration/images-before.txt /srv/migration/images-after.txt
docker compose up -d
docker compose psThe diff is the verification. “The services started” is not
sufficient — a stack can start happily while a rarely-used sidecar
image is missing, and you find out at the next deploy.
Step 7: Reclaim space, last and separately
Do not reclaim the old store on the day of the change. While it remains, the rollback is a configuration revert and a restart. Once it is gone, the rollback is gone with it.
Treat space reclamation as a separate change, after the host has served real traffic and survived a reboot.
On the experimental automatic migration
The upstream page documents an Experimental automatic migration
feature, gated behind a containerd-migration feature flag in
daemon.json, with a DOCKER_MIGRATE_SNAPSHOTTER_THRESHOLD
environment variable set on the daemon’s service configuration to
control when the automatic switch happens. Upstream’s own guidance on
it is direct: “This feature is experimental. It’s provided for those
who want to test it, but starting fresh is the recommended approach.”
Common failure patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
docker image ls is empty after the restart | Expected — the old store is hidden, not deleted | Repopulate by pull or load; the old images return if you revert |
| Daemon will not start | Malformed daemon.json, or userns-remap in effect | Revert the file, dockerd --validate, restart; check SecurityOptions |
| Root partition fills during repopulation | containerd storage does not follow a custom data-root | Configure containerd’s own directory; escalate to the platform team |
| Disk usage far higher than before | containerd holds compressed and uncompressed layers | Expected; plan capacity, prune once rollback is no longer needed |
| One image cannot be repopulated | Built locally, never pushed, and never exported | Revert to make it visible again, then push it before retrying |
DriverStatus unchanged after restart | The feature flag was added outside the features object, or the file was not the one the daemon reads | Check dockerd --validate and systemctl cat docker for a --config-file override |