How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
Every time you run docker run nginx, a chain of processes coordinates to create the illusion of an isolated system: its own process tree, its own network stack, its own filesystem root, its own hostname. None of that requires a hypervisor or a separate kernel. It is built from Linux syscalls that have existed since the 2.6 era, composed by a layered stack of tools that most engineers never look below.
This article traces that chain from the CLI down to the kernel, covering the six Linux namespace types, cgroups v2 resource accounting, the OCI runtime specification, how containerd and runc split responsibilities, how overlay filesystems assemble image layers, and how container networking is wired with veth pairs and bridges.
The Six Linux Namespaces
A namespace is a kernel data structure that wraps a global resource and makes processes inside it see only their own copy. Creating a namespace is a matter of passing flags to clone(2) or unshare(2). There is no container daemon required for this step.
The six namespaces containers use:
| Namespace | Flag | Isolates |
|---|---|---|
| PID | CLONE_NEWPID | Process ID numbering |
| Network | CLONE_NEWNET | Network interfaces, routes, iptables |
| Mount | CLONE_NEWNS | Filesystem mount points |
| UTS | CLONE_NEWUTS | Hostname and NIS domain name |
| IPC | CLONE_NEWIPC | System V IPC, POSIX message queues |
| User | CLONE_NEWUSER | UID/GID mappings |
A seventh, time namespace (CLONE_NEWTIME), was added in Linux 5.6 but is rarely used by container runtimes today.
The PID namespace is worth examining closely. When a process is created with CLONE_NEWPID, it sees itself as PID 1. From outside the namespace, the host kernel tracks its real PID. The process acts as init for its namespace: if it exits, all processes in that namespace are killed. This is why container runtimes run a tiny init process (or tini) as PID 1 rather than letting your application claim that slot, which would make it responsible for reaping zombie child processes.
The network namespace creates a fully independent network stack. The container gets its own loopback interface, its own routing table, and its own iptables rules. Initially this namespace has only a loopback interface. Connecting it to the host network requires an explicit step covered later.
The mount namespace is the reason a container can have a different filesystem root from the host. After entering a new mount namespace, the process calls pivot_root(2) (or the older chroot(2)) to change what directory is treated as /. Any mounts made after that point are scoped to the namespace and invisible to the host.
User namespaces deserve special attention because they enable rootless containers. A user namespace maps a range of UIDs inside the namespace to a different range outside. A process that appears to be UID 0 (root) inside its user namespace might be UID 100000 on the host. The kernel enforces the real UID for access decisions on host resources while letting the container process operate as root within its own scope.
Cgroups v2: Resource Accounting and Limits
Namespaces control visibility. Cgroups control resource consumption. A container with no cgroup limits can consume all CPUs and RAM on the host; namespaces do not prevent that.
Cgroups v2 (unified hierarchy) replaced the fragmented v1 design where CPU, memory, and I/O were each in separate hierarchies. In v2, everything lives under a single tree rooted at /sys/fs/cgroup. A container runtime creates a subdirectory there, writes PIDs into cgroup.procs, and then writes limits into controller-specific files.
For a CPU limit of 0.5 cores and 256 MiB of memory:
/sys/fs/cgroup/docker/abc123/
cgroup.procs # PIDs assigned to this cgroup
cpu.max # "50000 100000" = 50ms per 100ms window = 0.5 CPU
memory.max # "268435456" bytes = 256 MiB
memory.swap.max # "0" disables swap
io.max # device-specific read/write byte and IOPS limits
The cpu.max format deserves explanation. The value 50000 100000 means the cgroup can consume 50,000 microseconds of CPU time per 100,000 microsecond period. This is the CFS quota mechanism. Setting it to max 100000 removes the limit entirely.
Memory limits are hard: if a cgroup exceeds memory.max, the kernel invokes the OOM killer within that cgroup before touching the host. The container process gets killed. memory.high is a softer threshold that triggers reclaim pressure first, useful for detecting memory leaks before they become crashes.
Kubernetes translates resource requests and limits directly to cgroup settings. A pod with resources.limits.cpu: "500m" results in cpu.max being set to 50000 100000. A pod with resources.limits.memory: "256Mi" sets memory.max to 268435456. The mapping is exact and direct.
The OCI Runtime Specification
In 2015, Docker, CoreOS, and others formed the Open Container Initiative to standardize two things: the image format and the runtime interface. Without that standardization, every orchestrator would need to know about every runtime.
The OCI runtime spec defines a config.json file that describes everything needed to start a container: the process to run, environment variables, the root filesystem path, namespace configuration, cgroup paths, mounts, hooks, and security settings. A compliant runtime reads this file and a prepared root filesystem, then starts the container.
A minimal excerpt of config.json:
{
"ociVersion": "1.0.2",
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"cwd": "/"
},
"root": {
"path": "rootfs",
"readonly": false
},
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" }
],
"cgroupsPath": "/docker/abc123"
}
}
The OCI spec also defines lifecycle hooks: prestart, createRuntime, createContainer, startContainer, and poststop. Container runtimes and orchestrators inject network configuration, volume mounts, and other side effects through these hooks.
containerd and runc: The Layered Execution Model
Docker, Kubernetes, and most modern container platforms do not call the kernel directly. They use a two-layer architecture: a high-level container manager and a low-level OCI runtime.
containerd is the high-level daemon. It manages the image lifecycle (pull, unpack, store), creates and manages container snapshots, communicates with the CRI (Container Runtime Interface) used by Kubernetes, and delegates the actual process creation to a lower-level runtime via the containerd-shim.
runc is the reference OCI runtime. It is a small Go binary that reads config.json, prepares namespaces, sets up cgroups, applies seccomp filters, and calls exec. It exits after the container process starts. The container process is not a child of runc; it is re-parented to the shim process, which stays alive to forward signals and collect the exit code.
The shim architecture solves a real problem: if containerd crashes and restarts, the running containers should not be affected. Because each container has its own shim process, the containers remain running and the restarted containerd can re-attach to existing shims.
The call sequence for docker run nginx looks like this:
- Docker CLI sends a gRPC request to
dockerd. dockerdsends a request tocontainerdvia its gRPC API.containerdchecks its snapshot store. If the image layers are not present, it pulls and unpacks them.containerdcreates a snapshot representing the container’s writable layer on top of the image layers.containerdwritesconfig.jsoninto a bundle directory and spawnscontainerd-shim-runc-v2.- The shim calls
runc createwith the bundle path. runcsets up all namespaces and cgroups, prepares the root filesystem, and writes the container PID to a file.containerdcallsrunc startto execute the user process.runcexits. The shim holds the container process.
Overlay Filesystems and Image Layers
OCI images are stored as a series of content-addressable layers. Each layer is a tar archive of filesystem changes relative to the layer below it. When a container starts, these layers are assembled into a unified filesystem using OverlayFS.
OverlayFS is a union mount filesystem built into the Linux kernel. It takes two directories: a lower directory (read-only) and an upper directory (writable), and presents them as a single merged view. Reads on files that exist only in the lower layer come from there with no copy. Writes to any file trigger a copy-up: the file is copied from the lower layer to the upper layer and the write is applied there. Deletes create whiteout files in the upper layer that mask the lower entry.
For a multi-layer image:
lowerdir=/var/lib/containerd/snapshots/3:/var/lib/containerd/snapshots/2:/var/lib/containerd/snapshots/1
upperdir=/var/lib/containerd/snapshots/container-abc123-upper
workdir=/var/lib/containerd/snapshots/container-abc123-work
merged=/run/containerd/io.containerd.runtime.v2.task/default/abc123/rootfs
The lowerdir can chain multiple read-only layers separated by colons, with leftmost taking precedence. The workdir is an internal scratch directory required by OverlayFS for atomic operations. The merged directory is the container’s root filesystem.
This design is why container image layers are shared between containers running the same image: all containers share the same read-only lower layers and each gets its own upper layer. A host running 10 containers from the same 200 MiB image does not use 2 GiB of disk for the image data.
Container Networking: veth Pairs and Bridges
When a container is created with a new network namespace, that namespace is initially isolated. Connecting it to the host requires a virtual ethernet pair (veth pair): two virtual network interfaces that act like opposite ends of a pipe. Whatever enters one end exits the other.
The runtime creates the veth pair on the host, moves one end into the container’s network namespace, and assigns IP addresses to both ends. The host end is attached to a bridge (typically docker0 or a CNI-created bridge), which acts as a virtual switch for all containers on that host.
Traffic from the container to the outside world flows through NAT. The host runs iptables rules that masquerade outbound packets from the container’s IP range behind the host’s public IP. Inbound port mapping adds a DNAT rule that redirects traffic arriving on a host port to the container’s IP and port.
For Kubernetes, the CNI (Container Network Interface) plugin handles this setup. Popular plugins like Flannel, Calico, and Cilium all implement the CNI spec: they receive a network namespace path and a configuration file, and they are responsible for wiring the container into whatever network fabric the cluster uses. Cilium replaces iptables with eBPF programs loaded directly into the kernel, which is why its datapath has lower latency and better observability than traditional iptables-based approaches.
Production Security: Rootless Containers, Seccomp, and AppArmor
Running container runtimes as root is a significant attack surface. If a process escapes the container’s mount namespace or exploits a kernel vulnerability, it has full host root access.
Rootless containers use user namespaces to map the container’s UID 0 to an unprivileged host UID. The container runtime itself runs without host root privileges. podman is rootless by default. Docker has supported rootless mode since 20.10. The tradeoff is that some features requiring true kernel capabilities (like binding to ports below 1024 or using certain storage drivers) are unavailable or need additional configuration.
Seccomp (Secure Computing Mode) filters the syscalls a container process can make. Docker applies a default seccomp profile that blocks roughly 44 syscalls out of the ~300+ available, including ptrace, personality, keyctl, and others that are rarely needed by application code but commonly exploited for privilege escalation. The profile is a JSON file loaded via prctl(PR_SET_SECCOMP) and evaluated by a BPF program in the kernel for every syscall.
AppArmor and SELinux provide mandatory access control at the filesystem and capability level, independent of UID. Docker generates an docker-default AppArmor profile that restricts access to /proc/sysrq-trigger, /proc/sys, and other dangerous paths. Kubernetes uses SELinux labels via the pod’s securityContext.seLinuxOptions to constrain what a compromised container process can touch on the host filesystem.
Runtime Tradeoffs
| Runtime | Isolation Model | Performance Overhead | Syscall Interception | Rootless | Use Case |
|---|---|---|---|---|---|
| runc | Linux namespaces + cgroups | Near zero | None (direct syscalls) | Yes (with user ns) | General workloads, default choice |
| crun | Same as runc, written in C | Slightly lower than runc | None | Yes | Lower memory overhead, same security model |
| gVisor (runsc) | User-space kernel (Sentry) | 10-40% CPU overhead | All syscalls intercepted by Sentry | Partial | Untrusted workloads, multi-tenant sandboxing |
| Firecracker | MicroVM with KVM | Low (5-15ms startup) | Full VM isolation | No | Serverless, Lambda-style execution |
| Kata Containers | Full VM per container | Higher (full VM boot) | Full VM isolation | Partial | Regulated workloads, strong isolation requirements |
runc and crun are functionally equivalent from a security model perspective. They both use namespaces and cgroups, which means a kernel vulnerability can breach the isolation. gVisor interposes all syscalls through a user-space kernel written in Go, which means the attack surface is the Sentry’s syscall surface rather than the full Linux kernel. The tradeoff is real CPU overhead and some syscall incompatibilities. Firecracker and Kata provide VM-level isolation by running containers inside lightweight virtual machines, which is the approach AWS Lambda and AWS Fargate use. The startup time is higher but the isolation guarantee is qualitatively different.
Closing Insight
The container runtime stack is remarkably shallow once you look past the tooling. Namespaces partition global resources. Cgroups enforce resource budgets. OverlayFS composes image layers. Veth pairs and bridges wire networking. The OCI spec standardizes the contract between containerd and runc so that orchestrators do not need to understand runtime internals.
Understanding this stack changes how you reason about container security. Namespaces are not a security boundary in the same sense a VM boundary is. A kernel vulnerability breaks all namespace isolation simultaneously. Rootless containers, seccomp filters, and AppArmor profiles reduce the blast radius of a container escape but do not eliminate the shared kernel attack surface. For workloads where that matters, gVisor or a microVM runtime is the correct choice, not just a stricter seccomp profile.
The same mental model applies when debugging: a container that cannot bind a port is missing a capability, not hitting a firewall rule. A container that runs out of memory gets killed by the cgroup OOM handler, not the kernel’s global OOM killer. A container that writes to its root filesystem is writing to the OverlayFS upper directory, not to the image layers. The kernel primitives explain the behavior; the container tooling just automates their configuration.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.
How Cloud Spanner Works Internally: TrueTime, Paxos Replication, and the Globally-Distributed Architecture Behind Consistent Reads at Any Scale
A deep dive into Cloud Spanner's internals: TrueTime and bounded clock uncertainty, Paxos-based replication with leader leases, the read/write transaction protocol, snapshot reads without locks, interleaved table hierarchies, non-blocking schema changes, and production considerations for split management and hotspot avoidance.