Every blob in an image, whether a layer, the config, a manifest or the index, is stored and fetched under the SHA-256 hash of its own bytes. This is content-addressable storage: the name is a checksum, so any change produces a new name, identical content is stored once however many images share it, and a client can verify everything it downloads. You can check the claim with nothing more than sha256sum:
docker image inspect alpine:3 --format '{{.Id}} {{json .RepoDigests}}' | cut -c 1-90
docker buildx imagetools inspect --raw alpine:3 | sha256sum
M=$(docker buildx imagetools inspect --raw alpine:3 | jq -r '.manifests[0].digest'); echo $M
docker buildx imagetools inspect --raw "alpine:3@$M" | sha256sumsha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 ["alpine@sha256:29 294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 - sha256:d56c381f961d307a21b3ca004cf1e3910f106644aefb1f43e654c8a56c4fd395 d56c381f961d307a21b3ca004cf1e3910f106644aefb1f43e654c8a56c4fd395 -
Hashing the index document reproduces alpine:3's digest exactly, and hashing the amd64 manifest reproduces the digest the index lists for it. The terms are easy to mix up:
| Term | Hash of | Where you see it |
|---|---|---|
| Digest (repo digest) | The index or manifest | docker pull, RepoDigests, @sha256: references |
| Image ID | Index or manifest (containerd 234,762 store); config (older store) | docker image ls |
| Layer digest | The compressed layer tarball | Manifests, registry URLs |
| Diff ID | The uncompressed layer tarball | RootFS.Layers in the config |
With Engine 29's containerd image store, the image ID of a pulled image is the digest of what you pulled, which is why alpine:3's ID and repo digest match. The classic store used the config's digest instead, so older articles show IDs that never match a registry.