Virtualization · Containers

How Seven Namespaces Compose Into a Container

Each namespace type isolates a different resource table, and the order and ownership of unshare calls determine what a process can still see.

A container is not a kernel object. There is no container_create syscall, no container identifier the kernel tracks, and no container type in /proc. What exists is a set of independent namespace types, each replacing one global table with a per-instance one, plus resource limits and privilege reduction. A “container” is the informal name for a process whose namespaces were all unshared together.

That composition is the whole design, and it is why isolation is uneven: each namespace isolates exactly one thing, and the things that have no namespace are not isolated at all.

What each namespace actually replaces

The kernel currently exposes eight namespace types, seven of which are relevant to ordinary containers.

The mount namespace gives a process its own mount table: its own root, its own set of mounts, its own propagation rules. A mount performed inside is invisible outside. This is the namespace that makes a container’s filesystem view possible, and it is the one that must be created for the root switch described in From Image Manifest to Mounted Root Filesystem to have any effect.

The PID namespace gives a process its own process ID table. The first process in the namespace gets PID 1, and processes in ancestor namespaces still see the container’s processes with their outer PIDs. Isolation is one-directional: a process outside can signal into the namespace, a process inside cannot signal out.

The network namespace owns interfaces, addresses, routing tables, the ARP cache, the firewall rules, and the socket port space. A new network namespace starts with only a loopback interface, down. Everything else must be created or moved in.

The IPC namespace separates System V IPC objects and POSIX message queues. It does not separate pipes, sockets, or shared memory created through memfd, which are tied to file descriptors rather than to the IPC namespace.

The UTS namespace separates the hostname and domain name, which is the entire content of the namespace. It exists because sethostname is a global operation otherwise.

The user namespace separates user and group ID mappings, and with them the capability sets. A process can be UID 0 inside and an unprivileged ID outside; a capability held inside applies only to resources owned by that namespace. This is the only namespace an unprivileged process can create, and it is the foundation of rootless containers.

The cgroup namespace virtualises the view of /proc/self/cgroup and of cgroup paths, so a process sees its own cgroup as the root. It changes reporting, not enforcement: limits are still applied by the cgroup hierarchy, which the namespace does not hide from the parent.

The eighth type, time, virtualises the boot-time and monotonic clocks. It is rarely used for containers and is excluded from the usual set.

Unshare order is not arbitrary

Creating namespaces is a matter of passing flags to clone or unshare. The order in which the resulting namespaces are entered determines what the process can still do afterwards, because capabilities are evaluated against the user namespace that owns the resource.

The standard order for a container init is: create the user namespace first, then the namespaces that require privileges within it. Creating a mount namespace without CAP_SYS_ADMIN in the current user namespace fails; creating it after entering a new user namespace, where the process holds all capabilities, succeeds.

/* die() and write_file() are local helpers: perror-and-exit, and a
   formatted write to a file opened by path. */

/* Enter a new user namespace, then a mount namespace owned by it. */
uid_t uid = getuid(), gid = getgid();
if (unshare(CLONE_NEWUSER) < 0)
    die("unshare user");

/* Mapping must be written by the parent or by this process before
   further privileged operations; "deny" setgroups is required first. */
write_file("/proc/self/setgroups", "deny");
write_file("/proc/self/uid_map", "0 %d 1\n", uid);
write_file("/proc/self/gid_map", "0 %d 1\n", gid);

if (unshare(CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWIPC |
            CLONE_NEWUTS | CLONE_NEWCGROUP) < 0)
    die("unshare rest");

/* A new PID namespace takes effect for children, not for this process. */
pid_t child = fork();

The PID namespace is the exception that catches implementations: unshare(CLONE_NEWPID) changes the namespace that future children are placed in. The calling process stays in its old PID namespace, so getpid() still returns the outer value until it forks. A runtime that unshares PID and then execs the workload in the same process has not created a PID namespace for the workload at all. The fork is mandatory, and the child is PID 1 of the new namespace.

