k8wiz
Back to Articles

Ready to test your Kubernetes knowledge? Try our Kubernetes quiz!

OCI images explained: manifests, layers, media types and artifacts

What an OCI image is made of, how layers stack, how Helm charts and Flux artifacts reuse the format, and how tools fetch only the pieces they need.

14 min read
By k8wiz Team
  • oci
  • containers
  • registry
  • helm
  • flux
  • image-volume

A container image looks like one thing when you type docker pull, but a registry stores it as a handful of JSON documents and tarballs, each named by the SHA-256 of its bytes. Once you know those pieces, a lot of behavior stops being mysterious: why a multi-arch tag gives different bytes on a Mac and on a server, why deleting a file in a Dockerfile doesn't shrink the image, how a Helm chart fits in a container registry, and how Flux pulls one layer out of an artifact and ignores the rest.

This guide walks through the format from the bottom up, then shows how real tools query it. The interactive graph of an image in a registry and the graph of OCI artifacts draw the same objects, if you'd rather click through them.

Three specifications

The Open Container Initiative maintains three specs, and they split the work cleanly.

  • The image spec defines the documents: image index, image manifest, image config, layers and the descriptors that link them.
  • The distribution spec defines the HTTP API a registry serves: push and pull manifests and blobs, list tags, find referrers.
  • The runtime spec defines how a runtime such as runc starts a container from an unpacked filesystem and a config.json. Registries never see it.

Version 1.1 of the image and distribution specs, released in February 2024, made registries officially general-purpose. It added the artifactType and subject fields to manifests and the referrers API, which is how signatures and SBOMs get attached to an image without changing its digest.

Everything is content-addressed

A registry is a key-value store where the key is a digest such as sha256:4f2c8e…, the hash of the exact bytes stored. Change one byte and the key changes. That gives you two properties for free. Content can't be altered without changing its name, and identical content is stored once no matter how many images use it.

Objects point at each other with descriptors. A descriptor is a small JSON object that says what the target is, its digest and its size:

{
  "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
  "digest": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
  "size": 29150641
}

The client checks both the size and the digest after download, so a registry or proxy that corrupts or swaps a blob gets caught. Descriptors can also carry annotations, platform (in an index) and artifactType.

The four kinds of object

A typical multi-platform image is a small tree. A tag points at an index, the index points at one manifest per platform, and each manifest points at a config and an ordered list of layers.

Image index

The index, media type application/vnd.oci.image.index.v1+json, lists manifests and says which platform each is for. Docker's older equivalent is the manifest list, application/vnd.docker.distribution.manifest.list.v2+json.

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:7d1c…",
      "size": 1024,
      "platform": { "architecture": "amd64", "os": "linux" }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:e83a…",
      "size": 1024,
      "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" }
    }
  ]
}

A client pulling shop:1.4.2 on an arm64 node reads this, picks the entry matching its platform and fetches only that manifest. The amd64 layers are never downloaded. That's also why docker inspect on two machines can show different image IDs for the same tag.

Image manifest

The manifest, application/vnd.oci.image.manifest.v1+json, describes one image for one platform: a config and the layers, in order.

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:b5d2…",
    "size": 2913
  },
  "layers": [
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:a3ed…", "size": 29150641 },
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:1f8b…", "size": 8312447 },
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:c09e…", "size": 14223104 }
  ],
  "annotations": {
    "org.opencontainers.image.source": "https://github.com/acme/shop",
    "org.opencontainers.image.revision": "4f2c8e1"
  }
}

The digest of this document is what you pin with image: ghcr.io/acme/shop@sha256:7d1c…. Pinning the index digest works too and keeps the image multi-platform.

Image config

The config, application/vnd.oci.image.config.v1+json, holds everything the runtime needs that isn't files: platform, environment, entrypoint, working directory, user, exposed ports, labels, plus the build history and the list of layer diff_ids.

{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "User": "10001",
    "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
    "Entrypoint": ["/usr/local/bin/shop"],
    "WorkingDir": "/app"
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": ["sha256:9a1b…", "sha256:44d0…", "sha256:7e3f…"]
  },
  "history": [
    { "created_by": "debian:bookworm-slim base" },
    { "created_by": "RUN apt-get install -y ca-certificates tzdata" },
    { "created_by": "COPY shop /usr/local/bin/shop" },
    { "created_by": "ENTRYPOINT [\"/usr/local/bin/shop\"]", "empty_layer": true }
  ]
}

