Image anatomy: index, manifest, config and layers

What an image is on the wire and on disk.

Intermediate16 min · lesson 1 of 24
Lesson files
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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker pull -q alpine:3.22
docker.io/library/alpine:3.22
$ docker buildx imagetools inspect alpine:3.22 --raw > alpine-index.json jq '{schemaVersion, mediaType, manifests: .manifests[0:2]}' alpine-index.json
{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [ { "annotations": { "com.docker.official-images.bashbrew.arch": "amd64", "org.opencontainers.image.base.name": "scratch", "org.opencontainers.image.created": "2026-09-17T20:37:41Z", "org.opencontainers.image.revision": "32cb3f1f45f4fee15882936c06a264eb9e5130fe", "org.opencontainers.image.source": "https://github.com/alpinelinux/docker-alpine.git#32cb3f1f45f4fee15882936c06a264eb9e5130fe:x86_64", "org.opencontainers.image.url": "https://hub.docker.com/_/alpine", "org.opencontainers.image.version": "3.22.6" }, "digest": "sha256:3e9b4b680bfc9fb5269227cffbd6d42be39fbf7c0b908123913864aa4447e764", "mediaType": "application/vnd.oci.image.manifest.v1+json", "platform": { "architecture": "amd64", "os": "linux" }, "size": 1022 }, { "annotations": { "com.docker.official-images.bashbrew.arch": "amd64", "vnd.docker.reference.digest": "sha256:3e9b4b680bfc9fb5269227cffbd6d42be39fbf7c0b908123913864aa4447e764", "vnd.docker.reference.type": "attestation-manifest" }, "digest": "sha256:136a7e91b81ee0a601b537ae15392eca6a3c8f6e8496f1f256acf29160bc1fe1", "mediaType": "application/vnd.oci.image.manifest.v1+json", "platform": { "architecture": "unknown", "os": "unknown" }, "size": 838 } ] }

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ jq -r '.manifests[] | [.digest[7:19], .platform.os + "/" + .platform.architecture + (if .platform.variant then "/" + .platform.variant else "" end), .annotations["vnd.docker.reference.type"] // "image"] | @tsv' alpine-index.json
3e9b4b680bfc linux/amd64 image 136a7e91b81e unknown/unknown attestation-manifest 450c744b1ef4 linux/arm/v6 image f46290174829 unknown/unknown attestation-manifest 947bab19f99a linux/arm/v7 image 139bbf958aa5 unknown/unknown attestation-manifest 2e1a7aa4cbc4 linux/arm64/v8 image 5c31d5314188 unknown/unknown attestation-manifest 1136d3a02432 linux/386 image fac6ecbe38df unknown/unknown attestation-manifest d3f9354d41e5 linux/ppc64le image e6529a469f2e unknown/unknown attestation-manifest ddd567990d0f linux/riscv64 image d66f3df088e4 unknown/unknown attestation-manifest 5fd1c1a839a5 linux/s390x image 8b078795f726 unknown/unknown attestation-manifest

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker image ls --tree alpine:3.22
IMAGE ID DISK USAGE CONTENT SIZE EXTRA alpine:3.22 5291449c3df7 13.4MB 4.21MB ├─ linux/amd64 3e9b4b680bfc 0B 0B ├─ linux/arm/v6 450c744b1ef4 0B 0B ├─ linux/arm/v7 947bab19f99a 0B 0B ├─ linux/arm64/v8 2e1a7aa4cbc4 13.3MB 4.12MB ├─ linux/386 1136d3a02432 0B 0B ├─ linux/ppc64le d3f9354d41e5 0B 0B ├─ linux/riscv64 ddd567990d0f 0B 0B └─ linux/s390x 5fd1c1a839a5 0B 0B
$ docker image inspect alpine:3.22 --format '{{.Id}}' docker image inspect alpine:3.22 --format '{{json .Descriptor}}' | jq .
sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 { "mediaType": "application/vnd.oci.image.index.v1+json", "digest": "sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8", "size": 9218 }

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ arch=$(docker version --format "{{.Server.Arch}}") m=$(jq -r --arg a "$arch" '.manifests[] | select(.platform.architecture == $a) | .digest' alpine-index.json) echo "manifest for linux/$arch: $m" docker buildx imagetools inspect "alpine:3.22@$m" --raw | jq .
manifest for linux/arm64: sha256:2e1a7aa4cbc4e9e5222bb4c24a839aa1a6170ea5492d644777ce7b178824e44f { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:e09dd31eab4f4aeaade69891673134f31880ef51c6ee514b0cea072df06d7547", "size": 627 }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:16fc4f52163f03cd2189c3d6a4b3f28a605cfb7919af64b3da4562cca69d2306", "size": 4123084 } ], "annotations": { "com.docker.official-images.bashbrew.arch": "arm64v8", "org.opencontainers.image.base.name": "scratch", "org.opencontainers.image.created": "2026-09-17T20:37:26Z", "org.opencontainers.image.revision": "32cb3f1f45f4fee15882936c06a264eb9e5130fe", "org.opencontainers.image.source": "https://github.com/alpinelinux/docker-alpine.git#32cb3f1f45f4fee15882936c06a264eb9e5130fe:aarch64", "org.opencontainers.image.url": "https://hub.docker.com/_/alpine", "org.opencontainers.image.version": "3.22.6" } }

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:

