The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): imglayers.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/imglayers.tar.gz && tar -xzf imglayers.tar.gz, which creates ~/lab/imglayers/. SHA-256: d2e05d3c6c8697cd6b0217d81d475868368fc66bb6d005b798db804d9add4b79
This course, Docker in depth, assumes Docker fundamentals: you can run and inspect containers, write a Dockerfile, use volumes, networks and Compose, and push to a registry. Every command runs on the main lab VM, secopslog-docker, built from the lab kit as described in "Installing Docker Engine and running your first container" in Docker for beginners (./setup/create-lab.sh, then multipass shell secopslog-docker). A few lessons change the daemon or the host and use the second VM, secopslog-docker-sec; they say so in their first lines, with the commands to create that VM (./setup/create-lab.sh --profile sec) and open a shell in it (multipass shell secopslog-docker-sec). Reset the main VM between lessons with /opt/secopslog-lab/setup/reset-lab.sh if you want a clean start.
Run docker pull without -q and the output ends with a Digest: line. That digest is not the hash of a file inside the image, and it is not the image ID of the old Docker either. It names a small JSON document, and everything else about the image hangs off that document by more digests. This lesson walks the chain once, from the tag to the bytes, first on alpine:3.22 from Docker Hub and then on an image you build and push to a registry of your own. The lesson files (a Dockerfile and a two-line script) go in ~/lab/imglayers.
A tag points at an index
docker buildx imagetools inspect reads an image straight from its registry without pulling it, and --raw prints the exact JSON the registry serves. Save it to a file so the next commands can work on the same copy:
mediaType says this is an OCI image index: a list of manifests, each with a digest, a size and a platform. The first entry is the image for linux/amd64. The second has platform unknown/unknown and the annotation vnd.docker.reference.type: attestation-manifest; it is not something to run but a record about the image named in vnd.docker.reference.digest, here the amd64 manifest. The whole index, one line per entry:
Eight platforms, each followed by its attestation manifest. When docker pull runs on this arm64 VM it reads the index, picks the linux/arm64/v8 entry and downloads only that. The local store keeps the index and remembers the platforms it skipped:
Only linux/arm64/v8 has content; the other rows are known but cost nothing. On an amd64 machine the linux/amd64 row carries the data instead. The second command shows what Docker 29 means by an image ID. With the containerd image store (the default on fresh 29 installs) the ID is the digest of the index, the same digest the registry serves for the tag, and .Descriptor says so explicitly: media type index, that digest, its size in bytes. Older hosts on the legacy graph-driver store use the digest of the config instead; "Inspecting, exporting and cleaning up images" shows both.
The manifest: one config, a list of layers
Follow the entry for this machine's architecture. The command asks the daemon for its CPU architecture so the same lines work on arm64 and amd64:
Besides schemaVersion, mediaType and optional annotations, an image manifest points at two kinds of content. config points at one JSON blob that holds the runtime defaults (command, environment, user, working directory), the build history and the list of layer IDs. layers lists the filesystem layers in order, bottom first, each a gzip-compressed tar archive with its digest and compressed size. alpine has a single layer of about 4 MB. Every arrow in this chain is a digest: the tag resolves to the index digest, the index lists manifest digests, the manifest lists config and layer digests. Change one byte of a layer and its digest changes, so the manifest changes, so the index does. That is why a digest at the top pins everything below it.
From a tag to the bytes
Registry name
alpine:3.22
a tag: a movable pointer to one index digest
Index (application/vnd.oci.image.index.v1+json)
linux/amd64 manifest
one entry per platform
linux/arm64/v8 manifest
the entry this VM pulls
attestation manifests
unknown/unknown, one per platform, provenance about it
Image manifest
config blob
Cmd, Env, User, history, rootfs.diff_ids
layer blobs
tar+gzip, bottom first, addressed by compressed digest
On this host (containerd store)
content store
the compressed blobs as pulled
snapshots
the unpacked layers containers start from
Every link is a digest of the thing it points to, so the index digest pins the manifests, the config and every layer.
Build an image and read it back
Now an image whose contents you know. The Dockerfile mixes instructions that change files with instructions that only change metadata:
#0 building with "default" instance using docker driver
...
#9 exporting layers
#9 exporting layers done
#9 exporting manifest sha256:19c28e17728ff27cd5f3d239210514b4382b3ef8ddd918465c5cb4c177961947 0.0s done
#9 exporting config sha256:c64d42c09e6834ac8350287891a0f85a8764800184f6f79420189cdc7c78195a done
#9 exporting attestation manifest sha256:05864eec971b2d3e0f886111db93aa12112006d3a2dd65772d5e18905b19b4b0 0.0s done
#9 exporting manifest list sha256:5e8290cdfcb1db4d9ab0c6669b8adf18b45784b2fc41e652c5a0c1538fafdb35 done
#9 naming to docker.io/library/lab-anatomy:1 done
#9 unpacking to docker.io/library/lab-anatomy:1 0.0s done
#9 DONE 0.1s
The steps before the export are cut here (they read CACHED on a second run). The export lines at the end are the chain from the previous section, produced locally: the image manifest, its config, an attestation manifest, and the manifest list (another name for an index) that ties them together. The last digest is the image ID. Even a single-platform build produces an index on Docker 29, because BuildKit attaches a minimal provenance attestation by default. The image runs as expected:
History has nine rows: two from alpine's own build and seven from this Dockerfile. The image has four layers: alpine's, then WORKDIR (it had to create /app), RUN adduser (it changed /etc/passwd, /etc/group and /etc/shadow) and COPY. ENV, LABEL, USER and CMD show 0B because they changed only the config. They add history entries and no layer at all, not even an empty one. <missing> in the IMAGE column only means that no separate image exists for that step, which is normal for anything built with BuildKit or pulled. The cache rules that decide which of these steps reruns on the next build are the subject of "Layers and the build cache" in Docker for beginners.
Note what .RootFS.Layers lists: these are not the layer digests from a manifest. They are diff IDs, the digests of the uncompressed tar streams. The next section shows the difference with a real push.
What a registry stores
Start a registry:3 container on a lab network, published on the VM's loopback address only, and push the image to it:
Each line is one blob: the four layers, the provenance document and the two-byte empty config of the attestation manifest. The image config and the manifests go up after them without a progress line. The digest the push prints is the index digest, the same value as the local image ID. Read back what the registry now holds, the index, then the image manifest it points to:
The first layer's digest is identical to the single layer of alpine's arm64 manifest above. Same bytes, same digest, so a registry stores it once and a host that already has alpine never downloads it again. That sharing is the whole reason layers exist as separate blobs. The config, read through --format '{{json .Image}}', shows the runtime defaults and the history with empty_layer marking the metadata-only steps:
config, manifest says: sha256:c64d42c09e6834ac8350287891a0f85a8764800184f6f79420189cdc7c78195a
config, blob hashes to: sha256:c64d42c09e6834ac8350287891a0f85a8764800184f6f79420189cdc7c78195a
top layer, manifest says: sha256:0f5198bd57d84b454b864c74ce4384ac56d6c80e86f472dad8b5e1c872c4445b
top layer, blob hashes to: sha256:0f5198bd57d84b454b864c74ce4384ac56d6c80e86f472dad8b5e1c872c4445b
top layer, gunzipped: sha256:70b7dfc4dabe125dfeb44c16a6c5f99cfef3485604afb90df65846c312e03ebd
The config blob hashes to its digest. The layer blob hashes to the digest in the manifest, and the same layer decompressed hashes to the last entry of diff_ids. Registries and manifests deal in compressed blobs; the config and the unpacked snapshots on a host deal in uncompressed content. The same layer recompressed with different settings gets a new blob digest in the manifest but keeps its diff ID, which is why you cannot compare a manifest's layer digests with docker image inspect output and expect a match.
The attestation manifest
The second entry of your pushed index is the provenance BuildKit attached:
It is an ordinary OCI manifest with an artifactType that marks it as an attestation, an empty config, and one layer of media type application/vnd.in-toto+json whose annotation names the predicate: SLSA provenance v1, a description of how the image was built. subject points back at the image manifest it describes. Tools that do not understand attestations skip the unknown/unknown entry, which is why docker pull and docker run ignore it. Two consequences matter in practice. Your own single-platform images are indexes once pushed, so the digest to record and deploy is the index digest. And copying an image between repositories should carry the attestations along, which promoting the index digest does ("Tags, digests and promotion"). What the provenance contains, the SBOM attestation, and how to verify both are covered in "Pinning, SBOMs, provenance and scanning" in Advanced container security.
Two things this lesson leaves to their owners. A running container adds a writable layer on top of these read-only ones ("Images, containers and the core commands" in Docker for beginners), and where the content store and snapshots live on disk is the subject of "Where Docker keeps data". Deleting a file in a later layer hides it without removing its bytes from the earlier layer; "Slimming images and layer hygiene" in Advanced container security shows how that leaks data. Remove what you created:
01docker image inspect myapp:1 --format '{{len .RootFS.Layers}}' prints 4 for an image whose Dockerfile is FROM alpine:3.22, WORKDIR /srv, ENV MODE=prod, RUN apk add --no-cache curl, COPY app /srv/app, USER 10001, CMD ["/srv/app"]. Which instructions produced the four layers?
Incorrect — ENV only changes the config. It adds a history entry with empty_layer, not a layer.
Correct — WORKDIR created /srv, RUN and COPY changed files; ENV, USER and CMD changed only the config.
Incorrect — USER and CMD are metadata, and the base image layer always counts.
Incorrect — That would be six. Metadata-only instructions add no layer at all.
02docker buildx imagetools inspect --raw of an image shows two manifests: linux/amd64 and one with platform unknown/unknown and the annotation vnd.docker.reference.type: attestation-manifest. What is the second one?
Incorrect — The platform is unknown because it is not runnable at all; the annotation says what it is.
Incorrect — Clients skip attestation manifests when choosing a platform; pulls work normally.
Incorrect — The config is a blob referenced from the amd64 manifest, not an index entry.
Correct — Its in-toto layer describes how the manifest named in vnd.docker.reference.digest was built.
03You compare the layer digests in an image manifest from the registry with .RootFS.Layers from docker image inspect on the same image. None match. Why?
Correct — The lab hashed one layer both ways: the gzip blob matched the manifest, the decompressed stream matched diff_ids.
Incorrect — Images are immutable; a change would give a different image, and the ID would differ too.
Incorrect — diff IDs come from the config blob, which is identical everywhere the image is pulled.
Incorrect — A registry stores blobs as pushed; the lab showed the blob hashing to its manifest digest.
Try this
Work through “The attestation manifest” 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 image anatomy: index, manifest, config and layers, keep “The attestation manifest”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.