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: d432ff45d836f2d96fb5d630fec692fabdb3ec7e455f89a6b3f7abaa8131909a
Watch out
The last section 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 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. 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 don't recognise. You'll work in ~/lab/imgmgmt on the main VM, where the lesson files (a Dockerfile) go. First pull the two images you'll inspect; on a VM that already has them, the pull only confirms the tags. Then list them. There's no REPOSITORY, TAG or SIZE column any more:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.9.2
$ docker pull -q alpine:3.24 docker pull -q nginx:1.31-alpine
docker.io/library/alpine:3.24 docker.io/library/nginx:1.31-alpine
$ docker image ls --filter reference=alpine:3.24 --filter reference=nginx:1.31-alpine
IMAGE ID DISK USAGE CONTENT SIZE EXTRA alpine:3.24 be2656061b53 13.6MB 4.28MB nginx:1.31-alpine a3554c0d0b48 94.1MB 27.2MB

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. On a host that uses the containerd image store (every fresh 29 install), that's the digest of the image index. It's 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.28MB of content costs 13.6MB of disk. The legacy graph-driver store kept only the unpacked layers, so you can't compare the old SIZE column with this one.

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.9.2
$ docker image ls --tree nginx:1.31-alpine
IMAGE ID DISK USAGE CONTENT SIZE EXTRA nginx:1.31-alpine a3554c0d0b48 94.1MB 27.2MB ├─ linux/amd64 2db7d28517d1 0B 0B ├─ linux/arm/v6 303db0a524d3 0B 0B ├─ linux/arm/v7 10eaf262ab53 0B 0B ├─ linux/arm64/v8 41c8801e327f 93.2MB 26.3MB ├─ linux/386 155035959fcc 0B 0B ├─ linux/ppc64le 70a21d46f8df 0B 0B ├─ linux/riscv64 d9cab13c373e 0B 0B └─ linux/s390x 82ba4ea9d6c5 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, and they cost nothing. On an amd64 machine the linux/amd64 row carries the data and the arm64 row shows 0B. The CLI help still marks --tree as experimental. On 29.8.2, the last version whose help was checked, docker image ls had no --platform flag; docker image inspect, docker image history, docker save, docker load and docker run take one.

Scripts that parse the old columns don't 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.9.2
$ docker image ls --digests nginx
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE nginx 1.31-alpine sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 a3554c0d0b48 4 days ago 94.1MB

With the containerd store the DIGEST column and the IMAGE ID are the same value. The last column you'll want to know is EXTRA. Start a container and the image gets a U, for in use:

ubuntu@secopslog-docker:~/lab/imgmgmt · Docker 29.9.2
$ docker run -d --name lab-web nginx:1.31-alpine docker image ls nginx:1.31-alpine
52f649ebdb596cc1538f2ee86aabe7436cc8b5b556d653125c4e05c5f5924aea IMAGE ID DISK USAGE CONTENT SIZE EXTRA nginx:1.31-alpine a3554c0d0b48 94.1MB 27.2MB U

"In use" means a container exists from that image, running or stopped. That's the line docker image prune -a draws later on.

History: find the step that made it big

When an image grows between two releases, one instruction usually explains it. Build the lab image and read its history. Its Dockerfile writes 3 MB of random bytes in one RUN and a small text file in another:

Dockerfile
FROM alpine:3.24
# 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.9.2
$ docker build -q --label secopslog.lab=true --build-arg BUILD=1 -t lab-tool:1 .
sha256:39a7cd6b01cc8fc212cc36a638db132d711d56d389b2f723f8d6e7628e9d5818
$ docker image history lab-tool:1
IMAGE CREATED CREATED BY SIZE COMMENT 39a7cd6b01cc Less than a second ago CMD ["cat" "/build.txt"] 0B buildkit.dockerfile.v0 <missing> Less than a second ago RUN |1 BUILD=1 /bin/sh -c echo "lab-tool bui… 8.19kB buildkit.dockerfile.v0 <missing> Less than a second ago ARG BUILD=1 0B buildkit.dockerfile.v0 <missing> Less than a second ago RUN /bin/sh -c head -c 3000000 /dev/urandom … 3.01MB buildkit.dockerfile.v0 <missing> 4 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0 <missing> 4 days ago ADD alpine-minirootfs-3.24.3-aarch64.tar.gz … 9.31MB buildkit.dockerfile.v0

