Namespaces and cgroups
You have spent this course watching the kernel sell each process the illusion of a private machine — a
private
address
space, a private slice of CPU time. A container asks the kernel to extend that illusion one
enormous step further: give a process a private view of the whole operating system — its own
set of process IDs, its own root filesystem, its own network interfaces, its own hostname — while it
still runs as an ordinary process on the one shared kernel. No guest kernel, no emulated hardware. Docker,
containerd, runc, LXC, Kubernetes — every one of them is, underneath, a thin choreography of exactly
two Linux kernel features: namespaces and cgroups.
This is the pivotal realisation of the whole module, and it is worth saying bluntly:
a container is just a process. If you run \texttt{ps} on the
host you can see it, kill it, and read its memory maps like any other process. What makes it feel like a
separate machine is that the kernel has been told to lie to it — about what it can see (namespaces) and
how much it can take (cgroups). Learn these two primitives and containers stop being magic.
Namespaces — a private view of one kernel resource
A namespace partitions a single kind of global kernel resource so that processes inside
the namespace see their own instance of it, and cannot see the instances belonging to others. Crucially,
each namespace type covers one resource. You compose them: a "container" is a process that has
been placed into a fresh instance of several namespace types at once. The kernel call that does
it is
\texttt{clone()} (or
\texttt{unshare()} /
\texttt{setns()}) with the right flags.
The classic example is the PID namespace. Inside a fresh one, the first process becomes
\texttt{PID 1} — it thinks it is \texttt{init}, the
root of the process tree, and it cannot see any process outside its namespace. On the host, that very
same process has some ordinary large PID like \texttt{48213}. Two numbers, one
process: the kernel maintains a translation. That is the whole trick, repeated for each resource kind.
Assembling a container from primitives
Picture the process in the middle, and the namespaces wrapped around it like nested filters — each one
restricting what it can perceive of a different resource — with a cgroup underneath capping what it can
consume. Stack enough of them and the process is, for all it can tell, alone on its own machine.
Notice there is no line in this picture for "the guest kernel", because there is no guest kernel. The
process makes system calls straight to the host kernel, at full native speed; the kernel simply
consults which namespaces the caller belongs to before answering.
The seven namespace types
Linux ships (as of the 5.x/6.x series) seven namespace kinds. Each isolates exactly one class of global
resource. Real container runtimes create most of them at once; the odd one out is the
user namespace, which is what lets an unprivileged user run a "rootful"-looking
container (rootless Docker/Podman) by mapping container-root to an ordinary host UID.
| Namespace | clone flag | Isolates | So inside you get… |
| pid | CLONE_NEWPID | process IDs | your own PID 1 and a private process tree |
| mount | CLONE_NEWNS | the mount table | your own root filesystem & mounts |
| net | CLONE_NEWNET | network stack | your own interfaces, routes, ports, firewall |
| uts | CLONE_NEWUTS | hostname & domainname | your own \texttt{hostname} |
| ipc | CLONE_NEWIPC | System V IPC, POSIX queues | your own shared-memory segments |
| user | CLONE_NEWUSER | UID/GID mappings | root inside, unprivileged UID outside |
| cgroup | CLONE_NEWCGROUP | the cgroup root view | your cgroup hierarchy looking like the root |
(There is also a newer time namespace, which virtualizes the boot/monotonic clocks —
handy for checkpoint-restore. Seven is the number people usually memorise.)
cgroups — hierarchical limits and accounting
Namespaces control what a process can see. They do nothing about how much it can take: a
process alone in every namespace can still fork-bomb the box or eat all the RAM, because it shares the one
physical machine. That is the job of control groups — cgroups. A cgroup
is a node in a tree of processes to which the kernel attaches controllers that meter and cap a
resource:
- cpu — a share/quota of CPU time (e.g. "at most 1.5 CPUs", via a period + quota);
- memory — a hard ceiling on resident memory; exceed it and the cgroup's OOM killer
fires inside the group, not across the host;
- io — read/write bandwidth and IOPS limits per block device;
- pids — a cap on the number of processes (the fork-bomb defence).
Limits are hierarchical: a child cgroup can never exceed its parent's ceiling, so an
orchestrator like Kubernetes can hand a node's budget down to pods and containers with the guarantee that
the sums always fit. The kernel moved from cgroups v1 (a separate hierarchy per
controller — flexible but confusing) to cgroups v2 (a single unified hierarchy, one tree
all controllers share), which is now the default on modern distributions and the one Kubernetes targets.
- a container is an ordinary host process — one shared kernel, no guest OS, native
syscall speed;
- namespaces give it a private view of a kernel resource (pid, mount, net,
uts, ipc, user, cgroup) — isolation of visibility;
- cgroups give it a bounded share of a resource (cpu, memory, io, pids),
metered and hierarchical — isolation of consumption;
- a runtime (runc) just does \texttt{clone()} with the namespace flags,
writes the cgroup limits, pivots the root, drops privilege, and
\texttt{execve()}s your program.
Building one, in slow motion
The simulation below is the conceptual skeleton of what \texttt{runc} actually
does. It never touches a real kernel — it just walks the sequence of primitives so you can see that
"start a container" is a short recipe of namespace flags plus cgroup writes, ending in an ordinary
\texttt{exec}.
// A toy model of how a runtime (runc) assembles a container from kernel primitives.
// Nothing here is real isolation — it just narrates the sequence of steps.
interface Limits { cpus: number; memoryMB: number; maxPids: number; }
function createContainer(cmd: string, namespaces: string[], limits: Limits): void {
console.log(`# runc: launching "${cmd}" as a container\n`);
// 1) clone() with one flag per namespace -> a private view of each resource.
console.log("clone() flags (each = a private view of ONE kernel resource):");
for (const ns of namespaces) {
console.log(` CLONE_NEW${ns.toUpperCase()} -> private ${ns} namespace`);
}
// 2) Write cgroup limits (v2 unified hierarchy) -> a bounded share.
console.log("\ncgroup v2 limits written to /sys/fs/cgroup/mycontainer/:");
console.log(` cpu.max = ${Math.round(limits.cpus * 100000)} 100000 (${limits.cpus} CPUs)`);
console.log(` memory.max = ${limits.memoryMB} MiB (OOM-kill inside the group past this)`);
console.log(` pids.max = ${limits.maxPids} (fork-bomb defence)`);
// 3) pivot_root, drop caps, then become the target program.
console.log("\npivot_root -> new mount ns root; drop privileges; execve():");
console.log(` host sees: PID 48213 running "${cmd}"`);
console.log(` container sees: PID 1 running "${cmd}" — it thinks it is init`);
console.log(`\n=> one process, ${namespaces.length} namespaces, ${limits.cpus} CPU / ${limits.memoryMB} MiB cap. No guest kernel.`);
}
createContainer(
"/bin/nginx",
["pid", "mnt", "net", "uts", "ipc", "user"],
{ cpus: 1.5, memoryMB: 512, maxPids: 200 },
);
Almost nothing at runtime, and that is the point. The kernel does the isolation; the userland stack just
makes it pleasant. \texttt{runc} is the tiny OCI runtime that performs the
\texttt{clone()} + cgroup + \texttt{pivot\_root}
dance and exits. \texttt{containerd} is the daemon that manages images, pulls
layers, and supervises many runc invocations. The \texttt{docker} CLI and
daemon add the friendly UX, build system, and networking on top. Kubernetes sits above all of it,
scheduling containers across a fleet. Peel every layer away and at the bottom is one process and two
kernel features. The tower is deep; the foundation is two ideas.
A tempting misconception is that being in a full set of namespaces makes a container "as isolated as a
VM". It does not. Every process in every container shares the same kernel — the same scheduler,
the same syscall table, the same memory allocator, the same bugs. A namespace changes what a process can
name and see; it does not give it a separate copy of kernel code. So a single kernel
vulnerability (a bad \texttt{ioctl}, a race in a filesystem driver) is a
potential escape hatch out of any container on the host, in a way it simply is not for a true VM
with its own guest kernel. That shared-kernel attack surface is exactly why capabilities, seccomp, and
microVMs — the rest of this module — exist. Namespaces are containment, not a hypervisor.