What an image is made of

The Cloud page describes a container image as a read-only package with everything an application needs. Under the hood, following the OCI image specification (see the OCI standards), that package is a small set of documents and archives, each stored under the hash of its own content:

  • Layers: compressed tar archives with file system changes, one per build step that changed files.
  • A configuration: a JSON document with the processor architecture, environment variables, the user, the working directory, the default command and the build history.
  • A manifest that ties them together.

An image manifest is that JSON document: it names the configuration and lists the layers in order, each with its media type, size in bytes and digest. A registry serves the manifest first; the engine then downloads only the layers it does not have yet. A trimmed manifest for the meter-api image:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:0c2475f1ca408798da73ea4b54716f9ebdc0963249c1954f9848ea1629c4613c",
    "size": 6512
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:bbeda6b4abb749f743f4547ef5a28070a14de8caef211daa5afb78d5b5fa21ba",
      "size": 30204351
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:a2c5ac708d8b046b36bb9ff13512a4ebb02740989de6f68337004fe5473a996d",
      "size": 1281339
    }
  ]
}

The sizes in a manifest are compressed sizes, which is what crosses the network. Adding them up tells you how much a pull costs, and comparing the digests of two images tells you which layers they share. Two Kestrel images built on the same Python base:

import json

# Layer lists from two image manifests (digests shortened, sizes in bytes,
# compressed, as stored in the registry)
meter_api = json.loads("""{"layers": [
  {"digest": "sha256:bbeda6b4", "size": 30204351},
  {"digest": "sha256:a2c5ac70", "size": 1281339},
  {"digest": "sha256:e41644b4", "size": 11853017},
  {"digest": "sha256:74202e18", "size": 250},
  {"digest": "sha256:5f0c11d2", "size": 93},
  {"digest": "sha256:c81e7a40", "size": 4412671},
  {"digest": "sha256:0d9b3f66", "size": 641}]}""")
billing_export = json.loads("""{"layers": [
  {"digest": "sha256:bbeda6b4", "size": 30204351},
  {"digest": "sha256:a2c5ac70", "size": 1281339},
  {"digest": "sha256:e41644b4", "size": 11853017},
  {"digest": "sha256:74202e18", "size": 250},
  {"digest": "sha256:7aa3e915", "size": 2208114}]}""")

def total(manifest):
    return sum(layer["size"] for layer in manifest["layers"])

for name, manifest in [("meter-api", meter_api), ("billing-export", billing_export)]:
    size = total(manifest)
    print(f"{name:15} {size:>10,} bytes = {size / 1e6:5.1f} MB = {size / 2**20:5.1f} MiB")

sizes = {layer["digest"]: layer["size"] for m in (meter_api, billing_export) for layer in m["layers"]}
shared = {l["digest"] for l in meter_api["layers"]} & {l["digest"] for l in billing_export["layers"]}
print(f"shared layers: {len(shared)}, downloaded once: {sum(sizes[d] for d in shared) / 1e6:.1f} MB")
print(f"pulling both:  {sum(sizes.values()) / 1e6:.1f} MB, not {(total(meter_api) + total(billing_export)) / 1e6:.1f} MB")
meter-api       47,752,362 bytes =  47.8 MB =  45.5 MiB
billing-export  45,547,071 bytes =  45.5 MB =  43.4 MiB
shared layers: 4, downloaded once: 43.3 MB
pulling both:  50.0 MB, not 93.3 MB

Docker's own size columns use decimal megabytes (MB, 1,000,000 bytes); other tools show binary mebibytes (MiB, 1,048,576 bytes), so the same image can look 5% smaller in one tool than in another.

Layers and the writable layer

Each image layer records what one build step changed. docker history lists them, newest first. For the meter-api image (the Dockerfile is on Writing Dockerfiles), with full instructions:

$ docker history --format "{{.Size}}\t{{.CreatedBy}}" --no-trunc kestrel-meter-api:1.0 | head -8
0B	CMD ["python" "app.py"]
0B	USER 10001
0B	EXPOSE map[8000/tcp:{}]
927B	COPY app.py . # buildkit
11.6MB	RUN /bin/sh -c pip install --no-cache-dir -r requirements.txt # buildkit
17B	COPY requirements.txt . # buildkit
0B	WORKDIR /app
0B	CMD ["python3"]

