Skip to content
Menu

Virtualization6 min read

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
  1. rootfs/

    Filesystem tree (bin, lib, etc, usr...)

  2. config.json

    Namespaces, cgroups, mounts, process, hooks

  3. 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:

bash
{
  // 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

SectionControls
processCommand, user, env, capabilities, rlimits
rootFilesystem path, read-only flag
mountsBind mounts, tmpfs, procfs, sysfs, devpts
linux.namespacesWhich namespaces to create or join
linux.resourcescgroup limits (CPU, memory, PIDs, blockIO)
linux.seccompSyscall filter (allowlist/denylist)
linux.devicesDevice access allowlist
linux.maskedPathsPaths hidden from the container
hooksLifecycle callbacks at each stage

Hooks Execution Order

  1. prestart — deprecated, runs in runtime namespace after create
  2. createRuntime — after runtime environment is created, before pivot_root
  3. createContainer — after pivot_root but before start
  4. startContainer — after start called, before user process executes
  5. poststart — after user process starts
  6. poststop — after container process exits and is cleaned up

Container Lifecycle — State Machine

creating
--create-->
created
--start-->
running
--exit-->
stopped
StateDescriptionTransition
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

Host Kernel
  1. Parse config.json — validate spec version, extract all settings
  2. Set up cgroup — create cgroup directory under cgroupsPath, write resource limits (memory.max, cpu.max, pids.max)
  3. Call clone() with namespace flags (CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | ...) — spawns child process in new namespaces
  4. In child: join cgroup namespace, apply cgroup membership
  5. pivot_root() to rootfs — the container now sees only its own filesystem tree
  6. Mount /proc, /dev, /sys inside the new root
  7. Set hostname (UTS namespace)
  8. Apply seccomp BPF filter — restricts available syscalls
  9. Drop capabilities — reduce privileges to configured set
  10. Wait — the container process blocks, waiting for the start signal. Container is now in "created" state.

What runc start Does

  1. Signal the waiting container process to proceed (via a Unix socket pipe)
  2. Container process calls exec() with the configured command (e.g., /bin/sh)
  3. 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

  1. Parse config.json

    Validate OCI spec, extract settings

  2. Create cgroup

    mkdir cgroup dir, write limits

  3. clone()

    New PID, NET, MNT, UTS, IPC, cgroup NS

  4. pivot_root

    Switch to rootfs

  5. Harden

    seccomp + drop caps

  6. 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
  1. runc create

    Container in "created" state, net NS exists but empty

  2. prestart hook

    CNI plugin runs: creates veth, assigns IP, sets routes

  3. 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

console
console

    $ 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

RuntimeLanguageKey Difference
runcGoReference implementation, most widely used
crunCFaster startup, lower memory footprint (~50% less than runc)
youkiRustMemory-safe alternative, Rust ecosystem
runsc (gVisor)GoUser-space kernel sandbox — see Sandboxed Runtimes
kata-runtimeGo/RustPer-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.

Solidnines — solidnines.com