Open Container Initiative Runtime Spec
The Open Container Initiative (OCI) Runtime Specification is the standards document that defines what “a container” is at the lowest layer of the cloud-native stack: a filesystem bundle on disk (a root filesystem plus a
config.jsondescribing how to run it) plus a lifecycle state machine of well-defined operations (create,start,kill,delete,state) that any conforming runtime must implement (opencontainers/runtime-spec). The spec was founded together with the broader Open Container Initiative on 22 June 2015 by Docker, CoreOS, and a coalition of vendors under the umbrella of the Linux Foundation, with the explicit goal of preventing a runtime fork between Docker and rkt and creating a vendor-neutral baseline that Kubernetes, Mesos, Podman, and every later runtime could rely on (OCI About). The most recent revision is v1.3.0, released 4 November 2025 (OCI v1.3 release post). Understanding this spec is what lets you say “a container is not a thing — a container is a process run with a particular set of Linux kernel primitives applied” and have that statement be technically precise rather than rhetorical.
Mental Model
A container, in OCI’s mental model, is the intersection of a root filesystem and a description of how that filesystem should be entered as a process. The description is a single JSON file, config.json, and the kernel features it touches are five Linux primitives that, applied in combination, produce what users experience as isolation.
flowchart TB BUNDLE["OCI Filesystem Bundle<br/>(directory on disk)"] BUNDLE --> ROOTFS["rootfs/<br/>(unpacked image layers)"] BUNDLE --> CONFIG["config.json<br/>(per-instance run config)"] CONFIG --> PROCESS["process: argv, env, cwd, user"] CONFIG --> ROOT["root: rootfs path, readonly?"] CONFIG --> MOUNTS["mounts: tmpfs, bind, /proc, /sys, /dev"] CONFIG --> HOOKS["hooks: prestart, poststart, poststop"] CONFIG --> LINUX["linux: per-platform config"] LINUX --> NS["namespaces<br/>PID UTS MNT NET IPC USER CGROUP"] LINUX --> CG["cgroups (v1/v2)<br/>CPU mem pids io"] LINUX --> CAPS["capabilities<br/>bounding/effective/inheritable/permitted/ambient"] LINUX --> SECCOMP["seccomp<br/>syscall allow/deny list"] LINUX --> MAC["selinuxLabel / apparmorProfile"] LINUX --> RLIM["rlimits"] RUNTIME["OCI Runtime<br/>runc / crun / youki / runsc"] -->|reads| CONFIG RUNTIME -->|chroots/pivot_root into| ROOTFS RUNTIME -->|applies| NS RUNTIME -->|applies| CG RUNTIME -->|applies| CAPS RUNTIME -->|applies| SECCOMP RUNTIME -->|applies| MAC RUNTIME -->|execve()| PROCESS
Caption. The OCI bundle on the left is data at rest; the runtime on the right is the actor that consumes it. Every arrow from runtime to a kernel primitive is a real system call (unshare(2), clone(2) with namespace flags, cgroup filesystem writes, prctl(PR_CAP_AMBIENT), seccomp(2), etc.). The insight to extract: there is no “container kernel”. A container is a regular Linux process that has been wrapped in namespaces, constrained by cgroups, stripped of capabilities, and filtered by seccomp. Removing those primitives and the “container” vanishes; the process remains.
Mechanical Walk-through
The Three OCI Specs and How Runtime Spec Sits Among Them
The OCI maintains three specifications that, taken together, describe the full life of an image-shaped artifact from registry to running process:
- Image Spec — defines the on-disk format for images: a manifest, a config blob, and an ordered list of compressed filesystem layers (opencontainers/image-spec). When you
docker pull alpine, what you get is an OCI image. - Distribution Spec — defines the HTTP API by which registries serve images; reached v1.0 in May 2020 (opencontainers/distribution-spec). This is what Docker Hub, ECR, GHCR, and Harbor implement on the wire.
- Runtime Spec — defines what happens after you unpack an image’s layers onto disk. This note is about (3); (1) and (2) are cross-referenced only where they meet the runtime layer.
The hand-off point between Image Spec and Runtime Spec is the filesystem bundle: a directory containing the unpacked rootfs (the union-mounted layers) and the config.json (derived from the image config plus any per-instance customization the caller applied). Higher-level runtimes (containerd, CRI-O) own the image-to-bundle conversion; low-level runtimes (runc, crun) own the bundle-to-process conversion.
What config.json Describes
config.json is the per-instance configuration: every time you start a container from the same image, you can produce a different config.json and get a different running container. The spec (spec.md) enumerates the top-level fields that any conforming runtime must understand:
ociVersion— the spec version this bundle targets (e.g.,"1.2.0"). Runtimes refuse bundles whose version they don’t support.process— the program to execute:args(argv),env(envp),cwd(initial working directory),user(uid/gid),terminal(whether to allocate a PTY), plus all ofcapabilities,rlimits,noNewPrivileges,apparmorProfile,selinuxLabel,oomScoreAdjthat should be applied to the process itself rather than the container as a whole.root—pathto the rootfs directory andreadonlyflag. The runtime willpivot_root(2)into this directory (orchroot(2)on platforms withoutpivot_root).hostname— the container’s UTS hostname (requires a UTS namespace). A siblingdomainnamefield sets the NIS/YP domain name, also requiring a UTS namespace.mounts— a list of additional filesystems to mount inside the container’s mount namespace beforeexecve. The Linux convention is at minimum/proc(proc),/sys(sysfs, read-only),/dev(tmpfs with selectively bind-mounted device nodes),/dev/pts(devpts),/dev/shm(tmpfs), and any user-specified bind mounts.hooks— escape hatches that let outside-the-container code run at specific lifecycle transitions:prestart(after namespaces created, before user process executes; deprecated in favor ofcreateRuntime/createContainer/startContainersince spec 1.0.2),poststart,poststop. The CNI networking plugin contract historically used the prestart hook to attach a veth pair to the container’s network namespace.linux— the entire Linux-specific configuration block:namespaces,resources(cgroups),seccomp,maskedPaths,readonlyPaths,rootfsPropagation,mountLabel,cgroupsPath,intelRdt, plus more recentlydevices,personality,timeOffsets. Sibling top-level blockswindows,solaris,freebsd,vm,zosexist for the other supported platforms (Windows containers, Solaris zones, FreeBSD jails —freebsdwas added in v1.3.0 — Kata-style VMs, IBM z/OS). Thefreebsd,vm,zos, and platform fields are all OPTIONAL; on Linux only thelinuxblock is populated.annotations— free-form key-value metadata. Higher layers (CRI, Kubernetes) stuff their own identifiers here.
The five Linux primitives that the spec maps onto kernel features:
Namespaces (namespaces(7)). Eight flavors as of current kernels (namespaces(7)): pid (process tree isolation; container processes see PID 1 as their entry process), mount (independent mount table), network (independent interfaces, routing, firewall), uts (independent hostname and NIS domain), ipc (independent System V IPC + POSIX message queues), user (UID/GID mapping; the foundation for rootless containers), cgroup (independent cgroup hierarchy view), and time (independent CLOCK_MONOTONIC/CLOCK_BOOTTIME offsets, added in Linux 5.6 and surfaced in the OCI spec via linux.timeOffsets). The OCI config.json lets the runtime request the first seven directly under linux.namespaces; the time namespace is driven by the separate linux.timeOffsets block. The runtime selects which namespaces to create by listing entries in config.json’s linux.namespaces; an entry with a path joins an existing namespace rather than creating a new one, which is how Kubernetes pods make sibling containers share a network namespace via the Pause Container.
Cgroups (cgroups(7)). The kernel mechanism for accounting and limiting resource use. The spec’s linux.resources block has subsections for memory (limit, swap, reservation, kernel limits, OOM behavior), cpu (shares, quota+period, cpus, mems, idle, burst), pids (max), blockIO (weights and per-device throttles), hugepageLimits, network (priority, classID), devices (allow/deny rules), rdma, unified (cgroups v2 raw key-value passthrough), and cpu.idle. Cgroups v1 and v2 differ in hierarchy structure — v1 had one tree per controller, v2 has a single unified hierarchy — and modern runtimes (crun, recent runc) prefer v2.
Capabilities (capabilities(7)). Linux’s split of the historical “root vs not-root” binary into ~40 fine-grained powers (CAP_NET_BIND_SERVICE, CAP_SYS_ADMIN, CAP_NET_RAW, CAP_DAC_OVERRIDE, etc.). The spec lets the bundle declare five capability sets the process should be born with: bounding, effective, inheritable, permitted, ambient. A hardened container drops all capabilities by default and adds back only what’s needed (Kubernetes’ Pod Security Standards Restricted profile insists on this).
Seccomp (seccomp(2)). A BPF-driven syscall filter installed on the process before execve. The spec models seccomp as a default action (SCMP_ACT_ALLOW, SCMP_ACT_ERRNO, SCMP_ACT_KILL_PROCESS, etc.) plus per-syscall and per-syscall-argument overrides. Docker and containerd ship a default seccomp profile that is an allow-list with a SCMP_ACT_ERRNO default action; per Docker’s seccomp documentation it “disables around 44 system calls out of 300+” considered dangerous or unportable (some write-ups cite ~51, because the count of syscalls that simply fall off the allow-list differs from the count explicitly named for blocking, and the exact figure drifts release to release as the kernel adds finer-grained alternatives). Kubernetes exposes this profile as RuntimeDefault in the Pod’s securityContext.seccompProfile.
MAC labels and rootfs constraints. SELinux process labels (linux.mountLabel, process.selinuxLabel) and AppArmor profiles (process.apparmorProfile) ride alongside seccomp as defense-in-depth on syscall semantics. The linux.maskedPaths and linux.readonlyPaths lists let the runtime bind-mount /dev/null over sensitive procfs/sysfs entries (the default list masks /proc/kcore, /proc/keys, /proc/timer_list, etc.) and force /proc/asound, /proc/bus, /proc/fs, /proc/irq, /proc/sys, /proc/sysrq-trigger to be read-only inside the container.
The Lifecycle State Machine
The spec defines five runtime operations and the states they transition between. A conforming runtime must expose these as CLI verbs (the CLI surface is technically a recommendation of the spec, but every real runtime has aligned on it):
+----------+ create +---------+ start +---------+
(no state) ──▶ creating ───────────▶ created ──────────▶ running │
+----------+ +---------+ +----┬────+
│ │ (user process exits)
│ kill ▼
└──────────────▶ +---------+
│ stopped │
+----┬────+
│ delete
▼
(no state)
Caption. The four OCI container states and the operations that move between them. The insight: created is a deliberately frozen state — namespaces, cgroups, mounts and network are all set up but the user process has not yet been execve’d — and this gap is precisely the interposition window that CNI plugins and sidecar injectors exploit (via the createRuntime/createContainer hooks) before start launches the workload.
create— readconfig.json, set up namespaces, cgroups, mounts, network, capabilities, etc., and prepare to execute the user process but do not run it. The container is increatedstate. This split is what lets external tooling (CNI hooks, sidecar injectors) interpose between bundle materialization and process launch.start— actuallyexecvethe user process. Transitions torunning.state— query: returns a JSON document withociVersion,id,status,pid,bundle,annotations. Thepidis the PID of the user process as seen from the host, not from inside the container.kill— send a signal to the container’s user process (defaults to SIGTERM). Transitionsrunning→stoppedonce the process exits.delete— tear down namespaces, cgroups, networking, and any other state. The spec restrictsdeleteto containers in thestoppedstate (runtime.md: “This operation MUST generate an error if the container is notstopped”); you cannot delete arunningcontainer without firstkilling it.deleteis required even after the process has exited, because the runtime keeps some state on disk (/run/runc/<id>/) forstatequeries between exit anddelete.
The spec is precise that there are four states — creating, created, running, stopped (runtime.md) — but five operations, because both create (which produces a state) and state (which is a pure query that produces no transition) operate on top of those four. The diagram above shows the transitions; note that the only edge into stopped is via the user process exiting (which kill triggers by signalling it), and the only edge out of stopped is delete.
The spec also requires that runtimes persist enough state across host reboots that orphaned containers can be cleaned up — though in practice most deployments wipe runtime state on reboot.
Configuration / API Surface
A minimal-but-real Linux config.json (truncated for brevity) — the kind a runc spec invocation generates as a starting template. The ociVersion shown is 1.2.0 to reflect what older runc builds still stamp; the current spec is 1.3.0 (Nov 2025) and runtimes accept the version they were built against or older:
{
"ociVersion": "1.2.0",
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0 },
"args": ["sh"],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": "/",
"capabilities": {
"bounding": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
"effective": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
"permitted": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"]
},
"rlimits": [
{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }
],
"noNewPrivileges": true
},
"root": { "path": "rootfs", "readonly": true },
"hostname": "container",
"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs",
"options": ["nosuid","strictatime","mode=755","size=65536k"] },
{ "destination": "/sys", "type": "sysfs", "source": "sysfs",
"options": ["nosuid","noexec","nodev","ro"] },
{ "destination": "/dev/pts", "type": "devpts", "source": "devpts",
"options": ["nosuid","noexec","newinstance","ptmxmode=0666","mode=0620","gid=5"] }
],
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" }
],
"resources": {
"memory": { "limit": 536870912 },
"cpu": { "shares": 1024, "quota": 200000, "period": 100000 },
"pids": { "limit": 256 }
},
"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["read","write","open","close","stat","fstat","lstat","poll",
"lseek","mmap","mprotect","munmap","brk","rt_sigaction","execve",
"exit","exit_group","wait4","getpid","getppid","clone","fork"],
"action": "SCMP_ACT_ALLOW" }
]
},
"maskedPaths": ["/proc/kcore","/proc/keys","/proc/timer_list","/sys/firmware"],
"readonlyPaths": ["/proc/asound","/proc/bus","/proc/fs","/proc/irq","/proc/sys"]
}
}Line-by-line commentary. process.args is the actual command — note this is argv, not a shell string. capabilities lists only three of the ~40 capabilities, which is the default-deny posture every hardened container should adopt. noNewPrivileges: true sets the kernel’s PR_SET_NO_NEW_PRIVS flag so that even a setuid binary inside the container cannot gain new capabilities. root.readonly: true is the foundation of immutable-rootfs containers — any writes must go to explicit mounts. The five namespaces listed are the conventional set for a standalone container; a Kubernetes pod’s user containers would additionally have path entries pointing at the pause container’s namespaces. The cgroup resources block translates to memory.max=512Mi, two whole CPUs (quota/period = 200ms/100ms), and a max of 256 processes. The seccomp block is an allow-list with SCMP_ACT_ERRNO default — every syscall not enumerated returns EPERM to the caller. maskedPaths are bind-mounted with /dev/null to hide them; readonlyPaths are remounted read-only.
The runtime is invoked with this bundle on disk:
$ ls /var/lib/containers/storage/runtime-foo/
config.json rootfs/
$ runc create --bundle /var/lib/containers/storage/runtime-foo foo
$ runc state foo
{
"ociVersion": "1.2.0",
"id": "foo",
"status": "created",
"pid": 12345,
"bundle": "/var/lib/containers/storage/runtime-foo",
"annotations": {}
}
$ runc start foo # user process now running
$ runc kill foo SIGTERM # graceful stop
$ runc delete foo # tear downReplacing runc with crun, youki, or runsc should produce identical observable behavior for the same bundle — that’s the point of having a spec.
Failure Modes
Spec-version skew. A bundle generated against ociVersion: "1.2.0" may use fields a runtime built against 1.0.2 does not understand. Conservative runtimes ignore unknown fields; strict runtimes refuse. The cgroup unified block (raw v2 passthrough) is a recurrent skew point — older runc builds didn’t know about it.
Cgroups v1/v2 mismatch. A container’s config.json may request cgroup v1 controllers (memory, cpu, pids) on a host that has only the unified v2 hierarchy mounted (modern Fedora, Ubuntu 22.04+, RHEL 9). Old runc versions panic; current runc translates. Kubernetes’ kubelet must be told via --cgroup-driver=systemd (or cgroupfs) which manager owns the hierarchy, and a mismatch between kubelet’s belief and the runtime’s belief causes containers to start without limits or to fail outright. The symptom is “containers run but oom_kill triggers earlier than expected” or “containers refuse to start with cgroups: cannot find cgroup mount destination.”
Seccomp profile and runtime-version skew. A newer kernel adds syscalls (e.g., clone3, faccessat2, close_range); a static seccomp profile that doesn’t list them returns EPERM, and the application crashes with glibc falling back inconsistently. The default Docker/containerd profile shipped with these syscalls disabled until 2021–2022; some glibc versions silently fall back, others (e.g., glibc 2.34+) require clone3 and break. The symptom is “the container starts but Bash crashes on read” or “ldd reports missing libraries that exist on disk.”
Capability silently insufficient. Dropping CAP_NET_BIND_SERVICE and then trying to listen on port 80 inside the container returns EACCES; dropping CAP_KILL and then sending a signal to a sibling process fails with EPERM. The errors are syscall-level and easily misread as application bugs.
Hook execution path differences. The deprecated prestart hook ran inside the runtime’s namespace-creation flow; the newer createRuntime/createContainer/startContainer hooks run at finer-grained lifecycle points. Migrating CNI plugins between hook generations is a known source of “pod has no network, but kubelet says it started fine” failures.
User namespace + filesystem ownership. Enabling a user namespace remaps UIDs/GIDs so that root inside the container is some unprivileged UID on the host. The rootfs’s on-disk ownership must match the outside mapping, not the inside, which means chown-ing rootfs trees during bundle preparation. Getting this wrong manifests as “files in rootfs appear as nobody:nogroup and processes inside can’t read their own config.”
The default profile’s blocked-syscall count (≈44 of 300+, per Docker’s seccomp docs) is itself version-dependent — for any production claim, read the runtime’s actual default.json rather than trusting a fixed number.
Alternatives and When to Choose Them
The OCI Runtime Spec is the contract; multiple runtimes implement it. The dominant alternatives, when to pick each, and their isolation models are covered in detail in runc. Briefly:
- runc — reference Go implementation; safe default; what Docker, containerd, and CRI-O ship with.
- crun — C reimplementation; ~50× smaller binary, lower memory, native cgroup v2 support; default in OpenShift and RHEL 8+.
- youki — Rust reimplementation; performance roughly between runc and crun; mostly a systems-research / curiosity choice in 2026.
- gVisor / runsc — Google’s user-space syscall sandbox; intercepts the application’s syscalls in a Go-implemented “kernel” rather than letting them reach the host kernel; trades 5–20% performance for hostile-multi-tenancy isolation.
- Kata Containers / kata-runtime — runs each container (or pod) inside a lightweight VM, so the “namespace boundary” is reinforced by a hypervisor boundary; chosen when you must defend against kernel-level escapes.
All five consume the same config.json and are interchangeable at the OCI level; what differs is how they apply the spec’s intent. gVisor and Kata’s “isolation” is much stronger than runc’s; their performance overhead is correspondingly higher.
Production Notes
The dockershim removal made the spec load-bearing. Until Kubernetes 1.24 (May 2022), kubelet talked to Docker via the dockershim adapter, which translated CRI calls into Docker Engine calls, which in turn used containerd, which used runc. With dockershim removed (Dockershim Removal FAQ), kubelet talks directly to a CRI runtime (containerd or CRI-O), which calls an OCI runtime (runc or crun) with an OCI bundle. The OCI spec went from “an implementation detail behind Docker” to “the single contract every K8s node depends on.”
OpenShift chose crun. Red Hat made crun the default OCI runtime in RHEL 8.5+ and OpenShift 4.9+, citing memory footprint at high pod density and cgroup v2 maturity. The switch was operationally transparent precisely because both implementations honor the same config.json.
OCI Runtime Spec v1.3.0 (released 4 November 2025) bundled 24 pull requests merged since v1.2.1 and was almost entirely additive (v1.3 release notes). The substantive new fields were: vm.hwConfig (declarative vCPU/memory for VM-backed runtimes like Kata), linux.intelRdt.schemata and linux.intelRdt.enableMonitoring (fuller Intel Resource Director Technology coverage — L2 cache allocation, Code and Data Prioritization, and a cleaner monitoring knob replacing earlier loosely-defined fields), linux.netDevices (declaratively move host network devices into the container’s network namespace), linux.memoryPolicy (NUMA memory placement policy), and the new top-level freebsd platform block (FreeBSD jails). (Time namespaces — linux.timeOffsets — and RDMA cgroups were earlier additions, not v1.3; the previous wording of this note was wrong on that point.) The spec changes slowly and additively — rarely breaking — which is why containers built years ago typically still run on current runtimes. Landlock LSM support and a vTPM specification are noted as future proposals.
A container is a process — verify it yourself. On any K8s node, ps -ef --forest shows pod containers as ordinary host processes underneath the kubelet (or rather, underneath containerd-shim instances). ls /proc/$pid/ns/ shows the namespace inode IDs; cat /proc/$pid/status shows the capability sets; nsenter --target $pid --net --pid --mount ip addr lets you join the namespaces and see what the container sees. The internalized version of “a container is just a process” is what these commands prove.
To be precise about the producer side: containerd does not consume a pre-existing config.json — it generates one. When kubelet (via the CRI) asks containerd to create a container, containerd’s oci package builds the runtime spec from the OCI image config plus the CRI runtime options, then writes a genuine on-disk OCI bundle — a directory containing config.json and rootfs/ — and hands it to the io.containerd.runc.v2 shim, whose current working directory is set to that bundle. The shim then invokes runc create --bundle <dir> (containerd OCI runtime integration; containerd Runtime v2 docs). The bundle is a real file on disk under /run/containerd/io.containerd.runtime.v2.task/<namespace>/<id>/, not an in-memory-only artifact — you can cat it on a running node — though it is ephemeral in the sense that containerd owns its lifecycle and removes it when the task is deleted.
See Also
- Container Orchestration Architecture — the platform layer above this spec
- Kubernetes MOC — the parent MOC for K8s topics
- runc — the reference implementation; alternatives compared there in detail
- Pause Container — joins the OCI namespaces created here, doesn’t create new ones
- Container Runtime Interface — the gRPC layer between kubelet and the OCI-bundle producer (containerd/CRI-O)
- containerd — produces OCI bundles, calls runc
- CRI-O — produces OCI bundles, calls runc/crun
- cgroups Integration — how Pod limits become OCI
linux.resources - SecurityContext — how K8s declares capabilities/seccomp that end up in
config.json - Pod Security Standards — restricts what fields a Pod’s eventual
config.jsonmay contain