Only steps that change files produce real content: the pip install step added 11.6 MB, copying the code 927 bytes. Steps such as CMD, USER and EXPOSE only change the configuration and show 0B. Everything from CMD ["python3"] downward came from the base image. Without --format, docker history also shows an IMAGE column in which most rows read <missing>. That is normal: those layers were downloaded or built by BuildKit and have no separate local image ID. It does not mean anything is lost.

A running container adds one more layer. The image layers are stacked read-only, and the container gets a thin writable layer on top. When the application changes a file that came from the image, the storage driver first copies the file up into the writable layer and changes the copy, a form of copy-on-write. Ten containers from one image therefore share one copy of the image on disk and each own only their changes.

Two containers, A and B, each with its own writable layer, sit on top of the same stack of four read-only image layers: the python:3.13-slim base layers, COPY requirements.txt, RUN pip install and COPY app.py. The writable layers belong to one container each and are deleted with docker rm; the image layers are shared by every container. containers A: writable layer B: writable layer one per container, gone after docker rm image COPY app.py . (927 B) RUN pip install … (11.6 MB) COPY requirements.txt . (17 B) python:3.13-slim base layers read-only layers, shared by every container
One image, two containers. Reads fall through to the image layers; writes land in the container's own writable layer.

Two practical consequences:

  • Data in the writable layer is temporary. It survives a stop and a start, but not docker rm, and every update replaces the container. Data that must last belongs in a volume (see Volumes and Bind Mounts).
  • Layers only grow. Deleting a file in a later build step hides it but does not remove it from the earlier layer, which is still downloaded. A password copied into an image and deleted one step later can still be read. How to avoid this is on Building Good Images.

Reading an image reference

A full image reference has up to four parts. For the Kestrel portal pinned to an exact build:

registry.example.com/kestrel/portal:1.4.2@sha256:3f1c…
Part Here If you leave it out
Registry (host, optional port) registry.example.com Docker Hub, docker.io
Repository (namespace/name) kestrel/portal On Docker Hub, a single name gets the namespace library/, used for the official images
Tag 1.4.2 latest, unless a digest is given
Digest sha256:3f1c… Whatever the tag points to at the moment of the pull

The first part counts as a registry only if it contains a dot or a colon, or is localhost. These rules, written out:

def parse_reference(ref):
    """Splits an image reference the way Docker fills in the defaults."""
    name, _, digest = ref.partition("@")
    tag = None
    last_part = name.rsplit("/", 1)[-1]
    if ":" in last_part:
        name, tag = name.rsplit(":", 1)
    first, _, rest = name.partition("/")
    if rest and ("." in first or ":" in first or first == "localhost"):
        registry, repository = first, rest
    else:
        registry, repository = "docker.io", name
        if "/" not in repository:
            repository = "library/" + repository
    if tag is None and not digest:
        tag = "latest"
    return registry, repository, tag or "-", digest[:19] + "…" if digest else "-"

references = [
    "busybox",
    "alpine:3.22",
    "kestrel/portal:1.4.2",
    "registry.example.com/kestrel/portal:1.4.2",
    "localhost:5000/kestrel/meter-api",
    "alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8",
]
print(f"{'registry':22}{'repository':22}{'tag':8}digest")
for ref in references:
    registry, repository, tag, digest = parse_reference(ref)
    print(f"{registry:22}{repository:22}{tag:8}{digest}")
registry              repository            tag     digest
docker.io             library/busybox       latest  -
docker.io             library/alpine        3.22    -
docker.io             kestrel/portal        1.4.2   -
registry.example.com  kestrel/portal        1.4.2   -
localhost:5000        kestrel/meter-api     latest  -
docker.io             library/alpine        -       sha256:5291449c3df7…

The third line is the one to remember. kestrel/portal:1.4.2 without the registry host does not mean "our portal": it means a repository called kestrel/portal on Docker Hub, which belongs to whoever registered the name kestrel there.

