Virtualization · Image Supply Chain
How an OCI Manifest and Index Name Their Layers
The descriptor graph behind a single image reference, how platform selection picks one manifest out of an index, and the failure cases in the matching rules.
A reference like alpine:3.20 resolves to one of two document shapes. It is either a manifest, which describes a single image, or an index, which describes a set of manifests and leaves the choice to the client. The distribution protocol does not distinguish them at the API level: both are fetched from /v2/<name>/manifests/<reference> and both are served with a media type that identifies which shape the body is. Everything about how an image is assembled follows from that small branching decision, and almost every confusing pull failure traces back to a client that guessed the shape or matched a platform attribute carelessly.
One descriptor, five fields
The connective tissue of the whole graph is the descriptor. A descriptor is a JSON object with a media type, a digest, a size, and optionally a platform and annotations.
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:6b0d4a5e...",
"size": 2935847,
"annotations": {
"org.opencontainers.image.title": "bin"
}
}
The size is the number of bytes of the blob as stored, and it must match the digest’s preimage. The mediaType tells the client how to interpret the bytes, and it is load-bearing: a layer tagged tar+gzip must be decompressed as gzip before it is unpacked as tar, while tar is already a tar stream, and tar+zstd needs a different decoder. Clients that dispatch on the digest alone and assume gzip will fail on non-gzip layers.
platform appears only on descriptors inside an index. It carries architecture, os, and optionally variant, os.version, and os.features. These strings are not free-form in intent; the specification defers to the platform conventions used by Go’s GOOS/GOARCH naming, which is why values like arm64 and v7 appear rather than aarch64 and armv7l.
What a manifest actually pins
A manifest lists a config descriptor and an ordered array of layer descriptors. Order is semantic, not incidental. Layers are applied in the array order, later layers shadowing earlier ones, and a client that reorders them produces a different filesystem. The config blob holds the diff IDs (one per layer, in the same order) and those are digests of the uncompressed tar streams, distinct from the digests of the compressed blobs.
The distinction matters during verification. A compressed blob’s digest is what the registry can check on upload and what the client checks on download. A diff ID is what lets an implementation confirm that the layer it unpacked is the layer whose content the config describes, and it is what makes cross-repository layer sharing safe when two manifests reference the same compressed blob with different compression settings. A client that verifies only the compressed digest has not verified the uncompressed content against the config.
The config blob itself is small, on the order of a few kilobytes, and contains the architecture and OS of the image, the rootfs diff IDs, the history array, and the runtime settings. It is the authoritative statement of what platform the image targets. A manifest does not repeat platform information; an index’s descriptor does, and that duplication is a place where producers get things wrong.
Index selection is client-side, and the rules are soft
An index contains an array of manifests, each with a platform. The client picks one. There is no server-side negotiation: the registry returns the whole index and the client decides. That design keeps registries simple and keeps the choice close to the thing that knows its own capabilities, but it means selection quality is entirely a client property.
The matching rules in the specification are recommendations, and different clients implement them differently. A reasonable order is:
- Match
osandarchitectureexactly. - If the client needs a variant, prefer an exact
variantmatch, then a compatible one. ARM is where this becomes contentious: a client onarm/v7can often runarm/v6images, and the variant naming conventions have been interpreted inconsistently across producers. - Consider
os.versiononly where the OS defines it, which in practice means Windows compatibility boundaries. - Fall back to no match and fail rather than guessing.
The failure mode is subtle. An index may contain a single manifest with no platform field at all, which is legal and means the index is not a multi-platform selection but a level of indirection. Clients that treat a missing platform as “unknown, skip” will report that no image is available for the host. Clients that treat it as “matches anything” get the right answer here and the wrong answer when a producer omits the field on one entry out of five.
def select(index, want_os, want_arch, want_variant=None):
exact, loose = [], []
for m in index["manifests"]:
p = m.get("platform")
if p is None:
# An index entry with no platform is an indirection, not a match.
loose.append(m)
continue
if p.get("os") != want_os or p.get("architecture") != want_arch:
continue
if want_variant and p.get("variant") == want_variant:
exact.append(m)
elif want_variant and p.get("variant") is None:
exact.append(m) # no variant declared: usable
else:
loose.append(m)
if exact:
return exact[0]
if loose:
return loose[0]
raise LookupError("no manifest for %s/%s" % (want_os, want_arch))
Two details in that snippet are worth calling out because they cause real pull failures. First, a manifest whose platform omits variant is usually the right choice for any variant of that architecture, and rejecting it because the variant string is absent produces spurious “no matching manifest” errors. Second, an index entry without a platform must not be discarded, because a nested index is a normal construction: an outer index may point at per-architecture indexes, each of which then points at a manifest.
Nested indexes and the depth question
The specification permits an index to contain another index. Producers use this to group by architecture and then by variant, or to separate Windows versions under one os. A client must therefore recurse, and it must bound the recursion. Without a depth limit, a malicious or malformed index can point at itself by digest and drive a client into unbounded fetches; with a limit set too low, legitimate two-level layouts fail to resolve.
The practical bound is small. Two levels covers the constructions that appear in practice, and a limit of three or four is generous. Each level costs one manifest fetch, so an index tree of depth d with n entries per level costs up to O(n·d) round trips before the first layer download begins, which is the reason a client should fetch only the branch it needs rather than walking the tree.
Media types are the compatibility surface
The manifest and index media types carry the version and the provenance of the format. Docker’s original schema 2 types (application/vnd.docker.distribution.manifest.v2+json and its list counterpart) and the OCI types (application/vnd.oci.image.manifest.v1+json and application/vnd.oci.image.index.v1+json) describe structurally similar documents with different field spellings and different rules for annotations.
A client must accept both if it wants to pull from the general population of registries, and it must decide which to send when it pushes. Registries advertise accepted types, but many accept the OCI types whether or not they advertise them, and some older registries reject them. The media type also travels in the descriptor that points at the manifest, so a mismatch between the descriptor’s declared type and the actual document shape is a detectable error, and a client that does not check it will parse the wrong structure and fail later with an unhelpful message.
There is a compatibility trap specific to indexes. Because the Docker list type predates OCI, a client that only understands Docker types will see an OCI index and treat it as an opaque blob. Since a single-platform image is often published as a Docker manifest and a multi-platform set as an OCI index, a client with narrow type support works for simple images and fails for exactly the images that need selection logic.
Where the graph becomes a trust boundary
Every edge in this graph is a digest, and every digest is checked against bytes the client fetched. That makes the graph structurally trustworthy from the top down, provided the top is trusted. The client chooses a digest (from a tag lookup, from a deployment manifest, or from a signature) and then all subsequent fetches are constrained by what that digest’s document names.
The weakness is that a signature over a manifest does not automatically cover the index above it, nor the manifests beside it. Signing an index commits to the digests of its children, and therefore transitively to their layers; signing one manifest inside an index commits to nothing about its siblings. Which of these a producer signs, and what a verifier requires, is the subject of What an Image Signature Covers. Before that, the question of how the client obtains the bytes at all (authentication, redirects, chunked transfer, and the resumable upload protocol) is handled in The Registry Pull Path From Manifest to Blob.
References
- OCI Image Format Specification — Open Container Initiative
- OCI Image Index Specification — Open Container Initiative
- Go language documentation — runtime.GOARCH — Go project