# Inside an OCI image: tag, index, manifests, config and layers

> How a registry stores a multi-platform container image, with its attestations and a signature, and how containerd walks from a tag to a running container's root filesystem.

Interactive graph: https://k8wiz.com/graphs/oci-image-index-manifest-config-layers

A tag like `ghcr.io/acme/shop:1.4.2` points at a tree of JSON documents and tarballs, each stored under the SHA-256 digest of its bytes. The index lists one manifest per platform. Each manifest lists a config and the layers, in the order they stack.

Signatures and SBOMs sit next to the image, not inside it, because adding anything to the image would change its digest. They point back at it with a `subject` field, and the registry's referrers API finds them. The article [OCI images explained](/articles/oci-images-explained-manifests-layers-media-types-and-artifacts) covers every object here in more detail.

## Resources

### Pod shop-7d9f8c6b5-x2k4p

you write it, v1, namespace shop

Asks for `ghcr.io/acme/shop:1.4.2`. Pinning `@sha256:…` instead would skip the tag lookup and make every node run exactly the same bytes.

### Container runtime containerd

comes with an add-on

Pulls for the kubelet. It sends an `Accept` header listing every manifest format it understands, picks the manifest for the node's platform, downloads only the blobs its content store lacks, and checks each one against its digest and size.

### Tag shop:1.4.2

made by a build or publishing tool

A name that points at a digest, here the index's. Tags can be moved to another digest at any time, which is why `1.4.2` today may not be `1.4.2` tomorrow.

### Image index sha256:5e1a…

made by a build or publishing tool

Media type `application/vnd.oci.image.index.v1+json`. Lists one manifest per platform, plus attestation manifests that `docker buildx` adds. Pin this digest to keep the image multi-platform.

### Image manifest linux/amd64 sha256:7d1c…

made by a build or publishing tool

Media type `application/vnd.oci.image.manifest.v1+json`. One image for one platform: a config descriptor and three layer descriptors, bottom layer first.

### Image manifest linux/arm64 sha256:e83a…

made by a build or publishing tool

The arm64 build, with its own config and layers. An amd64 node never downloads any of it. A node whose platform isn't in the index fails with `no match for platform in manifest`.

### Attestation manifest unknown/unknown sha256:0c4d…

made by a build or publishing tool

Added by `docker buildx` for the amd64 image. Its layers have the media type `application/vnd.in-toto+json` and hold SLSA provenance and, if you asked for one, an SPDX SBOM. The `unknown/unknown` platform keeps runtimes from trying to run it.

### Image config sha256:b5d2…

made by a build or publishing tool

Media type `application/vnd.oci.image.config.v1+json`. What the runtime needs besides files: user, environment, entrypoint and working directory, plus `rootfs.diff_ids`, the digests of the uncompressed layers.

### Layer #1 base OS, 29 MB

made by a build or publishing tool

A gzipped tar of the Debian base filesystem. Every image built on the same base shares this blob in the registry and this snapshot on the node, so it's usually already there.

### Layer #2 packages, 8 MB

made by a build or publishing tool

Only the files that `apt-get install` added or changed. A file deleted here would show up as a whiteout entry named `.wh.<name>`, and the bytes in layer 1 would still be shipped.

### Layer #3 app binary, 14 MB

made by a build or publishing tool

The `COPY` of the application binary. It changes on every release, so it's last: rebuilding reuses layers 1 and 2, and a node that ran the previous version downloads only this one.

### Signature cosign bundle

made by a build or publishing tool

A separate manifest created by `cosign sign`. Its `subject` points at the index digest, so the registry lists it under `GET /v2/acme/shop/referrers/sha256:5e1a…`. Admission policies such as Kyverno's `verifyImages` look it up before a Pod may start.

### Snapshots overlayfs root filesystem

created by a controller

containerd unpacks each layer into a snapshot on top of the one below, keyed by chain ID, and mounts them as overlay lower directories. The container gets an empty writable layer on top, which disappears with the container.

## How the resources connect

