Every layer is a tar archive answering one question: what changed between this
layer and the one below it. Inspecting an image layer by layer is the fastest
route to βwhy is this 1.2 GBβ and βwhere did that file come fromβ β and the
second of those questions has security consequences that surprise people.
Three tools, three questions:
Question
Tool
What will this image do when it runs?
docker image inspect
Which build step produced which layer, and how big?
docker image history
Which file is taking the space?
dive, or docker save and tar
docker image inspect β the runtime contract
Read-only / Safethe useful fieldsβ One call, the four things a reviewer actually needs.
An empty user= is the single most common finding in an image review: the
container will run as root unless the runtime overrides it, because no USER
instruction was ever set.
The fields worth knowing, and what each is for:
Field
Use
.Id
Local identity β the config JSON hash
.RepoDigests
Remote identity β what the registry served
.RootFS.Layers
Layer diff_ids (uncompressed), not the manifestβs blob digests
.Config.User
Runs as root if empty
.Config.Env
Everything baked into the environment, including mistakes
.Config.Healthcheck
Absent means the orchestrator has nothing to work with
.Config.ExposedPorts
Documentation only β it publishes nothing
.Config.Labels
Ownership, source commit, build metadata
.Architecture, .Os
What this image actually is, versus what you expected
.Size
Sum of layer sizes, uncompressed on disk
docker image history β where the size went
Read-only / Safehistoryβ Each row is a layer. The SIZE column is what that step added.
$ docker image history myorg/myapp:1.0 --no-trunc --format 'table {{.Size}}\t{{.CreatedBy}}'
SIZE CREATED BY
0B CMD ["node" "server.js"]
0B EXPOSE map[3000/tcp:{}]
412MB RUN /bin/sh -c npm install
1.2kB COPY package.json ./ # buildkit
0B WORKDIR /app
0B /bin/sh -c #(nop) CMD ["node"]
77.8MB /bin/sh -c #(nop) ADD file:... in /
Illustrative output
Two features of this output cause confusion every time.
Rows with 0B are metadata, not layers.CMD, ENV, EXPOSE, WORKDIR,
LABEL and USER change the config JSON and create no filesystem content.
They still appear as history entries.
The IMAGE column is mostly <missing>. On a pulled image, or one built with
BuildKit, the daemon has no local image ID for the intermediate steps, because
those intermediate configs were never created on this host. <missing> means
exactly that and nothing more β it is not corruption, and it is not a sign the
image is broken. It does mean you cannot docker run an intermediate layer to
poke around in it, which is the technique people are usually reaching for when
they notice.
Reading a layer directly
You do not need a third-party tool for this, and knowing the manual route
matters because it is what works on a hardened host, in an air-gapped
environment, or when you are auditing an image you do not trust enough to run.
Read-only / Safeextractβ Unpack an image to a temporary directory and inspect its layers as ordinary tar files.
IMAGE=myorg/myapp:1.0
WORK=$(mktemp -d)
docker save "$IMAGE" | tar -x -C "$WORK"
# Layer blobs, largest first.
find "$WORK" -name '*.tar' -o -path '*blobs*' -type f \
| xargs -r du -h 2>/dev/null | sort -rh | head -5
# What is inside the biggest one, without extracting it.
BIG=$(find "$WORK" -path '*blobs*' -type f -printf '%s %p\n' \
| sort -rn | head -1 | cut -d' ' -f2)
tar -tvf "$BIG" 2>/dev/null | sort -k3 -rn | head -20
rm -rf "$WORK"
That last pipeline β list a layerβs contents sorted by size β is the answer to
βwhat single file is making this image hugeβ, and it also finds the .env file,
the private key, and the .git directory that a careless COPY . . swept in.
Read-only / Safediveβ Host-installed binary. No socket handed to a container.
# Install from the project's releases, then:
IMAGE=myorg/myapp:1.0
dive "$IMAGE"
# Non-interactive: fail a build whose layer efficiency is below a threshold.
CI=true dive "$IMAGE" --lowestEfficiency 0.95
docker image inspect versus the remote tools
docker image inspect requires the image to be on the host. That is a real
constraint in CI, on a jump host, and when you are triaging an image you would
rather not pull.
Read-only / Safelocal vs remoteβ The same questions, answered without pulling anything.
IMAGE=nginx:1.27
# Local. Requires a pull. Gives the runtime config and the diff_ids.
docker image inspect "$IMAGE" --format '{{.Config.User}} {{.Architecture}}'
# Remote. Pulls only the manifest. Shows platforms and the index digest.
docker buildx imagetools inspect "$IMAGE"
# Remote. Pulls only the config blob β a few kilobytes for any image.
crane config "$IMAGE" | jq '{ user: .config.User,
entrypoint: .config.Entrypoint,
env: .config.Env,
layers: (.rootfs.diff_ids | length) }'
crane config on a 2 GB image transfers a few kilobytes, because the config
JSON is a separate small blob. For a policy check that runs against every image
in a registry, that difference is the whole feasibility of the exercise.
Inspection as a gate
The point of all this is a check that runs before a deployment and fails.
Read-only / Safepolicy gateβ Reject an image that runs as root, has no healthcheck, or leaks a credential-shaped variable.
IMAGE=$1
FAIL=0
USER_SET=$(docker image inspect --format '{{.Config.User}}' "$IMAGE")
if [ -z "$USER_SET" ] || [ "$USER_SET" = "root" ] || [ "$USER_SET" = "0" ]; then
echo "FAIL $IMAGE runs as root (Config.User is empty or root)" >&2
FAIL=1
fi
if ! docker image inspect --format '{{if .Config.Healthcheck}}ok{{end}}' "$IMAGE" | grep -q ok; then
echo "WARN $IMAGE declares no HEALTHCHECK" >&2
fi
if docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" \
| grep -Eiq '(TOKEN|SECRET|PASSWORD|_KEY)='; then
echo "FAIL $IMAGE has a credential-shaped environment variable baked in" >&2
FAIL=1
fi
OWNER=$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.source"}}' "$IMAGE" 2>/dev/null)
if [ -z "$OWNER" ]; then
echo "FAIL $IMAGE has no org.opencontainers.image.source label" >&2
FAIL=1
fi
exit "$FAIL"
Each of those is a real finding somebody has shipped. The grep for
credential-shaped variables in particular finds things regularly, and it costs
nothing to run on every image in a registry.
Knowledge check
Knowledge check Β· 6 questions
Q1. What exactly is `docker image inspect IMAGE --format "{{.Id}}"` showing you?
Q2. A Dockerfile installs build tools in one RUN and deletes the apt cache in the next RUN. What happens to the image size?
Q3. Which of these can be read by anyone who can pull the image, without running it? Select all that apply.
Q4. Rows showing `<missing>` in the IMAGE column of `docker image history` indicate a corrupted or partially pulled image.
Q5. You want to check the entrypoint and environment of two hundred images in a registry. What is the efficient approach?
Q6. You delete a 4 GB image to reclaim disk and `df` barely moves. Why?
Passing score: 75%. Answers are checked in this browser.