runc

runc is the reference low-level container runtime of the Open Container Initiative — a small Go CLI tool that takes an OCI filesystem bundle (a rootfs/ plus a config.json) and turns it into a running Linux process by applying namespaces, cgroups, capabilities, seccomp filters, and MAC labels through direct kernel syscalls (opencontainers/runc). It was donated by Docker to the OCI on 22 June 2015 as the founding code drop alongside the runtime spec (OCI About) and remains a default OCI runtime under containerd, Docker, and Podman (Red Hat platforms have since moved their default to crun — see Alternatives). runc is what makes the slogan “a container is just a process” operational: there is no daemon, no kernel module, no novel abstraction — runc is a compact Go program (plus a small C bootstrap, nsexec) that orchestrates clone(2), unshare(2), setns(2), pivot_root(2), the cgroupfs, prctl(2), and seccomp(2) in the right order and refuses to do anything more. This note covers what runc does, the code path it walks from runc create to execve, and the catalog of OCI-compatible alternatives (crun, youki, gVisor/runsc, Kata) and when to choose each.

Mental Model

flowchart LR
    USER["operator / runtime caller<br/>(containerd / CRI-O / Docker)"]
    BUNDLE["OCI bundle on disk<br/>config.json + rootfs/"]
    RUNC["runc CLI<br/>Go + nsexec C bootstrap"]
    LIBC["libcontainer<br/>(internal Go package)"]
    NS["clone() + CLONE_NEW* flags<br/>or unshare()"]
    CG["cgroupfs writes<br/>or systemd dbus"]
    PR["pivot_root() into rootfs<br/>chroot fallback"]
    CAP["prctl + capset<br/>capability sets"]
    SEC["seccomp(2)<br/>BPF program load"]
    EXEC["execve(args)<br/>user process is born"]

    USER -->|runc create id, runc start id| RUNC
    RUNC -->|reads| BUNDLE
    RUNC -->|invokes| LIBC
    LIBC --> NS
    LIBC --> CG
    LIBC --> PR
    LIBC --> CAP
    LIBC --> SEC
    LIBC --> EXEC

    subgraph "OCI Alternatives (drop-in)"
        CRUN["crun (C)<br/>same config.json"]
        YOUKI["youki (Rust)"]
        RUNSC["runsc / gVisor<br/>userspace kernel"]
        KATA["kata-runtime<br/>VM-isolated"]
    end
    USER -.->|swap binary| CRUN
    USER -.->|swap binary| YOUKI
    USER -.->|swap binary| RUNSC
    USER -.->|swap binary| KATA

Caption. runc is the thinnest possible layer between a JSON manifest and a Linux process: every box on the right is a real syscall or a real cgroupfs write. The four alternatives at the bottom are drop-in replacements — they consume the same config.json because they all implement the same OCI Runtime Spec. The insight: choosing a different runtime is choosing a different how, not a different what. The container that runc starts as a pid-namespaced root process, runsc starts as a syscall-intercepted process inside its userspace kernel; both look like “a container” to Kubernetes.

Mechanical Walk-through

What runc Actually Does, Step by Step