- Pod shop-7d9f8c6b5-x2k4p → Tag shop:1.4.2: image: (references)
- Container runtime containerd → Tag shop:1.4.2: resolves (traffic)
- Tag shop:1.4.2 → Image index sha256:5e1a…: points at digest (references)
- Image index sha256:5e1a… → Image manifest linux/amd64 sha256:7d1c…: linux/amd64 (references)
- Image index sha256:5e1a… → Image manifest linux/arm64 sha256:e83a…: linux/arm64 (references)
- Image index sha256:5e1a… → Attestation manifest unknown/unknown sha256:0c4d…: attestation-manifest (references)
- Attestation manifest unknown/unknown sha256:0c4d… → Image manifest linux/amd64 sha256:7d1c…: reference.digest (references)
- Signature cosign bundle → Image index sha256:5e1a…: subject (references)
- Image manifest linux/amd64 sha256:7d1c… → Image config sha256:b5d2…: config (references)
- Image manifest linux/amd64 sha256:7d1c… → Layer #1 base OS, 29 MB: layers[0] (references)
- Image manifest linux/amd64 sha256:7d1c… → Layer #2 packages, 8 MB: layers[1] (references)
- Image manifest linux/amd64 sha256:7d1c… → Layer #3 app binary, 14 MB: layers[2] (references)
- Container runtime containerd → Image manifest linux/amd64 sha256:7d1c…: picks node platform (traffic)
- Layer #1 base OS, 29 MB → Snapshots overlayfs root filesystem: lowest (traffic)
- Layer #2 packages, 8 MB → Snapshots overlayfs root filesystem: on top (traffic)
- Layer #3 app binary, 14 MB → Snapshots overlayfs root filesystem: top (traffic)
- Image config sha256:b5d2… → Snapshots overlayfs root filesystem: diff_ids verify (references)
- Container runtime containerd → Snapshots overlayfs root filesystem: unpacks (creates)

## How it works

1. **The tag resolves to an index.** The kubelet asks containerd for `ghcr.io/acme/shop:1.4.2`. containerd sends `GET /v2/acme/shop/manifests/1.4.2` with an `Accept` header for OCI and Docker formats, and gets back the index with its digest in the `Docker-Content-Digest` header.
2. **containerd picks a platform.** The index lists amd64, arm64 and an attestation manifest. On an amd64 node, containerd fetches only the amd64 manifest. The arm64 one and the `unknown/unknown` attestation are ignored.
3. **The manifest names a config and three layers.** The manifest lists blobs by digest. containerd fetches the config and each layer it doesn't already have, from `/v2/acme/shop/blobs/<digest>`, several in parallel, and checks every download against its digest and size.
4. **Layers stack into the root filesystem.** Each layer is decompressed, checked against its `diff_id` from the config, and applied as a snapshot on top of the one below. Layer 1 is the base OS, layer 2 adds packages, layer 3 adds the binary. Whiteout files in an upper layer hide files from lower ones.
5. **The config starts the process.** The runtime builds the container from the snapshots and the config: user `10001`, the `PATH` variable, working directory `/app` and the entrypoint `/usr/local/bin/shop`. Any `command` or `args` in the Pod spec replace the config's entrypoint and command.
6. **Signatures and attestations live beside the image.** The cosign signature is its own manifest with `subject` set to the index digest. The image's digest never changes, and a verifier finds the signature through the referrers API. The buildx attestation sits in the index and points at the amd64 manifest with `vnd.docker.reference.digest`.

## Common mistakes

- `exec format error` at startup almost always means the image has no manifest for the node's platform, or one built for the wrong one. `crane manifest ghcr.io/acme/shop:1.4.2` shows which platforms the index lists.
- Pinning a single platform's manifest digest in a cluster with mixed architectures makes the Pods fail on the other nodes. Pin the index digest.
- Deleting a file in a later Dockerfile step doesn't shrink the image. The layer that added it still ships, and the later layer adds a whiteout. Remove temporary files in the same `RUN` that created them.
- A signature made for one digest doesn't cover another. Re-tagging and pushing rebuilt bytes needs a new signature, and so does a manifest that a registry or a copy tool rewrote.
- With `imagePullPolicy: IfNotPresent` and a moving tag, nodes that pulled `1.4.2` earlier keep the old bytes. Use digests, or immutable tags enforced by the registry.

## The manifests you write

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: shop
  namespace: shop
spec:
  containers:
    - name: shop
      image: ghcr.io/acme/shop:1.4.2
      imagePullPolicy: IfNotPresent
```
