Inspecting, saving & pruning images
history, inspect, save/load, prune.
A built image is a sealed shipping crate. Most days you trust the label and move on. Now and then you pry the lid off to see what is actually inside, ship the whole crate to someone who cannot order one for themselves, or drag the empties out of the warehouse before they fill the floor. That is the whole job here: read an image before you trust it, copy it to a machine with no registry (the shared server that stores images and hands them out) in the middle, and clear out the old copies that quietly eat your disk. None of it is glamorous. On a busy build host it is the difference between a machine that keeps building and one wedged at 100% full.
Pry the lid off before you trust the label
docker inspect prints an image's whole configuration as JSON (JavaScript Object Notation, a plain-text way of writing structured data): the entrypoint (the command that runs when the container starts), the environment variables, the user it runs as, the ports it exposes, the digests (content hashes that identify each layer exactly). When you pull something somebody else built, this is how you find out what it will do before it does it on your host. The -f flag (short for format) takes a Go template, a small pattern language for picking out the two or three fields you asked about instead of the four hundred lines you did not. docker history is the other half of the pair. It lists how each layer was built, newest at the top. That is how you spot the layer that doubled the image size, or the one that baked a secret into the build.
$ docker inspect -f 'user={{.Config.User}} entry={{.Config.Entrypoint}} ports={{.Config.ExposedPorts}}' payments-api:1.0user=nonroot entry=[/server] ports=map[3000/tcp:{}]
$ docker history payments-api:1.0IMAGE CREATED CREATED BY SIZE COMMENTa1b2c3d4e5f6 2 hours ago ENTRYPOINT ["/server"] 0B buildkit.dockerfile.v0<missing> 2 hours ago COPY /app/server /server 18.4MB buildkit.dockerfile.v0<missing> 2 hours ago USER nonroot 0B buildkit.dockerfile.v0<missing> 3 weeks ago CMD ["/bin/sh"] 0B<missing> 3 weeks ago ADD alpine-minirootfs.tar.gz / 7.8MB
Move an image when there is no registry in the middle
Sometimes the machine that needs your image cannot reach a registry at all. An air-gapped host, one kept deliberately off every network for security, has no way to run docker pull, so the usual push-then-pull routine is off the table. Posting a parcel instead of using the courier network is the closest everyday equivalent. docker save writes the image and every layer under it into a single tar archive (tar is short for tape archive, an old format that is still the normal way to bundle many files into one). You carry that file across by whatever route you have, scp (secure copy, which moves files over an encrypted connection) or a USB (universal serial bus) stick, and docker load unpacks it on the far side. Name several images in one save and they all ride along in the same archive.
$ docker save -o payments-1.0.tar registry.internal/payments-api:1.0$ ls -lh payments-1.0.tar-rw------- 1 deploy deploy 27M Jul 16 09:14 payments-1.0.tar$ scp payments-1.0.tar airgap-host:/tmp/payments-1.0.tar 100% 27MB 42.1MB/s 00:00
$ docker load -i /tmp/payments-1.0.tarLoaded image: registry.internal/payments-api:1.0$ docker run --rm registry.internal/payments-api:1.0 --versionpayments-api 1.0.0
The nameless images quietly eating your disk
Rebuild an image under a tag it already has and the old build does not go anywhere. The tag hops across to the new image, and the previous one is left with no name at all, showing up as <none> in docker images. That is a dangling image: real layers holding real gigabytes with nothing pointing at them. It is the warehouse box whose label got peeled off and stuck on a newer box. The box is still there, still taking up floor space, and nobody can tell you what is in it. A handful of them is nothing. A continuous integration (CI) build host, the machine that rebuilds your code automatically on every push, rebuilds fifty times a day and grows a graveyard fast. docker system df (df is short for disk free) is the one command that shows the damage across images, containers, volumes and build cache in a single table, with a reclaimable column so you know what you can actually get back.
$ docker images -f dangling=trueREPOSITORY TAG IMAGE ID CREATED SIZE<none> <none> 9f2a3b4c5d6e 14 minutes ago 412MB<none> <none> 1a2b3c4d5e6f 2 hours ago 398MB$ docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 42 6 38.6GB 31.2GB (80%)Containers 9 3 1.2GB 820MB (66%)Local Volumes 14 4 6.4GB 4.1GB (64%)Build Cache 210 0 9.8GB 9.8GB (100%)
Take the space back, and know what goes with it
docker image prune on its own is the careful option. It deletes the dangling <none> images and nothing that still carries a tag. Add -a and the meaning changes completely. Now it removes every image that no container references, tagged or not, running or stopped. On your laptop that is a fast way to claw back gigabytes. On a build host it is a trap. Your base images and cached layers count as unused the second no container is running them, so -a sweeps them out and the next build starts from nothing. Build cache is a separate pool again, cleared with docker builder prune. Every one of these prints the exact space it freed, so afterwards you can tell whether it was worth the cold rebuild.
$ docker image pruneWARNING! This will remove all dangling images.Are you sure you want to continue? [y/N] yDeleted Images:deleted: sha256:9f2a3b4c5d6e8a1f...deleted: sha256:1a2b3c4d5e6f7b2c...Total reclaimed space: 810MB
$ docker image prune -aWARNING! This will remove all images without at least one container associated to them.Are you sure you want to continue? [y/N] yDeleted Images:untagged: registry.internal/payments-api:0.9deleted: sha256:7c8d9e0f1a2b...Total reclaimed space: 31.2GB$ docker builder prune -fDeleted build cache objects:lm3n4o5p6q7r8s9tTotal: 9.8GB
docker image prune -a and docker system prune -a count your base images and cached layers as unused the instant no container is running them. A tidy-up you scheduled to keep the disk healthy can delete the exact layers tomorrow's build depends on, turning a five-minute job into a cold twenty-minute rebuild. When you automate cleanup, put a clock on it instead: docker image prune -a --filter until=168h removes only images older than a week and leaves this week's cache alone. Keep the bare -a for your laptop.Where these four commands bite people
The four verbs are easy to type. The surprises live in what they do not do. docker inspect only reads what the daemon already holds, so it can tell you nothing about a tag you have not pulled yet. docker history prints <missing> for layers that were built on some other machine, which is normal for anything you pulled and not a sign of tampering. And prune is the verb that bites during an incident, when the debug image you wanted to run turns out to have been swept up by last night's cleanup job.
On shared runners, name your filters rather than reaching for docker image prune -a. A filter of until=24h clears out last night's CI leftovers while the golden base images everyone builds on stay put, and label!=keep=true lets you mark the ones nobody is allowed to touch. When you save an image for an air-gap transfer, write the digest into the ticket and check it again after the load on the far side. save never pulls missing layers from a registry. It packs what the local daemon already has, so do the pull on the connected machine before you go anywhere near the air-gapped one.
In production, treat image inventory the way you treat package inventory on a server. Know which nodes hold which digests, know how you would get the images back onto a node after a disk wipe, and decide who is allowed to run docker rmi on a Swarm manager (the node that controls a Docker Swarm cluster). The trade-off between an aggressive prune and a fast rollback is real. Keep the previous two digests on the box and a rollback is one docker service update away. Wipe everything and the rollback now depends on the registry being reachable, which is exactly the thing that tends to be broken during an outage.
Before you prune anything on a host that matters, run docker system df, paste the table into the ticket, then paste the after table beside it. Two tables and the hostname turn "we freed up some space" into something a colleague can check at 3am when a build fails. The delete is the only step in this lesson you cannot undo. Everything else here is a read or a copy, so the paper trail really only has to cover the prune.
Try this
Run these against a lab engine (Docker 24 or newer is fine). Read the sample output first so you know what a healthy result looks like before you lean on any of it on a machine you care about.
$ docker images --digestsREPOSITORY TAG DIGEST IMAGE ID SIZEnginx 1.27 sha256:4f8… 9a1b2c3d4e5f 187MB$ docker image inspect nginx:1.27 --format 'entrypoint={{json .Config.Entrypoint}} user={{.Config.User}}'entrypoint=["/docker-entrypoint.sh"] user=$ docker save nginx:1.27 | gzip > nginx-1.27.tgz$ docker image prune -fDeleted Images:untagged: sha256:deadbeef…Total reclaimed space: 312MB
Takeaway
Read the image with inspect and digests, move it with save and load when a registry cannot help you, and prune with a filter that spares the digest you would roll back to. A cleanup that frees 31GB and takes your last known-good image with it is not cleanup. It is tomorrow's downtime, paid for a day early.
docker image prune -a a risk there when plain docker image prune is not?