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/Xmagic 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 trickip netns adduses to make a network namespace that persists with zero processes inside it. This note walks the kernel objects that implement this (struct nsproxyand thestruct ns_commonrefcount, 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/net → net:[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
nsproxyis itself refcounted and shared. Its owncountis “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’snsproxy->count(cheap); aclone/unsharewith anyCLONE_NEW*flag triggerscreate_new_namespaces(), allocating a freshnsproxywith 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 namedpid_ns_for_children, notpid_ns. A task’s own PID namespace is fixed at creation and is read viatask_active_pid_ns(tsk), whilepid_ns_for_childrenis 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_nsvstime_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:
-
Creation —
create_new_namespaces()callscopy_mnt_ns,copy_utsname,copy_ipcs,copy_pid_ns,copy_cgroup_ns,copy_net_ns, andcopy_time_nsin a fixed order. Eachcopy_*either creates a new namespace (if itsCLONE_NEW*bit is set, taking a freshcount = 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. -
Sharing —
get_nsproxy()just doesrefcount_inc(&ns->count); a plain fork uses this. -
Teardown — when a process exits,
exit_task_namespaces()callsswitch_task_namespaces(p, NULL), whichput_nsproxy()s the old proxy.put_nsproxy()doesif (refcount_dec_and_test(&ns->count)) free_nsproxy(ns);. Andfree_nsproxy()in turn callsput_mnt_ns,put_uts_ns,put_ipc_ns,put_pid_ns,put_time_ns(twice — once each fortime_nsandtime_ns_for_children),put_cgroup_ns, andput_net— each of which decrements that namespace’sns_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)):
-
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.”
-
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 → freedlsns (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, whichlsnsreveals as anNPROCS 0row that never disappears. - Leaked namespace fds. A long-running process that opens
/proc/X/ns/netand never closes it pins that namespace forever — a real resource leak. Diagnose withlsns/ls -l /proc/PID/fdand look fornsfsentries. - Comparing namespaces by PID instead of inode. Two processes are co-resident in a namespace iff their
ns/Xinode numbers match; comparing PIDs or paths is wrong. Usestat/readlinkon the magic link. - Assuming
nsproxyholds the user namespace. It does not — the user namespace is on the credentials. Code that walksnsproxylooking foruser_nswill not find it. - Confusing
pid_ns_for_childrenwith the active PID namespace. Readingnsproxy->pid_ns_for_childrentells you where the next child goes, not which namespace the task is in; usetask_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 whatip netnsstandardizes 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 betweencreateandstart). 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
- clone unshare and setns — the syscalls that create references (clone/unshare) and join existing namespaces (setns)
- The proc ns Directory and Namespace File Descriptors — the
/proc/[pid]/ns/Xmagic links these references are taken on - nsenter and Entering Namespaces — joining a refcount-pinned, process-less namespace
- PID Namespaces — why
pid_ns_for_childrenis separate from the active PID namespace - User Namespaces — the hierarchical, credential-bound namespace whose refcounting keeps its ancestors alive
- Network Namespaces — the namespace
ip netnspersists with the bind-mount trick - Pause Container — the canonical process-based namespace pin in Kubernetes
- Linux Containers and Isolation MOC — parent map (§D, Namespace Lifecycle — The Syscalls)