Namespace Lifecycle and Reference Counting

A Linux namespace is not tied to a process — it is a reference-counted kernel object that lives exactly as long as something still points at it. That “something” can be (a) a member process whose nsproxy points at the namespace, (b) a bind mount of the namespace’s /proc/[pid]/ns/X magic link somewhere on the filesystem, or (c) an open file descriptor to that link held by any process (namespaces(7)). When the last such reference drops, the namespace is freed. This refcounting model is exactly what lets a namespace outlive every process that ever ran in it — the trick ip netns add uses to make a network namespace that persists with zero processes inside it. This note walks the kernel objects that implement this (struct nsproxy and the struct ns_common refcount, both pinned to Linux 6.12 LTS) and the special hierarchical refcounting of user namespaces.

Mental Model

The thing to internalize: processes hold references to namespaces, not the other way around. A namespace has a counter; creating a reference increments it, dropping one decrements it, and reaching zero frees the object. Three independent kinds of reference can hold a namespace alive, and they are interchangeable — a namespace does not “know” whether its last surviving reference is a running process or a stashed file descriptor.

flowchart TB
  subgraph REFS["References that keep a namespace ALIVE"]
    P["Member process<br/>(its nsproxy points here)"]
    BM["Bind mount of<br/>/proc/PID/ns/X"]
    FD["Open fd to<br/>/proc/PID/ns/X"]
  end
  P --> NS["Namespace object<br/>ns_common.count = refcount_t"]
  BM --> NS
  FD --> NS
  NS -->|"count reaches 0"| FREE["Namespace FREED"]
  NS -. "as long as count > 0" .-> ALIVE["Namespace stays alive<br/>even with NO processes"]

The three reference kinds and the single refcount they all feed. What it shows: a namespace’s lifetime is governed by one counter (ns_common.count), and any of three independent references — a member process, a bind mount, or an open fd — can hold that counter above zero. The insight: because a bind mount or an fd is just as good a reference as a process, you can build a namespace, pin it with a bind mount, let all its processes exit, and the namespace survives — empty but ready to be re-entered with setns(). This decoupling of namespace from process is the whole basis of “persistent” namespaces.

The Reference Count Itself: struct ns_common

Every namespace type in Linux embeds a common header, struct ns_common, that carries the bookkeeping shared by all of them. From v6.12 include/linux/ns_common.h:

struct ns_common {
    struct dentry *stashed;              /* cached nsfs dentry for /proc/.../ns/X */
    const struct proc_ns_operations *ops; /* type-specific ops (install, get, put...) */
    unsigned int inum;                    /* the namespace's INODE NUMBER — its identity */
    refcount_t count;                     /* THE reference count */
};

Two of these fields are load-bearing for lifecycle. The count is a refcount_t — a saturating, overflow-checked integer; when refcount_dec_and_test() drives it to zero, the type-specific destructor runs and the namespace object is freed. The inum is the namespace’s identity: it is the inode number you see in readlink /proc/$$/ns/netnet:[4026531840]. Two processes are in the same namespace if and only if these inode numbers match — the man page tells you to compare them with stat()’s st_dev/st_ino fields (namespaces(7)). The stashed dentry caches the nsfs (the in-kernel “namespace filesystem”) object that backs the /proc/[pid]/ns/X link, so repeated opens of the same namespace return the same inode.

Each concrete namespace struct embeds this header. For example struct user_namespace { ...; struct ns_common ns; ... }, struct time_namespace { ...; struct ns_common ns; ... }, and so on, so &user_ns->ns.count is that user namespace’s refcount. The to_ns_common() _Generic macro in nsproxy.h exists precisely to extract the common header from any of the eight namespace pointer types.

The nsproxy Structure

A process does not hold eight separate namespace pointers in its task_struct; seven of them are gathered behind a single shared pointer, task_struct->nsproxy, of type struct nsproxy. From v6.12 include/linux/nsproxy.h:

struct nsproxy {
    refcount_t count;                              /* tasks sharing THIS nsproxy */
    struct uts_namespace      *uts_ns;
    struct ipc_namespace      *ipc_ns;
    struct mnt_namespace      *mnt_ns;
    struct pid_namespace      *pid_ns_for_children; /* NOT the task's active pid ns! */
    struct net                *net_ns;
    struct time_namespace     *time_ns;
    struct time_namespace     *time_ns_for_children;
    struct cgroup_namespace   *cgroup_ns;
};

Several design decisions here directly shape namespace lifecycle:

  • The nsproxy is itself refcounted and shared. Its own count is “the number of tasks holding a reference,” and the header comment states: “The nsproxy is shared by tasks which share all namespaces. As soon as a single namespace is cloned or unshared, the nsproxy is copied.” So a fork that changes no namespaces simply bumps the parent’s nsproxy->count (cheap); a clone/unshare with any CLONE_NEW* flag triggers create_new_namespaces(), allocating a fresh nsproxy with new namespace pointers. This is copy-on-write at the namespace-set granularity.

  • The PID namespace is the exception. The comment is explicit: “The pid namespace is an exception — it’s accessed using task_active_pid_ns. The pid namespace here is the namespace that children will use.” That is why the field is named pid_ns_for_children, not pid_ns. A task’s own PID namespace is fixed at creation and is read via task_active_pid_ns(tsk), while pid_ns_for_children is where its next child will be born. This dual-pointer design is exactly what makes unshare(CLONE_NEWPID) and setns() into a PID namespace affect only future children, never the caller.

  • The time namespace has the same split (time_ns vs time_ns_for_children), for the identical reason — a running process’s clock offsets cannot be changed under it, so a new time namespace applies to children.

  • The user namespace is not in nsproxy. It is reached through the task’s credentials (task_struct->cred->user_ns), because the user namespace must be tied to the credential (UID/GID/capability) context, not the generic namespace set. Its lifetime and hierarchical refcounting are discussed below.

How nsproxy references rise and fall

The reference-management functions in v6.12 kernel/nsproxy.c make the lifecycle concrete:

  • Creationcreate_new_namespaces() calls copy_mnt_ns, copy_utsname, copy_ipcs, copy_pid_ns, copy_cgroup_ns, copy_net_ns, and copy_time_ns in a fixed order. Each copy_* either creates a new namespace (if its CLONE_NEW* bit is set, taking a fresh count = 1) or takes a reference to the parent’s existing one (e.g. get_time_ns() bumps the refcount). The fixed ordering matters: it is the canonical order used everywhere (mnt → uts → ipc → pid → cgroup → net → time), and the error path unwinds it in reverse.

  • Sharingget_nsproxy() just does refcount_inc(&ns->count); a plain fork uses this.

  • Teardown — when a process exits, exit_task_namespaces() calls switch_task_namespaces(p, NULL), which put_nsproxy()s the old proxy. put_nsproxy() does if (refcount_dec_and_test(&ns->count)) free_nsproxy(ns);. And free_nsproxy() in turn calls put_mnt_ns, put_uts_ns, put_ipc_ns, put_pid_ns, put_time_ns (twice — once each for time_ns and time_ns_for_children), put_cgroup_ns, and put_net — each of which decrements that namespace’s ns_common.count. This is the chain that, when the last process exits, drops the per-namespace refcounts — and any namespace whose count hits zero is freed unless a bind mount or fd is still holding it.

How a Namespace Outlives Its Processes

Here is the payoff. The /proc/[pid]/ns/ directory holds magic symbolic links — one per namespace type the process belongs to (ns/mnt, ns/net, ns/pid, ns/user, ns/uts, ns/ipc, ns/cgroup, ns/time, plus the _for_children variants). They are not ordinary symlinks; the kernel treats them specially, and two operations on them take a real reference on the underlying namespace (namespaces(7)):

  1. Bind mounting one of these files elsewhere: “Bind mounting (see mount(2)) one of the files in this directory to somewhere else in the filesystem keeps the corresponding namespace of the process specified by pid alive even if all processes currently in the namespace terminate.”

  2. Opening one of these files (or a file bind-mounted to one): “As long as this file descriptor remains open, the namespace will remain alive, even if all processes in the namespace terminate.”

