The OCI image spec: index, manifest, config and layers as a Merkle DAG of descriptors
The OCI Image Spec is the vendor-neutral standard that split from Docker schema2 in 2016. An image is a Merkle DAG of descriptors — index to manifests, manifest to config plus layers — each a mediaType, digest and size. Wrong media types make old registries say manifest unknown.
The image built fine, pushed fine, and ran fine on every developer laptop. Then the deploy to the customer’s air-gapped cluster failed on pull with one line: manifest unknown. The registry was a years-old Harbor that nobody had touched, and it was rejecting the manifest with the media type application/vnd.oci.image.manifest.v1+json. The build machine had been upgraded to a Docker version that ships the containerd image store, and that store emits OCI media types by default — not the older application/vnd.docker.distribution.manifest.v2+json (Docker schema2) the old registry knew how to store. Nothing in the image bytes was wrong. The layers were identical tarballs they would have accepted under either name. The registry was doing a string comparison on the manifest’s mediaType field, finding a vnd.oci string it had no code path for, and refusing the whole DAG. The fix was four characters of build flag — emit the schema2 type — but finding it took an afternoon, because the team had never had to think about the fact that an OCI image is a typed graph of documents, not an opaque blob. Once you see the descriptor graph, manifest unknown stops being a mystery and becomes a media-type mismatch you can read off the error.
Once you see the descriptor graph, manifest unknown stops being a mystery and becomes a specific media-type mismatch you can name and fix in minutes.
Four document types, linked by descriptors
Why does understanding the spec matter? Because when something breaks at the registry or supply-chain layer, the error message is always in spec terms — media types, descriptors, digests — and without the vocabulary you are debugging with one hand tied behind your back. The OCI Image Spec reached v1.0 in 2017, two years after the project formed in 2015 to lift Docker’s v2 schema2 manifest out of one vendor and into a neutral standard. It defines four JSON document types and one primitive that glues them together — the descriptor. A descriptor is the only way one OCI document points at another, and it is just three fields:
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:7f0a3c…",
"size": 2089
}That is the whole linking mechanism. The mediaType says what kind of thing is at the other end, the digest is the sha256 that content-addresses it, and size is its byte length so a client can allocate and verify before it even fetches. Every reference in an OCI image — index to manifest, manifest to config, manifest to each layer — is a descriptor. The four document types it links are:
- Image index —
application/vnd.oci.image.index.v1+json. The multi-arch fan-out: a list of descriptors, one per platform, each pointing at a manifest. Roughly 1–3 KB for a handful of platforms. - Image manifest —
application/vnd.oci.image.manifest.v1+json. One platform’s recipe: aconfigdescriptor and an orderedlayersarray of descriptors. Typically about 2 KB. - Image config —
application/vnd.oci.image.config.v1+json. The JSON you met last lesson: Env, Entrypoint, rootfs.diff_ids, history. Its digest is the image ID. - Layer blobs —
application/vnd.oci.image.layer.v1.tar+gzip. The gzipped filesystem tarballs, the bulk of the bytes.
Pull resolves a tag to the top descriptor, fetches that document, reads the descriptors inside it, fetches those, and recurses down until it has the config and every layer.
A manifest references its config and three layers. By what mechanism does the manifest point at each of those four blobs?
Why it is a Merkle DAG
Because every link is a digest of the thing it points at, the references compose into a Merkle DAG — the same structure as a git commit graph. The config’s digest is computed from the config bytes. The manifest embeds that config digest plus each layer digest, so the manifest’s own digest is computed from bytes that include all of those child digests. The index embeds the manifest digests, so the index digest covers everything below it. The consequence is the property the whole supply chain leans on: change any blob and every digest above it changes. Flip one byte in a base layer and that layer’s digest changes, which changes the manifest that lists it, which changes the index that lists the manifest. You cannot alter content anywhere in the graph without the tag’s top digest moving. That is exactly why docker pull image@sha256:… is a hard guarantee: pinning the top digest pins the entire transitive set of bytes, the same way a git commit hash pins a whole tree.
▸Why this works
Why standardize on this typed graph instead of one fat manifest with everything inlined? Because the descriptor indirection is what makes images shareable and verifiable at once. Separating the small typed documents from the large content blobs lets a registry store each blob once by digest and lets a client fetch only the descriptors it needs to decide what to pull — it can read a 1 KB index, pick the one platform manifest matching its CPU, and skip the other architectures’ layers entirely. And because every edge carries the expected digest and size, a client validates each blob the moment it arrives, before trusting a single byte. One uniform primitive — the descriptor — gives you content addressing, lazy fetching, multi-arch selection, and tamper-evidence, all from the same three fields.
The media-type war: OCI versus schema2
The descriptors only work if both ends agree on the vocabulary, and there are two vocabularies in the wild. Docker’s original is schema2: application/vnd.docker.distribution.manifest.v2+json for the manifest and application/vnd.docker.image.rootfs.diff.tar.gzip for layers. OCI’s is the application/vnd.oci.image.* family above. The bytes a layer carries are the same gzipped tar either way; only the type string differs. But old registries and old clients dispatch on that string, so an image tagged with OCI media types pushed to a registry that only codes for schema2 fails with manifest unknown or a pull error — not because anything is corrupt, but because the receiver has no handler for that mediaType. This is why build tooling exposes format flags: BuildKit’s --output type=image,oci-mediatypes=true|false and buildx’s --provenance=false (provenance attestations force an OCI index, which some registries reject). Modern Docker is OCI-compatible and, with the containerd snapshotter image store, the daemon emits OCI media types by default — which is precisely the change that surprised the team in the hook. When you hit manifest unknown against an old registry, the first move is to re-push with schema2 media types and see if the error clears.
A fifth thing rides on the same descriptor plumbing: the referrers / subject mechanism. A manifest can carry a subject descriptor pointing at another image’s manifest by digest, which attaches an artifact — a signature, an SBOM, a provenance attestation — to that image without changing its digest. The registry’s referrers API lets you ask “what is attached to sha256:…?” and get back the descriptors of every signature and SBOM. The attachment is by digest, so it inherits the same tamper-evidence: the artifact names the exact image bytes it vouches for.
A push to an old registry fails with manifest unknown, yet the same image runs everywhere else. The build host was recently upgraded to the containerd image store. Most likely cause?
- 01Name the four OCI document types, their media types, and how each references the next.
- 02Why is an OCI image a Merkle DAG, and what does the manifest-unknown error against an old registry actually mean?
The OCI Image Spec is the vendor-neutral standard that lifted Docker’s v2 schema2 manifest out of one vendor — the project formed in 2015, split the media types in 2016, and shipped v1.0 in 2017. It defines four JSON document types and one linking primitive. The primitive is the descriptor: exactly {mediaType, digest, size}, where the type names what is at the other end, the sha256 digest content-addresses it, and size lets a client verify before fetching. Every reference in an image is a descriptor. The index (application/vnd.oci.image.index.v1+json) is the multi-arch fan-out, one descriptor per platform; each points at a manifest (application/vnd.oci.image.manifest.v1+json, about 2 KB), which points at one config (application/vnd.oci.image.config.v1+json, whose digest is the image ID) and an ordered array of layer blobs (application/vnd.oci.image.layer.v1.tar+gzip). Because every edge is a digest of its target, the whole image is a Merkle DAG like a git graph: change any blob and every digest above it moves, which is why pinning image@sha256 pins the entire transitive set of bytes. The media types matter as much as the bytes: schema2 (vnd.docker.distribution.manifest.v2+json) and OCI carry identical layer tarballs, but old registries and clients dispatch on the type string, so an OCI-tagged image pushed to a schema2-only registry fails with manifest unknown — a mismatch, not a corruption — and the fix is build format flags (oci-mediatypes, provenance) to emit schema2. Modern Docker is OCI-compatible and the daemon emits OCI media types by default once the containerd snapshotter image store is on, which is the exact change that breaks against legacy registries. Riding the same descriptor plumbing, the subject/referrers field attaches signatures and SBOMs to an image by pointing at its manifest digest, inheriting the DAG’s tamper-evidence. Read the image as a typed, content-addressed graph and manifest unknown stops being a mystery — it is a media-type you can name. Now when you see that error against a registry you do not control, your first move is to check which media-type family the registry speaks and emit that one from the build flags, not to assume the image is broken.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.