Images, Layers and Registries
What a container image is made of: layers, tags and digests, multi-architecture images, and pulling and pushing images to public and private registries.
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 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 pruneremoves dangling images: layers left over from rebuilds, without a tag. -
docker image prune -aremoves every image that no container uses, including base images you will need again.