Mechanically, both operations create an nsfs reference that bumps the same ns_common.count. So the namespace’s refcount can be > 0 with zero member processes — the mount or the fd is the sole reference keeping it alive. To later use that orphaned namespace, a process opens the bind-mounted file (or already has the fd) and calls setns() on it, joining the still-alive but process-less namespace.

The ip netns add bind-mount trick

The canonical user of this is ip netns add NAME from iproute2. It creates a network namespace and immediately bind-mounts the new namespace’s link to a file under /run/netns/NAME (the ip-netns(8) man page: “By convention a named network namespace is an object at /var/run/netns/NAME that can be opened. … Holding that file descriptor open keeps the network namespace alive.” (ip-netns(8))). The process that ran ip netns add then exits — yet the namespace survives, because the bind mount under /run/netns/ is still a live reference. Every later ip netns exec NAME cmd simply opens /run/netns/NAME and setns()-es into it. ip netns del NAME removes the bind mount, dropping that reference; if no fd and no process remain, the count hits zero and the namespace is freed.

You can reproduce the same trick by hand: touch /tmp/myns; unshare --net mount --bind /proc/$$/ns/net /tmp/myns then exit the shell — /tmp/myns now pins an empty, persistent network namespace you can nsenter --net=/tmp/myns into.

Hierarchical Refcounting of User Namespaces

User namespaces add a second dimension to lifecycle: they form a tree, and a child user namespace keeps its parent alive. From v6.12 include/linux/user_namespace.h:

struct user_namespace {
    ...
    struct user_namespace *parent;   /* the OWNING/parent user namespace */
    int                    level;    /* depth in the hierarchy (0 = init_user_ns) */
    kuid_t                 owner;
    ...
    struct ns_common       ns;       /* embeds the standard refcount */
    bool                   parent_could_setfcap;
};

Per user_namespaces(7), “each user namespace—except the initial (‘root’) namespace—has a parent user namespace”, and the parent is the user namespace of the process that created it via clone/unshare with CLONE_NEWUSER. The level field records depth, and the kernel imposes “a limit of 32 nested levels of user namespaces” (since Linux 3.11) — exceeding it returns EUSERS (from unshare) or ENOSPC.

The refcounting consequence: when a user namespace is created, it takes a reference on its parent (ns->parent = parent_ns; ns->level = parent_ns->level + 1; in create_user_ns(), v6.12 kernel/user_namespace.c). So a child user namespace keeps its parent alive, and transitively its whole ancestor chain, because each child holds a reference on its parent. The teardown is worth reading precisely. put_user_ns() does if (refcount_dec_and_test(&ns->ns.count)) __put_user_ns(ns);; __put_user_ns() does not free inline — it does schedule_work(&ns->work), deferring to free_user_ns(), which then walks up the chain with a loop: do { parent = ns->parent; ...free ns...; ns = parent; } while (refcount_dec_and_test(&parent->ns.count));. So freeing a leaf user namespace decrements its parent’s count, and only if that parent’s count also hits zero does the loop continue up another level. The chain therefore unwinds exactly as far as the dropped references allow, and naturally stops at init_user_ns, whose level is 0 and whose parent is NULL (it is the root and is never freed). The level cap of 32 bounds how deep this chain can get — a defense against a fork bomb of nested user namespaces exhausting kernel memory.

This hierarchy is also why every non-user namespace records an owning user namespace: “When a nonuser namespace is created, it is owned by the user namespace in which the creating process was a member at the time of the creation” and “privileged operations on resources governed by the nonuser namespace require that the process has the necessary capabilities in the user namespace that owns the nonuser namespace” (user_namespaces(7)). That owning-user-namespace pointer is itself a held reference — a network namespace owned by user namespace U keeps U alive. See User Namespaces for the privilege side of this relationship.

Configuration / Inspection — Observing Lifecycle

# A namespace's identity is its inode number — same number ⇒ same namespace.
readlink /proc/$$/ns/net          # net:[4026531840]   (your shell's net ns)
readlink /proc/self/ns/net        # compare against another process
 
