← All chapters
Chapter 32· 18 min read · illustrated

Containers, Namespaces & cgroups

A container is not a tiny VM — it is an ordinary Linux process wearing a disguise

In the last chapter we watched a hypervisor conjure whole virtual machines, each with its own guest kernel booting on emulated firmware. Containers look like they solve the same problem — package an app with everything it needs and run it anywhere — but they solve it in a completely different, and much cheaper, way. There is no guest kernel. There is no boot. A container is just a normal process running directly on the host’s kernel, tricked into believing it has the machine to itself.

That single sentence is the whole chapter, and it is worth slowing down on because so much marketing has made "container" sound like magic. It is not magic; it is three plain Linux features you can drive by hand from a shell. Namespaces control what a process is allowed to see. Control groups (cgroups) control how much it is allowed to consume. A packaged root filesystem gives it its own files. Stack those together and you have Docker, containerd, and every Kubernetes pod on the planet.

This is the payoff chapter for working engineers. By the end you will be able to explain, without hand-waving, why a container starts in milliseconds while a VM takes half a minute, why "it works on my machine" mostly stopped being a sentence people say, why a misbehaving container gets killed with exit code 137, and why sharing one kernel is both the reason containers are so light and the reason they are not a security boundary you would bet a bank on. Everything here builds on primitives you already met — PIDs and namespaces, CPU shares, memory limits, and how a process is born — so it should feel less like new territory and more like the moment the earlier pieces snap together.

01

A container is not a VM

The cleanest way to understand containers is to hold them up against the virtual machines from the previous chapter. A VM virtualises hardware: the hypervisor hands each guest a fake CPU, fake RAM, and fake devices, and on top of that fake machine a complete guest operating system boots its own kernel. Run three VMs and you are running three kernels, each convinced it owns a computer.

A container virtualises the operating system instead. There is exactly one kernel — the host’s — and every container is a set of ordinary processes running on it. No guest kernel is loaded, no firmware runs, nothing "boots" in the hardware sense. When you start a container the runtime simply launches your process with a restricted view of the system and a cap on its resources. That is why a container is ready in the time it takes to exec a program, while a VM has to power on a virtual machine and wait for an OS to come up.

Virtual machine
A full guest OS with its own kernel running on virtualised hardware provided by a hypervisor. Strong isolation, heavier footprint.
Container
One or more host processes given an isolated view and limited resources. No guest kernel — it uses the host kernel directly.
Shared kernel
The defining feature of containers: every container on a host makes system calls into the same one kernel.

The consequences fall out immediately. Containers are small, because they ship an application and its libraries but not an operating system. They are fast, because starting one is starting a process, not booting a machine. And they are dense — a single host that could run a handful of VMs can comfortably run hundreds of containers, since they are not each paying for a private kernel and its memory.

The trade-off, stated plainly: One shared kernel is exactly why containers are light — and exactly why their isolation is weaker than a VM’s. A VM guest that gets compromised is still trapped inside emulated hardware; a container that escapes its restrictions is talking straight to the host kernel every other container shares. Hold that tension; the whole chapter returns to it.

Tap to enlarge
02

The big idea: a process with a restricted view

So if it is not a VM, what is a container, precisely? Take an ordinary process — the same kind of process from our chapters on fork and exec — and do three things to it. First, give it a private view of the system so it cannot see other processes, other network interfaces, or the host’s real filesystem. Second, put a ceiling on the resources it may consume, so it cannot hog the CPU or exhaust memory. Third, point its root directory at a self-contained bundle of files that includes its own libraries and binaries. That is the entire recipe.

Namespaces — the view
Kernel feature that isolates what a process can see: which PIDs, network interfaces, mount points, hostnames, and users exist from its point of view.
cgroups — the limits
Kernel feature that meters and caps what a process can consume: CPU time, memory, I/O bandwidth, and number of PIDs.
Root filesystem — the files
A packaged directory tree the process sees as "/", giving it its own binaries and libraries independent of the host.

None of these three is new to the kernel and none was invented for Docker. Namespaces and cgroups are general Linux facilities that any program can use through system calls. Docker and its peers did not add a container feature to the kernel; they wrote friendly tooling that assembles these existing primitives, packages the filesystem, and manages the lifecycle. Understanding that is the difference between treating containers as a black box and being able to reason about — and debug — exactly what they are doing.

Mental model to keep: A container is not a thing the kernel knows about. Ask the kernel and it will tell you it is running processes — some of which happen to have their own namespaces and sit inside a cgroup. "Container" is a word we humans use for that arrangement.

