PID namespaces and process isolation, explained: what CLONE_NEWPID actually gives you
A PID namespace gives a set of processes their own numbering space. That is genuinely all it is. It does not hide files, it does not hide the network, it does not stop anything from talking to the kernel, and on its own it does not even stop ps from printing every process on the host. What it does do is make one process into an init, and the kernel treats init differently in ways that will absolutely ruin an afternoon before you learn them.
I'm Ajay; I built PandaStack, a Firecracker microVM platform for running untrusted and model-generated code. This post is the process-visibility entry in a series on the Linux isolation primitives: what CLONE_NEWPID creates, why PID 1 ignores your SIGTERM, why /proc is the misconfiguration nearly everyone ships at least once, the blunt list of what PID namespaces do not isolate, and the handful of things they are genuinely excellent at. Short version: use them, enjoy them, and do not point at one and call it a sandbox.
What a PID namespace actually is
The kernel keeps one global table of tasks. A PID namespace does not partition that table; it adds a second numbering scheme over part of it. When a process calls unshare(CLONE_NEWPID) or clone(CLONE_NEWPID), the kernel creates a fresh number space in which allocation starts at 1 again. The task structs are the same task structs the host always had. What changed is which numbers name them, and to whom.
The first surprise is that unshare(CLONE_NEWPID) does not move the caller. It cannot: a process's PID is fixed for its lifetime, and there is no way to retroactively hand it a number in a namespace that did not exist when it was created. So the caller stays put and the namespace applies to its future children. The first child born after the unshare lands in the new namespace and becomes PID 1. This is why unshare(1) has a --fork flag, and why omitting it produces one of the more baffling error messages in Linux.
The second surprise is that PIDs are relative, not absolute. A task inside a nested namespace has a number there, a different number in its parent namespace, and a different one again in each ancestor out to the initial namespace where everything on the machine is numbered. Nesting is allowed to a kernel-defined depth (32 levels on mainline). The question "what is that process's PID" is incomplete unless you also say from where.
Visibility runs one way. A process in an ancestor namespace can see, inspect and signal processes in descendant namespaces, subject to the usual permission checks. A descendant cannot see, name or signal anything above it, because those tasks have no number in its namespace. There is no permission error to catch; they simply do not exist as far as it can tell. That asymmetry is a visibility feature, not an access-control one.
PID 1 is not a normal process, and this is where the bugs live
Inside a PID namespace, process 1 inherits the special-casing the kernel has always applied to system init. Three behaviours matter, and they cause almost every real-world problem people blame on containers.
Signals that never arrive
The kernel does not deliver a signal to PID 1 unless PID 1 installed a handler for it. That is not a scheduling delay or a race; delivery is suppressed. Signals sent from inside the namespace are blocked outright for anything unhandled, including SIGKILL and SIGSTOP, so a namespace's own members cannot accidentally shoot their init. Signals from an ancestor namespace are also suppressed when unhandled, with SIGKILL and SIGSTOP as the two exceptions: those are forcibly delivered from outside and cannot be caught.
Now read the classic complaint in that light. Your server runs as the container's main process and never installed a SIGTERM handler, because on a normal system SIGTERM's default action terminates you and that was fine. The runtime sends SIGTERM. The kernel drops it. Ten seconds later the runtime gives up and sends SIGKILL, which is forcibly delivered from the ancestor namespace, and your process dies without flushing anything. Container stops are not slow. Your process is at PID 1 and is, as far as the kernel is concerned, declining to hear a request it never agreed to.
Orphans, zombies, and who is supposed to clean up
When a process dies, its children are reparented to the nearest ancestor marked as a child subreaper, or failing that to PID 1 of their PID namespace. PID 1 then owns them, so PID 1 is what has to call wait() when they exit. A terminated process nobody has waited on stays as a zombie: no memory, no code, just an exit status holding a task-table slot forever. In the initial namespace real init reaps constantly and you never think about it. In your namespace the reaper is whatever you put at PID 1, and if that is a Python web server it will never reap anything, because reaping is not a thing web servers do.
This is why tini, dumb-init and s6 exist, and why systemd inside a namespace works fine: they are small correct inits that forward signals to the workload and reap orphans in a loop. It is also why the shape of your command line matters more than it looks.
Consider sh -c "myserver". The shell is PID 1, and depending on the shell and the command it may fork myserver as a child rather than exec'ing it. Now PID 1 is a shell that neither forwards signals nor reaps, your server is PID 2 and invisible to the stop path, and orphans pile up behind it. Write sh -c "exec myserver" and the shell replaces itself, putting your actual process where the signals are aimed. That one word is among the highest-value-per-character fixes available in a Dockerfile — and it still leaves you with a non-reaping init unless your server reaps, which is the argument for tini.
When PID 1 dies, everyone dies
If PID 1 of a namespace terminates, the kernel SIGKILLs every remaining process in that namespace and tears it down. No negotiation, no graceful phase. Anything that then tries to fork into the dead namespace gets ENOMEM, the kernel's slightly unhelpful way of saying the number space has no init and is closed. This is genuinely useful for teardown, and it also means a crash in your init takes the whole workload with it, so whatever sits at PID 1 should be boring.
Proving the signal rule to yourself
People refuse to believe the signal special-casing until they watch it. Forty lines of C settle it: the same binary, unchanged, survives a SIGTERM at PID 1 and dies from the identical signal at PID 2.
/* pid1-signals.c -- the kernel's init special-casing, demonstrated.
*
* cc -o pid1-signals pid1-signals.c
*
* A) PID 1, no handler -> SIGTERM is NEVER DELIVERED. Only SIGKILL from an
* ancestor namespace can stop it. This is your slow container stop.
* sudo unshare --pid --fork --mount-proc ./pid1-signals &
* sudo pkill -TERM -x pid1-signals # ...nothing happens
* sudo pkill -KILL -x pid1-signals # forcibly delivered, dies
*
* B) same binary, NOT pid 1, no handler -> default action, dies immediately.
* ./pid1-signals & sleep 1; kill -TERM %1 # Terminated
*
* C) PID 1 WITH a handler -> delivered normally, and si_pid is 0 because the
* sender lives in a namespace where we have no way to name it.
* sudo unshare --pid --fork --mount-proc env HANDLE=1 ./pid1-signals &
* sudo pkill -TERM -x pid1-signals # "SIGTERM delivered, si_pid=0"
*/
#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t got_term = 0;
static void on_term(int sig, siginfo_t *info, void *ctx) {
(void)sig; (void)ctx;
got_term = 1;
/* printf is not async-signal-safe; write(2) is. */
char msg[64];
int n = snprintf(msg, sizeof msg,
"SIGTERM delivered, si_pid=%d\n", (int)info->si_pid);
(void)!write(STDERR_FILENO, msg, (size_t)n);
}
int main(void) {
fprintf(stderr, "pid in my own namespace: %d\n", (int)getpid());
if (getenv("HANDLE")) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = on_term;
sa.sa_flags = SA_SIGINFO;
if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction"); return 1; }
fprintf(stderr, "SIGTERM handler installed -- signal will arrive\n");
} else {
fprintf(stderr, "no SIGTERM handler -- at PID 1 the kernel will not"
" even deliver it\n");
}
/* At PID 1 every orphan in the namespace becomes our child. On Linux,
* SIG_IGN for SIGCHLD makes the kernel auto-reap them: the lazy-but-
* correct reaper. A real init loops on waitpid(-1, ..., WNOHANG) so it
* can log exit statuses too. Skip this and the namespace slowly fills
* with zombies that nothing else will ever collect. */
signal(SIGCHLD, SIG_IGN);
while (!got_term) pause();
fprintf(stderr, "shutting down cleanly\n");
return 0;
}Case B is the control, and it is the important one. Nothing about the program changed between B and A. The only difference is the number the kernel assigned it, and that number changed the delivery rules.
The /proc gotcha, which is the misconfiguration everybody ships once
A PID namespace does nothing whatsoever for /proc. The procfs superblock you have mounted was instantiated against some PID namespace at mount time, and it keeps reporting that one. Unshare a PID namespace, keep reading the /proc you inherited, and every tool built on procfs — ps, top, pgrep, htop, half of your monitoring — enumerates the host's process table and reports it with total confidence. Isolation is working perfectly; your instrument is plugged into the wrong socket.
The fix is to mount a fresh procfs inside the new namespace. That is a mount, so it needs a mount namespace too, or you have just replaced the host's /proc for everyone and made a far more interesting problem. This is why PID and mount namespaces are effectively always used together, and why util-linux gives you --mount-proc, which quietly implies --mount.
# ---------------------------------------------------------------------------
# WRONG #1: no --fork. unshare(2) does not move the CALLER into the new PID
# namespace, only its future children. The first child becomes PID 1... and
# then exits, which kills the namespace. Watch the second command fail.
# ---------------------------------------------------------------------------
$ sudo unshare --pid bash
# ls / # this child IS pid 1 of the new ns, and it exits
bin boot dev etc home ...
# ls / # pid 1 is gone, so the namespace is dead
bash: fork: Cannot allocate memory
# ---------------------------------------------------------------------------
# WRONG #2: --fork, but the inherited procfs still reports the HOST namespace.
# The shell really is PID 1. ps is just asking the wrong superblock.
# ---------------------------------------------------------------------------
$ sudo unshare --pid --fork bash
# echo $$
1
# ps -e --no-headers | wc -l
412 # every process on the box. "Isolation is broken!"
# ls /proc | grep -c '^[0-9]*$'
411
# ---------------------------------------------------------------------------
# RIGHT: --mount-proc implies --mount (a private mount table) and then mounts
# a fresh procfs, which is instantiated against OUR pid namespace.
# ---------------------------------------------------------------------------
$ sudo unshare --pid --fork --mount-proc bash
# echo $$
1
# ps -e
PID TTY TIME CMD
1 pts/0 00:00:00 bash
9 pts/0 00:00:00 ps
# ---------------------------------------------------------------------------
# Inspecting namespaces from the host.
# ---------------------------------------------------------------------------
$ lsns -t pid
NS TYPE NPROCS PID USER COMMAND
4026531836 pid 318 1 root /sbin/init
4026532661 pid 2 8412 root bash
$ readlink /proc/self/ns/pid
pid:[4026531836] # an inode id; two processes matching = same ns
$ sudo readlink /proc/8412/ns/pid
pid:[4026532661]
# One task, two numbers. NSpid lists the PID in each namespace, outermost
# first, so 8412 out here is 1 in there.
$ grep -E '^(Name|NSpid)' /proc/8412/status
Name: bash
NSpid: 8412 1
# Entering: -p alone joins the PID namespace but leaves you reading YOUR
# procfs, so ps lies again. Bring -m along. (nsenter forks by default for -p,
# because setns(CLONE_NEWPID) only affects children -- same rule as unshare.)
$ sudo nsenter -t 8412 -p ps -e --no-headers | wc -l
412 # wrong procfs, wrong answer
$ sudo nsenter -t 8412 -p -m ps -e
PID TTY TIME CMD
1 pts/0 00:00:00 bash
14 ? 00:00:00 psWhat PID namespaces do not isolate
Bluntly, so there is no room for optimism: a PID namespace isolates process numbers. Not the filesystem — that is the mount namespace. Not the network — that is the network namespace. Not users or credentials — that is the user namespace. Not CPU, memory or process count — that is cgroups. Not the syscall surface — that is seccomp. Not the subset of root's powers a process holds — those are capabilities.
And above all, not the kernel. Everything in the namespace issues syscalls to the same kernel image on the same hardware as everything outside it. /proc/sys is still the host's tunables. dmesg still reads the host's kernel ring buffer unless you separately restricted it. /sys is still one sysfs describing the real machine. Shared slab caches, one scheduler, one set of in-kernel parsers for every format you can hand a syscall. A PID namespace hides your neighbours. It does not defend against them, and it does not defend against the floor you are both standing on.
- PID namespace — covers: which processes exist as numbers you can see, wait on and signal, plus init semantics for PID 1. Does not cover: files, network, credentials, resource consumption, the syscall surface, or the kernel underneath all of it.
- Mount namespace — covers: which filesystems are attached where, and therefore which paths resolve to anything at all. Does not cover: which processes are visible, inherited file descriptors that bypass path lookup, or what a shared kernel does with a malicious filesystem image.
- Network namespace — covers: interfaces, addresses, routes, nftables rules and the socket port space. Does not cover: anything reachable without a socket, and not the single shared kernel network stack parsing the packets.
- User namespace — covers: the UID/GID mapping, so root inside can be an unprivileged UID outside, plus which capabilities you hold against namespaces you own. Does not cover: the fact that root-in-a-namespace hands unprivileged code reachability into kernel paths it previously could not touch.
- cgroups v2 — covers: how much CPU, memory and IO a group may consume, and how many PIDs, with pids.max being the actual fork-bomb defence. Does not cover: visibility or access to anything at all; it is accounting and throttling, not a boundary.
- seccomp — covers: which syscalls, and simple argument shapes, a thread may issue — the only primitive here that shrinks the attack surface rather than rearranging the view. Does not cover: what a permitted syscall does once it is inside the kernel.
- Linux capabilities — covers: splitting root into individual bits so a process holds only what it needs and can drop the rest irreversibly. Does not cover: the kernel bugs reachable through the bits you kept, several of which are effectively root anyway.
- microVM (KVM + Firecracker) — covers: the whole guest, with its own kernel, process table, page tables and memory, mediated by the CPU's virtualization extensions rather than by kernel bookkeeping. Does not cover: bugs in the VMM or the hardware, and it costs a machine boot where a namespace costs a fork.
A namespace changes what a process can name. A hypervisor changes which kernel answers when it asks. Only one of those is a security question, and it is not the first one.
What PID namespaces are genuinely good for
None of that is an argument against using them. It is an argument against using them alone, for the wrong job. For the right jobs they are excellent, and I reach for them constantly.
- Teardown that actually completes. Kill PID 1 and the kernel SIGKILLs every process in the namespace. No process-group bookkeeping, no hunting for the child that reparented itself to init and is still holding your port, no pkill -f pattern that matches your own script. If you have ever written a cleanup function that greps ps output, this is the primitive you wanted.
- A clean unit to attach a pids cgroup to. The namespace gives you an unambiguous set of processes; cgroups v2 gives you a ceiling on how many there can be. Together they turn a fork bomb into a boring EAGAIN.
- Preventing accidental cross-process signalling. Test harnesses and CI runners kill by name or pattern with alarming regularity. Inside a PID namespace a stray pkill can only reach processes you put there, because nothing else has a number to match.
- Making supervision legible. When PID 1 is your supervisor and PID 2 is your workload, the process tree is small enough to read, and "is it still running" stops being an archaeological question.
# The namespace does not stop a fork bomb. It just gives the bomb a private
# numbering scheme in which to exhaust the host's ONE global task table.
# The actual limit is one directory away, in cgroups v2.
# 1. Make sure the pids controller is delegated to child cgroups.
echo '+pids' | sudo tee /sys/fs/cgroup/cgroup.subtree_control
sudo mkdir -p /sys/fs/cgroup/demo
echo 64 | sudo tee /sys/fs/cgroup/demo/pids.max
# 2. Join the cgroup BEFORE forking anything -- membership is inherited by
# children, so order matters -- then unshare a PID namespace inside it.
sudo bash -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs
exec unshare --pid --fork --mount-proc bash'
# ...and inside that shell, the classic:
# :(){ :|:& };:
# bash: fork: retry: Resource temporarily unavailable
# bash: fork: retry: Resource temporarily unavailable
# The host stays responsive. Without pids.max it would not.
# 3. From the host, watch it hit the ceiling and stay there.
cat /sys/fs/cgroup/demo/pids.current # 64
cat /sys/fs/cgroup/demo/pids.events # max 1337 <- refusals, counted
# 4. Teardown IS the namespace's contribution: one kill on the outside PID of
# the namespace's init, and the kernel SIGKILLs everything inside it.
lsns -t pid -o NS,NPROCS,PID,COMMAND | grep unshare
sudo kill -9 8412
cat /sys/fs/cgroup/demo/pids.current # 0
sudo rmdir /sys/fs/cgroup/demoDebugging PID namespaces without guessing
Four tools cover essentially everything. lsns -t pid lists every PID namespace on the box with its process count and the outside PID of its lowest member. readlink /proc/<pid>/ns/pid returns an inode identifier: two processes with the same string share a namespace, and comparing against /proc/self/ns/pid tells you immediately whether you are inside or outside.
grep NSpid /proc/<pid>/status maps one task across the whole chain, outermost namespace first, which is how you translate the PID your orchestrator logged into the PID your application logged. And nsenter -t <pid> -p -m gets you a shell that sees what the workload sees — do not drop the -m, or you enter the PID namespace while still reading your own procfs, ps confidently reports the host, and you lose twenty minutes convinced nsenter is broken. One habit worth building: when a container will not stop, check what is at PID 1 before you check anything else.
The microVM contrast, and where PandaStack fits
Everything above describes a filtered view of one shared table. A microVM is not that. In a Firecracker guest the workload has its own kernel, booted from its own image, with its own task table, its own PID allocator and its own init. Its PID 1 is genuinely the first process of that machine, not a renumbering of a host process. There is no ancestor namespace watching, because there is no namespace relationship at all — the guest's process table and the host's are two data structures in two separate kernels, and the boundary between them is enforced by the CPU's virtualization extensions rather than by an if-statement in shared code.
That removes a bug class rather than mitigating it. There is no procfs to forget to remount, because the guest's /proc is its own kernel's and could not report host processes if it wanted to. There is no namespace gap to escape through, because escaping a namespace means confusing the kernel that maintains it — and here that is the guest's own kernel, which the workload was already allowed to be root on. Getting from there to my hardware is a different and much harder problem, aimed at a VMM whose entire design goal is to be small.
PandaStack still uses namespaces heavily, just not as the boundary. Every sandbox gets its own Linux network namespace on the host side, holding the tap device and the routing for its traffic, drawn from 16,384 pre-allocated /30 subnets per host — that is what makes network setup milliseconds instead of a hundred of them, and part of why creates land at a p50 of 179ms. Namespaces are excellent plumbing. They just sit underneath the hypervisor rather than standing in for it. Inside the guest the tenant is welcome to use PID namespaces for their own processes, because what a workload does to itself has never been my threat model.
from pandastack import Sandbox
# Ask a sandbox what its process world looks like. On a namespace-based
# platform, a probe like this is how you find out the procfs is wrong.
probe = r"""
echo "pid 1 is: $(cat /proc/1/comm)"
echo "processes: $(ls /proc | grep -c '^[0-9]*$')"
echo "pid ns id: $(readlink /proc/self/ns/pid)"
echo "nspid chain: $(grep NSpid /proc/self/status)"
echo "kernel: $(uname -r)"
"""
with Sandbox.create(template="base", ttl_seconds=300) as sbx:
sbx.filesystem.write("/workspace/probe.sh", probe)
r = sbx.exec("bash /workspace/probe.sh", timeout_seconds=30)
assert r.exit_code == 0, r.stderr
print(r.stdout)
# The interesting lines are the process count -- it is the number of processes
# in THIS guest, because there is no larger table for it to be a subset of --
# and the NSpid chain, which has exactly one entry, because there is no
# ancestor namespace holding a second number. The sandbox dies with the block.When a PID namespace is enough, and when it is the wrong tool
It is enough — genuinely, comfortably enough — when the code is yours. Your build steps, your test harness, your batch jobs, your multi-process daemon, your CI runner executing a pipeline your team wrote. The threat model there is accidents: a runaway process, a stray pkill, a cleanup path that misses a child, a fork bomb from a typo in a recursive make. PID namespaces plus a pids cgroup handle all of that beautifully, they cost a fork, and reaching for a VM instead would be silly. Add the mount namespace so /proc is right, add a real init so signals work, and you are done.
It is the wrong tool the moment the code is not yours: untrusted submissions, customer plugins, model-generated code no human has read, anything you would describe as "assume it's hostile." Hiding process numbers is not a mitigation there, because the attack does not go through the process table. It goes through a syscall, to a kernel that is also your kernel, and every namespace you stacked is bookkeeping inside the thing under attack. Layer seccomp and capabilities to shrink what that code can even ask for, then put a hardware boundary underneath so the answer comes from a kernel you were willing to lose.
And it is overkill in a direction people rarely consider. If you have exactly one process, no children, and a supervisor that already tracks it, a PID namespace buys almost nothing and costs you the PID 1 signal semantics — a net loss. You will have introduced the slow-shutdown bug in exchange for a process list you were not reading. Reach for it when there is a process tree to own, not as a reflex.
For the adjacent primitives: the filesystem view is in /blog/mount-namespaces-and-pivot-root-explained, which you need for /proc anyway; the identity axis is in /blog/user-namespaces-explained-for-sandboxing; the resource ceilings, including the pids.max that actually stops fork bombs, are in /blog/cgroups-v2-explained-for-sandboxing; the syscall surface is in /blog/seccomp-explained; and the dismantling of root into droppable bits is in /blog/linux-capabilities-explained-for-sandboxing. Each covers a different axis. None of them, alone or stacked, changes which kernel is listening.
Frequently asked questions
Why does my container take 10 seconds to stop?
Almost always because your main process is PID 1 and has no SIGTERM handler. Inside a PID namespace the kernel refuses to deliver a signal to PID 1 unless PID 1 installed a handler for it, so the runtime's SIGTERM is silently dropped. The runtime waits out its grace period, then sends SIGKILL, which is forcibly delivered from the ancestor namespace and cannot be caught, so you get an abrupt kill with no cleanup. Fix it by handling SIGTERM in your process, by exec'ing so your process is genuinely PID 1 rather than sitting behind a shell, or by putting a small init like tini or dumb-init at PID 1 to forward signals for you.
Do I actually need tini or dumb-init in a container?
You need something at PID 1 that forwards signals and reaps orphans. If your application does both — many language runtimes do neither — you do not need a separate init. If it does not, you have two problems: signals aimed at PID 1 are dropped unless handled, and every orphaned process in the namespace is reparented to PID 1 and stays a zombie until PID 1 waits on it. A tiny init solves both in a few kilobytes. If your workload is a single process that never forks and handles SIGTERM itself, exec'ing it directly is fine and simpler.
Why does ps show host processes inside my PID namespace?
Because a PID namespace does nothing for /proc. A procfs superblock reports whichever PID namespace it was mounted against, and you inherited one mounted against the host. ps, top, pgrep and htop all read procfs, so they enumerate the host's table and present it confidently. Mount a fresh procfs inside the namespace, which needs a mount namespace too so you do not clobber the host's. From a shell, unshare --pid --fork --mount-proc does all of it; --mount-proc implies --mount. The same trap bites nsenter: use -p -m together, because -p alone leaves you reading your own procfs.
Does a PID namespace stop a fork bomb?
No, and this is the most common misconception about it. A PID namespace is a numbering scheme, not a quota. Processes inside it still occupy entries in the host's single global task table, so a bomb inside a namespace exhausts host resources exactly as it would outside — just with tidier numbering. The defence is the pids controller in cgroups v2: write a ceiling to pids.max on a cgroup containing the workload, and forks past the limit fail with EAGAIN instead of taking the machine down. Put the process in the cgroup before it starts forking, since membership is inherited by children.
How do I find a process's PID inside a namespace, given its PID outside?
Read /proc/<outside-pid>/status and look at the NSpid line. It lists that task's PID in each namespace from the outermost inwards, so NSpid 8412 1 means the process the host calls 8412 is PID 1 in its own namespace; nested namespaces add further entries. Related tools: readlink /proc/<pid>/ns/pid gives an inode identifier you can compare between processes to see whether they share a namespace, lsns -t pid enumerates every PID namespace with its process count, and nsenter -t <pid> -p -m gives you a shell that sees the same process table the workload does.
Keep reading
- Mount namespaces and pivot_root, explained — The filesystem axis, and the namespace you need before you can remount /proc correctly.
- cgroups v2, explained for sandboxing — Where pids.max actually lives, plus the CPU, memory and IO ceilings a namespace does not give you.
- User namespaces, explained for sandboxing — The identity axis: what root-inside-a-namespace really means, and what it opens up.
- seccomp, explained — The one primitive here that shrinks the kernel attack surface instead of rearranging the view of it.
49ms p50 cold start. Fork, snapshot, and scale to zero.