Tracing runc create foo --bundle /var/run/containers/foo (the call containerd makes for each pod container):

  1. Parse config.json. Validate against the supported OCI Runtime Spec version. Reject if the bundle requests features this runc doesn’t support (e.g., a Linux runc on a Windows-spec bundle).
  2. Re-exec a runc init helper, running C before the Go runtime starts. runc re-executes itself as runc init. The critical detail is when the namespace work happens: the libcontainer/nsenter package registers a C constructor, nsexec(), that runs as soon as the runc init binary loads — before the Go runtime has booted any goroutines or threads (runc nsenter README). This ordering is mandatory: Go is multi-threaded from startup, and setns(2) for the user namespace, plus the unshare/clone dance, cannot be done reliably once the runtime has spawned threads. nsexec() reads its bootstrap data — namespace paths, clone flags, UID/GID maps, the console path — from the parent over the _LIBCONTAINER_INITPIPE file descriptor (ibid.).
  3. Join then create namespaces (setns(2) + clone(2)). Inside nsexec(), runc first calls setns(2) for each namespace the bundle asks to join (the typical Kubernetes pod case where user containers join the Pause Container’s netns/IPC), then clone(2)s a child with the requested CLONE_NEW{PID,NET,IPC,UTS,MNT,USER,CGROUP} flags for the namespaces it must create, applies the user/group ID mappings, and sends the new child’s PID back up the init pipe. The README is explicit that runc does “both setns(2) and clone(2) even if we don’t have any CLONE_NEW* clone flags,” because it must fork a fresh process for the Go runtime to take over (ibid.). Only after nsexec() returns does the Go half of runc init begin executing.
  4. Set up cgroups. runc creates a cgroup at cgroupsPath (or systemd unit if the systemd cgroup driver is in use), writes the memory/CPU/pids/io limits from linux.resources, and moves the helper PID into the new cgroup. On v2 hosts a single unified hierarchy; on v1 hosts one cgroup per controller.
  5. Wire up the filesystem. Inside the helper’s new mount namespace, runc mounts the rootfs as a private mount, performs pivot_root(2) into it (or chroot fallback for old kernels), then mounts the conventional auxiliaries (/proc, /sys, /dev tmpfs, /dev/pts, /dev/shm) and any user-specified bind mounts.
  6. Apply linux.maskedPaths and linux.readonlyPaths. Bind-mount /dev/null over each masked path; bind-remount each readonly path with MS_RDONLY.
  7. Set the hostname (if a UTS namespace exists): sethostname(2).
  8. Apply capabilities. prctl(PR_SET_KEEPCAPS), setgroups, setuid/setgid to the requested user, then capset(2) for each of bounding/effective/inheritable/permitted, plus prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, ...) for ambient capabilities.
  9. Set no_new_privs. prctl(PR_SET_NO_NEW_PRIVS) so subsequent setuid/setcap binaries can’t elevate.
  10. Install seccomp filter. Compile the OCI seccomp JSON into a BPF program (libseccomp does this), then seccomp(SECCOMP_SET_MODE_FILTER, 0, prog). The filter is per-thread and inherited across fork/execve.
  11. Apply AppArmor / SELinux labels if requested (aa_change_onexec, setexeccon).
  12. Wait. At this point runc create returns success; the helper is parked, paused before execve. State is persisted to /run/runc/<id>/state.json.
  13. runc start foo. The parked helper receives a signal, finishes setting cwd, applies rlimits, and calls execve(args). The user’s binary begins running. From here forward runc is no longer in the picture for the container’s user process; it stays around only to receive runc kill / runc delete calls and report state.

The critical observation: after execve returns, there is no “container runtime” attached to the process. It’s a normal Linux process whose properties happen to include “is in a non-default PID namespace” and “has a non-default seccomp filter installed.” Kill runc and the container keeps running, because runc was never the parent at runtime — containerd-shim (or crio-conmon) is, holding the TTY and reaping the exit code.

libcontainer

The Go package that does the actual work is libcontainer, originally a separate library that Docker open-sourced before runc existed and that the runc team eventually folded into runc’s repo as opencontainers/runc/libcontainer. Its public API is what Kubernetes’s older “Docker shim” and various ad-hoc tooling consumed; modern callers should go through the runc CLI rather than embedding libcontainer, but the package remains because runc itself uses it.

CLI Surface

runc spec                       # write a template config.json into the CWD
runc create <id> --bundle DIR   # set up everything but execve
runc start <id>                 # execve the user process
runc run <id> --bundle DIR      # create + start as one call (interactive use)
runc exec <id> -- /bin/sh       # additional process inside an existing container
runc kill <id> SIGTERM          # send a signal to the container's process group
runc delete <id>                # tear down (must be in stopped state, or --force)
runc state <id>                 # JSON state dump
runc list                       # all containers known to this runc
runc ps <id>                    # processes inside the container's PID namespace
runc events <id>                # cgroup events stream (memory pressure, OOMs)
runc pause <id> / runc resume   # freeze/thaw via the freezer cgroup
runc checkpoint <id> / runc restore   # CRIU-based state save/restore

