Containers Are Processes With the Kernel’s View Narrowed
A container is an ordinary process (or tree of processes) whose view of the system has been narrowed by namespaces, whose resource use is capped by control groups, and whose root filesystem is a stack of layers — there is no guest kernel, which is both why containers are cheap and why their isolation is weaker than a VM’s.
The problem
Not a lightweight virtual machine
A hardware virtual machine runs a whole operating system — its own kernel, its own device drivers, its own page tables under a hypervisor’s page tables. A container runs no kernel of its own. docker run nginx starts a process on the host, scheduled by the host’s The Scheduling Problem, with its memory managed by the host’s Why Virtual Memory?, making system calls to the host kernel (System Calls). Run ps aux on the host and you will see the nginx workers as plain processes with plain PIDs. Everything that makes it feel like a separate machine is the kernel *lying selectively* to that process about what exists.
The lies are implemented by three Linux mechanisms that predate Docker: namespaces (what the process can *see*), control groups (what the process can *use*) and a layered filesystem (what the process sees as /). A container runtime — runc, crun, containerd, Podman — is a program that assembles those three around a process. Docker is a client, a daemon and an image format on top.
This is Linux-first, and the lesson says so. macOS and Windows have no namespaces or cgroups of this shape; Docker Desktop on both runs a Linux virtual machine (Apple’s Virtualization framework or WSL 2 / Hyper-V) and your containers are Linux processes *inside that VM*. Windows also has native Windows containers using its own isolation primitives (server silos, Hyper-V isolation); they run Windows images and are a different technology with the same vocabulary. When you docker run on a laptop, you are almost always talking to a Linux kernel in a VM.
- One kernel: a container cannot run a different kernel version than the host, and a kernel panic takes every container down.
- Startup is
fork+execplus namespace setup: tens of milliseconds, not the seconds a VM needs to boot. - Density: a container’s overhead is the process’s own memory plus a few kB of kernel bookkeeping — hundreds per host are normal.
Namespaces: what the process can see
Each namespace type wraps one global kernel resource and gives the process a private copy of the *table*. clone() or unshare() with the right flags creates them; setns() joins an existing one, which is what docker exec and nsenter do. The kernel object is unchanged — the same scheduler, the same TCP stack — but its indexes are partitioned.
A namespace is not a security boundary by itself; it is a *visibility* boundary. A process in its own pid namespace cannot name a host process, so it cannot kill it — but a process with CAP_SYS_ADMIN and no seccomp filter can create new namespaces, mount filesystems and, through the shared kernel, reach almost anything. Isolation comes from stacking all of the namespaces *and* dropping capabilities *and* filtering syscalls; each mechanism closes one class of reach. A runtime that skips one (--pid=host, --net=host) has not made a slightly weaker container; it has removed that boundary entirely.
host$ docker run -d --name web nginx
host$ docker inspect -f '{{.State.Pid}}' web
28914
host$ ls -l /proc/28914/ns/ | awk '{print $9, $10, $11}'
cgroup -> cgroup:[4026532610]
ipc -> ipc:[4026532607]
mnt -> mnt:[4026532605]
net -> net:[4026532612]
pid -> pid:[4026532608]
user -> user:[4026531837] # same as the host: no user namespace by default
uts -> uts:[4026532606]
host$ docker exec web ps -o pid,comm
PID COMMAND
1 nginx # PID 1 in here is PID 28914 out there| Namespace | Isolates | Inside the container you see |
|---|---|---|
pid | process IDs | the first process as PID 1; host processes invisible (Process Isolation: One Kernel, Many PID 1s) |
net | interfaces, routes, ports, iptables, sockets | its own eth0, own port 80, own routing table (Network Namespaces) |
mnt | mount table | its own /, /proc, /tmp — a different filesystem tree |
uts | hostname and domain name | a hostname that is the container id |
ipc | System V IPC, POSIX message queues, /dev/shm | its own Shared Memory: Zero Copies, Zero Protection segments, invisible to the host |
user | uid/gid mappings | uid 0 that maps to an unprivileged uid on the host |
cgroup | cgroup hierarchy root | its own cgroup as /, hiding sibling containers’ limits |
time | boot and monotonic clock offsets | (5.6+) a different uptime; rarely used |
Control groups: what the process can use
Namespaces do nothing to stop a container from consuming all of the host’s CPU and RAM. cgroups (v2 on any modern distribution) do: every process belongs to a node in a hierarchy under /sys/fs/cgroup, and each node carries limits. cpu.max sets a quota per period (200000 100000 = two cores’ worth); memory.max sets a hard byte limit; memory.high a soft one that throttles first; pids.max caps the number of processes; io.max caps disk bandwidth. docker run --cpus 2 --memory 512m writes exactly these files.
What happens at the memory limit is the thing to understand precisely. When the cgroup’s charged memory hits memory.max and reclaim cannot free enough (Memory Pressure, Swap and the OOM Killer), the kernel’s OOM killer runs scoped to that cgroup: it picks the largest process *inside the container* and sends it SIGKILL. The host is unaffected; the container’s main process exits with status 137; Docker reports OOMKilled: true; Kubernetes shows Reason: OOMKilled and restarts the pod. The line in dmesg names the cgroup. This is not the host running out of memory — it is the container hitting a ceiling you set, and the fix is a larger limit or a smaller working set, never “add RAM to the host”.
CPU limits behave differently: hitting cpu.max does not kill anything, it throttles — the scheduler stops running the cgroup’s threads until the next period. A service with a 1-core quota and 8 threads that all wake at once burns the quota in the first 12 ms of each 100 ms period and then sleeps for 88 ms, which shows up as p99 latency spikes with average CPU at 60%. cpu.stat’s nr_throttled and throttled_usec are the numbers to look at.
- Page-cache pages read by the container are charged to its cgroup too; a container that streams a large file can hit
memory.maxon cache alone, and reclaim (not OOM) handles that. - JVM (10+), .NET and recent Go read cgroup limits to size heaps and thread pools; Node and CPython do not by default — set
--max-old-space-sizeyourself.
[184201.442] Memory cgroup out of memory: Killed process 31022 (node) total-vm:1187204kB, anon-rss:519708kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:1284kB oom_score_adj:0
[184201.443] oom_reaper: reaped process 31022 (node), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
$ docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' api
true 137
$ cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.events
low 0
high 0
max 4127
oom 1
oom_kill 1Filesystem layers: what the process sees as /
A container image is an ordered list of read-only layers — each a tarball of files added or changed by one Dockerfile step. At run time the runtime stacks them with overlayfs: the image layers are lowerdirs, a fresh empty directory is the upperdir, and the union is mounted as the container’s / inside its mount namespace. A read walks the stack top-down and returns the first match; a write to a file from a lower layer triggers copy-up — the whole file is copied into upperdir and modified there — which is the Copy-on-Write idea at file granularity. Deleting a lower-layer file creates a whiteout entry in the upper layer.
The consequences are practical. Fifty containers from one image share one copy of the image’s files in the page cache, which is where the density comes from. Anything written inside the container lives in upperdir and is deleted with the container — hence volumes for data that must survive. And the first write to a large file that lives in a lower layer pays for a full copy: appending one line to a 2 GB lower-layer file copies 2 GB. Databases and logs belong on volumes or bind mounts, which are ordinary directories mounted *over* the overlay and bypass it entirely.
host$ docker inspect -f '{{json .GraphDriver.Data}}' web | jq
{
"LowerDir": "/var/lib/docker/overlay2/3f1…/diff:/var/lib/docker/overlay2/9a0…/diff:…", # image layers
"MergedDir": "/var/lib/docker/overlay2/c72…/merged", # the container's /
"UpperDir": "/var/lib/docker/overlay2/c72…/diff", # writes land here
"WorkDir": "/var/lib/docker/overlay2/c72…/work"
}
host$ docker exec web mount | head -1
overlay on / type overlay (rw,relatime,lowerdir=…,upperdir=…,workdir=…)Seccomp, capabilities, and why `top` lies
Namespaces and cgroups narrow what a process sees and uses; they do not by themselves reduce what it may *ask the kernel to do*. Two more mechanisms do. Capabilities split root’s power into ~40 flags — CAP_NET_BIND_SERVICE to bind ports below 1024, CAP_SYS_ADMIN for mounting and much else, CAP_NET_RAW for raw sockets — and Docker starts containers with a small default set, dropping the dangerous ones. seccomp filters system calls by number and arguments: Docker’s default profile blocks around 40 syscalls (mount, reboot, kexec_load, ptrace variants…) so that even a root process in the container cannot reach the kernel surface that matters most for escapes. --privileged disables both and gives back a process that is root on the host with a different view of the filesystem — which is to say, not isolated.
Now the classic surprise: run top, nproc or free inside a 1-CPU, 512 MB container and they report the host’s 64 cores and 256 GB. Nothing in the namespace list above virtualises /proc/cpuinfo, /proc/meminfo or sysconf(_SC_NPROCESSORS_ONLN) — those describe the *kernel*, and there is one kernel. The container’s real limits are in /sys/fs/cgroup/cpu.max and memory.max, and only software that reads those (the JVM, Go, .NET, lxcfs for the rest) sizes itself correctly. A Node process that spawns os.cpus().length workers inside a 2-core cgroup starts 64 workers that share 2 cores’ worth of quota and throttle constantly.
docker run --cap-drop ALL --cap-add NET_BIND_SERVICEand--security-opt seccomp=…: the two knobs that actually shrink the attack surface.--pid=host,--net=host,-v /:/hostand-v /var/run/docker.sock:…each remove one namespace or hand over a root-equivalent resource; treat them as “not a container any more”.- The absence of a kernel boundary is the whole security story: VM vs Container: Where the Boundary Is shows what a VM adds and what microVMs cost.
Key points
- A container is a host process with namespaces (what it sees), cgroups (what it may use) and an overlay root filesystem — no guest kernel.
- Linux-first: Docker Desktop on macOS/Windows runs containers inside a Linux VM; Windows containers are a separate technology.
- Namespaces: pid, net, mnt, uts, ipc, user, cgroup (and time).
docker execissetns(). - cgroups:
memory.maxhit → OOM kill *inside the container*, exit 137;cpu.maxhit → throttling and latency spikes, not kills. - overlayfs: shared read-only image layers, per-container upper layer, copy-up on first write; data goes on volumes.
- Capabilities and seccomp limit what the process may ask of the shared kernel;
--privilegedremoves them. top,nprocandfreeinside a container show the host’s hardware because/procdescribes the one kernel; read/sys/fs/cgroupinstead.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why not just run a VM per service?
A VM boots a kernel, allocates a guest page-table hierarchy and duplicates every driver and daemon. A container is a process, so it starts in milliseconds and its idle cost is nearly zero. Density and startup time are the entire reason containers displaced VMs for stateless services.
▸Why does the OOM killer kill inside my container when the host has free memory?
Because memory.max is a ceiling on the cgroup, enforced independently of host free memory. The kernel reclaims within the cgroup and, failing that, kills within it. It is doing exactly what the limit asked.
▸Why do image layers exist instead of one big filesystem per container?
So that fifty containers share one set of file pages in the page cache, image pulls only fetch changed layers, and a container’s writes can be discarded by deleting one directory.
▸Why is `--privileged` dangerous if the process is still in namespaces?
Namespaces change what a process sees, not what the kernel will do for it. With all capabilities and no seccomp filter, a root process can mount the host disk, load kernel modules or open raw devices — the view is narrowed, the power is not.
Container anatomy
How it fails
What the failure looks like from inside real software.
- Container exits with 137 and
OOMKilled: truewhile the host shows 100 GB free — the cgroup limit was hit; raise--memoryor shrink the working set. - p99 latency spikes with low average CPU:
cpu.statshowsnr_throttledclimbing because the thread count exceeds the CPU quota. - A JVM or Node process sized from
nproc/freeinside a small container over-allocates heap or workers and is throttled or OOM-killed. - Data written inside the container disappears on
docker rmbecause it lived in the overlay’supperdir, not a volume. - A database inside a container writes to overlayfs and pays copy-up on every first write to a large file; disk I/O is inexplicably slow until the data directory is moved to a volume.
docker stoptakes exactly 10 s every time because PID 1 has noSIGTERMhandler (Signals: Asynchronous Notifications From the Kernel).