Tags and digests

An image tag is a human-readable label for one image in a repository, such as 3.22, 1.4.2 or latest. A tag is a pointer, not a property of the image: the owner of the repository can move it to a different image at any time, and one image can carry several tags. Like a Git tag, it should mark a release, and like a Git tag it can be moved by someone determined to. Kestrel tags its own images with semantic versions. (The version numbers on these pages are for the examples; production meter-api is at 2.14.3 in October 2026, see the INC-22047 rollback.)

An image digest is the SHA-256 hash of an image's manifest (or index), written as sha256: plus 64 hexadecimal characters. Because the manifest lists the digests of the configuration and every layer, changing a single byte anywhere changes the digest. A digest cannot be moved: it is the content. This is what a hash function does to a tiny "layer" of meter readings:

import hashlib

layer_v1 = b"KE-0001;4123.7\nKE-0002;977.1\n"
layer_v2 = b"KE-0001;4123.7\nKE-0002;977.2\n"   # one character changed

for name, content in [("v1", layer_v1), ("v2", layer_v2), ("v1 again", layer_v1)]:
    print(f"{name:9} sha256:{hashlib.sha256(content).hexdigest()}")
v1        sha256:9f39b3a72a5ec0388143c0a8f32d186595006a81d7608d7a22e5ea5e8ea96b84
v2        sha256:f7fbee6edc23b9dfa70c70e140657cfe32c578c902c41e15bfc9e0f7da05bce8
v1 again  sha256:9f39b3a72a5ec0388143c0a8f32d186595006a81d7608d7a22e5ea5e8ea96b84

Same content, same digest; one changed character, a completely different one. That is also how a pull verifies its download (the "Verifying Checksum" lines in the output of the first container). Docker shows the digests of pulled images with --digests:

$ docker images --digests alpine
$ docker inspect --format '{{json .RepoDigests}}' alpine:3.22
$ docker inspect --format '{{.Id}}' alpine:3.22
REPOSITORY   TAG       DIGEST                                                                    IMAGE ID       CREATED       SIZE
alpine       3.22      sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8   e09dd31eab4f   2 weeks ago   8.54MB
["alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8"]
sha256:e09dd31eab4f4aeaade69891673134f31880ef51c6ee514b0cea072df06d7547

Two different hashes for one image, and they mean different things:

  • The repository digest (sha256:5291…) identifies the image in the registry. It is the same on every machine and is what you write down, pin and deploy.
  • The image ID (e09dd31eab4f) is a local identifier. On this Docker 28 host it is the digest of the image's configuration; with the containerd image store it is derived differently. Do not use it to compare machines.

Images you built yourself have no repository digest until they are pushed: docker images --digests shows <none> for them. Running by digest gives you exactly that image, whatever happened to the tag since:

$ docker run --rm --name digest alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 cat /etc/alpine-release
3.22.6

The tag 3.22 pointed to release 3.22.6 on that day. When Alpine publishes 3.22.7, the tag moves; the digest keeps pointing to 3.22.6. Tagging is cheap because it copies nothing: docker tag only adds a name, and both names show the same IMAGE ID:

$ docker tag kestrel-meter-api:1.1 kestrel-meter-api:latest && docker images kestrel-meter-api
REPOSITORY          TAG        IMAGE ID       CREATED         SIZE
kestrel-meter-api   badorder   8c03990a8e98   3 minutes ago   155MB
kestrel-meter-api   1.1        6f23f9446e05   3 minutes ago   155MB
kestrel-meter-api   latest     6f23f9446e05   3 minutes ago   155MB
kestrel-meter-api   1.0        0c2475f1ca40   3 minutes ago   155MB

This is the classic table of Docker 28. Docker Engine 29 shows docker image ls as a collapsed tree view by default and hides untagged images unless you add --all, so the same command looks different there; the information is the same.

Multi-platform images