In Docker's classic image store, the config's digest is the image ID. With the containerd image store, Docker shows the manifest or index digest instead. Instructions like ENV or ENTRYPOINT only change the config, and the history marks them empty_layer: true.

Layers

A layer is a tar archive of filesystem changes, usually compressed. The image spec defines three media types:

Media typeContent
application/vnd.oci.image.layer.v1.tarUncompressed tar
application/vnd.oci.image.layer.v1.tar+gzipGzip, the default almost everywhere
application/vnd.oci.image.layer.v1.tar+zstdZstandard, faster to decompress and usually smaller

Docker's own format uses application/vnd.docker.image.rootfs.diff.tar.gzip. The "non-distributable" layer types from the 1.0 spec, meant for Windows base layers, are deprecated in 1.1 and new tools shouldn't write them.

Zstd layers need containerd 1.5 or later. If old nodes or scanners still pull your images, check before you switch, because a client that doesn't know a media type refuses the whole image.

How layers stack

Each layer records what changed compared with the layers below it. The runtime unpacks them in order, and the result is the container's root filesystem. On Linux, containerd's overlayfs snapshotter keeps each layer as a separate directory and stacks them as overlay lowerdirs, with an empty writable upperdir on top for the running container.

Adding or changing a file is easy, because the layer just contains the new file. Deleting needs a marker, because a tar can't express "this file is gone". The spec uses whiteout files:

  • A file named .wh.config.yaml in a layer means config.yaml from a lower layer is deleted.
  • A file named .wh..wh..opq in a directory makes the directory opaque: everything below it from lower layers is hidden, and only this layer's contents show.

That explains the classic surprise. A Dockerfile that downloads a 500 MB archive in one RUN and deletes it in the next produces an image that still carries the 500 MB, in the lower layer. The upper layer only adds a whiteout. Download and delete in the same RUN, or use a multi-stage build, and the archive never lands in a layer.

Two digests per layer

Every layer has two digests, and mixing them up confuses many people.

  • The layer digest in the manifest is the hash of the compressed blob. It's what the registry stores and what you download.
  • The diff ID in the config's rootfs.diff_ids is the hash of the uncompressed tar. It's what the runtime checks after decompressing.

The same filesystem change compressed with gzip and with zstd gives two layer digests but one diff ID. Runtimes go one step further and compute a chain ID for each position in the stack: the first layer's chain ID is its diff ID, and each later one is the SHA-256 of the previous chain ID, a space and the layer's diff ID. A snapshot with a given chain ID is the same filesystem no matter which image it came from, so two images built on the same base share the unpacked base on disk.

Layer order and caching

Builders cache by layer. If a step and everything above it are unchanged, the layer is reused, and a registry skips the upload because it already has the digest. Put rarely changing steps (base image, OS packages, dependency install) first and fast-changing ones (copying your source) last. Changing a line near the top invalidates every layer above it.

Multi-platform images and attestations

docker buildx build --platform linux/amd64,linux/arm64 pushes one manifest per platform and an index over them. With provenance or SBOM generation on, which recent buildx does by default for provenance, the index gets extra entries that confuse people the first time:

{
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "digest": "sha256:0c4d…",
  "platform": { "architecture": "unknown", "os": "unknown" },
  "annotations": {
    "vnd.docker.reference.type": "attestation-manifest",
    "vnd.docker.reference.digest": "sha256:7d1c…"
  }
}

That's an attestation manifest for the amd64 image. Its layers have the media type application/vnd.in-toto+json, and each layer's in-toto.io/predicate-type annotation says whether it's an SBOM (https://spdx.dev/Document) or SLSA provenance. The unknown/unknown platform keeps runtimes from ever trying to run it.

Not just containers: OCI artifacts

Nothing in the distribution API cares what the blobs contain. A manifest whose config and layers have other media types is an artifact, and registries store it like any image. Clients tell artifacts apart by artifactType or, in older tools, by the config's media type.