Tap to enlarge
03

Namespaces: isolating what a process can see

A namespace answers one question: when this process looks at some category of system resource, what does it get to see? Linux does not give a process a single all-or-nothing sandbox; it slices isolation by category, and a container typically uses several namespaces at once. The beauty is that the process itself does nothing special — it makes the same getpid, socket, and open calls as always, and the kernel quietly answers from within the process’s namespaces rather than the host’s global view.

PID namespace
Gives the process its own process-ID number line. The first process inside becomes PID 1, and it cannot even see processes outside its namespace.
Mount namespace
A private set of mount points and filesystem tree, so the container’s "/" differs from the host’s without affecting it.
Network namespace
Its own network interfaces, IP addresses, routing table, and ports — two containers can each bind port 8080 with no clash.
UTS namespace
A private hostname and domain name, so each container can call itself something different.
IPC namespace
Isolated inter-process communication objects (shared memory, semaphores, message queues) so containers cannot reach each other’s.
User namespace
Maps user and group IDs, so root (UID 0) inside the container can be an ordinary unprivileged user on the host — a big security win.
cgroup namespace
Hides the host’s cgroup layout, so a container sees its own resource-control hierarchy rooted at itself.

The PID namespace is the one that most vividly shows the trick, and it ties straight back to how we described processes and PIDs earlier. Inside a container the main process is PID 1 — the same honoured slot that init occupies on a full system. It genuinely believes it is the first and only ancestor. From the host, that very same process is just another entry in the global process table with some large PID like 24601. One process, two identities, depending on which namespace is doing the looking.

The same process, two views. Inside the container it is PID 1; on the host it is an ordinary PID.bash
# Inside the container:
$ ps -e
  PID TTY          TIME CMD
    1 ?        00:00:00 nginx        # our app thinks it is init
   28 ?        00:00:00 nginx
   34 pts/0    00:00:00 ps

# On the host, the very same nginx process:
$ ps -o pid,cmd -C nginx
  PID CMD
24601 nginx        # just another PID in the global table
lsns lists the namespaces on a host and which process owns each.bash
$ lsns
        NS TYPE   NPROCS   PID USER   COMMAND
4026531835 cgroup    142     1 root   /sbin/init
4026532210 pid         3 24601 root   nginx        # the container
4026532211 net         3 24601 root   nginx
4026532212 mnt         3 24601 root   nginx
4026532213 uts         3 24601 root   nginx

The recurring theme: That PID-1-inside idea is not a curiosity — it drives real behaviour. Signals, zombie reaping, and graceful shutdown all treat the container’s main process as init, which is why choosing what runs as PID 1 in an image matters. We come back to it when we talk about SIGTERM at the end.

Tap to enlarge
04

Building isolation by hand

The best way to convince yourself containers are ordinary kernel features is to build one by hand, with no Docker anywhere. Linux exposes namespace creation through two system calls: clone, which starts a new process directly in fresh namespaces, and unshare, which detaches the calling process into new namespaces of the kinds you request. There is a matching unshare command-line tool, so you can do this from a plain shell.

Create a new PID + mount namespace and get a shell that thinks it is PID 1.bash
# --fork so the new PID namespace gets a real init;
# --mount-proc so /proc reflects the new PID view.
$ sudo unshare --pid --mount --fork --mount-proc bash

# Now inside the new namespaces:
# ps -e
  PID TTY          TIME CMD
    1 pts/0    00:00:00 bash     # this shell is PID 1
    9 pts/0    00:00:00 ps
# echo $$
1

That is it. With one command you have the same PID isolation a container gives you: the shell is PID 1, /proc shows only processes inside the namespace, and the host’s hundreds of processes are invisible. Add --net for a private network stack or --uts for a private hostname and you are assembling, flag by flag, what a runtime does for you automatically.

The syscall underneath: clone() with namespace flags starts a child already isolated.c
#define _GNU_SOURCE
#include <sched.h>      /* clone, CLONE_NEW* */
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

static char stack[65536];

static int child(void *arg) {
    /* We are PID 1 in a brand-new PID + UTS + mount namespace. */
    printf("child sees itself as pid %d\n", getpid());  /* prints 1 */
    execlp("/bin/sh", "sh", (char *)NULL);
    return 1;
}

int main(void) {
    int flags = CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS | SIGCHLD;
    pid_t pid = clone(child, stack + sizeof stack, flags, NULL);
    waitpid(pid, NULL, 0);   /* the host sees a normal child PID */
    return 0;
}