Dockerfile
FROM alpine:3.22
WORKDIR /app
RUN adduser -D -H -u 10001 app
COPY --chmod=0755 greet.sh /app/greet.sh
ENV GREETING=hello
LABEL org.opencontainers.image.title="lab-anatomy"
USER app
CMD ["/app/greet.sh"]
greet.sh
#!/bin/sh
echo "$GREETING from $(id -un) on $(uname -m)"
ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker build --progress=plain -t lab-anatomy:1 .
#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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker run --rm lab-anatomy:1
hello from app on aarch64

Compare the history with the layers:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker image history lab-anatomy:1
IMAGE CREATED CREATED BY SIZE COMMENT 5e8290cdfcb1 12 minutes ago CMD ["/app/greet.sh"] 0B buildkit.dockerfile.v0 <missing> 12 minutes ago USER app 0B buildkit.dockerfile.v0 <missing> 12 minutes ago LABEL org.opencontainers.image.title=lab-ana… 0B buildkit.dockerfile.v0 <missing> 12 minutes ago ENV GREETING=hello 0B buildkit.dockerfile.v0 <missing> 12 minutes ago COPY --chmod=0755 greet.sh /app/greet.sh # b… 12.3kB buildkit.dockerfile.v0 <missing> 12 minutes ago RUN /bin/sh -c adduser -D -H -u 10001 app # … 32.8kB buildkit.dockerfile.v0 <missing> 17 minutes ago WORKDIR /app 8.19kB buildkit.dockerfile.v0 <missing> 2 weeks ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0 <missing> 2 weeks ago ADD alpine-minirootfs-3.22.6-aarch64.tar.gz … 9.2MB buildkit.dockerfile.v0
$ docker image inspect lab-anatomy:1 --format '{{json .RootFS.Layers}}' | jq .
[ "sha256:9fd9b3248f2fb670edfab1393d1d1e8cd828eb9c65de376b2fcc23268a45d500", "sha256:d2c047549c4dd6c7b7daaf3b8e907b49c9ca6f8efb426d412309b377d8b0f48c", "sha256:dd53c0a1945db74bdd851642b607627c2a043a0de2860e92841e835bf9dbf309", "sha256:70b7dfc4dabe125dfeb44c16a6c5f99cfef3485604afb90df65846c312e03ebd" ]

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker network create lab-net docker run -d --name lab-registry --network lab-net -p 127.0.0.1:5000:5000 registry:3
0e3aab38fa414bd3c0fdc1d4a618fff75e9a6d2714d9f892512ceece73024359 e1107bc4745a97ffd7d5d20197cbbc6ea9a871f958f65f55cbd6b96ae789f92d
$ docker tag lab-anatomy:1 localhost:5000/lab-anatomy:1 docker push localhost:5000/lab-anatomy:1
The push refers to repository [localhost:5000/lab-anatomy] 0f5198bd57d8: Waiting 70ac069746a8: Pushed d191288a61be: Pushed 44136fa355b3: Pushed c67bd46a6576: Waiting 0f5198bd57d8: Pushed 16fc4f52163f: Pushed c67bd46a6576: Pushed 1: digest: sha256:5e8290cdfcb1db4d9ab0c6669b8adf18b45784b2fc41e652c5a0c1538fafdb35 size: 856

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker buildx imagetools inspect localhost:5000/lab-anatomy:1 --raw | jq .
{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [ { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:19c28e17728ff27cd5f3d239210514b4382b3ef8ddd918465c5cb4c177961947", "size": 1044, "platform": { "architecture": "arm64", "os": "linux" } }, { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:05864eec971b2d3e0f886111db93aa12112006d3a2dd65772d5e18905b19b4b0", "size": 837, "annotations": { "vnd.docker.reference.digest": "sha256:19c28e17728ff27cd5f3d239210514b4382b3ef8ddd918465c5cb4c177961947", "vnd.docker.reference.type": "attestation-manifest" }, "platform": { "architecture": "unknown", "os": "unknown" } } ] }
$ m=$(docker buildx imagetools inspect localhost:5000/lab-anatomy:1 --raw | jq -r '.manifests[] | select(.platform.os == "linux") | .digest') docker buildx imagetools inspect "localhost:5000/lab-anatomy:1@$m" --raw | jq .
{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:c64d42c09e6834ac8350287891a0f85a8764800184f6f79420189cdc7c78195a", "size": 1950 }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:16fc4f52163f03cd2189c3d6a4b3f28a605cfb7919af64b3da4562cca69d2306", "size": 4123084 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:70ac069746a87aa9a2e695c1ef0c6d73d46b15e2483f9c2a8aeae431db7f94e4", "size": 93 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:c67bd46a657607bd7891f1dc02dec84b4e16bd141fb1b3e4fed36c1785fdce53", "size": 894 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:0f5198bd57d84b454b864c74ce4384ac56d6c80e86f472dad8b5e1c872c4445b", "size": 191 } ] }

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker buildx imagetools inspect localhost:5000/lab-anatomy:1 --format '{{json .Image}}' | jq '{config: .config, diff_ids: .rootfs.diff_ids, history: [.history[] | {created_by, empty_layer}]}'
{ "config": { "User": "app", "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "GREETING=hello" ], "Cmd": [ "/app/greet.sh" ], "WorkingDir": "/app", "Labels": { "org.opencontainers.image.title": "lab-anatomy" }, "ArgsEscaped": true }, "diff_ids": [ "sha256:9fd9b3248f2fb670edfab1393d1d1e8cd828eb9c65de376b2fcc23268a45d500", "sha256:d2c047549c4dd6c7b7daaf3b8e907b49c9ca6f8efb426d412309b377d8b0f48c", "sha256:dd53c0a1945db74bdd851642b607627c2a043a0de2860e92841e835bf9dbf309", "sha256:70b7dfc4dabe125dfeb44c16a6c5f99cfef3485604afb90df65846c312e03ebd" ], "history": [ { "created_by": "ADD alpine-minirootfs-3.22.6-aarch64.tar.gz / # buildkit", "empty_layer": null }, { "created_by": "CMD [\"/bin/sh\"]", "empty_layer": true }, { "created_by": "WORKDIR /app", "empty_layer": null }, { "created_by": "RUN /bin/sh -c adduser -D -H -u 10001 app # buildkit", "empty_layer": null }, { "created_by": "COPY --chmod=0755 greet.sh /app/greet.sh # buildkit", "empty_layer": null }, { "created_by": "ENV GREETING=hello", "empty_layer": true }, { "created_by": "LABEL org.opencontainers.image.title=lab-anatomy", "empty_layer": true }, { "created_by": "USER app", "empty_layer": true }, { "created_by": "CMD [\"/app/greet.sh\"]", "empty_layer": true } ] }

Finally, prove the content addressing yourself. Fetch the config and the top layer straight from the registry's HTTP API and hash them:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ m=$(docker buildx imagetools inspect localhost:5000/lab-anatomy:1 --raw | jq -r '.manifests[] | select(.platform.os == "linux") | .digest') man=$(curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" http://127.0.0.1:5000/v2/lab-anatomy/manifests/$m) cfg=$(echo "$man" | jq -r .config.digest); top=$(echo "$man" | jq -r ".layers[-1].digest") blob() { curl -s "http://127.0.0.1:5000/v2/lab-anatomy/blobs/$1"; } echo "config, manifest says: $cfg" echo "config, blob hashes to: sha256:$(blob $cfg | sha256sum | cut -d" " -f1)" echo "top layer, manifest says: $top" echo "top layer, blob hashes to: sha256:$(blob $top | sha256sum | cut -d" " -f1)" echo "top layer, gunzipped: sha256:$(blob $top | gunzip | sha256sum | cut -d" " -f1)"
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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ a=$(docker buildx imagetools inspect localhost:5000/lab-anatomy:1 --raw | jq -r '.manifests[] | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") | .digest') docker buildx imagetools inspect "localhost:5000/lab-anatomy:1@$a" --raw | jq .
{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "artifactType": "application/vnd.docker.attestation.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.empty.v1+json", "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", "size": 2, "data": "e30=" }, "layers": [ { "mediaType": "application/vnd.in-toto+json", "digest": "sha256:d191288a61beab8ca0ee39042d09be075692417e51ed32ae86569025d8cb3e3f", "size": 1095, "annotations": { "in-toto.io/predicate-type": "https://slsa.dev/provenance/v1" } } ], "subject": { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:19c28e17728ff27cd5f3d239210514b4382b3ef8ddd918465c5cb4c177961947", "size": 1044 } }

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:

ubuntu@secopslog-docker:~/lab/imglayers · Docker 29.8.2
$ docker rm -f -v lab-registry docker network rm lab-net docker image rm lab-anatomy:1 localhost:5000/lab-anatomy:1 rm alpine-index.json
lab-registry lab-net Untagged: lab-anatomy:1 Untagged: localhost:5000/lab-anatomy:1 Deleted: sha256:5e8290cdfcb1db4d9ab0c6669b8adf18b45784b2fc41e652c5a0c1538fafdb35
Quick check
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.

Related