Inspecting, exporting and cleaning up images

image ls on Docker 29, save and load, disk usage and pruning.

Intermediate20 min · lesson 3 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): imgmgmt.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/imgmgmt.tar.gz && tar -xzf imgmgmt.tar.gz, which creates ~/lab/imgmgmt/. SHA-256: d07306e3f370a58baad3d670cfcda28742d54b2ea67d7c1b3051cdd81a55694f
Watch out
The last section of this lesson changes /etc/docker/daemon.json and restarts Docker. Run that part only in the SecOpsLog disposable lab VM (secopslog-docker-sec), never on a machine you care about. 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. Everything before that section runs on the main lab VM, secopslog-docker, and changes nothing outside the images it builds.

Someone who learned Docker a few years ago types docker image ls on a Docker 29 host and gets a table they do not recognise. Work in ~/lab/imgmgmt on the main VM, where the lesson files (a Dockerfile) go, and pull the two images this lesson inspects first; on a VM that already has them, the pull only confirms the tags. Then list them. There is no REPOSITORY, TAG or SIZE column any more:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker pull -q alpine:3.22 docker pull -q nginx:1.30-alpine
docker.io/library/alpine:3.22 docker.io/library/nginx:1.30-alpine
$ docker image ls --filter reference=alpine:3.22 --filter reference=nginx:1.30-alpine
IMAGE ID DISK USAGE CONTENT SIZE EXTRA alpine:3.22 5291449c3df7 13.4MB 4.21MB nginx:1.30-alpine 0985e772fb9f 92.9MB 26.9MB

Docker 29 made this "new view" the default. Each row is one image name. ID is the first 12 hex digits of the image's digest, and on a host that uses the containerd image store (every fresh 29 install) that is the digest of the image index, the same value a registry reports for the tag ("Image anatomy: index, manifest, config and layers" shows where it comes from). The two size columns answer different questions. CONTENT SIZE is what the image's compressed blobs take up, roughly what a pull downloads. DISK USAGE adds the unpacked filesystem snapshot that containers start from. The containerd store keeps both copies, which is why alpine's 4.21MB of content costs 13.4MB of disk. The legacy graph-driver store kept only the unpacked layers, so the old SIZE column and this one are not comparable.

Platforms, the classic table and the U flag

Most official images are multi-platform: one name, one index, a manifest per CPU architecture. --tree expands a row into the platforms the index lists:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker image ls --tree nginx:1.30-alpine
IMAGE ID DISK USAGE CONTENT SIZE EXTRA nginx:1.30-alpine 0985e772fb9f 92.9MB 26.9MB ├─ linux/amd64 8f84ed99befc 0B 0B ├─ linux/arm/v6 2e7697c6a693 0B 0B ├─ linux/arm/v7 3a46b783293c 0B 0B ├─ linux/arm64/v8 3abad30db61d 92MB 26MB ├─ linux/386 6b7a4f13aa1c 0B 0B ├─ linux/ppc64le a2056a035f0a 0B 0B ├─ linux/riscv64 d0f15a8a48f1 0B 0B └─ linux/s390x b2cc574e7079 0B 0B

Only linux/arm64/v8 holds data, because the lab VM is arm64 and docker pull fetched just that platform. The other rows are entries in the index whose content never came down; they cost nothing. On an amd64 machine the linux/amd64 row carries the 92MB and the arm64 row shows 0B. The CLI help still marks --tree as experimental, and in 29.8.2 docker image ls has no --platform flag; docker image inspect, docker image history, docker save, docker load and docker run take one.

Scripts that parse the old columns do not break silently, because any explicit layout switches back to the classic table. --digests is one of them, and so is --format:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker image ls --digests nginx
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE nginx 1.30-alpine sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 0985e772fb9f 2 weeks ago 92.9MB

With the containerd store the DIGEST column and the IMAGE ID are the same value. The last column worth knowing is EXTRA. Start a container and the image gets a U, for in use:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker run -d --name lab-web nginx:1.30-alpine docker image ls nginx:1.30-alpine
bbcf2ed5ae1fd27f7f64445ca4f3035327b3de117f7856c39cf2e9bdcec3fa6c IMAGE ID DISK USAGE CONTENT SIZE EXTRA nginx:1.30-alpine 0985e772fb9f 92.9MB 26.9MB U

"In use" means a container exists from that image, running or stopped. That is the line docker image prune -a draws later in this lesson.