runc run is the one humans type; runc create + runc start is the split that orchestrators want, because it gives them a window to attach CNI networking, file descriptors, or stdio between bundle materialization and process launch.

Configuration / Code / Specification

A Minimal Walk-through With runc Itself

# 1. Build a bundle directory
$ mkdir -p mycontainer/rootfs
$ docker export $(docker create alpine:latest) | tar -C mycontainer/rootfs -xf -
$ cd mycontainer
$ runc spec      # writes a template config.json
 
# 2. (optional) Edit config.json — change args, add caps, etc.
 
# 3. Run it
$ sudo runc run hello
/ #                 # we are now inside the container as root, PID 1
/ # ps -ef
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    7 root      0:00 ps -ef
/ # cat /proc/1/status | grep CapBnd
CapBnd: 00000000a80425fb
/ # exit

What you observed at each step:

  • runc spec wrote a default config.json that runs sh interactively with a conservative capability set, the standard mount list, and PID/UTS/IPC/MNT/NET namespaces enabled. No user namespace by default — the container’s root is the host’s root.
  • runc run hello performed every step from §Mechanical Walk-through; from inside, sh is PID 1 because it’s the entry point of a fresh PID namespace.
  • CapBnd: 00000000a80425fb is the hex bitmap of bounding capabilities — vastly smaller than a host root shell’s CapBnd: 000001ffffffffff.
  • The container has its own network namespace with only a loopback interface (because no prestart hook attached an external interface — runc itself is not a CNI plugin, that’s containerd/CRI-O’s job).

Choosing the Cgroup Driver

runc supports two cgroup drivers, set by the caller (containerd / CRI-O / kubelet must agree):

  • cgroupfs — runc writes directly to /sys/fs/cgroup/.... Simple, but conflicts with systemd’s own cgroup management.
  • systemd — runc asks systemd via D-Bus to create a transient scope/slice; systemd writes the cgroup. Required on hosts where systemd manages cgroups (RHEL/CentOS/Fedora defaults). Kubernetes’ kubelet must be told --cgroup-driver=systemd to match.

A driver mismatch between kubelet and runc shows up as “containers start without their memory limits applied” — the symptoms can be subtle until an OOM at unexpectedly high memory use.

Failure Modes

runc and host cgroup conflicts. On a host where systemd owns the cgroup hierarchy but runc is configured for cgroupfs, runc’s writes can race or be overwritten by systemd, producing containers whose limits silently aren’t applied. Diagnose with cat /sys/fs/cgroup/.../memory.max for the pod’s cgroup — if it’s max instead of the spec’d value, the driver is wrong.

Hung runc init helpers from stale state. If a container’s host crashes mid-create, /run/runc/<id>/state.json may persist while the helper PID has gone away. The next runc state call panics or returns stale data; recovery is runc delete --force <id> and then a fresh start. Kubelet’s container GC handles this in practice, but it is a known source of “phantom” containers visible only via crictl ps.

Cgroups v1 vs v2 drift. A node that’s mid-migration (v1 mounted but kernel command line set to systemd.unified_cgroup_hierarchy=1) confuses older runc. The precise support timeline, from runc’s own docs/cgroup-v2.md, is: runc gained experimental cgroup v2 (unified mode) support in v1.0.0-rc91, and has fully supported cgroup v2 since v1.0.0-rc93 — that is, before the v1.0.0 stable release (runc cgroup-v2 docs). On cgroup v2 hosts runc’s documentation states it is “highly recommended to run runc with the systemd cgroup driver (runc --systemd-cgroup), though not mandatory,” with a recommended systemd version of 244 or later, because older systemd cannot delegate the cpuset controller (ibid.). At the far end of the timeline, runc v1.4.0 (2025-11-27) deprecated cgroup v1 (runc CHANGELOG). Symptoms of a too-old runc on a v2 host: refused runc create calls with errors mentioning cpuset.cpus.effective.

Capability set mismatch with image expectations. A container whose application expects CAP_NET_BIND_SERVICE (to bind port 80) but whose bundle drops it will start successfully and then crash with EACCES on bind(2). The error path is the application’s error path, not runc’s — runc did exactly what config.json asked.