ContentConfig media typeLayer media types
Container imageapplication/vnd.oci.image.config.v1+jsonapplication/vnd.oci.image.layer.v1.tar+gzip
Docker imageapplication/vnd.docker.container.image.v1+jsonapplication/vnd.docker.image.rootfs.diff.tar.gzip
Helm chartapplication/vnd.cncf.helm.config.v1+jsonapplication/vnd.cncf.helm.chart.content.v1.tar+gzip, plus application/vnd.cncf.helm.chart.provenance.v1.prov if signed
Flux artifactapplication/vnd.cncf.flux.config.v1+jsonapplication/vnd.cncf.flux.content.v1.tar+gzip
Cosign signature (classic)application/vnd.oci.image.config.v1+jsonapplication/vnd.dev.cosign.simplesigning.v1+json
Generic ORAS fileapplication/vnd.oci.empty.v1+jsonWhatever you declare, such as application/vnd.acme.report.v1+json

Helm charts

helm push shop-0.9.0.tgz oci://ghcr.io/acme/charts stores the chart as ghcr.io/acme/charts/shop:0.9.0. The config blob is Chart.yaml as JSON, and the single content layer is the packaged .tgz. Helm replaces a + in the version with _ in the tag, because tags can't contain +.

helm install shop oci://ghcr.io/acme/charts/shop --version 0.9.0 fetches the manifest, finds the layer with the chart media type and downloads only that. There's no index.yaml, so listing versions means listing tags.

Flux artifacts

flux push artifact oci://ghcr.io/acme/manifests/shop:4f2c8e1 --path=./deploy --source=https://github.com/acme/shop --revision=main@sha1:4f2c8e1 packs a directory of YAML into one layer and records the Git source and revision as annotations. An OCIRepository pulls it, and a Kustomization applies it, with no Git access from the cluster.

Empty configs and artifactType

An artifact with nothing to put in its config uses the empty descriptor defined in the 1.1 spec. It always has the same digest, because its content is always {}:

{
  "mediaType": "application/vnd.oci.empty.v1+json",
  "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
  "size": 2,
  "data": "e30="
}

The manifest's artifactType then says what it is. ORAS writes this layout by default:

oras push ghcr.io/acme/reports/scan:4f2c8e1 \
  --artifact-type application/vnd.acme.scan-report.v1 \
  report.json:application/vnd.acme.report.v1+json

Each file becomes a layer with an org.opencontainers.image.title annotation holding its file name, which is how oras pull knows what to call it on disk.

Signatures, SBOMs and referrers

A signature can't live inside the image it signs, because adding it would change the image's digest. The 1.1 answer is the subject field. The signature is a separate manifest whose subject descriptor points at the image. The registry indexes it, and anyone can ask for everything that refers to a digest:

GET /v2/acme/shop/referrers/sha256:7d1c…?artifactType=application/vnd.dev.sigstore.bundle.v0.3+json

The answer is an image index listing the matching manifests. On a registry without the referrers API, clients fall back to a tag named after the digest, sha256-7d1c…, which holds that index. Classic cosign used a similar convention, the tag sha256-7d1c….sig, before referrers existed. Recent cosign versions can store a Sigstore bundle and attach it through referrers instead.

Image volumes in Kubernetes

Since Kubernetes 1.36, image volumes (KEP-4639) are stable. A Pod can mount the filesystem of any image as a read-only volume, so data, models or config ship through the registry you already have, and the application image stays small:

apiVersion: v1
kind: Pod
metadata:
  name: inference
spec:
  containers:
    - name: server
      image: ghcr.io/acme/inference-server:3.2.0
      volumeMounts:
        - name: model
          mountPath: /models/sentiment
          readOnly: true
  volumes:
    - name: model
      image:
        reference: ghcr.io/acme/models/sentiment:2026-10
        pullPolicy: IfNotPresent

The volume image is an ordinary image, usually built FROM scratch with a single COPY of the files. The container runtime pulls it like any other image and mounts its unpacked layers. You need a runtime that supports it: containerd 2.1 or later, or CRI-O 1.31 or later. CRI-O can also mount OCI artifacts that aren't images. With containerd, build the volume as an image.

How tools query a registry

Every tool above speaks the same small HTTP API. You can drive it with curl. Here's the full flow for the public alpine image on Docker Hub.

  1. Get an anonymous pull token:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r .token)
  1. Fetch the tag. The Accept header lists the formats you understand, and the registry answers with one of them. The Docker-Content-Digest response header gives the digest:
curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" \
  https://registry-1.docker.io/v2/library/alpine/manifests/3.20 | jq .
  1. Pick a platform from the index and fetch that manifest by digest, with the same request and /manifests/sha256:… at the end.

  2. Fetch a blob, the config or a layer. Docker Hub answers with a redirect to its CDN, so follow it:

curl -sL -H "Authorization: Bearer $TOKEN" \
  https://registry-1.docker.io/v2/library/alpine/blobs/sha256:<config digest> | jq .config

A HEAD request on a manifest returns the digest without the body. That's why imagePullPolicy: Always is cheap. The runtime resolves the tag on every Pod start, but it only downloads blobs it doesn't already have.

The same with the usual tools

These all wrap the calls above.

crane manifest alpine:3.20                          # the index, raw
crane manifest --platform linux/arm64 alpine:3.20   # one platform's manifest
crane config alpine:3.20 | jq .config.Entrypoint    # just the config
crane blob alpine@sha256:<layer digest> | tar -tz    # list the files in one layer
crane digest alpine:3.20                            # what to pin
skopeo inspect --raw docker://docker.io/library/alpine:3.20
docker buildx imagetools inspect --raw ghcr.io/acme/shop:1.4.2
oras manifest fetch ghcr.io/acme/charts/shop:0.9.0
oras discover ghcr.io/acme/shop:1.4.2                # signatures, SBOMs and other referrers

Choosing one layer

A client that wants a specific piece reads the manifest and picks a layer by media type, then downloads only that blob.

  • Helm takes the layer whose media type is application/vnd.cncf.helm.chart.content.v1.tar+gzip.
  • Flux's OCIRepository has a layerSelector for it. With operation: extract, the default, source-controller unpacks the layer as the artifact. With operation: copy it keeps the tarball as is, which is what a HelmRelease's chartRef needs:
spec:
  url: oci://ghcr.io/stefanprodan/charts/podinfo
  ref:
    semver: ">=6.7.0"
  layerSelector:
    mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
    operation: copy
  • ORAS can filter by file name, because each layer carries an org.opencontainers.image.title annotation.

Reading part of a layer

Some setups go further and fetch only part of a blob with HTTP Range requests, so a container starts before its image is fully downloaded. eStargz, used by the stargz snapshotter, and zstd:chunked, used by Podman and CRI-O, append a table of contents to each layer that maps files to byte ranges. AWS's SOCI keeps the original layers and stores the index as a separate artifact that refers to the image. In all three, the runtime fetches the files a process opens first and pulls the rest in the background.

What containerd does on a pull

When the kubelet asks containerd for ghcr.io/acme/shop:1.4.2, these are the steps you'd see in the containerd debug logs:

  1. Resolve the tag with a HEAD or GET on /manifests/1.4.2, sending an Accept header for every format it supports.
  2. If it gets an index, pick the manifest for the node's platform.
  3. Fetch the manifest and the config, then every layer the content store doesn't already have, several in parallel. Each blob is checked against its digest and size.
  4. Decompress each layer, check it against its diff_id, and apply it to a snapshot on top of the previous layer's snapshot, keyed by chain ID.
  5. Record the image name and digest, which is what crictl images --digests lists.

Blobs live under /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ and the unpacked layers under the snapshotter's directory. Garbage collection removes blobs and snapshots that no image references anymore.

Practical checklist

  • Pin production images by digest, image@sha256:…. Tags can be moved, digests can't.
  • Pin the index digest, not one platform's manifest, if your cluster mixes architectures.
  • Check what you're about to pull with crane manifest or docker buildx imagetools inspect before blaming the runtime for an exec format error. It's usually an image without your platform.
  • Keep secrets and big temporary files out of every layer, not just the last one. Whiteouts hide files, they don't remove them.
  • Order Dockerfile steps from least to most frequently changed, so the cache and the registry can reuse lower layers.
  • Before switching layers to zstd, make sure every runtime, scanner and proxy that pulls the image supports it.
  • Use the referrers API, through cosign, notation or oras discover, to find signatures and SBOMs instead of guessing tag names.

Test yourself

Ready to test your Kubernetes knowledge? Try our Kubernetes quiz!