History: find the step that made it big

An image that grew between two releases is usually explained by one instruction. Build the lab image, whose Dockerfile writes 3 MB of random bytes in one RUN and a small text file in another, and read its history:

Dockerfile
FROM alpine:3.22
# 3 MB of random bytes: a layer big enough to spot in docker image history
RUN head -c 3000000 /dev/urandom > /opt/blob.bin
ARG BUILD=1
RUN echo "lab-tool build $BUILD" > /build.txt
CMD ["cat", "/build.txt"]
ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker build -q --label secopslog.lab=true --build-arg BUILD=1 -t lab-tool:1 .
sha256:768d954a9109722d1a3e31ba24d2b5175c0397a4c0b654365bc9cc2f3bb80846
$ docker image history lab-tool:1
IMAGE CREATED CREATED BY SIZE COMMENT 768d954a9109 8 hours ago CMD ["cat" "/build.txt"] 0B buildkit.dockerfile.v0 <missing> 8 hours ago RUN |1 BUILD=1 /bin/sh -c echo "lab-tool bui… 8.19kB buildkit.dockerfile.v0 <missing> 8 hours ago ARG BUILD=1 0B buildkit.dockerfile.v0 <missing> 8 hours ago RUN /bin/sh -c head -c 3000000 /dev/urandom … 3.01MB buildkit.dockerfile.v0 <missing> 2 weeks ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0 <missing> 2 weeks ago ADD alpine-minirootfs-3.22.6-aarch64.tar.gz … 9.2MB buildkit.dockerfile.v0

Newest step at the top. The 3.01MB row is the RUN head -c 3000000 ... line, so that is where to look. The two bottom rows are alpine's own build, which is why they are two weeks old. <missing> in the IMAGE column is normal: BuildKit records history entries, not an image per step, and only the final image has an ID. Rows with 0B (CMD, ARG) changed only the image config. Commands are cut at 45 characters; add --no-trunc to read them whole, which is also how you find a secret someone passed in a build argument. History shows the instruction, not the files; when you need to know which files a layer holds, extract it (as in "Image anatomy: index, manifest, config and layers") or use a tool such as dive.

Rebuilding under the same tag

Change the build argument and build again under the same name. On the legacy store this left the previous image behind as a nameless <none> image, and a CI host that rebuilt fifty times a day collected hundreds of them. On Docker 29 with the containerd store, look for one:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker build -q --label secopslog.lab=true --build-arg BUILD=2 -t lab-tool:1 . docker image ls -a lab-tool docker image ls --filter dangling=true
sha256:2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-tool:1 2140a77ad842 19.4MB 7.13MB IMAGE ID DISK USAGE CONTENT SIZE EXTRA <untagged> f7cc0bde5281 7.73MB 2.29MB

There is one lab-tool:1, and the dangling=true filter finds nothing. The containerd store drops the old image record when the tag moves to the new build, unless something still points at it. The last section of this lesson repeats the experiment on the legacy store, where the old image does stay behind. The bytes of earlier builds did not vanish, though. They live on in the build cache, which docker system df reports next to images, containers and volumes:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 30 2 8.203GB 7.892GB (96%) Containers 2 2 122.9kB 0B (0%) Local Volumes 5 0 8.273MB 8.273MB (100%) Build Cache 428 0 3.881GB 1.96GB

Read RECLAIMABLE, not SIZE. Images count as reclaimable when no container uses them; the active one here is the nginx image behind lab-web. The Build Cache row is the one that grows on a build host: every RUN result and copied context BuildKit keeps for later builds. The numbers belong to everything built on this VM, so yours will differ. docker system df -v breaks each row down per image, container, volume and cache record, including how much of each image is shared with others.

Moving an image without a registry

