⏱️ Reading time: 17 min
When you run docker run -it ubuntu bash, the Linux kernel has no idea what a container is: there’s no such thing. What that command actually assembles is a container runtime called runc, and even in 2026 it still has to cheat, because the language it’s written in, Go, doesn’t even have a safe fork() function.
📑 En este artículo
- TL;DR
- What is a container runtime?
- Namespaces, cgroups, and pivot_root: the real pieces behind “container”
- How runc builds the process: from docker run to execve
- The Go problem: why runc needs to fake a fork()
- Why Docker runs as root
- How to get started: your own mini runtime with unshare and chroot
- Real-world use cases
- Common mistakes and best practices
- Comparison with alternatives
- Going deeper: cgroups v2, user namespaces, and OCI hooks
- Frequently Asked Questions
- What’s the difference between runc and a virtual machine?
- Why does dockerd need root privileges?
- Can I use Linux namespaces without installing Docker?
- What is pivot_root and why does runc prefer it over chroot?
- Does Docker’s rootless mode have the same networking performance as normal mode?
- When does it make sense to use gVisor or Firecracker instead of runc?
- References
That cheat, re-executing itself from /proc/self/exe, is just one of the tricks holding up the entire container ecosystem: Docker, Kubernetes, containerd. None of them use any new isolation magic. They all reuse pieces of the Linux kernel that have existed for more than a decade.
TL;DR
- A container is just a regular Linux process with namespaces, cgroups, and pivot_root applied, nothing more.
- runc, the runtime that executes every docker run, is written in Go: a language with no native fork() and a multi-threaded runtime.
- The trick to mimic fork() is re-executing /proc/self/exe before the Go runtime launches its threads.
- A Docker image is just a folder: pivot_root turns it into the process’s root without copying anything.
- dockerd runs as root because namespaces and cgroups require privileges; rootless mode rebuilds networking in user space.
What is a container runtime?
A container runtime is the low-level program that translates an image and a set of constraints into an isolated Linux process. It applies namespaces, cgroups, and pivot_root, then runs the final process with execve. runc is the reference implementation used by Docker, containerd, and Kubernetes.
Namespaces, cgroups, and pivot_root: the real pieces behind “container”
There’s no line in the Linux kernel that says “this is a container.” Instead, a runtime combines independent mechanisms that existed long before Docker did. Namespaces hide what the process can see: its own PID tree, its own network interface, its own hostname. Cgroups limit how much CPU, memory, and I/O that process can consume, and pivot_root changes which folder on disk acts as the filesystem root for that specific process.
Docker enables almost all namespaces by default when you run a normal container, but you can selectively disable them with flags like --pid=host or --network=host. Each namespace is a different view of the same kernel: two processes in different network namespaces can both have an interface called eth0 without clashing, because each one lives in its own routing table.
Cgroups, in turn, are organized into controllers: cpu, memory, io, and pids. The cpu controller distributes processor time using relative weights; memory sets a hard RAM ceiling and can kill the process with the OOM killer if it’s exceeded; io limits disk bandwidth, and pids caps how many processes that group can create before blocking new fork() calls. Docker exposes these limits with flags like --memory, --cpus, and --pids-limit, but under the hood they’re just writes to files inside /sys/fs/cgroup/.
flowchart TD
A["docker run ubuntu bash"] --> B["Namespaces isolate PID, network, and hostname"]
A --> C["Cgroups limit CPU and memory"]
A --> D["pivot_root changes the process root"]
subgraph "Linux Kernel"
B
C
D
end
How runc builds the process: from docker run to execve
A Docker image is nothing more than a folder. When you pull ubuntu from Docker Hub, what you’re downloading are compressed layers that, once decompressed, form a directory tree identical to a minimal Ubuntu install: /bin, /etc, /lib, /usr. Nothing is running there yet.
The full flow of docker run -it --rm ubuntu /bin/bash passes through four programs before reaching the kernel. The docker client talks to the dockerd daemon. dockerd delegates to containerd, which manages the container’s lifecycle. containerd invokes runc with the image path and the container’s OCI spec, and runc is the one that finally makes the system calls: it clones the process with namespaces and cgroups, calls pivot_root so that decompressed folder becomes the process’s root, and finally runs /bin/bash with execve.
The difference between pivot_root and the classic old chroot is that pivot_root moves the old root to a separate mount point and allows unmounting it afterward, while chroot leaves the previous root accessible if the process manages to escape with a file descriptor that was already open. That’s why runc uses pivot_root whenever the kernel allows it, and falls back to chroot only as an alternative on systems where pivot_root isn’t available.
sequenceDiagram
participant U as User
participant D as dockerd
participant C as containerd
participant R as runc
participant K as Kernel
U->>D: docker run ubuntu bash
D->>C: create container
C->>R: create and start
R->>K: clone with namespaces and cgroups
R->>K: pivot_root to the image
R-->>K: execve the final process
Note over R,K: runc exits, the final process remains alone
The Go problem: why runc needs to fake a fork()
runc is written in Go, and Go has a design problem for this specific job: its runtime spins up several OS threads as soon as the program starts, to handle the garbage collector and the goroutine scheduler. The classic Unix fork() call only duplicates the thread that calls it, not the others. If runc called fork() directly after the Go runtime had already spun up those threads, the child process would end up with a half-initialized, corrupted Go runtime.
The solution the runc team adopted was to not use fork() at all. Instead, the binary re-executes itself by reading its own executable from /proc/self/exe, with a special argument that tells the new process to act as the container’s “init” instead of running the normal command. That child process, freshly started from scratch with its own clean Go runtime, is the one that makes the namespace, cgroup, and pivot_root calls before running the actual command with execve.
There’s another detail that complicates things further. Some of the system calls a container runtime needs, like setns() to join an existing namespace, require running on a single OS thread, without the Go scheduler moving the goroutine to another thread midway through. That’s why runc includes a small C module that runs before the Go runtime finishes initializing, in a window where there’s still only one active thread.
flowchart TD
A["runc starts in Go"] --> B["The Go runtime already spun up several threads"]
B --> C["A classic fork() would leave the child with a corrupted runtime"]
C --> D["runc re-executes /proc/self/exe"]
D --> E["The child process starts with a clean Go runtime"]
E --> F["Sets up namespaces, cgroups, and pivot_root"]
F --> G["execve() replaces the process with the actual command"]
Why Docker runs as root
If you use Docker Desktop on macOS or Windows you never notice this, because the daemon runs inside a Linux virtual machine that Docker Desktop manages for you. But on a real Linux server, dockerd runs as root, and for years that was the only option: creating namespaces, especially the network one, and manipulating cgroups were operations the kernel reserved for processes with superuser privileges.
That changed partially with user namespaces and with Docker’s rootless mode, which lets you run the daemon and containers without real root privileges. The problem shows up in networking: without root access to the host’s interfaces, rootless mode has to reimplement a good chunk of the network stack in user space, with tools like slirp4netns or pasta, to achieve something close to the network bridge that normal mode builds directly in the kernel.
Even when the process inside the container runs as UID 0, Docker trims the Linux capabilities by default: it removes permissions like CAP_SYS_ADMIN or CAP_NET_ADMIN that, on the host, would let it mount arbitrary filesystems or reconfigure other processes’ network interfaces. You can check which capabilities a container has active by running cat /proc/1/status | grep Cap from inside it, and decode the hex value with capsh --decode=<value>.
📌 Note: running as root inside the container is not the same as the daemon running as root on the host: these are two separate isolation layers, and Docker trims Linux capabilities inside the container even when the user in there is root.
How to get started: your own mini runtime with unshare and chroot
You don’t need to write Go or read the OCI spec to feel firsthand what runc does. Linux ships the loose pieces in the unshare command, which already comes installed on almost every modern distribution as part of the util-linux package. This runs on native Linux: it won’t work inside Docker Desktop on macOS or Windows because you’re already inside a Linux VM there, so you need a real Linux terminal or a VM.
First, set up a minimal folder with a static BusyBox binary, which brings its own commands without depending on system libraries:
mkdir -p /tmp/minicontainer/bin
cd /tmp/minicontainer
curl -L -o bin/busybox https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox
chmod +x bin/busybox
./bin/busybox --install bin
That last command creates symlinks so that ls, ps, sh, and the rest of BusyBox’s commands become available inside bin/. Now isolate a PID, mount, and UTS namespace, and use chroot to switch the root to that folder:
sudo unshare --pid --mount --uts --mount-proc --fork \
chroot /tmp/minicontainer /bin/sh
You’ll land in a shell where ps only sees its own processes, because the PID namespace hides everything from the host:
/ # ps
PID USER TIME COMMAND
1 root 0:00 sh
2 root 0:00 ps
That is, literally, the same principle runc uses: namespaces and its own root. It’s missing cgroups to limit resources and pivot_root instead of chroot to be complete, but the core idea (a regular process with a trimmed-down view of the system) is already there.
To confirm that the PID namespace actually isolated the process, open another terminal on the host and compare the namespace links before and after entering the chroot:
readlink /proc/1/ns/pid
readlink /proc/$(pgrep -f 'chroot /tmp/minicontainer')/ns/pid
If the two commands return different inode numbers, for example something like pid:[4026531836] versus pid:[4026532891], that confirms the process inside the chroot lives in a PID namespace different from the system’s. If they return the same number, the namespace wasn’t applied.
⚠️ Watch out: chroot on its own is not a security boundary. A process with root privileges inside the chroot can escape if it has access to syscalls like mknod or to a file descriptor open outside the folder. That’s why runc combines chroot or pivot_root with user namespaces and trimmed capabilities, never using it alone.
Real-world use cases
Kubernetes doesn’t run containers directly: it delegates to containerd or CRI-O, which in turn call runc or some OCI-compatible alternative. When a Pod restarts or gets rescheduled on another node, what actually gets destroyed and recreated is that tree of namespaces, cgroups, and the filesystem root, not a “machine” in the traditional sense.
CI environments also rely on this same mechanism to isolate builds. When GitHub Actions or GitLab CI run a job inside a container, they’re using the same container runtime to keep a malicious pipeline step from touching the rest of the runner. Serverless providers that offer short-lived functions usually use lighter variants of this same stack, sometimes replacing namespaces with microVMs when they need stronger isolation than a process sharing a kernel can provide.
At the edge, projects like K3s or MicroK8s use the same containerd and runc stack, but trimmed down to run on low-resource devices, like industrial gateways or cameras with embedded compute. The advantage over installing the application directly on the device’s operating system is that a bug in the process stays contained within its namespaces and cgroups, unable to read configuration files from other services running on the same gateway.
Common mistakes and best practices
The first classic mistake is assuming a container isolates as much as a virtual machine. It doesn’t: every container on a host shares the same kernel, so a privilege-escalation vulnerability in the kernel (not in the application) can jump from one container to another or to the host. That’s why they run with seccomp and trimmed capabilities by default, and why --privileged is a flag you should treat as an alarm, not a convenient shortcut to “make it work.”
The second mistake is not understanding that the compressed image you pull from a registry doesn’t include the kernel or a real init. Any process running as PID 1 inside the container inherits responsibilities that systemd normally handles, like reaping zombie processes. If your application doesn’t handle signals or orphaned children, you’ll end up with zombie processes piling up until the container restarts.
The third is confusing cgroups v1 with cgroups v2. Most modern distributions, like Fedora or recent Ubuntu, already use cgroups v2 by default, with a single unified hierarchy instead of separately mounted controllers. If a container behaves differently on your laptop than on an older server, confirm the version with:
cat /sys/fs/cgroup/cgroup.controllers
If that file exists and lists controllers like cpu memory io pids, you’re on cgroups v2. If the command fails, the system is still using the v1 hierarchy.
Comparison with alternatives
runc isn’t the only implementation of the OCI spec, and choosing another one can make sense depending on what you’re prioritizing: startup speed, a smaller memory footprint, or stronger isolation than sharing a kernel provides.
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| runc | Default runtime for Docker and Kubernetes | Implements the OCI standard and is compatible with the entire existing ecosystem | Written in Go, needs the re-execution trick to simulate fork() |
| crun | Hosts with cgroups v2 prioritizing fast startup | Written in C, smaller and lighter binary than runc | Lower default adoption on some distributions |
| gVisor | Multi-tenant setups where you don’t trust the shared kernel | Intercepts syscalls in user space as an extra isolation layer | Overhead on workloads with many system calls |
| Firecracker | Serverless with VM-style isolation | Boots a lightweight microVM with its own kernel per workload | Not a pure OCI runtime, needs its own orchestrator |
Going deeper: cgroups v2, user namespaces, and OCI hooks
User namespaces are the piece that makes it possible for “root inside the container” to not mean “root on the host.” They map a range of container UIDs to a different, unprivileged range on the host. The process can believe it’s UID 0 inside while actually running as an unprivileged UID, like 100000, from the outside. Docker has supported this for years with the --userns-remap flag, though few enable it because it complicates handling volumes shared with the host.
The OCI spec that runc follows also defines “hooks,” extension points where an orchestrator can inject its own logic before or after certain stages of the container’s lifecycle, like prestart, createRuntime, or poststop. Kubernetes and Docker use these hooks internally for tasks like setting up the Pod’s network before the main process starts, without having to modify runc.
Another detail that sets OCI runtimes apart from each other is how they handle CLONE_NEWCGROUP, the cgroup namespace. Mounting it incorrectly can let a process inside the container see the host’s entire cgroup hierarchy instead of just its own, leaking information about which other containers are running on the same machine.
seccomp is the fourth piece almost never mentioned alongside namespaces and cgroups. It filters which syscalls a process can make, not what it can see or how much it can consume. Docker’s default profile blocks a list of dangerous syscalls, like unrestricted ptrace or arbitrary mount, even if the process has the capabilities that would theoretically allow it. You can see a container’s active profile by running docker inspect --format '{{.HostConfig.SecurityOpt}}' <container>.
Your next step: run the mini runtime from the previous section, add your own cgroup by writing limits directly into /sys/fs/cgroup/, and confirm you can cap its memory at 50 MB before the kernel kills the process with the OOM killer.
Frequently Asked Questions
What’s the difference between runc and a virtual machine?
A virtual machine boots its own complete kernel on top of a hypervisor; a container run by runc shares the same kernel as the host and only changes what that process can see and how much it can consume. That’s why a container starts in milliseconds while a VM takes seconds, but it’s also why a failure in the shared kernel affects every container at once.
Why does dockerd need root privileges?
Because creating certain namespaces, especially the network one, and manipulating cgroups are operations the Linux kernel has historically reserved for processes with superuser privileges. Docker’s rootless mode avoids this by reimplementing parts of the network stack in user space.
Can I use Linux namespaces without installing Docker?
Yes. The unshare command, part of the util-linux package that already ships with almost every distribution, lets you create PID, network, mount, UTS, and IPC namespaces directly from the terminal, without going through dockerd or runc.
What is pivot_root and why does runc prefer it over chroot?
pivot_root changes which mount point acts as the filesystem root for a process, and allows unmounting the previous root after the switch. chroot only changes the apparent root without that extra step, which leaves an escape route if the process already had a file descriptor open outside the new root.
Does Docker’s rootless mode have the same networking performance as normal mode?
Not entirely. Without root access to the host’s interfaces, rootless mode relies on tools like slirp4netns or pasta to simulate networking in user space, which adds a translation layer that privileged mode doesn’t need.
When does it make sense to use gVisor or Firecracker instead of runc?
When namespace and cgroup isolation isn’t enough, for example in multi-tenant environments where workloads from different customers share the same host. gVisor intercepts syscalls in user space and Firecracker boots a lightweight microVM with its own kernel, both at the cost of more overhead than plain runc.
References
- Code and Cake: the original article documenting these Docker quirks as its author builds their own container runtime.
- GitHub: opencontainers/runc: source code of the reference OCI runtime implementation used by Docker and Kubernetes.
- GitHub: opencontainers/runtime-spec: the OCI spec that defines how any container runtime must behave.
- Wikipedia: Linux namespaces: overview of the eight namespace types exposed by the Linux kernel.
- Official Docker documentation: reference for the daemon, rootless mode, and the client-server architecture.
📱 Do you like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Featured image: Foto de Markus Spiske en Unsplash
Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.
Leave a comment
0 Comments