Process Isolation: One Kernel, Many PID 1s
Two containers on a host each have a process that believes it is PID 1 and a process tree that stops at it; the host kernel sees all of them as ordinary processes with ordinary PIDs, which is exactly why a kernel bug is a bug in every container at once.
The problem
The picture
Draw the host kernel as one box. Above it, two containers, each with a process 1 and a process 2. Inside container A, ps shows PIDs 1 and 2; inside container B, ps shows PIDs 1 and 2; on the host, ps shows four processes with PIDs like 28914, 28960, 31007 and 31040 — and every other host process besides. There is one scheduler (Scheduling Simulator: FCFS, Round Robin, Priority) choosing among all of them, one page allocator serving all of them, one network stack, one set of drivers. The containers are not *on top of* anything; they are *inside* the same process table with a different index.
A PID namespace is a mapping from *local* PIDs to *global* task structures. Every process has a PID in its own namespace and in each ancestor namespace, and the kernel exposes both: /proc/<pid>/status on the host has an NSpid: line listing the process’s PID at every level. The namespaces nest: container A’s PID namespace is a child of the host’s (the “initial” namespace), so the host can see into A, but A cannot see the host or B. docker exec works by joining A’s namespaces with setns() from the host side, where visibility is allowed.
host$ grep -E '^(Name|NSpid)' /proc/28914/status
Name: nginx
NSpid: 28914 1 # global PID, then PID inside the container's namespace
container$ ps -eo pid,ppid,comm
PID PPID COMMAND
1 0 nginx # "no parent": the parent is outside the namespace
29 1 nginxWhat PID 1 means inside
The first process created in a new PID namespace gets PID 1 there and inherits init’s special rules (Signals: Asynchronous Notifications From the Kernel): the kernel does not apply default signal actions to it, so a SIGTERM with no handler is silently ignored — the reason docker stop on a naive image waits its full grace period and then SIGKILLs. If PID 1 exits, the kernel kills every other process in the namespace with SIGKILL; there is no container without its init. And every orphaned process in the namespace is reparented to PID 1, which must wait() for them or they stay zombies (Process States). Real init systems do all of this; a Node server or a Python script does none of it, which is why tini exists and why docker run --init injects it.
What the container *sees* is the intersection of all its namespaces: its own process list, its own / (mount), its own interfaces and ports (net), its own hostname (uts), its own shared-memory segments (ipc). What it *does not* see but still shares: the kernel version (uname -r is the host’s), the kernel’s tunables in /proc/sys (mostly host-global; a few like net.* are per-net-namespace), the loaded modules, the clock, the hardware description in /proc/cpuinfo, and — critically — the kernel’s memory and its bugs.
unshare --pid --fork --mount-proc bashcreates a new PID namespace in one command;psinside shows onlybashas PID 1. This is the entire mechanism, minus the packaging.nsenter -t 28914 -n ss -tlnpruns a host tool inside the container’s *network* namespace only — the way to see a container’s listeners without installing anything in the image.
Why one kernel bug is every container’s bug
Every system call from every container enters the same kernel code. A vulnerability in that code — a race in a driver, a missing bounds check in a filesystem — is reachable from inside any container that is allowed to make the triggering syscall, and a successful exploit runs with the kernel’s privileges, above every namespace. That is a container escape: not a hole in Docker, a hole in the shared floor. Dirty COW (CVE-2016-5195, a race in Copy-on-Write handling that let an unprivileged process write to read-only mappings) and Dirty Pipe (CVE-2022-0847, uninitialised flags in the Pipes: A Kernel Buffer Between Two Processes buffer allowing overwrites of page-cache pages) were both exploitable from inside containers to gain root on the host.
This is the principled difference from a VM. A hypervisor’s attack surface is the virtual hardware interface — a few dozen device emulations — while a container’s attack surface is the entire syscall table, ~450 entries on x86-64, plus every filesystem and driver behind them. seccomp reduces the reachable surface (Containers Are Processes With the Kernel’s View Narrowed); it does not change the fact that one kernel serves everyone. Multi-tenant platforms that run untrusted code therefore do not rely on containers alone (VM vs Container: Where the Boundary Is).
User namespaces and rootless containers
By default the user namespace is *not* used: uid 0 in the container is uid 0 on the host, restricted only by capabilities and seccomp. If a process escapes the mount namespace — say, through a misconfigured bind mount — it is root on the host filesystem. A user namespace breaks that identity: /proc/<pid>/uid_map maps container uid 0 to an unprivileged host uid such as 100000, and container uids 1–65535 to 100001–165535. Root inside has full capabilities *within the namespace* — it can mount, chown and bind low ports on its own resources — but any access that reaches host objects is checked against uid 100000, which owns nothing.
Rootless containers (Podman by default, Docker in rootless mode) go one step further and run the container runtime itself as an unprivileged user, with the user namespace providing the fake root. Nothing in the chain ever holds real root, so a full escape from every namespace lands as an ordinary user. The costs are real but shrinking: networking needs a user-space helper (slirp4netns or pasta) because creating a veth pair on the host requires privilege, overlayfs needed kernel 5.11+ to work unprivileged, and some images assume they can chown to arbitrary uids. Kubernetes exposes the same idea as hostUsers: false.
- Even without rootless,
USER appin the Dockerfile plus--cap-drop ALLremoves most of what an escape would gain. - Kernel vulnerabilities still apply under user namespaces — the exploit runs as the kernel — but most post-escape steps (write to
/etc, load a module) now fail at the uid check.
container$ id
uid=0(root) gid=0(root)
container$ cat /proc/self/uid_map
0 100000 65536 # container uid 0..65535 → host uid 100000..165535
host$ ps -o user,pid,comm -p 28914
USER PID COMMAND
100000 28914 nginx # "root" inside is uid 100000 outsideKey points
- Containers share the host kernel: one scheduler, one memory manager, one network stack, one syscall table for everyone.
- A PID namespace maps local PIDs to global tasks;
NSpidin/proc/<pid>/statusshows both numbers. - PID 1 inside gets init’s rules: default signal actions are not applied, orphans are reparented to it, and its exit kills the namespace.
- A container sees its own process list, mounts, network, hostname and IPC — and shares the kernel version, tunables, hardware description and bugs.
- A kernel vulnerability reachable via syscall is a container escape; Dirty COW and Dirty Pipe were exactly that.
- User namespaces map container root to an unprivileged host uid; rootless containers keep real root out of the whole chain.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does the kernel keep two PIDs for one process?
So the container can have a stable, private numbering starting at 1 while the host keeps a single global table it can still administer. Nesting the namespaces gives visibility downward only.
▸Why is PID 1 special inside a container?
Because the namespace needs an init: something to reparent orphans to, and something whose death defines the end of the namespace. The kernel reuses the rules it already has for the real init.
▸Why does seccomp not make containers as safe as VMs?
seccomp shrinks the list of syscalls a container can make, but every allowed syscall still runs shared kernel code. A VM’s guest kernel bugs stay inside the guest; a container’s kernel bugs are the host’s.
▸Why are user namespaces not the default everywhere?
Compatibility: images that chown to arbitrary uids, volumes owned by host users, and host networking setup all assume real root. The gap is closing, and rootless is the default in Podman.
Namespaces: two views of one process
| Question | Inside container A | From the host |
|---|---|---|
| PID | 1 (init of the container) | 4213 |
| Parent | 0 — nothing above it | containerd-shim (pid 4200) |
| Hostname | 3f1a9c2b1d0e | host-01 |
| Interfaces | lo, eth0 172.17.0.2/16 | lo, eth0 10.0.0.5, docker0 172.17.0.1, veth3f1a, … |
| Mount root | / (overlay merged) | /var/lib/docker/overlay2/3f1a9c…/merged |
| uid of this process | root (0) | root (0) — real root on the host kernel |
| Visible processes | nginx master, worker (2) | all 312 processes on the host |
How it fails
What the failure looks like from inside real software.
docker stoptakes the full grace period every time: the app is PID 1 without aSIGTERMhandler.- Zombies accumulate inside a container until
fork()fails: PID 1 is not reaping; add--init. - A
--pid=hostsidecar cankill -9any host process; a-v /var/run/docker.sockmount is root on the host — both are “not isolated” dressed as flags. - An unpatched host kernel is exploited from a low-privilege container through a known CVE; every container on the node is compromised at once.
- A rootless container cannot bind port 80 or write to a root-owned bind mount; the fix is
sysctl net.ipv4.ip_unprivileged_port_startor correct volume ownership, notsudo.