Namespaces: the kernel view a container is allowed to see
A container is a normal process whose kernel-side view is fenced by namespaces. There are 8 types; runc unshares them via clone() flags. PID, mount, and network namespaces do the visible isolation work — and a forgotten PID namespace means nobody reaps zombies.
The on-call page said the node was out of PIDs. A worker pod ran a shell script that shelled out to curl thousands of times an hour, and every curl left a zombie behind: <defunct> entries piling up in ps, the kernel PID table filling, new forks failing with EAGAIN. The author swore the script reaped its children. It did — in the version that ran under systemd. In the container, the script ran as PID 1. And PID 1 has a kernel-special job inherited from the boot init: it must wait() on orphaned processes, or their entries never get cleared. A plain shell does not do that. The fix was three bytes in the Dockerfile — adding --init so a tiny reaper ran as PID 1 instead. The lesson underneath: the container had a PID namespace, the namespace re-numbered the script to 1, and PID 1 carries kernel responsibilities the author never signed up for.
Eight views, one process
Why does this matter to you? Because when something breaks — a zombie leak, a missing network interface, a root process that can’t mount — the namespace type tells you exactly which boundary failed and where to look. Knowing all eight is knowing the container’s full surface area.
A namespace wraps a single global kernel resource so that processes inside it see their own instance of it. There are 8 namespace types on a modern kernel, and a container is simply a process placed into a fresh set of them:
- PID — process IDs; the first process becomes PID 1.
- mount (
mnt) — the filesystem tree; this is what makes the container’s/its own root. - network (
net) — interfaces, routing tables, the loopback, iptables. - UTS — hostname and domainname (so
hostnameinside differs from the host). - IPC — System V IPC and POSIX message queues.
- user — UID/GID maps; lets root-in-container (UID 0) map to an unprivileged host UID.
- cgroup — what view of the cgroup tree the process sees.
- time — boot/monotonic clock offsets (added in kernel 5.6).
Together these eight types cover the complete kernel view a process can ask about: who am I, what files exist, what’s on the network, what’s my hostname, who can I talk to via IPC, what cgroup tree do I see, and what does the clock say. Drop any one of them and the container leaks into the host’s global view for that resource — which is exactly the class of bug that shows up as “it worked locally but not in the pod.”
The critical mental shift: the container process is the same kind of object as any host process. ps on the host shows it directly, scheduled by the same scheduler, in the same kernel. There is no guest kernel, no hypervisor trap. Isolation is just: when this process asks “what PIDs exist?”, the kernel answers from its PID namespace, not the global one.
// Roughly what a runtime does to create a container's first process.
// CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUTS |
// CLONE_NEWIPC | CLONE_NEWUSER | CLONE_NEWCGROUP — one flag per namespace.
int flags = CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER | CLONE_NEWCGROUP;
pid_t pid = clone(child_fn, stack_top, flags | SIGCHLD, arg);
// child_fn now runs as PID 1 in a brand-new namespace set.runc — the OCI runtime Docker and containerd call — reads the linux.namespaces array from the container’s config.json and unshare(2)s / clone(2)s exactly those. Namespaces are orthogonal: you can take a network namespace but share the host’s PID namespace, which is exactly what docker run --pid=host does, and what makes a debugging sidecar able to ps the target container.
A container's main process exits cleanly but a worker pod slowly accumulates <defunct> (zombie) processes until forks fail with EAGAIN. The image runs a shell script as its entrypoint. What is the root cause?
▸Why this works
Why does PID 1 carry this duty? When a process’s parent dies, the orphan is re-parented — in a PID namespace, to PID 1 of that namespace, not to the host’s init. The kernel hands every orphan’s corpse to PID 1 and expects PID 1 to wait() it. The host’s /sbin/init was written to do this; your entrypoint.sh was not. docker run --init (or a tini/dumb-init entrypoint) inserts a 10-KB reaper as PID 1 that forwards signals and reaps orphans, restoring the contract the namespace silently imposed.
Mount and network: the visible isolation
Two namespaces do most of the work you see. The mount namespace gives the container its own filesystem tree: the runtime pivot_roots into the unpacked image, so the container’s / is the image’s root, and host paths simply do not exist in its view unless explicitly bind-mounted. The network namespace gives it its own loopback, its own interfaces, its own routing and firewall tables — which is why a fresh container has only lo until the runtime moves one end of a veth pair into it and wires the other end to a bridge on the host. A namespace persists as long as a process is in it or a file descriptor or bind-mount pins it; this is how ip netns and docker exec re-enter an existing container’s namespaces via setns(2) rather than creating new ones.
The user namespace is the security keystone and the one most often left off: with userns-remap, UID 0 inside the container maps to an unprivileged host UID (say 100000), so a container “root” who escapes a mount has the host privileges of nobody. Without it, root-in-container is root-on-host the instant any other barrier (a bad bind-mount, a capability you forgot to drop) gives way.
Why can a debugging sidecar started with --pid=host run ps and see the target container's processes, even though the target has its own mount and network namespaces?
- 01Name the 8 namespace types and, for PID, mount, network, and user, say what global resource each fences and one concrete consequence of it.
- 02Explain why namespaces being orthogonal matters operationally, using --pid=host and setns as examples.
A container is not a small virtual machine; it is an ordinary process the kernel schedules like any other, distinguished only by which namespaced views it holds. A namespace wraps one global resource so its members see a private instance, and a modern kernel offers 8 of them: PID, mount, network, UTS, IPC, user, cgroup, and time. The OCI runtime runc reads the namespaces array from config.json and applies the matching CLONE_NEW* flags through clone() or unshare(). The PID namespace renumbers the entrypoint to 1, which silently hands it init’s job of reaping orphaned children — skip a reaper like —init or tini and zombies accumulate until the PID table is exhausted and forks return EAGAIN. The mount namespace pivot_roots into the unpacked image so the container’s root is the image and host paths vanish from view. The network namespace starts with only loopback until a veth pair bridges it to the host. The user namespace is the security keystone: remapping container root to an unprivileged host UID turns an escape into a non-event. Because namespaces are orthogonal, each can be shared or isolated alone — —pid=host shares exactly one so a sidecar can ps the target, and setns lets exec re-enter a pinned namespace rather than build a new one. Now when you see a container misbehave — zombies piling up, a missing network interface, a root that can’t mount — your first question is: which of the eight namespaces is involved, and is it isolated or shared?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.