Why do this once: You will almost never write clone() by hand in production — but doing it once dissolves the mystery. A container runtime is a careful, hardened version of exactly this: create the namespaces, set up the filesystem, place the process in a cgroup, and exec the app.

Tap to enlarge
05

cgroups: limiting and accounting resources

Namespaces control what a process can see, but they do nothing to stop it eating the whole machine. A container with a runaway loop could still starve every other process of CPU, or a memory leak could exhaust the host’s RAM. That is the job of the second primitive: control groups, or cgroups. Where namespaces isolate the view, cgroups meter and cap consumption — CPU time, memory, I/O bandwidth, and even the number of processes a group may create.

Modern Linux uses cgroup v2, a single unified hierarchy exposed as a filesystem under /sys/fs/cgroup. Each group is a directory, and you configure limits by writing plain text to control files inside it. This is the same CPU-sharing machinery we met when discussing the scheduler and CPU shares — Docker and Kubernetes are just writing to these files on your behalf.

Docker flags map directly onto cgroup control files.bash
# Cap the container at half a CPU and 512 MB of memory:
$ docker run --cpus=0.5 --memory=512m myapp

# Under the hood this writes to the container’s cgroup:
$ cat /sys/fs/cgroup/.../cpu.max
50000 100000      # 50ms of CPU every 100ms period = 0.5 CPU
$ cat /sys/fs/cgroup/.../memory.max
536870912         # 512 MB in bytes
$ cat /sys/fs/cgroup/.../pids.max
max               # no limit unless --pids-limit is set

The memory limit is where cgroups meet a consequence engineers hit constantly. When the processes inside a cgroup try to use more memory than memory.max allows, the kernel does not politely slow them down — it invokes the out-of-memory killer scoped to that cgroup and terminates a process to bring usage back under the cap. This is the same OOM mechanism from the memory chapters, but fired at the container’s private limit rather than at the whole machine running out of RAM.

Exit code 137, explained: A process killed by SIGKILL reports exit status 128 + 9 = 137. So when a container dies with exit code 137, that is almost always the cgroup OOM killer: your app asked for more memory than --memory allowed. It is one of the most common "why did my container restart?" answers in production, and now you know exactly what it means.

cpu.max
Quota and period in microseconds. "50000 100000" means 50ms of CPU per 100ms — half a core. Maps to --cpus.
memory.max
Hard memory ceiling in bytes. Exceeding it triggers the cgroup OOM killer. Maps to --memory.
io.max
Caps read/write bandwidth and IOPS to specific block devices, so one container cannot saturate the disk.
pids.max
Limits how many processes/threads the group may create, defusing fork bombs.
Tap to enlarge
06

The container filesystem: layers and copy-on-write

The third ingredient is the filesystem the container calls "/". Its ancestor is an old, simple idea: chroot, which changes a process’s notion of the root directory so it can only see files under a chosen subtree. Containers use a hardened relative, pivot_root, combined with a mount namespace so the change is private and the host filesystem is genuinely out of reach. But a plain copied directory per container would be slow to create and wasteful on disk. The clever part is how the filesystem is assembled.

Container images are built from stacked, read-only layers, combined at runtime by a union or overlay filesystem (OverlayFS on Linux). Each instruction that builds an image — install these packages, copy this code — adds one layer on top of the ones below. At run time the runtime stacks those read-only layers and adds a single thin writable layer on top, and the process sees the merged result as one ordinary filesystem.

Image layer
A read-only set of filesystem changes produced by one build step. Layers are content-addressed and cached.
Union / overlay filesystem
OverlayFS merges several layers into one view; upper layers shadow files in lower ones.
Copy-on-write (CoW)
The container reads shared layers directly; only when it writes a file is that file copied up into its private writable layer.
pivot_root
The syscall a runtime uses (inside a mount namespace) to switch the root filesystem to the image — the modern, safer chroot.

This is why images are small to ship and fast to start. Because layers are read-only and content-addressed, identical layers are stored once and shared: if ten containers all use the same Ubuntu base, that base exists once on disk and once in the page cache, not ten times. Because the writable layer is copy-on-write, starting a container copies nothing — the process reads straight from the shared layers, and only a file it actually modifies gets copied up into its own layer. Starting a hundred containers from one image costs a hundred thin writable layers, not a hundred full filesystems.

