The OCI Spec: What Actually Defines a Container?
Before OCI, Docker was the only definition of a container. Here's what the spec actually says.

TL;DR The Open Container Initiative (OCI) spec is a set of three documents: the Image Spec, the Runtime Spec, and the Distribution Spec; that define exactly what a container image is, what a container runtime must do with it, and how images are transported between registries. Before OCI, Docker was the only definition. OCI decoupled the format from the runtime. That decoupling is why Kubernetes can run containerd or CRI-O instead of Docker, why Podman-built images run on Docker, and why any compliant runtime can run any compliant image. This post goes through the spec files themselves.
In 2015, Docker had a problem. Not a technical problem. A political one.
CoreOS had built rkt, an alternative container runtime. The Cloud Native Computing Foundation was forming. Kubernetes needed to support multiple runtimes. And nobody could agree on what a container was, because the only definition that existed was Docker's proprietary implementation.
The Open Container Initiative was founded in June 2015 to fix this. Not to build a runtime. To write a spec.
The spec is the answer to: what is a container, formally?
Three Specs, One Standard
OCI is not one document. It's three:
Image Spec - defines what a container image is on disk: layers, manifests, configs
Runtime Spec - defines how a runtime executes a container from an OCI bundle: how to set up namespaces, mounts, the process, and the lifecycle hooks
Distribution Spec - defines how images are pushed and pulled from a registry: the HTTP API contract
Every major container tool implements these specs. Docker. containerd. Podman. CRI-O. The registries at Docker Hub, GitHub Container Registry, and AWS ECR. The spec is what makes them interoperable.
docker info --format '{{.DefaultRuntime}}'
Expected output:
runc
# runc is the default runtime reference implementation
runc --version
Expected output:
What this output means: spec: 1.2.1 - this tells you which OCI Runtime Spec version this binary implements. Any OCI-compliant runtime (crun, youki, gVisor) that also implements 1.2.1 can be swapped in and run the same container. The spec is the contract.
runc is the reference implementation of the OCI Runtime Spec. It's not Docker. It's not containerd. It's the ~10โ15 MB binary that actually calls
clone(), sets up the mounts, and runs your process. Everything above it: Docker, containerd, Podman, is orchestration. runc is execution.
Part 1: The Image Spec
A container image is not a tarball. It's a structured artifact defined by the OCI Image Spec.
An OCI image has three components:
Image Manifest - the index that lists layers and the image config
Image Config - the metadata: architecture, OS, environment variables, the command to run, the working directory
Layers - the filesystem content, stored as compressed tar archives (one per diff layer)
You can inspect all of this directly.
# Pull an image
docker pull ubuntu:22.04
# Save it to a tar to inspect its OCI layout
# Note: docker save outputs OCI Image Layout format on Docker 25+.
# On older versions it produces Docker's legacy format (manifest.json instead of index.json).
docker save ubuntu:22.04 -o ubuntu-oci.tar
mkdir ubuntu-oci
tar -xf ubuntu-oci.tar -C ubuntu-oci/
ls ubuntu-oci/
Expected output:
What this output means: This is an OCI Image Layout on disk. oci-layout identifies the spec version. index.json is the top-level manifest index. blobs/ contains all the content-addressed artifacts, the layers and configs, stored by their SHA256 digest.
# The index.json is the entry point
cat ubuntu-oci/index.json | python3 -m json.tool
Expected output:
What this output means: index.json is a manifest index, it can point to multiple manifests for different platforms (amd64, arm64, etc.). The digest is a content address: a SHA256 hash of the manifest file's content. Follow the digest to get the manifest.
# Follow the digest to the manifest
DIGEST="sha256:a1b2c3d4e5f6..." # use your actual digest
cat ubuntu-oci/blobs/${DIGEST/sha256:/sha256\/} | python3 -m json.tool
Expected output:
What this output means: The manifest has two parts: a config (the image metadata) and a layers array (the filesystem content, the array of objects containing ""annotations" and all here in above picture.). Each is referenced by digest. Ubuntu 22.04 has one layer; its base filesystem, 29.5MB compressed.
The OCI Image Config is the definition of your container's process before any runtime touches it. It's a JSON file, content-addressed, inside a blobs directory. When you set
ENV,CMD, orWORKDIRin a Dockerfile, you're writing to this file.
๐ฉ Try It Yourself
# Pull any image and inspect its OCI layout
docker pull nginx:alpine
docker save nginx:alpine -o nginx-oci.tar
mkdir -p /tmp/nginx-oci
tar -xf nginx-oci.tar -C /tmp/nginx-oci/
cat /tmp/nginx-oci/index.json | python3 -m json.tool
# Navigate to the manifest and then the config
# Read the config: Env, Cmd, ExposedPorts are all there
Part 2: The Runtime Spec - The Bundle
The OCI Runtime Spec defines a bundle: the on-disk structure a runtime receives in order to create a container.
A bundle has two things:
A
rootfs/directory - the container's root filesystemA
config.json- the complete specification of what the runtime should do
config.json is the center of gravity. It's a large JSON file that specifies: the process to run, the namespaces to create, the mounts to set up, the capabilities to grant, the seccomp profile to apply, and the lifecycle hooks to call.
# Generate an OCI bundle with runc
mkdir /tmp/mycontainer
cd /tmp/mycontainer
# Generate a default config.json
runc spec
ls
Expected output:
# Look at the structure
cat config.json | python3 -m json.tool | head -60
Expected output (abbreviated):
What this output means: The runtime spec has ociVersion at the top โ this is which version of the OCI Runtime Spec this config conforms to. The process block defines what runs: sh, as UID 0, with exactly three Linux capabilities. The root.path tells the runtime where the filesystem is. The runtime reads this JSON and translates every field into syscalls.
# The namespaces section โ the kernel primitives
cat config.json | python3 -m json.tool | grep -A 30 '"namespaces"'
Expected output:
What this output means: The Runtime Spec explicitly lists which namespaces to create. This is a direct instruction to the runtime: "call clone() with these flags." Each namespace type here corresponds to a CLONE_NEW* flag. This is the spec expressing kernel primitives as configuration.
The OCI Runtime Spec's
config.jsonis a formal description of a syscall sequence. The runtime reads it and callsclone(),mount(),pivot_root(),execve()in the right order with the right arguments. The spec is the contract. runc is the implementation. Your container is the result.
# The mounts section - what gets mounted inside the container
cat config.json | python3 -m json.tool | grep -A 50 '"mounts"'
Expected output (abbreviated):
What this output means: The runtime mounts /proc, /dev, and /sys inside the container. This is why ps aux works inside a container - /proc is explicitly mounted by the runtime per the spec. These aren't magic Docker behaviors. They're entries in config.json, executed by runc as mount() syscalls.
๐ฉ Try It Yourself
# Generate a bundle and read the full config
mkdir -p /tmp/oci-explore
cd /tmp/oci-explore
runc spec
cat config.json | python3 -m json.tool > config-readable.json
wc -l config-readable.json
# Expected: ~150-200 lines โ the full definition of a container
grep -A 5 '"capabilities"' config.json | head -20
# See exactly which Linux capabilities a default container gets
Part 3: The Distribution Spec - How Images Move
The Distribution Spec defines the HTTP API for container registries. It's what docker push and docker pull speak.
The spec defines a REST API. Every OCI-compliant registry implements it. This is why skopeo, crane, docker pull, and podman pull all work against Docker Hub, GHCR, and ECR.
# The OCI Distribution Spec is a REST API
# You can speak it with curl
# First, get an auth token for Docker Hub
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ubuntu:pull" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Get the manifest for ubuntu:22.04
curl -s \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
"https://registry-1.docker.io/v2/library/ubuntu/manifests/22.04" \
| python3 -m json.tool
Expected output(I'll prefer now to paste SS here):
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:a1b2c3...",
"size": 424,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:d4e5f6...",
"size": 424,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}
What this output means: This is the OCI Distribution Spec in action. The v2/library/ubuntu/manifests/22.04 endpoint returns a manifest index with entries for amd64, arm64, and other platforms. Your docker pull calls this exact API. The Accept header tells the registry which media type format to return, OCI vs. Docker's older schema.
# Check available endpoints โ the OCI Distribution Spec defines these
curl -s \
-H "Authorization: Bearer $TOKEN" \
"https://registry-1.docker.io/v2/"
Expected output:
{}
What this output means: An empty {} is a valid OCI Distribution Spec v2 API check. A 200 response here means the registry is reachable and authenticated, not full compliance. The Distribution Spec defines this endpoint (GET /v2/) as the health check.
docker pullmakes at minimum three HTTP calls: check the registry withGET /v2/, fetch the manifest index withGET /v2/<image>/manifests/<tag>, then fetch each layer blob withGET /v2/<image>/blobs/<digest>(one call per layer - a multi-layer image makes more). The OCI Distribution Spec defines every one of these endpoints. Any client that speaks the spec can pull from any compliant registry.
๐ฉ Try It Yourself
# Inspect any image's manifest directly from a registry using skopeo
# (skopeo is an OCI-native tool for this)
sudo apt-get install -y skopeo # or brew install skopeo
# Inspect without pulling
skopeo inspect docker://nginx:alpine
# Expected: full JSON config โ Env, Cmd, ExposedPorts, all layers
# List all available tags for an image (Distribution Spec: GET /v2/<name>/tags/list)
skopeo list-tags docker://nginx
# Expected: all nginx tags on Docker Hub
How the Three Specs Connect
The full lifecycle of a container, mapped to specs:
# Watch containerd create the OCI bundle
# containerd stores its state under /var/lib/containerd
sudo ls /var/lib/containerd/io.containerd.runtime.v2.task/
Expected output:
(Docker's namespace in containerd)
# Cleanup
docker rm -f spec-demo
Every running container has a
config.jsonon disk. It's not hidden inside Docker's internals. It's in containerd's runtime directory, readable, and it tells you exactly which syscalls were used to create that container. The config.json is not theoretical โ it's a conforming artifact of the spec, sitting on your disk for every running container.
๐ฉ Try It Yourself
# Run a container, find its bundle, read its config
docker run -d --name oci-inspect ubuntu sleep 600
CID=$(docker inspect oci-inspect --format '{{.Id}}')
sudo cat /var/lib/containerd/io.containerd.runtime.v2.task/moby/$CID/config.json \
| python3 -m json.tool > /tmp/container-config.json
# How many mounts does it have?
grep '"destination"' /tmp/container-config.json | wc -l
# What capabilities does it have?
grep -A 20 '"bounding"' /tmp/container-config.json
docker rm -f oci-inspect
Why OCI Matters: Portability as a Spec Property
Before OCI, portability was a promise. After OCI, it's a property of the format.
# Build with Docker, run with Podman
docker build -t my-test-app:latest .
docker save my-test-app:latest | podman load
podman run my-test-app:latest
# It just works. Same OCI image. Different runtime. Same spec.
# Copy images between registries without a Docker daemon
skopeo copy docker://nginx:alpine docker://ghcr.io/yourusername/nginx:alpine
# skopeo speaks Distribution Spec to both ends โ no daemon needed
What this output means: Portability isn't magic. It's two runtimes implementing the same spec. The image is the same JSON structure either way. The runtime reads config.json the same way either way.
The OCI spec is why you can build with Docker and deploy with containerd. It's why Podman images run on Kubernetes. It's why
skopeocan copy between registries without a daemon. Interoperability was designed into the format. It's not a Docker feature. It's a spec property.
What You Can Now Explain
What the three OCI specs are and what each one governs (Image, Runtime, Distribution)
How to navigate an OCI image layout on disk:
index.jsonโ manifest โ config โ layersWhat an OCI bundle is (
config.json+rootfs/) and what runc does with itWhere
CMD,ENV, andWORKDIRfrom your Dockerfile actually live (Image Config JSON, inblobs/)How the OCI Runtime Spec's
config.jsonmaps directly to kernel syscalls (clone(),mount(),execve())What the OCI Distribution Spec is and why any compliant registry speaks the same HTTP API
Where to find the live
config.jsonfor a running container on your machineWhy a Docker-built image runs on Podman (same OCI format, different runtime, same spec)
What runc actually is and where it fits in the container stack (reference OCI runtime, not a Docker component)
Next Post
โ Why your Docker build is slow, layer caching internals - now that you know what an OCI image layer is (a content-addressed tar archive), the next question is: when does Docker reuse one, when does it invalidate one, and what does it actually check? That's the build cache. And most engineers have the mental model wrong.
root/cause โ Not tutorials. Just the real picture.rootcause.hashnode.dev ยท @root.cause.dev