An air-gapped host cannot pull. docker save writes images to a tar archive and docker load reads one back. Record the ID first so you can prove the round trip kept the image intact:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker image inspect lab-tool:1 --format '{{.Id}}' | tee lab-tool.id docker save -o lab-tool-1.tar lab-tool:1 ls -l lab-tool-1.tar tar -tf lab-tool-1.tar
sha256:2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212 -rw------- 1 ubuntu ubuntu 7141376 Oct 8 07:35 lab-tool-1.tar blobs/ blobs/sha256/ blobs/sha256/137412594fbf226d2da0e4e64aca8cf183c4c92336af15fd79a804eafc9027c3 blobs/sha256/140045aaa4ad0a50b9c7b1ad8b56e52404837a9c98f73fae0238284c4be2c596 blobs/sha256/16fc4f52163f03cd2189c3d6a4b3f28a605cfb7919af64b3da4562cca69d2306 blobs/sha256/2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212 blobs/sha256/2d10a21e5979299fd9979336cc52c9986591d32269790e4bdabe978082be7516 blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a blobs/sha256/6e903093a4b63d941241baad33117806b582325bdb9ab3fdff0af6a4b33ea0a0 blobs/sha256/ba8356cd49f200cb8765d9377c9dbccba67a72a408fc21891a03df24c972d87b blobs/sha256/c91094a7f41968cfa98722d03785e854b5ef2432c01b9eb86ca8c593b16b9fd0 index.json manifest.json oci-layout
$ docker image rm lab-tool:1 docker load -i lab-tool-1.tar docker image inspect lab-tool:1 --format '{{.Id}}'
Untagged: lab-tool:1 Deleted: sha256:2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212 Loaded image: lab-tool:1 sha256:2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212

On the containerd store the archive is an OCI image layout: oci-layout, an index.json naming the image, content-addressed files under blobs/sha256/, plus a Docker-style manifest.json so older docker load versions can read it. The ID after the load is the one before it, because the blobs and their digests did not change. docker load also accepts a gzip-compressed archive, so docker save lab-tool:1 | gzip > lab-tool-1.tar.gz is fine for the transfer. Two practical cautions. The archive holds every layer, including anything a careless build left in an early one, so treat it with the same care as the image in a private registry. And it is no replacement for docker export, which flattens a container's filesystem into a tar and loses the config, history and layers.

save can only write what the store has. A pulled multi-platform image has content for one platform, as the tree view showed, and asking for another fails:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker save --platform linux/s390x -o lab-alpine-s390x.tar alpine:3.22
Error response from daemon: no suitable export target found: image with reference alpine:3.22 was found but does not provide the specified platform (linux/s390x)

To carry a platform you do not run locally, pull it first with docker pull --platform linux/s390x ... on a connected machine, then save with the same --platform. Without the flag, save writes the platforms that are present.

Pruning with filters

docker image prune with no flags removes dangling images only, which on a containerd-store host is usually nothing. -a widens it to every image no container uses, tagged or not, base images included. On a build host that wipes the bases your next build needs, and on a production host it can take the previous release you would roll back to. Narrow -a with filters. Build three more labelled images, one of them marked keep=true, then preview the selection with docker image ls, which accepts the same label filters:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker build -q --label secopslog.lab=true --build-arg BUILD=old1 -t lab-old:1 . docker build -q --label secopslog.lab=true --build-arg BUILD=old2 -t lab-old:2 . docker build -q --label secopslog.lab=true --label keep=true --build-arg BUILD=keep -t lab-keep:1 .
sha256:1d6039dd2abec1e1ebfd789cc67428bed519c5f42ecdbca2d474e260857a20cf sha256:2c2135911809e8fd4d19e2414a6595db49fcf9da259210344785a33bc32a0780 sha256:50a06c357302d99bd095c82d1a91bf494b3da254f6971229446a19a707d07ec5
$ docker image ls --filter label=secopslog.lab=true --filter 'label!=keep=true'
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-old:1 1d6039dd2abe 19.4MB 7.13MB lab-old:2 2c2135911809 19.4MB 7.13MB lab-tool:1 2140a77ad842 19.4MB 7.13MB
$ docker image prune -a -f --filter label=secopslog.lab=true --filter 'label!=keep=true'
Deleted Images: untagged: lab-tool:1 deleted: sha256:2140a77ad8428d2ad1e9694a6d41e56ba36b496c239df3ea945dd49f3f5b5212 deleted: sha256:140045aaa4ad0a50b9c7b1ad8b56e52404837a9c98f73fae0238284c4be2c596 deleted: sha256:c91094a7f41968cfa98722d03785e854b5ef2432c01b9eb86ca8c593b16b9fd0 untagged: lab-old:1 deleted: sha256:1d6039dd2abec1e1ebfd789cc67428bed519c5f42ecdbca2d474e260857a20cf deleted: sha256:9946f56d8bfe000454f6dfd4d4651eb6ab799221e46e0d6f2f56e628b1b5f0bd deleted: sha256:8e6a3acbf39ef2148312b6b4c90db6d4081a8d5149ef73fc7a9fea8c7f4ff1f3 untagged: lab-old:2 deleted: sha256:2c2135911809e8fd4d19e2414a6595db49fcf9da259210344785a33bc32a0780 deleted: sha256:b0333a185242ff372a2f5f5fd5b0a9e0edd7705e714e373ab2a762f53c865bb4 deleted: sha256:2199a45c9b40b07effacb267204c1de7d30faadb14903efb0b180a331fc7191a Total reclaimed space: 40.09kB
$ docker image ls --filter label=secopslog.lab=true
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-keep:1 50a06c357302 19.4MB 7.13MB

