Area: Containers and deployment
Related: Docker, Kubernetes, Authentication vs Authorization, Linux namespaces and cgroups
What is it?
A container image is a set of files plus a recipe for running them, stored as content-addressed blobs. Concretely it’s three kinds of JSON and tarballs:
- Layers: tar archives of filesystem changes (files added, changed, deleted).
- A config: JSON with the command to run, env vars, working directory, user, architecture, and the hashes of the unpacked layers.
- A manifest: JSON listing the config and the layers by their SHA-256 digests.
Nothing in an image is running. It’s the frozen filesystem and settings. A container is what you get when a runtime unpacks the layers into a root filesystem, applies the config, and starts the process.
An image is a Merkle tree of tarballs: one hash at the top identifies every byte underneath.
The analogy people use is class vs instance, or program file vs process. One image, many containers. Containers add a thin writable layer on top and the image underneath never changes.
Why it’s built this way
Early container tools shipped whole root filesystems. That made every update a full re-download, and two apps on the same base OS stored it twice.
Splitting the filesystem into layers fixes both. Ten images built FROM node:22-slim share the same base layers on disk and over the network. Changing your code ships only the top layer.
Naming every blob by its hash (content addressing) gives you the rest for free:
- Integrity: a downloaded blob that doesn’t hash to its name is corrupt or tampered with.
- Deduplication: same bytes, same name, stored once, across every image and repository.
- Immutability: you can’t change a blob without changing its name, and you can’t change a manifest without changing the image’s digest.
Docker defined the original format. In 2015 it was donated to the Open Container Initiative, which publishes three specs: the Image spec (this note), the Runtime spec (how to run an unpacked bundle), and the Distribution spec (the registry HTTP API). Docker, containerd, Podman, Kubernetes and the cloud container services all speak them.
The pieces, as actual JSON
Manifest
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:4b0d...",
"size": 7023
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:a1f3...", "size": 29150000 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:9c2e...", "size": 41200 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:e77b...", "size": 1830000 }
]
}Every reference is a descriptor: media type, digest, size. The size lets a client refuse a blob that’s bigger than promised before hashing it. The digest of this JSON is the image’s digest, the thing you write as myapp@sha256:....
Docker’s older format uses application/vnd.docker.distribution.manifest.v2+json with the same shape. Registries and clients handle both.
Config
{
"architecture": "amd64",
"os": "linux",
"created": "2026-10-21T09:00:00Z",
"config": {
"User": "node",
"Env": ["PATH=/usr/local/bin:/usr/bin:/bin", "NODE_ENV=production"],
"Entrypoint": ["docker-entrypoint.sh"],
"Cmd": ["node", "dist/server.js"],
"WorkingDir": "/app",
"ExposedPorts": { "3000/tcp": {} },
"Labels": { "org.opencontainers.image.source": "https://github.com/acme/api" }
},
"rootfs": {
"type": "layers",
"diff_ids": ["sha256:5d1e...", "sha256:0f88...", "sha256:c3a9..."]
},
"history": [
{ "created_by": "/bin/sh -c #(nop) ADD file:... in /" },
{ "created_by": "RUN /bin/sh -c npm ci", "comment": "buildkit.dockerfile.v0" },
{ "created_by": "ENV NODE_ENV=production", "empty_layer": true }
]
}Things worth noticing:
ENV,CMD,EXPOSE,USERdon’t create layers. They edit this config (empty_layer: truein history).EXPOSEis documentation. It doesn’t open or publish anything.historyis whatdocker historyprints, including theRUNcommands. Don’t put secrets in them.rootfs.diff_idsare hashes of the uncompressed layer tars. The manifest lists hashes of the compressed blobs.
Two hashes per layer
| Hash | Of what | Lives in | Used for |
|---|---|---|---|
| Layer digest | The compressed blob as stored and transferred | Manifest | Downloading, verifying the download |
| DiffID | The uncompressed tar | Config | Identifying the filesystem content |
Why both: the same filesystem content can be compressed differently (gzip level, zstd, a different gzip implementation) and get a different digest. The DiffID stays the same. The runtime verifies the download with the digest, decompresses, and checks the result against the DiffID.
Runtimes also compute a ChainID for each layer, which identifies “this layer on top of exactly these layers”:
ChainID(L0) = DiffID(L0)
ChainID(Ln) = sha256(ChainID(Ln-1) + " " + DiffID(Ln))
A snapshot of an unpacked layer is only reusable if everything beneath it is identical, so the local unpacked cache is keyed by ChainID.
Index: one name, many platforms
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{ "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:11aa...", "size": 1612,
"platform": { "architecture": "amd64", "os": "linux" } },
{ "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:22bb...", "size": 1612,
"platform": { "architecture": "arm64", "os": "linux", "variant": "v8" } }
]
}When you pull node:22-slim, the tag usually points at an index. The client picks the entry for its own platform and fetches that manifest. Docker calls this a “manifest list”. It’s why the same tag works on an M-series Mac and an x86 server. An image built only for one platform has no index, and pulling it on the other gives you exec format error at run time, or an emulated slow run if QEMU is installed.
The whole tree
tag "node:22-slim"
└─ index (sha256:ab12…)
├─ manifest amd64 (sha256:11aa…)
│ ├─ config (sha256:4b0d…) ── diff_ids ──▶ uncompressed layer hashes
│ ├─ layer (sha256:a1f3…) tar+gzip
│ ├─ layer (sha256:9c2e…)
│ └─ layer (sha256:e77b…)
└─ manifest arm64 (sha256:22bb…)
└─ ...
Change one byte in any file in any layer and every hash on the path up to the index changes. That’s the Merkle tree property. Pin the top digest and you’ve pinned everything.
What’s inside a layer
A layer is a plain tar of paths relative to /. It records what changed compared with the layers below:
- Added or modified files appear with their full contents. Change one byte of a 200 MB file and the layer holds all 200 MB.
- Deleted files become a whiteout entry: an empty file named
.wh.<name>in the same directory.rm /app/secret.txtproducesapp/.wh.secret.txt. - A directory replaced wholesale gets an opaque whiteout,
.wh..wh..opq, meaning “hide everything from lower layers in this directory”. - Permissions, owners (numeric UID/GID), timestamps, symlinks and extended attributes are all kept in the tar headers.
You can look for yourself:
docker save myapp:latest -o myapp.tar
mkdir img && tar -xf myapp.tar -C img
cat img/index.json | jq . # recent Docker writes an OCI image layout
ls img/blobs/sha256/ # every manifest, config and layer, named by hash
tar -tzf img/blobs/sha256/<layer-digest> | head # list files in one layerThis is how you prove that a “deleted” secret is still in the image: it’s in the tar of the layer where it was added, regardless of the whiteout above it.
How a pull actually works
The registry is a plain HTTP API (the OCI Distribution spec). You can pull by hand:
# 1. Ask for the manifest. Docker Hub answers 401 with a challenge.
curl -si https://registry-1.docker.io/v2/library/alpine/manifests/3.20 | grep -i www-authenticate
# WWW-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
# 2. Get a token from the realm it named, for that scope.
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r .token)
# 3. Fetch the index, saying which formats you accept.
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json" \
https://registry-1.docker.io/v2/library/alpine/manifests/3.20 | jq '.manifests[] | {digest, platform}'
# 4. Fetch the platform manifest by digest, then each blob.
curl -sL -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/library/alpine/blobs/sha256:<layer-digest> -o layer.tar.gz
sha256sum layer.tar.gz # must equal the digest you asked forStep 1 is the standard 401 + WWW-Authenticate flow from Authentication vs Authorization. The token’s scope (repository:library/alpine:pull) is authorization: it says which repository and which actions.
What a real client adds on top:
- Resolve the tag to a digest (the registry returns
Docker-Content-Digestin the response headers). - Pick the platform from the index.
- Read the manifest, skip any layer it already has by digest.
- Download missing layers in parallel, verify each digest.
- Decompress, check the DiffID, unpack into the snapshotter (overlay2 directories), keyed by ChainID.
Blob downloads are often redirected (307) to a CDN or object storage URL, so a registry behind a firewall needs those hosts allowed too.
library/ is Docker Hub’s namespace for official images. docker pull alpine is really docker.io/library/alpine:latest. Every short name expands that way, which matters when you mirror registries or set allowlists.
How a push works
HEAD /v2/<repo>/blobs/<digest> → 200: already there, skip
POST /v2/<repo>/blobs/uploads/ → 202, Location: <upload-url>
PATCH <upload-url> (chunks, optional)
PUT <upload-url>?digest=sha256:... → 201 Created
... repeat for each layer and the config ...
PUT /v2/<repo>/manifests/<tag> → 201, Docker-Content-Digest: sha256:...
- Blobs go first, the manifest last. A registry rejects a manifest that points at blobs it doesn’t have, so a half-finished push never produces a broken tag.
- Cross-repository mount:
POST /v2/<repo>/blobs/uploads/?mount=<digest>&from=<other-repo>tells the registry “you already have this blob in another repo, link it”. Pushing a new image on a shared base layer uploads almost nothing. - Pushing a tag moves it. The old manifest still exists by digest until garbage collection removes unreferenced manifests and blobs.
Tags, digests and what “the same image” means
| Reference | Example | Mutable? |
|---|---|---|
| Tag | myapp:1.4.2, node:22-slim, latest | Yes. Anyone with push access can move it. |
| Digest | myapp@sha256:7e1f... | No. It’s the hash of the manifest or index. |
| Both | myapp:1.4.2@sha256:7e1f... | The digest wins. The tag is just for humans. |
Two images are “the same” only if their digests match. Rebuilding from the same Dockerfile and the same commit usually gives a different digest, because:
- file modification times in the layers are the build time,
createdin the config is the build time,apt-get installandnpm installmay pull newer versions,- gzip output can differ between implementations.
Reproducible builds pin all of that: base image by digest, lockfiles, and SOURCE_DATE_EPOCH so BuildKit rewrites timestamps (docker buildx build --build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) with the rewrite-timestamp=true output option in recent BuildKit). Then two machines produce the same digest, and you can verify a published image matches its source.
Where images live on your machine
- Docker’s classic store keeps unpacked layers under
/var/lib/docker/overlay2/<id>/withdiff/(the layer contents),lower(the chain below) andmerged/(only for running containers). - containerd (and Docker when using the containerd image store) keeps a content store of the original compressed blobs,
/var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/, plus unpacked snapshots separately. Keeping the compressed blobs means it can re-push or export without recompressing, at the cost of storing layers twice. - On macOS all of this is inside Docker Desktop’s VM disk image, which is why “free up space” means
docker system prune, not deleting files in Finder.
docker images sizes are the uncompressed size and count shared layers once per image, so the column adds up to more than the disk actually used. docker system df -v shows the shared size.
Beyond plain images: artifacts, signatures, SBOMs
OCI 1.1 (2024) generalised manifests so registries can store things that aren’t runnable images:
artifactTypesays what it is (a Helm chart, a signature, an SBOM, a WebAssembly module).subjectpoints at another manifest’s digest: “this is attached to that image”.GET /v2/<repo>/referrers/<digest>lists everything attached to an image.
That’s how image signatures (Sigstore cosign, Notation) and attestations (SBOMs, SLSA build provenance) travel with an image without changing its digest. docker buildx build --sbom=true --provenance=true attaches them at build time. Admission controllers in Kubernetes can then refuse images that aren’t signed by your CI.
Performance
- Pull time dominates cold starts. The Slacker paper (FAST ‘16) measured pulling as about 76% of container startup time, while only around 6.4% of the pulled data was actually read at startup. Smaller images and shared base layers matter more than anything else.
- Layer count has little runtime cost on modern overlay2, but very deep stacks slow file lookups and there are kernel limits on overlay depth. Keep it to tens, not hundreds.
- Big single layers can’t be downloaded in parallel with themselves. One 2 GB layer pulls slower than four 500 MB ones.
- Compression: gzip is the universal default.
zstdlayers (application/vnd.oci.image.layer.v1.tar+zstd) decompress much faster. Older runtimes can’t read them, so check your targets before switching. - Lazy pulling (eStargz with the stargz snapshotter, AWS SOCI) lets a container start before the image is fully downloaded, fetching file ranges on demand. Worth it for very large images like ML runtimes.
- Pre-pulling base images onto nodes, or using a pull-through cache registry near your cluster, avoids Docker Hub’s pull rate limits for anonymous and free accounts (the limits have changed over time, so check the current numbers).
A real-world example
A SaaS app that deploys several times a day to a container service:
- CI builds one multi-platform image (
linux/amd64,linux/arm64) and pushes it tagged with the git SHA. The push returns the index digest. - The deploy config references
api@sha256:<digest>, never a tag, so a rollback is “deploy the previous digest” and nobody can change what’s running by re-pushing a tag. - The same digest is promoted from staging to production. It isn’t rebuilt, so what was tested is byte-for-byte what ships.
- CI attaches an SBOM and a cosign signature as referrers. The cluster refuses unsigned digests.
- Base images are pinned by digest and bumped by a bot weekly. Each bump rebuilds, and because only the base layers change, nodes pull only the new base and a small app layer.
- A pull-through cache in the same region serves base images, so a burst of new nodes doesn’t hit Docker Hub’s rate limit.
Gotchas
- An image doesn’t run. “The image is using 2 GB of RAM” means the container is.
EXPOSEdoesn’t publish a port.- Tags move. Two pulls of the same tag a day apart can be different images.
- Same Dockerfile, same commit, different digest is normal unless you make the build reproducible.
- Deleting a file in a later layer leaves it in the earlier one.
docker saveandtarwill find it. - Changing one byte of a big file stores the whole file again in the new layer.
docker historyshows everyRUNcommand. Secrets in commands are public to anyone who can pull.- An image built only for arm64 runs on amd64 only through emulation, or fails with
exec format error. docker imagessizes are uncompressed and double-count shared layers.- Registries garbage-collect untagged manifests. A digest you pinned can disappear if nothing tags it and your registry has GC or a retention policy.
- Short names expand to
docker.io/library/.... An allowlist that permitsalpinepermits whatever Docker Hub serves under that name. - Numeric UIDs in layers mean “owned by 1000”. On a host or volume where 1000 is someone else, permissions get confusing.
Practice
- Pull
alpineby hand withcurlandjqfollowing the steps above. Verify each blob’s SHA-256. Unpack the layer and compare withdocker run alpine ls /. docker saveone of your images and map every file in the archive to the manifest, config and layers. Find the layer for each Dockerfile step.- Add a file in one
RUN, delete it in the next, and find both the file and its.wh.whiteout in the exported layers. - Build the same Dockerfile twice and compare digests. Make them match with a pinned base digest and
SOURCE_DATE_EPOCH. - Compute DiffIDs (
gunzip -c layer | sha256sum) and the ChainID of your top layer by hand, and compare withrootfs.diff_idsin the config. - Build a multi-platform image with
buildx, push it to a local registry (docker run -d -p 5000:5000 registry:2), and inspect the index withdocker buildx imagetools inspectorcrane manifest. - Push two images sharing a base to the local registry and watch the second push mount or skip the shared blobs.
- Sign an image in that registry with cosign and list its referrers.
Sources worth reading
- OCI Image Format Specification (github.com/opencontainers/image-spec): manifest, index, config, layer and descriptor docs
- OCI Distribution Specification (github.com/opencontainers/distribution-spec)
- Docker docs: Image layers, Multi-platform builds, Reproducible builds with SOURCE_DATE_EPOCH
crane(go-containerregistry) andskopeodocs, for inspecting images without a daemon- Harter et al., Slacker: Fast Distribution with Lazy Docker Containers (USENIX FAST 2016)
- Sigstore cosign docs, and the SLSA provenance spec