# List every namespace the kernel currently knows about and who references it:
lsns                              # one row per namespace: NS, TYPE, NPROCS, PID, COMMAND
lsns -t net                       # just network namespaces
# A namespace held only by a bind mount shows NPROCS 0 but still appears.
 
# Watch a namespace OUTLIVE its processes via the ip netns bind-mount trick:
sudo ip netns add demo            # creates ns + bind-mount /run/netns/demo
ls -l /run/netns/demo             # the pinning bind mount
lsns -t net | grep demo           # exists with NPROCS 0 — no process inside!
sudo ip netns exec demo ip link   # setns() into it; only 'lo' visible
sudo ip netns del demo            # drop the bind mount → refcount 0 → freed
 
# Manual persistence without iproute2:
touch /tmp/netns
sudo unshare --net mount --bind /proc/$$/ns/net /tmp/netns   # then exit the shell
sudo nsenter --net=/tmp/netns ip link    # the empty ns is still alive
sudo umount /tmp/netns                    # last reference gone → freed

lsns (from util-linux) reads /proc/*/ns/* and is the easiest way to see refcounting: a row with NPROCS 0 is a namespace kept alive purely by a bind mount or an fd — concrete proof that namespace lifetime is decoupled from process lifetime.

Failure Modes and Common Misunderstandings

  • “A namespace dies when its last process exits.” False if a bind mount or open fd remains — that is the entire point of ip netns. The symptom of forgetting this is the opposite surprise: a leaked bind mount keeps a namespace (and its veth devices, IP rules, etc.) alive indefinitely, which lsns reveals as an NPROCS 0 row that never disappears.
  • Leaked namespace fds. A long-running process that opens /proc/X/ns/net and never closes it pins that namespace forever — a real resource leak. Diagnose with lsns/ls -l /proc/PID/fd and look for nsfs entries.
  • Comparing namespaces by PID instead of inode. Two processes are co-resident in a namespace iff their ns/X inode numbers match; comparing PIDs or paths is wrong. Use stat/readlink on the magic link.
  • Assuming nsproxy holds the user namespace. It does not — the user namespace is on the credentials. Code that walks nsproxy looking for user_ns will not find it.
  • Confusing pid_ns_for_children with the active PID namespace. Reading nsproxy->pid_ns_for_children tells you where the next child goes, not which namespace the task is in; use task_active_pid_ns().
  • Hitting the 32-level user-namespace cap. Deeply nested rootless-in-rootless setups can exhaust the depth limit and fail with EUSERS/ENOSPC.

Alternatives and When to Choose Them

  • Bind mount vs open fd for persistence. A bind mount (/run/netns/NAME) is filesystem-visible, survives the creating process, and is what ip netns standardizes on — best for named, long-lived, externally-managed namespaces. An open fd is private to the holding process and vanishes when that process exits or closes it — best for a runtime that wants to pin a namespace only as long as it is running (e.g. holding a container’s namespaces open between create and start). Choose the bind mount when humans/other tools must find the namespace by name; choose the fd when lifetime should track your process.
  • Letting a process pin the namespace vs explicit pinning. The simplest “lifecycle manager” is a process that just stays in the namespace — the Kubernetes pause container does exactly this for a pod’s network namespace, holding it open so the pod’s other containers can setns-join it and so the namespace survives individual container restarts. Explicit bind-mount pinning is the alternative when you do not want to spend a process on it.

Production Notes

lsns rows with NPROCS 0 are the field signature of refcount-pinned namespaces; SREs use them to hunt leaked container networking. The pause-container pattern is the most-deployed instance of process-based namespace pinning on the planet — every Kubernetes pod relies on a tiny pause process to keep the pod’s network (and optionally IPC/UTS) namespace alive across the comings and goings of the workload containers, exploiting precisely the “namespace outlives a given process” property described here. CNI plugins likewise lean on ip netns-style bind mounts so a pod’s network namespace can be set up by one short-lived process and joined later by another. On the kernel side, the move to refcount_t (saturating, with underflow/overflow detection) for ns_common.count hardened these objects against use-after-free from refcount bugs — a class of namespace-lifetime CVEs that motivated the conversion.

See Also