OCI Runtime Spec & runc
Given a rootfs and a config.json — how a container actually starts
What the OCI Runtime Spec Defines
A Standard Interface for Running Containers
- Given a rootfs (filesystem bundle) and a config.json (configuration), run a container
- The runtime doesn't care about images, registries, or orchestration — it just runs the bundle
- This separation is what makes container runtimes interchangeable: runc, crun, gVisor, Kata — all speak the same spec
rootfs/
Filesystem tree (bin, lib, etc, usr...)
config.json
Namespaces, cgroups, mounts, process, hooks
OCI Bundle
Everything a runtime needs to create a container
Note
Key insight: An OCI runtime never pulls images or talks to registries. The higher-level runtime (containerd, CRI-O) unpacks the image into a rootfs and generates config.json, then hands the bundle to the OCI runtime.
config.json — The Full Container Configuration
Every aspect of the container's environment is declared in a single JSON file. Here's the structure with annotations:
{
// Spec version this config conforms to
"ociVersion": "1.0.2",
// ─── What to run ───
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"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 }
]
},
// ─── Root filesystem ───
"root": {
"path": "rootfs",
"readonly": true
},
// ─── Mount points ───
"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs" },
{ "destination": "/dev/pts", "type": "devpts", "source": "devpts" },
{ "destination": "/sys", "type": "sysfs", "source": "sysfs",
"options": ["nosuid", "noexec", "nodev", "ro"] }
],
// ─── Linux-specific configuration ───
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" },
{ "type": "cgroup" }
// optional: "path" to join existing NS instead of creating new
],
"resources": {
"memory": { "limit": 536870912 }, // 512 MiB
"cpu": { "shares": 1024, "quota": 100000, "period": 100000 },
"pids": { "limit": 512 },
"blockIO": { "weight": 500 }
},
"cgroupsPath": "/mycontainer",
"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["read", "write", "exit", "..."], "action": "SCMP_ACT_ALLOW" }
]
},
"devices": [
{ "allow": false, "access": "rwm" } // deny all by default
],
"maskedPaths": ["/proc/kcore", "/proc/keys", "/sys/firmware"],
"readonlyPaths": ["/proc/asound", "/proc/bus", "/proc/irq"]
},
// ─── Lifecycle hooks ───
"hooks": {
"prestart": [{ "path": "/usr/bin/fix-mounts" }],
"createRuntime": [{ "path": "/usr/bin/setup-network" }],
"createContainer": [],
"startContainer": [],
"poststart": [{ "path": "/usr/bin/notify-started" }],
"poststop": [{ "path": "/usr/bin/cleanup" }]
}
}config.json Sections Summary
| Section | Controls |
|---|---|
process | Command, user, env, capabilities, rlimits |
root | Filesystem path, read-only flag |
mounts | Bind mounts, tmpfs, procfs, sysfs, devpts |
linux.namespaces | Which namespaces to create or join |
linux.resources | cgroup limits (CPU, memory, PIDs, blockIO) |
linux.seccomp | Syscall filter (allowlist/denylist) |
linux.devices | Device access allowlist |
linux.maskedPaths | Paths hidden from the container |
hooks | Lifecycle callbacks at each stage |
Hooks Execution Order
prestart— deprecated, runs in runtime namespace aftercreatecreateRuntime— after runtime environment is created, before pivot_rootcreateContainer— after pivot_root but beforestartstartContainer— afterstartcalled, before user process executespoststart— after user process startspoststop— after container process exits and is cleaned up
Container Lifecycle — State Machine
| State | Description | Transition |
|---|---|---|
| creating | Runtime is setting up namespaces, cgroups, mounts, filesystem | runc create initiates |
| created | Container environment exists but the user process hasn't started. Hooks run here (prestart, createRuntime, createContainer). | Setup completes, container waits for start |
| running | User process is executing (exec() has been called) |
runc start signals the waiting process |
| stopped | User process has exited. Exit code available via runc state. Poststop hooks run. |
Process exits (normally or killed) |
Tip
Why two phases (create + start)? The separation allows hooks to run between environment setup and process execution. This is critical for networking — the CNI plugin sets up the network in the gap between create and start.
runc — The Reference Implementation
About runc
- Written in Go, maintained by the OCI
- Based on Docker's original
libcontainer - The reference implementation — other runtimes (crun, youki) are alternatives with the same interface
What runc create Does
- Parse
config.json— validate spec version, extract all settings - Set up cgroup — create cgroup directory under
cgroupsPath, write resource limits (memory.max, cpu.max, pids.max) - Call
clone()with namespace flags (CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | ...) — spawns child process in new namespaces - In child: join cgroup namespace, apply cgroup membership
pivot_root()to rootfs — the container now sees only its own filesystem tree- Mount
/proc,/dev,/sysinside the new root - Set hostname (UTS namespace)
- Apply seccomp BPF filter — restricts available syscalls
- Drop capabilities — reduce privileges to configured set
- Wait — the container process blocks, waiting for the
startsignal. Container is now in "created" state.
What runc start Does
- Signal the waiting container process to proceed (via a Unix socket pipe)
- Container process calls
exec()with the configured command (e.g.,/bin/sh) - Container is now in "running" state — the original process is replaced by the user command
Warning
runc exits after starting the container. Once the container process is running, runc's job is done. It is NOT the parent process of the container. In production, the containerd-shim is the actual parent. See containerd & CRI.
runc create — Visualized
Parse config.json
Validate OCI spec, extract settings
Create cgroup
mkdir cgroup dir, write limits
clone()
New PID, NET, MNT, UTS, IPC, cgroup NS
pivot_root
Switch to rootfs
Harden
seccomp + drop caps
WAIT
State = "created"
prestart Hooks = CNI
Networking Happens Between create and start
The prestart / createRuntime hook is where CNI plugins are invoked to set up container networking:
- Create a veth pair — one end in the container's network namespace, one on the host bridge
- Assign an IP address from the CNI IPAM plugin
- Configure routes and DNS inside the container
- Set up iptables / nftables rules for NAT or policy
runc create
Container in "created" state, net NS exists but empty
prestart hook
CNI plugin runs: creates veth, assigns IP, sets routes
runc start
Process starts with networking ready
Note
Cross-reference: The prestart hook is where CNI plugins are invoked to set up container networking. See Net 12 — CNI for details on how veth pairs, bridge networks, and IP allocation work.
Terminal Example — Full Lifecycle
$ mkdir -p mycontainer/rootfs
$ cd mycontainer
# Export an image as a rootfs (or use debootstrap, busybox, etc.)
$ docker export $(docker create busybox) | tar -C rootfs -xf -
# Generate a default config.json
$ runc spec
# creates config.json with default settings
# Create the container (state: creating -> created)
$ sudo runc create mycontainer
# Check state
$ sudo runc state mycontainer
{
"ociVersion": "1.0.2",
"id": "mycontainer",
"status": "created",
"pid": 12345,
"bundle": "/home/user/mycontainer"
}
# Start the container (state: created -> running)
$ sudo runc start mycontainer
# List running containers
$ sudo runc list
ID PID STATUS BUNDLE CREATED
mycontainer 12345 running /home/user/mycontainer 2026-03-31T...
# After the process exits (state: running -> stopped)
$ sudo runc state mycontainer
"status": "stopped"
# Clean up
$ sudo runc delete mycontainer
Alternative OCI Runtimes
| Runtime | Language | Key Difference |
|---|---|---|
runc | Go | Reference implementation, most widely used |
crun | C | Faster startup, lower memory footprint (~50% less than runc) |
youki | Rust | Memory-safe alternative, Rust ecosystem |
runsc (gVisor) | Go | User-space kernel sandbox — see Sandboxed Runtimes |
kata-runtime | Go/Rust | Per-container lightweight VM — see Sandboxed Runtimes |
Tip
All interchangeable: Because they all conform to the OCI runtime spec, you can swap any of these by changing a single runtime path in containerd's or CRI-O's configuration. The higher-level stack doesn't need to change.