Mount propagation is the second ordering trap. A new mount namespace inherits the parent’s mounts with their propagation flags. If the parent’s mounts are shared, mounts performed inside the new namespace propagate back out to the host, which defeats the isolation. The fix is mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) immediately after entering the mount namespace, making the whole tree private before any container mount is created.

PID 1 is special

The first process in a PID namespace has kernel-enforced responsibilities that differ from every other process. Signals whose default action would terminate a process are not delivered to PID 1 unless a handler is installed: the kernel drops them, because a namespace whose init dies takes the whole namespace with it. This applies to SIGKILL sent from inside the namespace as well; only a process in an ancestor namespace can forcibly kill the namespace’s init.

The consequence is that a container running a shell script as its entrypoint, where the script does not trap SIGTERM, will ignore termination requests and be killed after a timeout by the runtime. The signal is delivered to the shell, the shell has no handler, and the kernel discards it.

Orphan reaping is the second responsibility. When a process’s parent exits, the child is reparented to the init of its PID namespace (or to the nearest subreaper), and that init must wait for it. If it does not, the namespace accumulates zombies that consume PIDs and are visible in ps but cannot be removed by anything except their reaper. A workload that forks children and does not reap them will eventually exhaust the PID limit; runtimes address this with a minimal init process that forwards signals and reaps, rather than by changing the workload.

The third is that PID 1’s death terminates the namespace: every process in it receives SIGKILL. There is no graceful drain.

What the boundaries do not cover

The namespaces are a partition of some kernel state, not all of it. The gaps are where container escapes and surprising interference live.

The kernel itself is shared. Everything reachable through a syscall that is not namespaced is common: the syscall table, the scheduler, memory management, the page cache, the network stack implementation. A kernel bug reachable from an unprivileged syscall is reachable from every container. Namespaces change what a process can name, not what code runs.

/proc and /sys are not namespaced, only filtered. A procfs mounted inside a container shows the container’s PIDs if it is mounted in the new PID namespace, but a process holding a descriptor to the host’s procfs can read the host’s. Mounting a fresh procfs after unsharing PID is what makes /proc consistent with the namespace.

Kernel keyrings, sysctl values, and load average are partly namespaced and partly not. The UTS namespace carries the hostname but not the kernel release; uname reports the host kernel version inside any container. sysctl knobs are per-namespace only where the kernel implements it, which is why a container can change some settings and not others.

Devices are not namespaced. A device node in the container’s filesystem refers to the host device, subject only to cgroup device controller policy. This is why device access is controlled by a controller and by the absence of the node, not by a namespace.

Time and randomness are shared. All containers see the same clock, and the kernel’s random pool is common.

The pattern is that namespaces isolate namespaces of names: the tables that map identifiers to objects. Where a resource has no identifier table, or where the table is global by design, there is nothing to unshare.

The composite is a policy, not a primitive

Two runtimes can both claim to run containers while producing different isolation, because the set of namespaces, the mount propagation, the user mapping, and the capability set are choices made per container. Running with --net=host shares the host network namespace and its port space; running with --pid=host shares the host PID table and makes the process visible to host tooling. Each choice removes one boundary and leaves the rest.

The user namespace is the one that changes the trust model rather than the visibility. Without it, a process that gains a capability inside the container has that capability against host resources of the same kind. With it, the capability is scoped: CAP_SYS_ADMIN in a user namespace allows mounting filesystems that the namespace is permitted to mount, and nothing more. This is covered in Capability Sets and UID Mapping in Rootless Containers, and it is the reason rootless containers can exist at all.

The practical reading is that “is it isolated” is the wrong question. The right one is which of the eight tables this process has its own copy of, which capabilities it holds in which user namespace, and which cgroup it is accounted to. The answers are all readable from /proc/<pid>/ns/, /proc/<pid>/status, and /proc/<pid>/cgroup, which is also how any inspection tool determines them.

References

  1. namespaces(7) — Linux man-pages project
  2. clone(2) — Linux man-pages project
  3. user_namespaces(7) — Linux man-pages project
  4. pid_namespaces(7) — Linux man-pages project