Inspecting, exporting and cleaning up images
image ls on Docker 29, save and load, disk usage and pruning.
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/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:
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:
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:
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:
"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:
FROM alpine:3.22# 3 MB of random bytes: a layer big enough to spot in docker image historyRUN head -c 3000000 /dev/urandom > /opt/blob.binARG BUILD=1RUN echo "lab-tool build $BUILD" > /build.txtCMD ["cat", "/build.txt"]
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:
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:
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:
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:
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:
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.
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":
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:
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:
--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:
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:
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:
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):
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.
docker image ls --filter dangling=true is empty and docker system df shows Build Cache at 38GB, 30GB reclaimable. Which command fits?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?-a; the lab showed label filters limiting it.until=24h at once.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?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.