Search code examples
container-image

Does a container image "digest" uniquely identify that particular container such that any change to the actual content will change the digest?


I've read through related questions, and also Google's own statement on the definition of Digest in an OCI container image (e.g. as used with docker or podman):

"The image digest is the hash of the image index or image manifest JSON document."

But for those of us who aren't experts on the internals, I'd appreciate a straightforward yes or no on whether this means any modification to the content would produce a different digest. Excluding the extremely unlikely possibility of a hash collision, that is.

Good evidence of this might include an explanation that the "image index" or "image manifest" is itself guaranteed to contain a sha256 hash of the actual contents of the image, or similar, and therefore the contents of the image (e.g. the actual files in it) definitely contribute to the uniqueness of the digest.


Solution

  • Yes, the digest of a container image is the hash of the root node of a Merkle tree. The hash of every element in that Merkle tree is a content addressable hash (a hash of its content). That image manifest contains the hashes of the image config (describing features like the command to run, environment variables, labels, etc) and an ordered list of image layers (each of those a tar filesystem diff).

    More details of this structure are described in the OCI image-spec.


    An example of this could look at an existing image. First the digest can be retrieved, and shown to match the sha256sum of the body of the manifest, which in this case is a multi-platform index manifest:

    $ regctl image digest nginx
    sha256:84c52dfd55c467e12ef85cad6a252c0990564f03c4850799bf41dd738738691f
    
    $ regctl manifest get nginx@sha256:84c52dfd55c467e12ef85cad6a252c0990564f03c4850799bf41dd738738691f --format body | sha256sum
    84c52dfd55c467e12ef85cad6a252c0990564f03c4850799bf41dd738738691f  -
    
    $ regctl manifest get nginx@sha256:84c52dfd55c467e12ef85cad6a252c0990564f03c4850799bf41dd738738691f --format body | jq .
    {
      "manifests": [
        {
          "annotations": {
            "org.opencontainers.image.base.digest": "sha256:36a9d3bcaaec706e27b973bb303018002633fd3be7c2ac367d174bafce52e84e",
            "org.opencontainers.image.base.name": "debian:bookworm-slim",
            "org.opencontainers.image.created": "2024-01-31T23:54:31Z",
            "org.opencontainers.image.revision": "4bf0763f4977fff7e9648add59e0540088f3ca9f",
            "org.opencontainers.image.source": "https://github.com/nginxinc/docker-nginx.git#4bf0763f4977fff7e9648add59e0540088f3ca9f:mainline/debian",
            "org.opencontainers.image.url": "https://hub.docker.com/_/nginx",
            "org.opencontainers.image.version": "1.25.3"
          },
          "digest": "sha256:d02f9b9db4d759ef27dc26b426b842ff2fb881c5c6079612d27ec36e36b132dd",
          "mediaType": "application/vnd.oci.image.manifest.v1+json",
          "platform": {
            "architecture": "amd64",
            "os": "linux"
          },
          "size": 2238
        },
        {
          "annotations": {
            "vnd.docker.reference.digest": "sha256:d02f9b9db4d759ef27dc26b426b842ff2fb881c5c6079612d27ec36e36b132dd",
            "vnd.docker.reference.type": "attestation-manifest"
          },
          "digest": "sha256:dfbc67277079dca8738faeb6d6e9a3216617f926c4abbb7727473f2c248ca4d8",
          "mediaType": "application/vnd.oci.image.manifest.v1+json",
          "platform": {
            "architecture": "unknown",
            "os": "unknown"
          },
          "size": 841
        },
        {
          "annotations": {
            "org.opencontainers.image.base.digest": "sha256:490dea635022ba0375b1e18f4b7add5c7dbc43cbed58e0e4211c65ccb1d1e0ea",
            "org.opencontainers.image.base.name": "debian:bookworm-slim",
            "org.opencontainers.image.created": "2024-02-01T12:04:04Z",
            "org.opencontainers.image.revision": "4bf0763f4977fff7e9648add59e0540088f3ca9f",
            "org.opencontainers.image.source": "https://github.com/nginxinc/docker-nginx.git#4bf0763f4977fff7e9648add59e0540088f3ca9f:mainline/debian",
            "org.opencontainers.image.url": "https://hub.docker.com/_/nginx",
            "org.opencontainers.image.version": "1.25.3"
          },
          "digest": "sha256:3884690c4c19778765a362d5e13887c328de9ca60489c0a734bc60bd17bc6c5f",
          "mediaType": "application/vnd.oci.image.manifest.v1+json",
          "platform": {
            "architecture": "arm",
            "os": "linux",
            "variant": "v5"
          },
          "size": 2238
        },
    ...
      "mediaType": "application/vnd.oci.image.index.v1+json",
      "schemaVersion": 2
    }
    

    Each entry in the index is a descriptor with a digest to a child manifest, in this case the image manifest with descriptors to the config and layers:

    $ regctl manifest get nginx@sha256:d02f9b9db4d759ef27dc26b426b842ff2fb881c5c6079612d27ec36e36b132dd --format body | jq .
    {
      "schemaVersion": 2,
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "config": {
        "mediaType": "application/vnd.oci.image.config.v1+json",
        "digest": "sha256:b690f5f0a2d535cee5e08631aa508fef339c43bb91d5b1f7d77a1a05cea021a8",
        "size": 7016
      },
      "layers": [
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:c57ee5000d61345aa3ee6684794a8110328e2274d9a5ae7855969d1a26394463",
          "size": 29150465
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:9b0163235c0874cc35a3090b84715be2a3b1a467a334396473683fb15c580833",
          "size": 41376857
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:f24a6f65277877f973f31f848b8024e614ffdade2547baa350fc802c87f86d77",
          "size": 627
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:9f3589a5fc50dafb3da7513b58f8a1d8f235aeae6ba583679034ce6f94ade74f",
          "size": 955
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:f0bd99a47d4a9c76baa21acd0117e2f32be31ba4ac981acc4666199fa144d4d0",
          "size": 365
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:398157bc5c51a9e2e4ceb552c196bee781b157c9ef4760f76ed3ddd34164fe71",
          "size": 1209
        },
        {
          "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
          "digest": "sha256:1ef1c1a36ec24a6e2e803471dd61fd79a5a28666c553c4cccc26f2225d48b307",
          "size": 1398
        }
      ],
      "annotations": {
        "org.opencontainers.image.base.digest": "sha256:36a9d3bcaaec706e27b973bb303018002633fd3be7c2ac367d174bafce52e84e",
        "org.opencontainers.image.base.name": "debian:bookworm-slim",
        "org.opencontainers.image.created": "2023-10-24T22:44:45Z",
        "org.opencontainers.image.revision": "4bf0763f4977fff7e9648add59e0540088f3ca9f",
        "org.opencontainers.image.source": "https://github.com/nginxinc/docker-nginx.git#4bf0763f4977fff7e9648add59e0540088f3ca9f:mainline/debian",
        "org.opencontainers.image.url": "https://hub.docker.com/_/nginx",
        "org.opencontainers.image.version": "1.25.3"
      }
    }
    

    The image config is JSON data about the image:

    $ regctl blob get nginx sha256:b690f5f0a2d535cee5e08631aa508fef339c43bb91d5b1f7d77a1a05cea021a8 | jq .
    {
      "architecture": "amd64",
      "config": {
        "ExposedPorts": {
          "80/tcp": {}
        },
        "Env": [
          "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
          "NGINX_VERSION=1.25.3",
          "NJS_VERSION=0.8.2",
          "PKG_RELEASE=1~bookworm"
        ],
        "Entrypoint": [
          "/docker-entrypoint.sh"
        ],
        "Cmd": [
          "nginx",
          "-g",
          "daemon off;"
        ],
        "Labels": {
          "maintainer": "NGINX Docker Maintainers <[email protected]>"
        },
        "StopSignal": "SIGQUIT",
        "ArgsEscaped": true,
        "OnBuild": null
      },
      "created": "2023-10-24T22:44:45Z",
      "history": [
    ...
    

    Pulling a (smaller) layer, first to verify the digest is content addressable, and then running it through tar to show the contents of the layer:

    $ regctl blob get nginx sha256:f24a6f65277877f973f31f848b8024e614ffdade2547baa350fc802c87f86d77 | sha256sum
    f24a6f65277877f973f31f848b8024e614ffdade2547baa350fc802c87f86d77  -
    
    $ regctl blob get nginx sha256:f24a6f65277877f973f31f848b8024e614ffdade2547baa350fc802c87f86d77 | tar -tvzf -
    -rwxr-xr-x 0/0            1620 2024-01-31 18:54 docker-entrypoint.sh
    

    Changing any of the child content would change its content addressable hash, which would change the reference to that content in the parent manifest, which would change the digest of that parent manifest.