There Is No Container: What Actually Runs When You docker run
There is no container object in the Linux kernel. A container is a normal process with four restrictions applied to it. Once you see the four, every container problem you have ever hit, an escape, an OOM kill, a permission error, image bloat, becomes a question about one specific piece.
You type docker run nginx and a container appears. It has its own IP, its own process tree, its own filesystem. It feels like a small, self-contained machine. So here is a question that trips up most engineers who use containers every day: what object in the Linux kernel is the container?
The answer is that there isn't one. There is no struct container in the kernel source, no container mode a process runs in, no container primitive at all. When you run that command, the kernel starts a process exactly the way it starts every other process, then restricts what that process can see, use, and do. Everything you think of as "a container" is an illusion assembled from four ordinary Linux features that all predate Docker by years.
This is not pedantry. It is the single most useful mental model for operating containers, because once you see the four pieces, every container problem you have ever hit, an escape, an OOM kill, a mysterious permission error, a 2GB image, stops being a container mystery and becomes a question about one specific, well-understood piece.
The one sentence to remember#
A container is a Linux process with four restrictions applied to it:
- Namespaces control what it can see.
- cgroups control what it can use.
- Capabilities (plus seccomp and the LSMs) control what it can do.
- A pivoted root filesystem controls what it runs against.
Remove all four and you have a normal process. The kernel has no concept tying them together; the runtime does. Docker's insight was never a new kernel primitive. It was packaging these four features behind one command and a distributable image format.
The four ingredients of the illusion#
Namespaces, what it can see. A namespace wraps a global kernel resource so the process inside gets its own isolated view. A PID namespace makes the process believe it is PID 1 and that no other processes exist. A network namespace gives it its own interfaces, IPs, and routing table. A mount namespace gives it its own filesystem tree. Crucially, namespaces isolate visibility, not resources: two containers with separate PID namespaces still run on the same scheduler and share the same physical CPU and RAM.
cgroups, what it can use. Control groups cap and account for the physical resources a process may consume: memory, CPU time, block IO, PIDs. This is the difference between "the process cannot see other processes" (a namespace) and "the process cannot use more than 512MB of RAM" (a cgroup). When a container has a PID namespace but no memory cgroup, it can still allocate until the host runs out of memory. That is not a Docker bug; it is a missing cgroup.
Capabilities, what it can do. On modern Linux, root is not one thing. The kernel split the historic all-powerful UID 0 into roughly forty discrete capabilities, each a specific privilege. A container process usually runs with a small subset (Docker grants about 14 by default and drops the rest), so even a process running as UID 0 cannot load kernel modules or mount filesystems unless it holds the specific capability. seccomp then filters which syscalls the process can even make, and an LSM (AppArmor or SELinux) restricts which objects it can touch.
The root filesystem, what it runs against. The runtime uses pivot_root inside a private mount namespace so the process sees the image's filesystem as /, assembled from stacked read-only image layers by a union filesystem (overlayfs). This is why deleting a file in a later image layer does not shrink the image: the bytes still ship in the lower layer.
No single one of these is "the container." The container is the combination, orchestrated by a runtime.
Prove it: find the container on the host#
The fastest way to internalize this is to watch a container appear as an ordinary process. Start one and ask Docker for its host PID:
docker run -d --name web nginx
docker inspect --format '{{.State.Pid}}' web
# 48213
That number is a real PID in the host's process table. From the host, it is nothing special:
ps -o pid,ppid,comm -p 48213
# PID PPID COMMAND
# 48213 48190 nginx
# Its parent (48190) is the containerd-shim, itself just another host process.
Inside the container, the same process believes it is PID 1:
docker exec web ps -o pid,comm
# PID COMMAND
# 1 nginx
One process, two PIDs: 48213 on the host, 1 inside. That gap is the PID namespace. Now look at what makes it a "container", its namespace and cgroup membership, both readable from /proc:
sudo ls -l /proc/48213/ns/
# net -> 'net:[4026532567]' pid -> 'pid:[4026532565]' mnt -> ...
cat /proc/48213/cgroup
# 0::/system.slice/docker-<id>.scope
There is no /proc/48213/container file, because there is nothing to point it at. "Container" is our word for a process whose ns/ symlinks and cgroup have been set up by a runtime.
What runc actually does#
At the very bottom of every container is runc, a small tool that does the real work. containerd hands it an OCI bundle (a root filesystem plus a config.json describing the container) and runc performs, roughly, this sequence:
- Read
config.jsonand create the configured namespaces withclone()/unshare(). - Set up the cgroup and write the resource limits, so the kernel enforces them from the start.
- Mount the filesystems and
pivot_rootinto the image rootfs, detaching the host filesystem. - Set the hostname, drop capabilities to the configured set, and install the seccomp filter.
- Switch to the target user and
execve()the entrypoint.
That final execve is the moment a "container" exists. Everything before it was preparing the box; the exec puts your program inside it. And you can do the core of it by hand with one standard command, no daemon required:
# New user, PID, mount, and UTS namespaces, as an unprivileged user
unshare -Upfm --uts --map-root-user bash
echo $$ # 1 <- this shell is PID 1 in its own PID namespace
hostname box # changing the hostname does not affect the host
Container vs VM, in one variable#
This is the interview question people fumble, and now you can answer it mechanically. A VM virtualizes the hardware: the hypervisor gives each guest fake CPUs and memory, and each guest boots its own kernel. A container virtualizes the operating-system view: every container calls into the one shared host kernel, isolated only by the namespaces, cgroups, and capabilities layered on top.
That single fact, whether a second kernel exists, explains the whole tradeoff. Containers start in milliseconds and add almost no overhead because there is no guest kernel to boot. But because every container makes syscalls straight into the host kernel, a kernel vulnerability is reachable from inside a container in a way it is not from inside a VM.
Why this is really a security story#
"Containers are not a security boundary" is repeated everywhere and understood almost nowhere. Mechanically, it means this: a container shares the host kernel, so its isolation is only as strong as the kernel's syscall surface plus the restrictions the runtime applied. A default container is good at isolation from accident (a bug in one workload will not casually corrupt a neighbor) but weak at isolation from attack (a determined adversary with code execution can often reach the host).
The good news, once you hold the four-ingredient model, is that most real escapes are not exotic kernel exploits. They are missing or wrong restrictions: a --privileged flag that grants every capability and host device, a mounted Docker socket that hands a container control of the root daemon, a host path mount, an unpatched runc. Each maps to one of the four pieces, and each has a specific fix: drop capabilities, never mount the socket, patch the runtime, and for genuinely untrusted code, stop sharing the kernel with a sandboxed runtime like gVisor or Kata.
The mental model#
Stop picturing a container as a small machine. Picture a normal Linux process with four dials set on it: what it can see (namespaces), what it can use (cgroups), what it can do (capabilities, seccomp, LSM), and what it runs against (a pivoted union filesystem). Every "magic" behavior and every failure mode is one of those four dials. An OOM kill is the memory cgroup. A permission error is a dropped capability or a seccomp rule. Image bloat is the union filesystem. An escape is a dial set too loose.
There is no container. There is a process, and four restrictions. Master those and containers stop being magic and start being debuggable.
This is the opening thesis of Container Internals and Runtime Engineering, a course on everything beneath the container abstraction: Linux namespaces and cgroups, the OCI image spec, container runtimes (runc, containerd, gVisor, Kata), isolation and escape, secure builds, and container security at the runtime and kernel level. For the Kubernetes layer that sits on top, see Walk Me Through What Happens When You Create a Pod. Related runtime-level reading: How to Debug Kubernetes OOMKilled for the memory-cgroup dial, Your Pod Is Using 5% CPU and Still Throttled for the CPU one, and Tracing the Blast Radius of a Compromised Pod for what happens when a dial is set too loose.