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 aconfig.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 orchestratesclone(2),unshare(2),setns(2),pivot_root(2), the cgroupfs,prctl(2), andseccomp(2)in the right order and refuses to do anything more. This note covers what runc does, the code path it walks fromrunc createtoexecve, 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):
- 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). - Re-exec a
runc inithelper, running C before the Go runtime starts. runc re-executes itself asrunc init. The critical detail is when the namespace work happens: thelibcontainer/nsenterpackage registers a C constructor,nsexec(), that runs as soon as therunc initbinary loads — before the Go runtime has booted any goroutines or threads (runc nsenter README). This ordering is mandatory: Go is multi-threaded from startup, andsetns(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_INITPIPEfile descriptor (ibid.). - Join then create namespaces (
setns(2)+clone(2)). Insidensexec(), runc first callssetns(2)for each namespace the bundle asks to join (the typical Kubernetes pod case where user containers join the Pause Container’s netns/IPC), thenclone(2)s a child with the requestedCLONE_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 “bothsetns(2)andclone(2)even if we don’t have anyCLONE_NEW*clone flags,” because it must fork a fresh process for the Go runtime to take over (ibid.). Only afternsexec()returns does the Go half ofrunc initbegin executing. - 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 fromlinux.resources, and moves the helper PID into the new cgroup. On v2 hosts a single unified hierarchy; on v1 hosts one cgroup per controller. - 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 (orchrootfallback for old kernels), then mounts the conventional auxiliaries (/proc,/sys,/devtmpfs,/dev/pts,/dev/shm) and any user-specified bind mounts. - Apply
linux.maskedPathsandlinux.readonlyPaths. Bind-mount/dev/nullover each masked path; bind-remount each readonly path withMS_RDONLY. - Set the hostname (if a UTS namespace exists):
sethostname(2). - Apply capabilities.
prctl(PR_SET_KEEPCAPS),setgroups,setuid/setgidto the requested user, thencapset(2)for each of bounding/effective/inheritable/permitted, plusprctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, ...)for ambient capabilities. - Set
no_new_privs.prctl(PR_SET_NO_NEW_PRIVS)so subsequent setuid/setcap binaries can’t elevate. - 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. - Apply AppArmor / SELinux labels if requested (
aa_change_onexec,setexeccon). - Wait. At this point
runc createreturns success; the helper is parked, paused beforeexecve. State is persisted to/run/runc/<id>/state.json. runc start foo. The parked helper receives a signal, finishes settingcwd, appliesrlimits, and callsexecve(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 receiverunc kill/runc deletecalls 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
/ # exitWhat you observed at each step:
runc specwrote a defaultconfig.jsonthat runsshinteractively 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 helloperformed every step from §Mechanical Walk-through; from inside,shis PID 1 because it’s the entry point of a fresh PID namespace.CapBnd: 00000000a80425fbis the hex bitmap of bounding capabilities — vastly smaller than a host root shell’sCapBnd: 000001ffffffffff.- The container has its own network namespace with only a loopback interface (because no
prestarthook 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=systemdto 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
| Runtime | Isolation | Memory | Use case |
|---|---|---|---|
| runc | Linux namespaces + cgroups | ~15 MB | Default; what most clusters run |
| crun | Linux namespaces + cgroups | ~3.7 MB peak RSS | High density; cgroup v2 hosts; RHEL 9 / Fedora / OpenShift 4.18+ |
| runsc (gVisor) | User-space kernel | ~15 MB + per-syscall overhead | Hostile multi-tenancy |
| kata-runtime | Lightweight VM | ~50–100 MB | Compliance, confidential compute |
| youki | Linux namespaces + cgroups | low (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
- Open Container Initiative Runtime Spec — the contract runc implements
- Container Runtime Interface — the gRPC layer above runc that kubelet talks to
- containerd — the typical CRI runtime that drives runc
- CRI-O — the alternative CRI runtime; ships with runc or crun
- Pause Container — the per-pod runc-launched process whose namespaces sibling containers join
- Static Pods — control-plane pods that kubelet runs via the same runc path as any other pod
- RuntimeClass — Kubernetes-side selection of runc vs runsc vs kata-runtime per workload
- cgroups Integration — how Pod resource limits become runc’s
linux.resources - SecurityContext — the Pod-spec fields that map to runc’s capability and seccomp config
- Container Orchestration Architecture — the platform context
- Kubernetes MOC — the parent MOC