Skip to main content
RunBook Academy

Docker & ContainersIV Β· ImagesInspection

Inspecting an image

Foundation⏱ ~26 mindocker

What you'll learn

  • Use `docker image inspect` to answer specific questions, and know what `.Id` really is
  • Read `docker history` including its size column and its `<missing>` rows
  • Explain why deleting a file in a later layer does not shrink the image
  • Extract and read a layer directly, without any third-party tool
  • Gate a deployment on image properties with a check that fails

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

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:

QuestionTool
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
$ docker image inspect nginx:1.27 --format 'user={{.Config.User}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} health={{if .Config.Healthcheck}}yes{{else}}none{{end}}'
user= entry=["/docker-entrypoint.sh"] cmd=["nginx","-g","daemon off;"] health=none

Illustrative output

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:

FieldUse
.IdLocal identity β€” the config JSON hash
.RepoDigestsRemote identity β€” what the registry served
.RootFS.LayersLayer diff_ids (uncompressed), not the manifest’s blob digests
.Config.UserRuns as root if empty
.Config.EnvEverything baked into the environment, including mistakes
.Config.HealthcheckAbsent means the orchestrator has nothing to work with
.Config.ExposedPortsDocumentation only β€” it publishes nothing
.Config.LabelsOwnership, source commit, build metadata
.Architecture, .OsWhat this image actually is, versus what you expected
.SizeSum of layer sizes, uncompressed on disk

docker image history β€” where the size went

Read-only / Safehistory
$ 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
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
# 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
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
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

  1. Q1. What exactly is `docker image inspect IMAGE --format "{{.Id}}"` showing you?

  2. Q2. A Dockerfile installs build tools in one RUN and deletes the apt cache in the next RUN. What happens to the image size?

  3. Q3. Which of these can be read by anyone who can pull the image, without running it? Select all that apply.

  4. Q4. Rows showing `<missing>` in the IMAGE column of `docker image history` indicate a corrupted or partially pulled image.

  5. Q5. You want to check the entrypoint and environment of two hundred images in a registry. What is the efficient approach?

  6. Q6. You delete a 4 GB image to reclaim disk and `df` barely moves. Why?

Passing score: 75%. Answers are checked in this browser.