The newest step is at the top. The 3.01MB row is the RUN head -c 3000000 ... line, so that's where you look. The two bottom rows are alpine's own build, which is why they're four days old in this run. <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; that's 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. 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.9.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:1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-tool:1 1c857f0e54b4 19.5MB 7.2MB IMAGE ID DISK USAGE CONTENT SIZE EXTRA

There's 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 repeats the experiment on the legacy store, where the old image does stay behind. The bytes of earlier builds didn't 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.9.2
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 11 1 1.488GB 1.254GB (84%) Containers 1 1 81.92kB 0B (0%) Local Volumes 1 0 4.196MB 4.196MB (100%) Build Cache 103 0 1.172GB 767.5MB

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. It holds 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. It also shows how much of each image is shared with others.

Moving an image without a registry

An air-gapped host can't 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.9.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:1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79 -rw------- 1 ubuntu ubuntu 7208960 Oct 26 12:45 lab-tool-1.tar blobs/ blobs/sha256/ blobs/sha256/1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79 blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a blobs/sha256/4d9fc70ebff2c84155e8d33591d00477722657de748ac36fc953cbeaeea1aaef blobs/sha256/56f833a274c7b61ef73a47c16ce385bae55598a7a3ad10e82f5245e065907094 blobs/sha256/668512a06ecb4614fc5375c5f834d921967faa9bfded2fdc6a6684beda601d25 blobs/sha256/76810a63ef9c80ac73d6ee233310392a4d8056c7e0d1580560c0ba2488395506 blobs/sha256/8b781154f50e7608901951555e44b4812f9e9e61083c7f0e4c37ac4a3866ff17 blobs/sha256/97d9bc17ba9d4d23920e27e01c84637b9e97852ae3892b87ed10bf2a0fd5e849 blobs/sha256/d3531b7a4f217bdc3426c57bcff84c170f086f456a39689d4a615c6d026c4be0 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:1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79 Loaded image: lab-tool:1 sha256:1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79

On the containerd store the archive is an OCI image layout. It has oci-layout, an index.json naming the image, and content-addressed files under blobs/sha256/. There's also 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 didn't 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 apply. 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's 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.9.2
$ docker save --platform linux/s390x -o lab-alpine-s390x.tar alpine:3.24
Error response from daemon: no suitable export target found: image with reference alpine:3.24 was found but does not provide the specified platform (linux/s390x)