Filters on different keys are ANDed: label=secopslog.lab=true selects the lab images and label!=keep=true then excludes the marked one. Each removed image lists three digests, the index and the two manifests it pointed to (the image manifest and its attestation manifest). Note the total: 40.09kB for three images of 19.4MB each. The 3 MB layer and the alpine base are shared with lab-keep:1 and every other alpine image, and a prune frees only data nothing else references. Measure with docker system df before and after rather than trusting the arithmetic of the image list.

The other filter you will meet is until. The documentation describes it as "images created before" a time, and what counts as created depends on the image store, as the last section shows.

Which prune, and what it takes
Disk filling up
run docker system df first: which row is large and reclaimable?
images, safe
docker image prune
dangling images only; on the containerd store usually little
images, wide
docker image prune -a --filter ...
unused images; add label or until filters, or it takes base images too
build cache
docker builder prune --reserved-space SIZE
trims cache to a budget, least recently used first; --filter until=24h by last use
everything
docker system prune
stopped containers, unused networks, dangling images, unused build cache; volumes only with --volumes
On build hosts prefer a cache budget over -a. On production hosts keep at least the previous release image.
ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.8.2
$ docker rm -f lab-web docker image rm lab-keep:1 rm -f lab-tool-1.tar lab-tool.id
lab-web Untagged: lab-keep:1 Deleted: sha256:50a06c357302d99bd095c82d1a91bf494b3da254f6971229446a19a707d07ec5

On the disposable VM: age, cache budgets and the legacy store

The rest 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/imgmgmt on that VM as well and work there. Start with the age filter. Pull alpine, check the creation date the image carries, and prune everything "older than a day":

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker pull -q alpine:3.22 sudo docker image inspect alpine:3.22 --format "created {{.Created}}"
docker.io/library/alpine:3.22 created 2026-09-17T20:37:30.780047516Z
$ sudo docker image prune -a -f --filter until=24h
Total reclaimed space: 0B

The image says it was created in September, yet nothing was removed. On the containerd store the filter left an image that arrived seconds earlier alone; in this lab it behaved as if it counted from when the image record was created on this host, not from the build date in the config. Keep that in mind before you rely on until, because the legacy store, a few steps further down, does the opposite.

