Namespaces and cgroups: The Kernel Mechanics Behind Every Container
Linux Containers established that a container is an ordinary process wearing two kinds of kernel-enforced restrictions: a restricted view of the system, and a restricted budget of resources. This article opens up exactly how the kernel implements both, using the same low-level tools Docker and Podman call internally — so that a container is no longer a black box, but a specific, reproducible combination of commands you can run by hand.
What You Will Learn
By the end of this article you will be able to:
- name the seven Linux namespace types and what each one isolates;
- create an isolated process by hand with
unshare, without any container runtime; - inspect and enter a running container's namespaces directly from the host;
- explain the difference between cgroups v1 and v2 and check which one a system is using;
- set a hard CPU and memory limit on a process using cgroups directly.
Namespaces: Controlling What a Process Can See
A namespace wraps a global kernel resource so that a process inside the namespace sees its own private instance of that resource, isolated from every other namespace on the same host. The kernel currently defines seven namespace types relevant to containers:
| Namespace | Isolates | Kernel flag |
|---|---|---|
| PID | Process IDs and the process tree | CLONE_NEWPID |
| Network | Network interfaces, routing tables, ports | CLONE_NEWNET |
| Mount | Filesystem mount points | CLONE_NEWNS |
| UTS | Hostname and NIS domain name | CLONE_NEWUTS |
| IPC | System V IPC objects, POSIX message queues | CLONE_NEWIPC |
| User | User and group ID mappings | CLONE_NEWUSER |
| Cgroup | The process's view of its own cgroup hierarchy | CLONE_NEWCGROUP |
A namespace is created either by passing the corresponding CLONE_NEW* flag to the clone(2) system call when starting a new process, or by calling unshare(2) to move an already running process into a new namespace. unshare, the command-line tool, is a thin wrapper around exactly that system call — which means it can demonstrate namespace isolation without Docker or Podman installed at all.
An eighth namespace: Time
Linux 5.6 (2020) added a Time namespace (CLONE_NEWTIME), which isolates the CLOCK_MONOTONIC and CLOCK_BOOTTIME clocks so a process can see a different system uptime than the host. It is deliberately left out of the seven above because it is the one namespace container runtimes largely do not use by default — Docker and Podman do not place containers in a separate time namespace, so a container's uptime reflects the host's. If an interviewer asks "how many namespaces are there," the precise answer is "eight defined in the kernel, seven of which every container runtime uses; the time namespace is the outlier."
PID Namespace: A Private Process Tree
Inside this shell, bash itself is PID 1 — the same PID the host's init/systemd normally occupies — and no other process on the real host is visible at all. --mount-proc remounts /proc inside the new namespace so that ps reads the namespace's own view rather than the host's; without it, ps would still show the host's full process list, because /proc itself is governed by the mount namespace, not the PID namespace, and the two need to be set up together for a convincing isolated view. Exiting this shell (exit) returns to the normal host PID namespace immediately.
Interview angle: why does PID 1 matter inside a container?
PID 1 has special responsibilities in any Unix-like system: it is the default target for orphaned processes (a child whose parent died gets re-parented to PID 1) and it must reap zombie processes with wait(). A container's main process, believing itself to be PID 1 because of the PID namespace, often does not implement these responsibilities — most application binaries were never written to be an init system. This is precisely why zombie processes accumulate in containers running a plain application binary directly, and why docker run --init (or an explicit tiny init like tini) exists: it inserts a minimal, correct PID 1 ahead of the application, solving a problem that is a direct, practical consequence of the PID namespace's behavior.
Network Namespace: A Private Set of Interfaces
A freshly created network namespace has only a loopback interface, and it starts down — no route to anywhere, not even to the host that created it. This is exactly the starting point every container's network namespace begins from; a container runtime's job includes wiring up a virtual Ethernet pair (veth) between the container's network namespace and a bridge on the host, which is what actually gives a container the outbound connectivity docker run -p and default bridge networking rely on.
Mount Namespace: A Private View of the Filesystem Tree
A mount namespace lets a process have its own set of mount points, independent of the host's — this is the primitive behind a container appearing to have its own root filesystem (/) entirely separate from the host's, even though, as established in Linux Containers, no separate kernel or filesystem driver is involved; it is the same host filesystem, with a different, restricted view mounted for this one process tree.
User Namespace: Mapping Root Inside to a Non-Root User Outside
The user namespace is the one most directly tied to container security. It lets a process be root inside the namespace — with full root capabilities for anything that namespace controls — while being mapped to an unprivileged, ordinary user on the host.
That last line is the mapping: UID 0 inside the namespace corresponds to UID 1000 (an ordinary user) outside it, for a range of 1 UID. A process root inside this namespace that somehow escaped its other isolation boundaries would land on the host as UID 1000, not as real root — a meaningful reduction in the blast radius of a container escape. This is the exact kernel mechanism behind rootless containers, which Docker and Podman in Practice covers from the tooling side: Podman's rootless mode runs the entire container, including a process that believes it is root, without the container engine itself ever needing real root privileges on the host.
Inspecting a Real Container's Namespaces
Every namespace a running process belongs to is visible under /proc/<pid>/ns/:
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026532451]'
lrwxrwxrwx 1 root root 0 net -> 'net:[4026532454]'
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026532449]'
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026532452]'
Each target (pid:[4026532451]) is a unique namespace identifier; two processes sharing the same identifier are, by definition, in the same namespace. nsenter uses exactly these /proc/<pid>/ns/ entries to join an existing container's namespaces from the host — this is how docker exec is implemented under the hood:
Running hostname through nsenter targeting the container's namespaces returns the container's own hostname (its short container ID by default) rather than the host's — direct, hands-on proof that docker exec web hostname is not a special Docker feature, but this same nsenter mechanism wrapped in a friendlier command.
cgroups: Controlling What a Process Can Consume
Namespaces answer "what can this process see?" Control groups (cgroups) answer a different question: "how much CPU, memory, and I/O is this process allowed to use?" Without a resource limit, a single runaway container process can exhaust memory or CPU for the entire host, starving every other workload — cgroups are the kernel's enforcement mechanism against exactly that failure mode, and they are also how systemd resource controls and process priority in general are implemented at the kernel level.
cgroups v1 vs. v2
A single cgroup2 mount line, rather than several separate cgroup lines per controller (cpu, memory, blkio, and so on), indicates cgroups v2, the unified hierarchy that current Ubuntu Server LTS and Red Hat Enterprise Linux releases use by default. cgroups v1 organized each resource controller as an entirely separate hierarchy, which allowed a process to be limited on CPU under one tree and on memory under an unrelated tree — flexible, but confusing, and prone to controllers disagreeing about which process a limit applied to. cgroups v2 consolidates every controller into one hierarchy per process, which is simpler to reason about and is now the default on any current distribution; the older cgroup (v1) filesystem may still appear on older systems or where a container runtime explicitly requests v1 compatibility.
This lists every controller the current unified cgroup hierarchy has available — memory and cpu are the two used in the examples below.
Setting a Manual Memory and CPU Limit
Warning
The following commands create and modify cgroups directly under /sys/fs/cgroup/, and the process being limited will be killed by the kernel's OOM mechanism if it exceeds the memory limit. Run this in a disposable lab VM or container, not against a production process, and confirm the target PID before running any command that references it.
memory.max defaults to max — unlimited — for a freshly created cgroup; setting a concrete value enforces a hard ceiling:
echo 104857600 | sudo tee /sys/fs/cgroup/demo/memory.max
echo 50000 100000 | sudo tee /sys/fs/cgroup/demo/cpu.max
memory.max is set here to 104857600 bytes (100 MiB) — any process in this cgroup that tries to exceed that will be reclaimed from or killed by the kernel, not merely slowed down. cpu.max uses a <quota> <period> pair in microseconds; 50000 100000 allows 50,000 out of every 100,000 microseconds — half of one CPU core — regardless of how many cores the host actually has.
Writing a PID (here, $$, the current shell's own PID) to cgroup.procs moves that process — and, from that point on, anything it forks — into the demo cgroup, immediately subject to both limits.
A cgroup directory can only be removed once it has no processes left inside it; move any remaining process out (or let it exit) before rmdir.
How This Relates to docker run --memory
docker run -d --name limited --memory=256m --cpus=0.5 nginx:alpine
docker inspect -f '{{.State.Pid}}' limited
cat /sys/fs/cgroup/system.slice/docker-*/memory.max 2>/dev/null || \
systemd-cgls | grep -A2 limited
docker run --memory=256m --cpus=0.5 does exactly what the manual commands above did — it creates a cgroup for the container and writes memory.max and cpu.max accordingly — the flag is a convenience layer over the same kernel interface just demonstrated by hand. Confirming this by inspecting the actual cgroup files under /sys/fs/cgroup/ for a running container is a genuinely useful debugging habit: when a container is unexpectedly OOM-killed, dmesg and the corresponding cgroup's memory.events file (specifically the oom_kill counter) are the first place to look, not just the container's own application logs, which often show nothing at all because the kernel killed the process before it could log anything.
Common Mistakes
- Believing namespaces provide resource limits. Namespaces control visibility only; a process in its own PID and network namespace with no cgroup limit can still consume 100% of host CPU and memory — the two mechanisms solve different problems and both are required for real isolation.
- Forgetting
--mount-procwithunshare --pid. Without it,/procstill reflects the host, and tools likepsinside the new PID namespace will misleadingly show the host's full process list. - Assuming a user namespace alone makes a process safe to run as "root." A user namespace maps root-inside to non-root-outside for that namespace's own resources, but a container still sharing the host's user namespace (the non-rootless default in most Docker configurations) has its root map directly to host root — checking this explicitly, rather than assuming rootless behavior, matters for any security review.
- Confusing cgroups v1 and v2 syntax.
memory.max(v2) andmemory.limit_in_bytes(v1) are not the same file and are not interchangeable; scripts written against one will silently do nothing on a system using the other.
Exercises
- Run
sudo unshare --pid --fork --mount-proc bashand, from a second terminal on the host, find the real host PID of thatbashprocess withps aux | grep bash. Explain why the PID visible inside the namespace and the PID visible from the host differ. - Start a container with
docker run -d --name test nginx:alpine, find its PID withdocker inspect, then usensenterto runhostnameandip addrinside its namespaces without usingdocker exec. Confirm the output matches whatdocker exec test hostnameanddocker exec test ip addrreport. - Create a cgroup with a
memory.maxof 50 MiB, move a memory-hungry test process into it (a small script allocating memory in a loop works well in a disposable VM), and observe it being killed once it exceeds the limit. Checkdmesgafterward for the OOM kill message. - Run
mount | grep cgroupon your lab system and state whether it is using cgroups v1 or v2, and how you can tell from the output alone.
Summary
Namespaces and cgroups are the two independent kernel mechanisms every container runtime builds on: namespaces restrict what a process can see (its own PID tree, network stack, mounts, hostname, and — with a user namespace — its own UID mapping), while cgroups restrict what a process can consume (CPU, memory, I/O), enforced through the unified cgroups v2 hierarchy on current distributions. unshare and nsenter demonstrate both without any container tooling installed, and docker inspect/nsenter show that docker exec and docker run --memory are convenience wrappers over exactly these primitives, not separate magic. Docker and Podman in Practice now builds the everyday tooling on top of this same foundation.