unkletayo
3

OCI Specifications Explained: How runc and Container Images Actually Work

What the vendor-neutral container standards say, how an image manifest is laid out, and what a filesystem bundle contains.

Adetayo Akinsanya 2026-10-06 13 min read

  • Docker
  • OCI
  • Container Standards
  • runc
  • DevOps
  • Linux

Part 3 of the series. Read What Happens When You Type docker run? From CLI to containerd to runc (Part 2) first if you have not.

In this article (4 sections)
  1. 1. The OCI Image Specification
  2. 2. The OCI Runtime Specification & Filesystem Bundle
  3. OCI Architectural Decoupling Matrix
  4. Summary

Before you start: you should know roughly what a container and an image are (Part 1), have seen the containerd, shim and runc chain from Part 2, and be able to read simple JSON (text made of "key": value pairs, with { } for objects and [ ] for lists).

Shipping containers work all over the world because everyone agreed on one set of sizes and one kind of lock. A crane in any port can lift a box that was loaded in any other country, and nobody has to know who packed it. Electrical plugs and sockets work the same way: any appliance with the right plug fits any socket that follows the same standard. The Open Container Initiative (OCI) is that kind of agreement for software containers. If a tool follows the agreed format, it can build, move, or run what another tool made.

Here is how that agreement came about.

In 2015, the container ecosystem faced a major risk of fragmentation.

Docker was the dominant container engine, but competing vendors and cloud providers began creating rival container formats and execution runtimes (such as CoreOS’s rkt and appc specification). rkt was a container runtime from the company CoreOS, and appc (App Container) was CoreOS’s own written format for container images, a rival to Docker’s.

If every cloud vendor required a different image format or runtime engine, developers would be trapped rebuilding separate container packages for every cloud infrastructure target.

To prevent vendor lock-in and standardize container primitives, tech leaders formed the Open Container Initiative (OCI) under the Linux Foundation. Vendor lock-in means being stuck with one company’s product because switching away from it costs too much. The Linux Foundation is a non-profit organization that hosts open-source projects, and the OCI is an industry group it hosts that writes shared, public rules (specifications) for containers.

The OCI maintains three open standards. This article covers the first two in depth and names the third at the end:

  1. The OCI Image Specification (Image-Spec): Defines how container images are formatted, stored, content-addressed, and archived on disk or in registries. Content-addressed means a file is named by a fingerprint of its contents instead of a name someone chose. A registry is a server that stores container images and hands them out on request (Docker Hub is one).
  2. The OCI Runtime Specification (Runtime-Spec): Defines how an unpacked container bundle is executed on a host system by a low-level runtime (such as runc).
  3. The OCI Distribution Specification (Distribution-Spec): Defines the HTTP API (the agreed set of web requests) that tools use to push images to a registry and pull them back down. It appears again in the table near the end.

Understanding the first two specifications explains how any OCI-compliant runtime (like runc, crun, or gVisor) can execute an image built by Docker, Podman, or BuildKit. Compliant means it follows the specification. crun is a runtime written in C, and gVisor is a Google project that provides an OCI runtime (runsc) which adds an extra layer of isolation between the application and the host operating system. Podman is a container tool that, like Docker, can build and run containers, but it does not need a background daemon (a program that keeps running in the background and waits for commands). BuildKit is the build engine Docker uses to turn a Dockerfile into an image. Because the builder and the runner both read the same file layout, they do not need to know anything about each other.


1. The OCI Image Specification

Think of an image as a recipe box. It has a table of contents (the index), a card listing the parts of one recipe (the manifest), a card with the cooking settings (the config), and the ingredient bags (the layers).

An OCI container image is not a virtual machine disk image (a VM disk image is a full copy of a whole computer’s disk, and an image is not that). It is an archived tarball containing a set of cryptographic, content-addressable JSON files and tar compressed layer diffs.

A tarball is a single file that packs many files and folders together, like a zip file (the tar tool does the packing, and gzip is a separate step that compresses the result). A layer is one step’s worth of file changes in an image, and a layer diff is just the files that were added, changed, or removed in that step. Stacking the layers in order gives the full set of files.

Two terms appear throughout this section. SHA-256 is a fingerprint function: it turns any file into a fixed 64-character code, and a different file gives a different code. That code is called a digest. A blob is simply a file stored under the name of its digest.

When you inspect an OCI image layout on disk, you find three top-level entries (index.json, oci-layout, and the blobs/ folder), and inside blobs/sha256/ four blobs that hold the real content:

[ OCI Image Layout Structure ]
├── index.json               <-- Image Index (List of manifests for multi-arch support)
├── oci-layout               <-- Layout version identifier ({"imageLayoutVersion": "1.0.0"})
└── blobs/
    └── sha256/
        ├── e3b0c44298fc1c... <-- Image Manifest JSON
        ├── 7a8b9c1d2e3f4a... <-- Image Configuration JSON
        ├── 1a2b3c4d5e6f7a... <-- Layer 1 tar.gz payload
        └── 9f8e7d6c5b4a3f... <-- Layer 2 tar.gz payload

The long hex names are the digests (the fingerprints) of each file, and the hashes in these examples are shortened or made up to illustrate the layout. Multi-arch means one image name that has separate builds for different processor types, such as Intel/AMD (amd64) and ARM (arm64). The index is what lists those builds. The oci-layout file only records which version of the layout format the folder uses.


A. The Image Index (index.json) and the Image Manifest (Manifest Blob)

The index (index.json) lists manifests, one for each platform build of an image. Each manifest lists one image’s config and layers.

The Image Manifest acts as the entry point for an image version tag (e.g., nginx:latest). A tag is a human-readable label for one version of an image, so nginx:latest means the image named nginx with the tag latest. The manifest links the image configuration JSON file to its underlying filesystem layers:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:7a8b9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
    "size": 1472
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
      "size": 2814021
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e",
      "size": 1420102
    }
  ]
}

Notice that every referenced file is identified by its SHA-256 Digest. If a layer’s contents change by a single byte, its SHA-256 digest changes, enforcing immutable content-addressable storage. Immutable means it cannot be changed. If anyone alters a file, its fingerprint no longer matches the one written in the manifest, so the change is detected, and the same file can safely be shared between many images.

Here is what each key in the manifest means:

  • schemaVersion is the version of the manifest format. It is always 2 for current manifests.
  • mediaType at the top says what kind of document this file is (an OCI image manifest). Everywhere else in the file, mediaType says what kind of file the entry points to: a config JSON, or a gzip-compressed tar layer.
  • config points to the Image Configuration JSON blob.
  • layers is the list of layer blobs, in the order they are stacked.
  • digest is the SHA-256 fingerprint of the file being pointed to, which is also its name under blobs/sha256/.
  • size is the length of that file in bytes.

B. The Image Configuration JSON

While layers store the filesystem files, the Image Config JSON defines execution metadata (data about how to run the image, not part of its files) for the target runtime:

{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin", "NGINX_VERSION=1.25.1"],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "WorkingDir": "/",
    "ExposedPorts": { "80/tcp": {} }
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:c2ad4e0... (Uncompressed Layer 1 SHA)",
      "sha256:e8f1b2a... (Uncompressed Layer 2 SHA)"
    ]
  }
}

The hashes here are placeholders. Here is what the keys mean:

  • architecture is the processor type the image was built for. amd64 is the 64-bit Intel/AMD type.
  • os is the operating system the image expects.
  • Env sets environment variables, which are named settings a program can read. PATH is the list of folders the system searches when you type a command name.
  • Cmd is the command to run when the container starts. In "daemon off;", a daemon is a program that detaches and keeps running in the background. This nginx setting tells nginx to stay in the foreground instead, because a container exits when its main process exits. (This is nginx’s own use of the word, not Docker’s daemon.)
  • WorkingDir is the folder the command starts in.
  • ExposedPorts records that the program listens on port 80 over TCP. It is a note for people and tools, and does not open the port by itself.
  • rootfs lists the layers. Each entry in diff_ids is the SHA-256 of one layer after it has been uncompressed.

That last point means each layer has two different fingerprints. The digest in the manifest is the fingerprint of the compressed file (the tar+gzip blob that is stored and downloaded). The diff_ids entry in the config is the fingerprint of the same layer’s tar once uncompressed, which is what matters after you unpack it.


2. The OCI Runtime Specification & Filesystem Bundle

The image is the packed, stored form, like a zipped game. The bundle is the unpacked, ready-to-launch form: the game’s installed folder plus its launch settings.

While the Image Spec governs static image archives, the OCI Runtime Specification governs how a container is executed at runtime.

To execute a container, an OCI-compliant runtime like runc requires an OCI Filesystem Bundle. A filesystem is the organized tree of files and folders on a disk. Part 2 followed the chain containerd, then the shim, then runc, and said the shim hands runc an OCI bundle. This part opens that bundle.

An OCI Bundle is a simple directory on disk containing exactly two things:

[ OCI Bundle Directory ]
├── config.json         <-- Execution configuration required by runc
└── rootfs/             <-- Unpacked root filesystem directory

rootfs/ is the folder the container will see as its entire disk, holding /bin, /etc, and so on. The layers from the image, unpacked and stacked, end up here.


The config.json Runtime Contract

When containerd unpacks an OCI image for runc, it reads the Image Config JSON and translates it into an OCI Runtime config.json.

This is the step where the stored form (the image) becomes the runnable form (the bundle). Recapping Part 2: containerd fetches the image’s files and manages the container, the shim stays beside the container and calls runc, and runc reads the bundle to set up and start the process. The translation maps image settings to runtime settings. Image Cmd becomes process.args, image Env becomes process.env, and image WorkingDir becomes process.cwd. ExposedPorts has no counterpart here, because networking is set up by the container engine and not by this file.

This file does not name system calls. It lists the settings runc uses when it asks the Linux kernel to isolate and start the process. The kernel is the core of the operating system, and a system call is a request a program makes to it. The spec describes the file as the container’s process, its root filesystem, mounts, namespaces, cgroup resource limits, capabilities, and more:

{
  "ociVersion": "1.0.2",
  "process": {
    "terminal": false,
    "user": { "uid": 0, "gid": 0 },
    "args": ["nginx", "-g", "daemon off;"],
    "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"]
  },
  "root": {
    "path": "rootfs",
    "readonly": true
  },
  "linux": {
    "namespaces": [
      { "type": "pid" },
      { "type": "network" },
      { "type": "ipc" },
      { "type": "uts" },
      { "type": "mount" }
    ],
    "resources": {
      "memory": { "limit": 536870912 }
    }
  }
}

Here is what each part of the file means. This example is shortened, and the spec also requires a process.cwd entry in a full file.

  • ociVersion is the version of the Runtime Spec the file follows.
  • process is what to run and as whom. terminal: false means no terminal is attached. user gives the user and group numbers (uid and gid), where 0 is the all-powerful root user. args is the command line to start, and env is its environment variables.
  • root is the folder to use as the container’s disk. readonly: true means the container cannot write to it.
  • linux.namespaces are the private views to create. A namespace gives the process its own view of one thing: pid is its own process list, network its own network, ipc its own inter-process messaging, uts its own hostname, and mount its own set of mounted filesystems.
  • linux.resources.memory.limit is the cgroup memory cap in bytes. A cgroup is a kernel feature that caps resources such as memory for a group of processes. 512×1024×1024=536,870,912512 \times 1024 \times 1024 = 536{,}870{,}912 bytes, which is 512MB.

Part 2 showed the shim calling runc create, which sets up the container without running your program yet. runc start then runs it. runc run does both in one command: it creates the container and starts it. That is why the line below says run.

When you invoke runc run my-container, runc parses this config.json file, configures the requested Linux namespaces and cgroup memory limits (536870912 bytes = 512MB), enters rootfs, and executes execve("nginx"). As in Part 2, execve swaps the current process for the nginx program.


OCI Architectural Decoupling Matrix

Decoupled means each part can be swapped for another without changing the others. A protocol is an agreed way for two programs to talk. In the table, the middle column is the spec, and the third column names example tools that implement it. runc is the reference runtime for the Runtime Spec, and distribution/distribution (the CNCF Distribution project) is the reference registry for the Distribution Spec. BuildKit and Docker Engine are widely used tools that produce OCI images, not reference implementations.

ComponentStandard SpecificationExample Tools / ImplementationsResponsibilities
Container Image FormatOCI Image SpecificationBuildKit / Docker EngineDefines image layers, digests, manifests, and config JSON
Container ExecutionOCI Runtime Specificationrunc / crun / gVisorConverts OCI bundle (config.json + rootfs/) into isolated Linux process
Registry ProtocolOCI Distribution SpecificationCNCF Distribution / Docker RegistryDefines HTTP API for pushing and pulling content-addressed blobs

Summary

The Open Container Initiative standardizes containerization across vendor ecosystems. In short, the Image Spec is how a package is stored and labeled, and the Runtime Spec is how an unpacked package is started:

  • OCI Image Spec defines content-addressable manifests, configuration JSON, and tarball layer diffs.
  • OCI Runtime Spec defines the filesystem bundle (config.json + rootfs/) passed to low-level runtimes.
  • runc reads the OCI bundle configuration to configure namespaces and cgroups before executing the target binary.

Questions or corrections

Found a mistake or have a question about this breakdown? Reach out directly.

Reply on X (@unkletayor) Email feedback