CoursesDocker in depthInspecting, saving & pruning images

Inspecting, saving & pruning images

history, inspect, save/load, prune.

Intermediate12 min · lesson 4 of 30

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.

terminal
$ docker inspect -f 'user={{.Config.User}} entry={{.Config.Entrypoint}} ports={{.Config.ExposedPorts}}' payments-api:1.0
user=nonroot entry=[/server] ports=map[3000/tcp:{}]
terminal
$ docker history payments-api:1.0
IMAGE CREATED CREATED BY SIZE COMMENT
a1b2c3d4e5f6 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.

build host
$ 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
airgap-host
$ docker load -i /tmp/payments-1.0.tar
Loaded image: registry.internal/payments-api:1.0
$ docker run --rm registry.internal/payments-api:1.0 --version
payments-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.

terminal
$ docker images -f dangling=true
REPOSITORY TAG IMAGE ID CREATED SIZE
<none> <none> 9f2a3b4c5d6e 14 minutes ago 412MB
<none> <none> 1a2b3c4d5e6f 2 hours ago 398MB
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 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.

terminal
$ docker image prune
WARNING! This will remove all dangling images.
Are you sure you want to continue? [y/N] y
Deleted Images:
deleted: sha256:9f2a3b4c5d6e8a1f...
deleted: sha256:1a2b3c4d5e6f7b2c...
Total reclaimed space: 810MB
terminal
$ docker image prune -a
WARNING! This will remove all images without at least one container associated to them.
Are you sure you want to continue? [y/N] y
Deleted Images:
untagged: registry.internal/payments-api:0.9
deleted: sha256:7c8d9e0f1a2b...
Total reclaimed space: 31.2GB
$ docker builder prune -f
Deleted build cache objects:
lm3n4o5p6q7r8s9t
Total: 9.8GB
Which prune, and what it takes with it
Build host out of disk
docker system df shows 31GB reclaimable
safe
docker image prune
Deletes only <none> dangling images. Tagged images, base layers and cache all survive.
aggressive
docker image prune -a
Deletes every image with no container. Base images and cache go too, so the next build runs cold.
targeted
prune -a --filter until=168h
Deletes only unused images older than 7 days. This week's cache stays put.
cache only
docker builder prune
Clears the BuildKit build cache and leaves your images alone.
Reach for the narrowest prune that frees enough. -a is a laptop tool, not a build-host habit.
prune -a eats the cache your next build needs
On a build host, 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.

terminal
$ docker images --digests
REPOSITORY TAG DIGEST IMAGE ID SIZE
nginx 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 -f
Deleted 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.

Quick check
01Your CI build host has run out of disk. Why is docker image prune -a a risk there when plain docker image prune is not?
Incorrect — No. It stays inside images. Containers and volumes have their own prune commands. The danger is about which images go.
Correct — Plain prune touches only the dangling <none> images. With -a, anything without a running or stopped container counts as unused, so your base images and cache go with it and the next build starts cold.
Incorrect — No. The delete itself is clean. The trouble is how much of it there is.
Incorrect — No. It works fine with the daemon access CI already has. Over-deleting is the problem, not a silent failure.
02In docker images you see a row where both REPOSITORY and TAG read <none>, sitting on a few hundred MB. How did that dangling image get there?
Correct — A rebuild moves the tag onto the new image and leaves the old one behind, untagged.
Incorrect — A failed build does not leave a finished <none> image. Dangling images come from a tag moving off a complete one.
Incorrect — Pulling without running leaves an ordinary tagged image, not an untagged one.
Incorrect — Docker never renames by age. The <none> name means nothing points at those bytes anymore.
03You have to get an image onto an air-gapped host that cannot reach any registry. Which route actually works?
Incorrect — Both ends of that need registry access, and the air-gapped host has none.
Incorrect — export and import flatten a container's filesystem and throw away the image's layers, history and config. Wrong tool for moving an image.
Correct — save and load are the offline twins of push and pull, bundling the image and every layer under it into one tar.
Incorrect — commit builds an image from a container on the same host. It moves nothing to another machine.

Related