To carry a platform you don't 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. On a production host it can take the previous release you'd roll back to. So 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.9.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:f25a8f04bac6e940afa92d2ba00089c36db81f071da091be5680631e263d3dfc sha256:511666ef603b2e375e5f3e6943226c2d1e02bbef4c715560f7c59d452822cb31 sha256:937cb2a91eadd59378819866d1a3740bb60a1c425a9fdd7ea90720d62d516bbd
$ docker image ls --filter label=secopslog.lab=true --filter 'label!=keep=true'
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-old:1 f25a8f04bac6 19.5MB 7.2MB lab-old:2 511666ef603b 19.5MB 7.2MB lab-tool:1 1c857f0e54b4 19.5MB 7.2MB
$ docker image prune -a -f --filter label=secopslog.lab=true --filter 'label!=keep=true'
Deleted Images: untagged: lab-old:1 deleted: sha256:f25a8f04bac6e940afa92d2ba00089c36db81f071da091be5680631e263d3dfc deleted: sha256:2f97568bbc52cd0604199a2fd67ab626598c1e6a4a753782406d043dd3827c8e deleted: sha256:4e325a8c90b68b3604f97346e39e6403a9fb3bb018277ebcb2cdba3546e090f7 untagged: lab-old:2 deleted: sha256:511666ef603b2e375e5f3e6943226c2d1e02bbef4c715560f7c59d452822cb31 deleted: sha256:cfda0a8b2c25ba266577613aa412760046c649e3946d3995688186857487cb0a deleted: sha256:eded2cb47f4a78a7dbe9cc1b93f2d23bb837ded0bdd21265fc63718a851e6fb8 untagged: lab-tool:1 deleted: sha256:1c857f0e54b40a8dde933a47fd2c20e6c3a5565b6157ed7eb896f0af9d1ece79 deleted: sha256:4d9fc70ebff2c84155e8d33591d00477722657de748ac36fc953cbeaeea1aaef deleted: sha256:56f833a274c7b61ef73a47c16ce385bae55598a7a3ad10e82f5245e065907094 Total reclaimed space: 40.19kB
$ docker image ls --filter label=secopslog.lab=true
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-keep:1 937cb2a91ead 19.5MB 7.2MB

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.19kB for three images of 19.5MB each. The 3 MB layer and the alpine base are shared with lab-keep:1 and every other alpine image. A prune frees only data nothing else references. So measure with docker system df before and after, rather than trusting the arithmetic of the image list.

The other filter you'll meet is until. The documentation describes it as "images created before" a time. 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.9.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:937cb2a91eadd59378819866d1a3740bb60a1c425a9fdd7ea90720d62d516bbd

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

The rest runs on secopslog-docker-sec. Your login there isn't 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.9.2
$ sudo docker pull -q alpine:3.24 sudo docker image inspect alpine:3.24 --format "created {{.Created}}"
docker.io/library/alpine:3.24 created 2026-10-21T22:51:39.084385804Z
$ sudo docker image prune -a -f --filter until=24h
Total reclaimed space: 0B