An image contains compiled programs for one processor architecture and operating system, such as linux/amd64 (most servers and PCs) or linux/arm64 (Raspberry Pi, Apple silicon, AWS Graviton). A multi-platform image is one reference whose digest points to an image index (Docker also calls it a manifest list) instead of a single manifest. The index lists one manifest per platform, and the engine picks the one that matches the host. docker buildx imagetools inspect shows the index without pulling anything:

$ docker buildx imagetools inspect alpine:3.22 | head -20
Name:      docker.io/library/alpine:3.22
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8

Manifests:
  Name:        docker.io/library/alpine:3.22@sha256:3e9b4b680bfc9fb5269227cffbd6d42be39fbf7c0b908123913864aa4447e764
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
  Annotations:
    org.opencontainers.image.url:             https://hub.docker.com/_/alpine
    org.opencontainers.image.version:         3.22.6
    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

  Name:        docker.io/library/alpine:3.22@sha256:136a7e91b81ee0a601b537ae15392eca6a3c8f6e8496f1f256acf29160bc1fe1
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown

The digest at the top (sha256:5291…) is the same repository digest as in the previous section: it identifies the index. Listing only the platforms:

$ docker buildx imagetools inspect alpine:3.22 | grep Platform
  Platform:    linux/amd64
  Platform:    unknown/unknown
  Platform:    linux/arm/v6
  Platform:    unknown/unknown
  Platform:    linux/arm/v7
  Platform:    unknown/unknown
  Platform:    linux/arm64/v8
  Platform:    unknown/unknown
  Platform:    linux/386
  Platform:    unknown/unknown
  Platform:    linux/ppc64le
  Platform:    unknown/unknown
  Platform:    linux/riscv64
  Platform:    unknown/unknown
  Platform:    linux/s390x
  Platform:    unknown/unknown

Eight real platforms. The unknown/unknown entries are not platforms at all: they are attestations, such as a software bill of materials and build provenance, attached to the manifest before them. The selection the engine makes, sketched for Kestrel's own meter-api, which is built for two platforms:

import json

# A trimmed image index for registry.example.com/kestrel/meter-api:2.3.0
index = json.loads("""
{
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {"digest": "sha256:3e9b4b680bfc", "size": 1022,
     "platform": {"os": "linux", "architecture": "amd64"}},
    {"digest": "sha256:136a7e91b81e", "size": 841,
     "platform": {"os": "unknown", "architecture": "unknown"},
     "annotations": {"vnd.docker.reference.type": "attestation-manifest"}},
    {"digest": "sha256:9c2d1e4f7a03", "size": 1022,
     "platform": {"os": "linux", "architecture": "arm64", "variant": "v8"}},
    {"digest": "sha256:52e8a0d3c6b1", "size": 1022,
     "platform": {"os": "linux", "architecture": "s390x"}}
  ]
}
""")

def pick(index, os_name, architecture):
    for entry in index["manifests"]:
        platform = entry["platform"]
        if platform["os"] == os_name and platform["architecture"] == architecture:
            return entry["digest"]
    return None

for arch in ["arm64", "amd64", "riscv64"]:
    digest = pick(index, "linux", arch)
    print(f"linux/{arch:8} -> {digest or 'no image for this platform'}")
linux/arm64    -> sha256:9c2d1e4f7a03
linux/amd64    -> sha256:3e9b4b680bfc
linux/riscv64  -> no image for this platform

When there is no match, the pull fails with a message such as no matching manifest for linux/arm64/v8 in the manifest list entries. Worse is an image built for one platform only, with no index: it pulls fine and then fails at start with exec format error, because the processor cannot run the program inside. You can check what you have with docker inspect --format '{{.Os}}/{{.Architecture}}{{if .Variant}}/{{.Variant}}{{end}}' (on the test machine it printed linux/arm64/v8 for alpine). How to build images for several platforms is on Building Good Images.

Registries and Docker Hub

A container registry stores images and serves them over the OCI distribution API. The Cloud page lists the managed registries of AWS, Azure and Google Cloud; companies also run their own, such as Harbor, or use the registry built into GitHub or GitLab.