Engineer’s takeaway: Layer sharing and CoW are why "docker pull" of a new version only downloads the layers that changed, and why ordering your build steps well — slow-changing dependencies before fast-changing app code — makes rebuilds and deploys dramatically faster. The filesystem design is a performance feature you control.

Tap to enlarge
07

The ecosystem: images, runtimes & orchestrators

Once you see that a container is namespaces plus cgroups plus a layered filesystem, the tangle of tool names sorts itself out — because every one of them ultimately drives those same kernel primitives. It helps to separate three jobs: packaging and distributing images, running a single container on one host, and orchestrating many containers across many hosts.

Image & registry
An image is the packaged layers plus metadata; a registry (Docker Hub, GHCR, ECR) stores and serves images by name and tag.
OCI
The Open Container Initiative standards for image format and runtime, so images and runtimes from different vendors interoperate.
runc
The low-level runtime: given an OCI bundle, it makes the syscalls to create namespaces and cgroups and exec the process. It is the thing that literally starts the container.
containerd
A higher-level daemon that pulls images, manages storage and the container lifecycle, and calls runc to do the actual creation.
Docker
The developer-facing toolkit — CLI, build, networking — that sits on top of containerd. "docker run" ends up as a runc call.
Kubernetes
An orchestrator that schedules containers (grouped into pods) across a cluster of hosts, restarting and scaling them; on each host it still uses containerd + runc.

Read that list top to bottom and you see a stack of decreasing abstraction: you type a Docker command, Docker asks containerd, containerd asks runc, and runc makes the clone and cgroup syscalls we drove by hand two sections ago. Kubernetes sits above all of it, deciding which host should run which container and keeping the desired number alive — but even Kubernetes, at the bottom of every layer, is still creating namespaces and writing cgroup files. Nothing new happens in the kernel; the tools just add scheduling, packaging, and operational muscle around the same primitives.

Why the split matters: This layering is why the industry could swap Docker for containerd underneath Kubernetes without applications noticing: they agree on the OCI standard, and the kernel primitives never changed. When you understand the bottom of the stack, the churn at the top is far less intimidating.

Tap to enlarge
08

Why engineers care

Step back and the reason containers took over software delivery is now concrete, not hype. An image bundles the application together with the exact libraries and files it needs and freezes them as read-only layers. That same image runs on your laptop, in CI, and in production, on the one shared host kernel — so the environment stops drifting between stages. The sentence "but it works on my machine" mostly died here, because now the machine is the image and you ship the machine.

  • Reproducible environments: the image is the environment, identical from laptop to production, so dependency drift and "works on my machine" largely disappear.
  • Density and cost: no per-container guest kernel means hundreds of containers per host, far better hardware utilisation than VMs.
  • Fast lifecycle: starting a container is starting a process — milliseconds — which makes autoscaling, rolling deploys, and quick restarts practical.
  • Debuggable with host tools: because a container is just host processes, you can point strace, lsns, ps, and /proc from the host straight at it — a superpower VMs do not give you.

The honest caveat is the shared kernel we flagged at the very start. Namespaces and cgroups are strong operational isolation, but they are not the hardware-enforced wall a VM provides — every container makes system calls into the same kernel, so a kernel vulnerability is a shared attack surface. When you need stronger isolation for untrusted or multi-tenant workloads, the answer is to put a thinner boundary back in: sandboxed runtimes like gVisor intercept syscalls in user space, and microVM approaches like Kata Containers or Firecracker run each container inside a tiny, fast-booting VM, buying much of the VM’s isolation while keeping most of the container’s speed.

Graceful shutdown — a callback worth internalising: When an orchestrator stops a container it sends SIGTERM to PID 1 — your app — waits a grace period, then sends SIGKILL. Because your process is PID 1, it is responsible for catching SIGTERM and shutting down cleanly: drain connections, finish in-flight requests, flush buffers. Ignore it and you get killed hard after the timeout, dropping live traffic. This is the signal handling from earlier chapters, now with real production stakes.

That is the whole picture: a container is an ordinary Linux process given a restricted view with namespaces, capped resources with cgroups, and its own layered filesystem — assembled by tools like runc, containerd, Docker, and Kubernetes, but resting entirely on kernel features you can now name and drive yourself. From here the natural next step is what happens when we run many of these containers across many machines that must cooperate over a network: the world of distributed systems, where the OS ideas you have built up meet the messy realities of partial failure and coordination.

What is next: We leave the single machine behind. The next chapter scales out to distributed systems — many hosts, many containers, one logical service — and asks how independent machines agree, fail, and recover together.

Tap to enlarge