Stale FIFO on runc start. runc uses a FIFO at /run/runc/<id>/exec.fifo to coordinate the create→start handoff. If the FIFO is missing (NFS-backed /run, weird volume mounts), runc start hangs. The fix is to ensure /run/runc is a local tmpfs.

Pivot-root failure on non-private rootfs. pivot_root(2) requires the new root to be a mount point and the old root not to be MS_SHARED. Bundles built with rootfs on certain filesystems (NFS in particular) hit this. The error is pivot_root failed: EINVAL.

Alternatives and When to Choose Them

The OCI Runtime Spec being a vendor-neutral standard (Open Container Initiative Runtime Spec) means runc can be swapped at the runtime layer without changing any container manifest, image, or Kubernetes object. The most common alternatives:

crun — C-based, low-memory, cgroup v2 native

crun (containers/crun) is a Red Hat–led reimplementation in C. Headline numbers, quoted directly from Red Hat’s introduction (An introduction to crun): the crun binary is “~300k” against runc’s “~15M” — runc is “about 50 times larger than crun.” In a 100-sequential-container benchmark crun’s maximum resident set size was 3,752 KB versus runc’s 15,120 KB (roughly a 75% memory reduction), and crun completed the run in 3.86 s versus runc’s 6.89 s, i.e. “crun can be twice as fast as runc” depending on container configuration (ibid.). crun’s own README corroborates the direction with its own micro-benchmark (“100 /bin/true … −49.4%”) and notes crun ran a container under a 512 KB memory limit where runc failed at a 4 MB limit (crun README).

When to choose crun:

  • High pod density per node. On a node running hundreds of pods, the per-container memory savings of the runtime helper add up.
  • cgroups v2 hosts. crun was designed v2-first; its v2 support is the most mature among the runtimes.
  • RHEL 9, recent Fedora, recent OpenShift. crun is the default container runtime in RHEL 9, and has been the default in Fedora since Fedora 31 (runc is deprecated in RHEL 9 and slated for removal in a future major release) (RHEL 9 — Selecting a container runtime; An introduction to crun). OpenShift switched its default for newly created containers to crun in OpenShift 4.18 (runc remains supported and selectable; in-place upgrades from 4.17 keep the prior runtime) (OCP 4.18 release notes). If you are on these platforms you are likely already using crun.
  • OCI-hooks-heavy workloads. crun’s startup overhead is low enough that hook-heavy pipelines (sidecar injection, audit hooks, fluent-bit hooks) feel snappier.

When not to choose crun: nothing significant — crun is fully OCI compliant. The remaining reasons to stay on runc are familiarity, the Go ecosystem fit (runc embeds libcontainer that some non-K8s tools still import), and the slightly larger feature surface around experimental annotations.

gVisor / runsc — User-space syscall sandbox

gVisor (gvisor.dev) implements a user-space Linux kernel in Go that intercepts the container’s syscalls and serves them from within gVisor’s own implementation instead of letting them reach the host kernel (Google Cloud Blog). The OCI runtime binary is called runsc and accepts the same config.json as runc. Performance overhead is 5–20% depending on syscall frequency (network-heavy workloads pay more; CPU-heavy workloads pay less).

When to choose gVisor:

  • Hostile multi-tenancy. When the workload is untrusted code from external users (GCP Cloud Run, AWS Lambda’s pre-Firecracker era, CI executors running arbitrary CI jobs).
  • Defense in depth on a CVE-rich kernel surface. A kernel vulnerability that escapes a normal container often does not escape gVisor, because the syscall never reached the host kernel.

When not to choose gVisor: I/O-bound workloads (gVisor’s network and storage paths are slower); workloads needing exotic syscalls or /proc features that gVisor doesn’t implement (the coverage is broad but incomplete).

Kata Containers / kata-runtime — VM-isolated containers

Kata (katacontainers.io) takes the strongest isolation stance: each pod (or each container — configurable) runs inside a real lightweight VM (typically Cloud Hypervisor, QEMU/microvm, or Alibaba’s Dragonball). The OCI runtime binary boots a guest VM with a minimal kernel and rootfs, then runs the container inside the guest. Two kernel boundaries now separate the workload from the host.