Next, the build cache. Build three variants, remove the image, and look at what the cache holds:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker build -q --label secopslog.lab=true --build-arg BUILD=1 -t lab-tool:1 . sudo docker build -q --label secopslog.lab=true --build-arg BUILD=2 -t lab-tool:1 . sudo docker build -q --label secopslog.lab=true --build-arg BUILD=3 -t lab-tool:1 .
sha256:960c9c402ced322a962eade026f8d47afe21053761cc276378535bd74c72894d sha256:bf0c6db758d35f7a604fa0a12997dfdd534e0f7ed769f49b6444d6cc674bfcea sha256:cbddb0dea233b23ac277322a8d95a83a9c5e8743cab4cdfa965f94f1090f7bae
$ sudo docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 19 0 6.019GB 5.839GB (97%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 7 0 19.39MB 37.11kB
$ sudo docker image rm lab-tool:1 sudo docker system df
Untagged: lab-tool:1 Deleted: sha256:cbddb0dea233b23ac277322a8d95a83a9c5e8743cab4cdfa965f94f1090f7bae TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 18 0 6.013GB 5.847GB (97%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 7 0 19.39MB 6.065MB
$ sudo docker buildx du
ID RECLAIMABLE SIZE LAST ACCESSED 33t9qpyyaavg6mdrl5576aykg* true 4.096kB Less than a second ago 0k4ba6djmvrlh4fyjg6b9bl5e* true 8.192kB Less than a second ago 9qtxb05y5nkhug6aca5add10n true 12.41kB Less than a second ago nkhtwgxsc6frretuyat883s74 true 12.41kB 1 second ago q9wcfn0yyulnhtph3mn6461j2 true 12.41kB 1 second ago oofgntrqsvxerr1mqbjudlod6 true 6.016MB Less than a second ago dkic7l4mz9psrd1s8mxz9m9ot true 13.33MB* 1 second ago Shared: 13.33MB Private: 6.065MB Reclaimable: 19.39MB Total: 19.39MB

While lab-tool:1 existed, almost none of the 19.39MB of build cache was reclaimable: on the containerd store the cache records and the image share the same snapshots, so the Build Cache SIZE overlaps with the Images row. With the image gone, 6.065MB became private to the cache. docker buildx du lists each record; a * marks one shared with an image. Now give the cache a budget:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker builder prune -f --reserved-space 5MB sudo docker system df
ID RECLAIMABLE SIZE LAST ACCESSED q9wcfn0yyulnhtph3mn6461j2 true 12.41kB 1 second ago nkhtwgxsc6frretuyat883s74 true 12.41kB 1 second ago 9qtxb05y5nkhug6aca5add10n true 12.41kB 1 second ago 33t9qpyyaavg6mdrl5576aykg* true 4.096kB 1 second ago oofgntrqsvxerr1mqbjudlod6 true 6.016MB 1 second ago Total: 6.057MB TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 18 0 6.013GB 5.847GB (97%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 2 0 13.33MB 8.192kB

--reserved-space deletes cache records, least recently used first, until the cache fits the budget or nothing reclaimable is left. Here it removed five records (6.057MB) and stopped at 13.33MB, above the 5MB budget, because what remains is shared with images that still exist, mainly the alpine snapshot that alpine:3.22 uses. Buildx 0.37 also offers --max-used-space and --min-free-space (older guides use --keep-storage, which the 0.37 help no longer lists), and --filter until=24h removes records not used in the last day. An unfiltered docker builder prune -f removes all reclaimable cache, and the next build starts cold.

Finally the legacy image store. Hosts upgraded from Docker 28 or older keep it until someone migrates them, so you will meet it. Switch this VM to it with the procedure from "Configuring the daemon safely": back up the current daemon.json (or record {} when there is none, as here), merge the one key into the backup with jq so anything already in the file survives, validate, and only then restart. "Where Docker keeps data" covers the stores themselves:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ 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 jq '. + {"features": {"containerd-snapshotter": false}}' /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}}'
{ "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 image ls
IMAGE ID DISK USAGE CONTENT SIZE EXTRA

The driver is now overlay2 and the image list is empty: switching stores hides the other store's images without deleting them. Repeat the age experiment here:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker pull -q alpine:3.22 sudo docker image prune -a -f --filter until=24h
docker.io/library/alpine:3.22 Deleted Images: untagged: alpine:3.22 untagged: alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 deleted: sha256:e09dd31eab4f4aeaade69891673134f31880ef51c6ee514b0cea072df06d7547 deleted: sha256:9fd9b3248f2fb670edfab1393d1d1e8cd828eb9c65de376b2fcc23268a45d500 Total reclaimed space: 8.54MB

On the legacy store until compares the creation date from the image config, so an image pulled a second ago but built in September is gone. A nightly "prune everything older than a day" job on such a host removes freshly pulled bases and keeps recent local builds. Know which store a host runs before you schedule until, and add a label filter so the job only touches images you own. Now rebuild under one tag, as in the main lab:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker build -q --label secopslog.lab=true --build-arg BUILD=1 -t lab-tool:1 . sudo docker build -q --label secopslog.lab=true --build-arg BUILD=2 -t lab-tool:1 . sudo docker image ls -a
sha256:63539745fdeb3408b7be7990513a58a7ecf0a5cd1920181d1ae519cbf432b39f sha256:eb7956fd5a897f5aff791cd31abfc43ce0f144c81832cd5e5ec68d52ceffd0c4 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-tool:1 eb7956fd5a89 11.5MB 0B <untagged> 63539745fdeb 11.5MB 0B
$ sudo docker image ls --filter dangling=true sudo docker image prune -f
IMAGE ID DISK USAGE CONTENT SIZE EXTRA <untagged> 63539745fdeb 11.5MB 0B Deleted Images: deleted: sha256:63539745fdeb3408b7be7990513a58a7ecf0a5cd1920181d1ae519cbf432b39f Total reclaimed space: 0B
$ sudo docker image inspect lab-tool:1 --format "{{.Id}}" sudo docker save lab-tool:1 | tar -xOf - manifest.json | jq -r ".[0].Config"
sha256:eb7956fd5a897f5aff791cd31abfc43ce0f144c81832cd5e5ec68d52ceffd0c4 blobs/sha256/eb7956fd5a897f5aff791cd31abfc43ce0f144c81832cd5e5ec68d52ceffd0c4

Here the second build leaves the first behind. The new view lists it as <untagged> (the classic table calls it <none>), dangling=true finds it, and plain docker image prune removes it; it freed nothing because every layer was shared with the new build. The ID also means something else on this store: it is the digest of the image config, which the Config entry of a saved archive confirms, while on the containerd store it is the index digest. Keep that in mind when a script compares IDs across hosts.

Switching back has a trap. Restore the backup and restart, and the daemon stays on overlay2, because it now finds legacy-store data and keeps using it. Only an explicit true brings the containerd store back, with the alpine image pulled at the start of this section still in it. One more thing to know first: systemd allows only three docker.service starts per minute, and this section has used them up, so the next restart would fail with start request repeated too quickly. systemctl reset-failed docker clears the counter (waiting a minute works too):

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.8.2
$ sudo docker image rm lab-tool:1 sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json sudo systemctl restart docker sudo docker info --format '{{.Driver}}'
Untagged: lab-tool:1 Deleted: sha256:eb7956fd5a897f5aff791cd31abfc43ce0f144c81832cd5e5ec68d52ceffd0c4 overlay2
$ jq '. + {"features": {"containerd-snapshotter": true}}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json sudo systemctl reset-failed docker sudo systemctl restart docker sudo docker info --format '{{.Driver}} {{json .DriverStatus}}' sudo docker image ls --format "{{.Repository}}:{{.Tag}}"
{ "features": { "containerd-snapshotter": true } } configuration OK overlayfs [["driver-type","io.containerd.snapshotter.v1"]] wordpress:php8.4-apache python:3.14-slim debian:trixie-slim mongo:8.0 docker:29-dind moby/buildkit:buildx-stable-1 mysql:8.4 ubuntu:26.04 registry:3 nginx:1.30-alpine redis:8-alpine node:24-alpine golang:1.26-alpine postgres:18-alpine alpine:3.22 tonistiigi/binfmt:latest hello-world:latest busybox:1.37

Leave that features line in daemon.json for as long as the legacy directories exist on the VM; without it the daemon falls back to them on the next start. "Configuring the daemon safely" shows how the leftover directories are removed, and recreating the sec VM clears everything.

Quick check
01A CI host on Docker 29 (fresh install, containerd store) is low on disk. docker image ls --filter dangling=true is empty and docker system df shows Build Cache at 38GB, 30GB reclaimable. Which command fits?
Incorrect — It only removes dangling images, and the list shows there are none. The space is in the build cache.
Correct — The build cache is the large reclaimable row; a budget trims it while keeping the most recently used records.
Incorrect — It targets unused images, and the 30GB is in the build cache, which image prune does not touch.
Incorrect — It also removes anonymous volumes, which may hold data, and still leaves the image question unanswered. Use the narrow tool for the row that is large.
02A build host upgraded from Docker 27 still uses the legacy image store. A nightly job runs docker image prune -a -f --filter until=24h. The morning after, builds pull python:3.14-slim again although it was pulled at 22:00 the evening before. Why?
Incorrect — The filter compares a creation time, not when the image was last used.
Incorrect — Filters do apply to -a; the lab showed label filters limiting it.
Correct — On the legacy store the filter reads the creation date in the image config, so a freshly pulled base matches until=24h at once.
Incorrect — A pulled image has its tag and is not dangling. The age filter is what selected it.
03You copy api:4.2 to an air-gapped host with docker save and docker load. How do you show the loaded image is the one you exported?
Incorrect — That only proves a tag was set. Any image saved under that name prints the same line.
Incorrect — Creation time is metadata inside the config; it does not prove the layers match.
Incorrect — They flatten a container filesystem and drop config, history and layers, so the digest changes.
Correct — docker image inspect --format '{{.Id}}' is derived from the content (the index digest on the containerd store), so equal IDs mean the same image.

Try this

Work through “On the disposable VM: age, cache budgets and the legacy store” 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 inspecting, exporting and cleaning up images, keep “On the disposable VM: age, cache budgets and the legacy store”. 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