The image says it was created on 21 October, days before this run, yet nothing was removed. On the containerd store the filter left alone an image that arrived seconds earlier. 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.9.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:f4a6a298134e42e80647fe12351a28fc40ab64f6e282e9df556501db8a6ddf7d sha256:5c989eb65b4245869daf96b7d6e325f1b4510b0a248986d5d5291d099ba7ea27 sha256:b23fb6ff88a30a8bf55470ddc8c44ad18443e7fbb6e575237039c1fb06757633
$ sudo docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 9 0 1.572GB 1.344GB (85%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 7 0 19.57MB 37.11kB
$ sudo docker image rm lab-tool:1 sudo docker system df
Untagged: lab-tool:1 Deleted: sha256:b23fb6ff88a30a8bf55470ddc8c44ad18443e7fbb6e575237039c1fb06757633 TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 8 0 1.566GB 1.338GB (85%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 7 0 19.57MB 6.065MB
$ sudo docker buildx du
ID RECLAIMABLE SIZE LAST ACCESSED j70mto4eqxijnivpjgy1vy9aj* true 4.096kB Less than a second ago nxytp0iiq71to8kuv3olv6dce* true 8.192kB Less than a second ago 83s5tkmbuo3eg9ikpd0ygdkdj true 12.41kB 1 second ago h90zgr8btt1dd3fg3dwkadhb5 true 12.41kB 1 second ago yaxnmsyrugast820tfglrhziz true 12.41kB Less than a second ago jth7pnqnxksoxhls3y9ngf2su true 6.016MB Less than a second ago wivu537hhhtxc08zhofc87177 true 13.5MB* 1 second ago Shared: 13.5MB Private: 6.065MB Reclaimable: 19.57MB Total: 19.57MB

While lab-tool:1 existed, almost none of the 19.57MB 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, and a * marks one shared with an image. Now give the cache a budget:

ubuntu@secopslog-docker-sec:~/lab/imgmgmt · Docker 29.9.2
$ sudo docker builder prune -f --reserved-space 5MB sudo docker system df
ID RECLAIMABLE SIZE LAST ACCESSED h90zgr8btt1dd3fg3dwkadhb5 true 12.41kB 1 second ago 83s5tkmbuo3eg9ikpd0ygdkdj true 12.41kB 1 second ago nxytp0iiq71to8kuv3olv6dce* true 8.192kB Less than a second ago yaxnmsyrugast820tfglrhziz true 12.41kB Less than a second ago j70mto4eqxijnivpjgy1vy9aj* true 4.096kB Less than a second ago jth7pnqnxksoxhls3y9ngf2su true 6.016MB Less than a second ago Total: 6.065MB TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 8 0 1.566GB 1.338GB (85%) Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 1 0 13.5MB 0B

--reserved-space deletes cache records, least recently used first, until the cache fits the budget or nothing reclaimable is left. Here it removed six records (6.065MB) and stopped at 13.5MB, above the 5MB budget. What remains is one record, the alpine snapshot that alpine:3.24 still uses, so none of it is reclaimable. Buildx 0.38 also offers --max-used-space and --min-free-space. Older guides use --keep-storage, which the help stopped listing by Buildx 0.37. --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'll 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's 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.9.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.9.2
$ sudo docker pull -q alpine:3.24 sudo docker image prune -a -f --filter until=24h
docker.io/library/alpine:3.24 Deleted Images: untagged: alpine:3.24 untagged: alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a deleted: sha256:f7bbaec1bd0330a8dba26f03e05f421bb3c8b807bb41873df3c3b0c3d1083d8f deleted: sha256:a278e6f39431cd0ed74238a3d377893ad0d905d68b676bf9a65529080669756b Total reclaimed space: 8.657MB

On the legacy store until compares the creation date from the image config. So an image pulled a second ago but built on 21 October 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. Add a label filter too, 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.9.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:c85b16cd66479a31fd3fd4cb8904d4038cd36c718d8ca5712df6143b0e6c09f1 sha256:faeadb80ea2d31459e737098e83ef3f67a1589c48d071c51cb2c9c8121b19312 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-tool:1 faeadb80ea2d 11.7MB 0B <untagged> c85b16cd6647 11.7MB 0B
$ sudo docker image ls --filter dangling=true sudo docker image prune -f
IMAGE ID DISK USAGE CONTENT SIZE EXTRA <untagged> c85b16cd6647 11.7MB 0B Deleted Images: deleted: sha256:c85b16cd66479a31fd3fd4cb8904d4038cd36c718d8ca5712df6143b0e6c09f1 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:faeadb80ea2d31459e737098e83ef3f67a1589c48d071c51cb2c9c8121b19312 blobs/sha256/faeadb80ea2d31459e737098e83ef3f67a1589c48d071c51cb2c9c8121b19312

Here the second build leaves the first behind. The new view lists it as <untagged> (the classic table calls it <none>), and dangling=true finds it. Plain docker image prune removes it, and it freed nothing because every layer was shared with the new build. The ID also means something else on this store. It's the digest of the image config, which the Config entry of a saved archive confirms. On the containerd store it's 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. There's one more thing you need to know first. systemd allows only three docker.service starts per minute, and this section has used them up. 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.9.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:faeadb80ea2d31459e737098e83ef3f67a1589c48d071c51cb2c9c8121b19312 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"]] docker:29-dind nginx:1.31-alpine golang:1.27-alpine postgres:18-alpine registry:3 docker:29-cli alpine:3.24 python:3.15-slim

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.

Try this

Before the main VM cleanup, rerun the docker image ls preview with only label=secopslog.lab=true, dropping the label!=keep=true exclusion. Predict which extra name you'll see, then compare with the preview above. That's the image the keep label saved from the prune.

Remember

Before any scheduled prune, run docker system df and narrow -a with a label filter, because the image list's sizes lie about what a prune frees: three 19.5MB images gave back 40.19kB. Check which image store the host runs before you trust until. The same "older than a day" filter removed nothing on the containerd store and deleted a freshly pulled alpine on the legacy one.

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

Related