When to choose Kata:

  • Regulatory or compliance requirements that mandate hypervisor-level isolation (FedRAMP High, certain financial certifications).
  • Confidential Containers — Kata 3.x integrates with AMD SEV-SNP and Intel TDX so the guest kernel runs in a hardware Trusted Execution Environment, hiding contents from the host operator.
  • Hostile workloads with high I/O. Kata’s I/O paths use paravirtio and are typically faster than gVisor’s, at the cost of larger per-container memory overhead (the VM kernel itself).

When not to choose Kata: density-sensitive deployments (the per-container VM overhead is real, typically 50–100 MB of guest memory), and the operational complexity of managing the guest kernel image alongside container images.

youki — Rust-based, performance between runc and crun

youki (youki-dev/youki) is a Rust implementation that’s largely a systems-research curiosity in 2026: it demonstrates that the OCI spec is implementable from scratch in a modern memory-safe language other than C, and its performance numbers fall between runc (Go) and crun (C). Adoption is small but growing. Choose youki if you have a specific reason to prefer Rust in your runtime stack (organizational alignment, planned future work that depends on Rust libraries), or if you’re contributing to it; for production, runc and crun remain the safer picks.

Decision Summary

RuntimeIsolationMemoryUse case
runcLinux namespaces + cgroups~15 MBDefault; what most clusters run
crunLinux namespaces + cgroups~3.7 MB peak RSSHigh density; cgroup v2 hosts; RHEL 9 / Fedora / OpenShift 4.18+
runsc (gVisor)User-space kernel~15 MB + per-syscall overheadHostile multi-tenancy
kata-runtimeLightweight VM~50–100 MBCompliance, confidential compute
youkiLinux namespaces + cgroupslow (Rust, no GC)Research / Rust alignment

RuntimeClass is the Kubernetes mechanism for selecting a runtime per pod — you can have most pods on runc and a specific namespace’s pods on runsc for hostile-workload isolation, on the same node.

Production Notes

The dockershim removal made runc the visible default. Before Kubernetes 1.24 (May 2022, Dockershim Removal FAQ) the chain was kubelet → dockershim → Docker Engine → containerd → containerd-shim → runc. Removing dockershim collapsed it to kubelet → containerd → containerd-shim → runc, surfacing runc as the obvious bottom of the stack. Operationally, this changed nothing — runc had been doing the work all along — but conceptually it clarified that the runtime is the OCI runtime, and Docker had been an unnecessary intermediary for Kubernetes since at least 2017.

OpenShift’s and RHEL’s choice of crun. RHEL 9 ships crun as its default container runtime and has deprecated runc, slated for removal in a future major release (RHEL 9 container runtime selection guide); OpenShift made crun the default for newly created containers in 4.18, while keeping runc supported and selectable (OCP 4.18 release notes). Both switches were operationally transparent because the OCI spec contract held: existing pod specs ran identically. The same neutrality means EKS and GKE could swap runc for crun in a future kubelet release without breaking workloads.

RuntimeClass in practice. GKE Sandbox uses gVisor’s runsc transparently via a gke-gvisor RuntimeClass; AWS Fargate uses a Firecracker-based runtime for each pod. The “K8s pod” abstraction sits on top of whichever runtime the pod’s RuntimeClass selects.

Auditing what runc actually did. crictl inspect <container-id> returns the merged OCI runtime spec for a running container — useful for verifying that securityContext in the Pod spec mapped to capabilities, seccomp, and linux.resources in the way you expected. The /run/runc/<id>/state.json file (root-only) is the source of truth.

Point-in-time caveat on the crun figures. The crun-versus-runc numbers in the Alternatives section (binary ~300 KB vs ~15 MB / “~50× larger,” peak RSS 3,752 KB vs 15,120 KB, ~2× faster startup) are the figures published in Red Hat’s 2020 introduction and crun’s own README, cited verbatim there. They are the canonical published benchmarks, but both binaries grow with every release, so treat the ratios as durable and the absolute bytes as dated — re-measure on your own runtime versions before capacity planning.

See Also