Docker Hub is Docker's public registry and the default for every image name without a registry host. It hosts the Docker Official Images (the repositories under library/, such as python, postgres and nginx), images from verified publishers, and anyone's public repositories. Anyone can publish there, so a repository's popularity says nothing about its safety; prefer official and verified images and pin what you use.

Docker Hub limits pulls. As of October 2026, according to Docker's documentation:

Who pulls Limit
Not logged in 100 pulls per 6 hours per IPv4 address or IPv6 /64 network
Logged in, free Personal account 200 pulls per 6 hours
Paid plans (Pro, Team, Business) Unlimited, subject to fair use

A pull is counted when a manifest is downloaded (a GET request); a HEAD request that only checks whether the digest changed does not count. Stricter limits announced for April 2025 were never enforced; Docker has said it will announce any future change at least six months ahead. Treat these numbers as something to check again, not as a constant.

The anonymous limit is per IP address. An office, a Kubernetes cluster or a farm of CI runners usually reaches the internet through one public address thanks to PAT, so all those machines share the 100 pulls. When builds start failing with toomanyrequests, the fix is not to retry harder:

  • log in (docker login) with a service account;
  • copy the base images you use into your own registry, or put a pull-through cache (a registry mirror) in front of Docker Hub;
  • avoid pulling on every run: use IfNotPresent-style pull behavior and fixed tags.

Pulling and pushing

docker pull downloads an image: first the index or manifest, then each layer it does not have yet:

$ docker pull alpine:3.22
3.22: Pulling from library/alpine
16fc4f52163f: Pulling fs layer
16fc4f52163f: Verifying Checksum
16fc4f52163f: Download complete
16fc4f52163f: Pull complete
Digest: sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
Status: Downloaded newer image for alpine:3.22
docker.io/library/alpine:3.22

Alpine is a single-layer image, so there is one layer line. The last line shows the full reference Docker made of the short name. You rarely need docker pull by hand: docker run pulls missing images itself.

Pushing your own image to a private registry takes three steps: log in, give the image a name that includes the registry, and push. Kestrel's release pipeline does this for the meter-api:

$ docker login registry.example.com
$ docker tag kestrel-meter-api:2.3.0 registry.example.com/kestrel/meter-api:2.3.0
$ docker push registry.example.com/kestrel/meter-api:2.3.0
The push refers to repository [registry.example.com/kestrel/meter-api]
0d9b3f66a1c2: Pushed
c81e7a40f3b9: Pushed
5f0c11d2e8a7: Pushed
74202e18a58b: Layer already exists
e41644b4b81a: Layer already exists
a2c5ac708d8b: Layer already exists
bbeda6b4abb7: Layer already exists
2.3.0: digest: sha256:7e4d2c9b8a1f0e6d5c4b3a29180f7e6d5c4b3a2918f7e6d5c4b3a29180f7e6d5 size: 1782

Only the three Kestrel layers are uploaded; the Python base layers are already in the registry. The last line gives the digest of what was pushed: record it in the release notes or the change record (CHG-nnnn), because it identifies the release exactly. In a pipeline, log in with a token that can push only to that repository, never with a personal password; see Container Security.

A failed pull often produces this message:

$ docker pull kestrel-meter-api:1.1
Error response from daemon: pull access denied for kestrel-meter-api, repository does not exist or may require 'docker login': denied: requested access to the resource is denied

The image existed only locally, so the short name sent Docker to Docker Hub, where there is no such repository. The registry gives the same answer for "does not exist" and "you may not see it", on purpose: it does not reveal which private repositories exist. When a user reports this error, check three things in order: the registry host in the name, the spelling of repository and tag, and whether the machine is logged in to that registry.

Disk space and cleaning up

Images pile up: every build and every pulled version stays on disk until removed. docker system df summarizes the space used by images, containers, volumes and the build cache. The SIZE column of docker images is the uncompressed size on disk, and layers shared between images are counted in each image's size, so adding up that column overstates the real total.

  • docker image rm <name> removes one image (the tag first; the layers once no tag or container uses them).
  • docker image prune removes dangling images: layers left over from rebuilds, without a tag.
  • docker image prune -a removes every image that no container uses, including base images you will need again.