Linux Namespaces

$ unshare --pid --uts --net --mount --fork --mount-proc bash
unshare · nsenter · lsns · ip netns // mnt · uts · ipc · pid · net · user · cgroup · time // kernel 2.4.19 to 5.6

The 8 Namespace Types

Namespaces partition kernel resources so one set of processes sees one set of resources, another sees a different set. They are the isolation layer behind every container runtime; cgroups (the resource-limiting half of containers) are a separate mechanism.

Mount
CLONE_NEWNS
unshare -m
Filesystem mount points. Each namespace gets its own mount table. This is how containers get their own rootfs.
Linux 2.4.19 (2002)
UTS
CLONE_NEWUTS
unshare -u
Hostname and NIS domain name. The simplest namespace type. Change hostname without affecting the host.
Linux 2.6.19 (2006)
IPC
CLONE_NEWIPC
unshare -i
System V IPC objects and POSIX message queues. Prevents cross-container IPC snooping.
Linux 2.6.19 (2006)
PID
CLONE_NEWPID
unshare -p
Process ID number space. First forked child becomes PID 1. Processes outside are invisible from inside.
Linux 2.6.24 (2008)
Network
CLONE_NEWNET
unshare -n
Entire network stack: interfaces, routes, iptables, sockets. Starts with only a loopback (and it's DOWN).
Linux 2.6.24 (2008) · complete by 2.6.29
User
CLONE_NEWUSER
unshare -U
UIDs, GIDs, and capabilities. Be root inside, nobody outside. Enables rootless containers.
Linux 3.8 (2013)
Cgroup
CLONE_NEWCGROUP
unshare -C
Cgroup root directory. Virtualizes /proc/self/cgroup so the container sees itself at the cgroup root.
Linux 4.6 (2016)
Time
CLONE_NEWTIME
unshare -T
CLOCK_MONOTONIC and CLOCK_BOOTTIME offsets. Make a process think the system booted at a different time.
Linux 5.6 (2020)

The Three Syscalls

Three syscalls power the entire namespace subsystem.

SyscallSignaturePurpose
clone() clone(fn, stack, flags, arg) Create a child process in new namespace(s). Flags are OR'd CLONE_NEW* constants.
unshare() unshare(flags) Move the calling process into new namespace(s). No fork needed.
setns() setns(fd, nstype) Join an existing namespace via its /proc fd. This is what nsenter uses.
C
// create child in new PID + UTS namespace
int flags = CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD;
pid_t pid = clone(child_fn, stack + STACK_SIZE, flags, arg);

// move current process into a new network namespace
unshare(CLONE_NEWNET);

// join an existing namespace
int fd = open("/proc/12345/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);

unshare: Create Namespaces

Create new namespace(s) and run a program inside them.

basics
# new UTS namespace, change hostname
$ sudo unshare --uts bash -c 'hostname mybox; bash'

# new PID namespace (must fork + remount /proc)
$ sudo unshare --pid --fork --mount-proc bash

# new network namespace (blank stack, only lo)
$ sudo unshare --net bash

# rootless user namespace (no sudo!)
$ unshare --user --map-root-user bash

# all namespaces (basically a container)
$ sudo unshare --pid --uts --mount --ipc --net --fork --mount-proc bash
Why --fork with --pid? The PID namespace applies to children of the unshare call. Without --fork, the calling process stays in the old PID namespace and you get confusing behavior. --mount-proc remounts /proc so ps/top see the new PID space.

nsenter: Join Namespaces

Enter existing namespaces of a running process. Uses setns() under the hood.

enter by pid
# enter all namespaces of PID 12345
$ sudo nsenter --target 12345 --all bash

# enter just the network namespace
$ sudo nsenter --target 12345 --net bash

# enter a Docker container (bypass docker exec)
$ PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
$ sudo nsenter --target $PID --all bash

# useful when dockerd is stuck but you need into a container

Network Namespaces

A new net namespace starts with nothing. You build the network from scratch using veth pairs.

veth pairs

veth pair
# create a virtual ethernet pair (two ends of a cable)
# ip link add veth-host type veth peer name veth-ns

# move one end into the namespace
# ip link set veth-ns netns myns

# assign IPs
# ip addr add 10.200.1.1/24 dev veth-host
# ip netns exec myns ip addr add 10.200.1.2/24 dev veth-ns

# bring up
# ip link set veth-host up
# ip netns exec myns ip link set veth-ns up
# ip netns exec myns ip link set lo up

# test
# ip netns exec myns ping 10.200.1.1

Internet access (NAT)

internet access
# enable IP forwarding
# echo 1 > /proc/sys/net/ipv4/ip_forward

# NAT the namespace traffic
# iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -j MASQUERADE

# default route inside the namespace
# ip netns exec myns ip route add default via 10.200.1.1

# now the namespace can reach the internet
# ip netns exec myns curl ifconfig.me

ip netns

ip netns
$ ip netns add myns          # create
$ ip netns list              # list
$ ip netns exec myns bash    # enter
$ ip netns exec myns ip a    # run command inside
$ ip netns del myns          # delete

# ip netns creates bind mounts at /var/run/netns/<name>
# this keeps the namespace alive even with no processes in it

Docker's bridge networking

Container A ---veth---+ | docker0 bridge --- eth0 --- internet | (+ iptables NAT) Container B ---veth---+

/proc Namespace Interface

Every process exposes its namespace memberships as symlinks under /proc/<pid>/ns/. Same inode number = same namespace.

/proc/<pid>/ns/ +-- cgroup cgroup:[4026531835] +-- ipc ipc:[4026531839] +-- mnt mnt:[4026531841] +-- net net:[4026531840] +-- pid pid:[4026531836] +-- pid_for_children +-- time time:[4026531834] +-- time_for_children +-- user user:[4026531837] +-- uts uts:[4026531838]
inspection
# your own namespaces
$ ls -la /proc/$$/ns/

# compare two processes
$ readlink /proc/1/ns/pid
pid:[4026531836]
$ readlink /proc/$$/ns/pid
pid:[4026531836]          # same inode = same namespace

# list all namespaces on the system
$ lsns

# filter by type
$ lsns -t net

# a process has TWO PIDs in a PID namespace
$ grep NSpid /proc/<pid>/status
NSpid:  12345   1       # host PID 12345, container PID 1

User Namespaces + UID Maps

The most security-relevant namespace. Maps UIDs between namespaces. Root inside the container is an unprivileged user on the host.

uid_map
# /proc/<pid>/uid_map format:
# <id_inside> <id_outside> <range>

         0       1000          1

# UID 0 inside = UID 1000 outside
# "root" in the container is
# your unprivileged user on host
rootless
# no sudo needed
$ unshare --user --map-root-user bash

# inside:
# whoami
root
# id
uid=0(root) gid=0(root)

# but on the host, it's still you
Why this matters Without user namespaces, containers need real root to mount filesystems, change hostnames, create network interfaces, and change UIDs. With them, the kernel checks capabilities within the user namespace, so unprivileged users can do all of this. This is what podman's rootless mode is built on.
The setgroups gotcha Writing gid_map by hand from an unprivileged process fails with EPERM until you write deny to /proc/<pid>/setgroups first (kernel 3.19+, closes a setgroups(2) privilege trick). unshare --map-root-user does this for you, which is why the examples above just work.
manual mapping
# from a second terminal, for an unshared process <pid>:
$ echo deny > /proc/<pid>/setgroups       # required before gid_map
$ echo "0 1000 1" > /proc/<pid>/uid_map
$ echo "0 1000 1" > /proc/<pid>/gid_map

Container from Scratch

A container is just namespaces + pivot_root + cgroups. The sequence:

  1. unshare / clone New PID, UTS, mount, IPC, and net namespaces (CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS | ...).
  2. Privatize mounts mount --make-rprivate / so nothing you do propagates back to the host mount namespace.
  3. Make the rootfs a mount point pivot_root requires its new root to be a mount point; a plain directory needs a self bind-mount first.
  4. pivot_root Swap / for the new rootfs; the old root lands on put_old and gets lazily unmounted.
  5. Mount /proc Inside the new root, so ps/top reflect the new PID namespace.
  6. exec The container's entrypoint becomes PID 1.
bash
#!/usr/bin/env bash
# minimal container in ~25 lines

ROOTFS="/tmp/container-root"
mkdir -p "$ROOTFS"/{bin,proc,.old}

# install a minimal userland (busybox)
cp /path/to/busybox "$ROOTFS/bin/busybox"
for cmd in sh ls ps mount umount cat echo; do
    ln -sf busybox "$ROOTFS/bin/$cmd"
done

# launch with all namespaces
sudo unshare --pid --uts --mount --ipc --net --fork \
    /bin/bash -c "
        hostname container
        mount --make-rprivate /           # no propagation to host
        mount --bind $ROOTFS $ROOTFS       # pivot_root needs a mount point
        cd $ROOTFS
        pivot_root . .old
        mount -t proc proc /proc
        umount -l /.old
        exec /bin/sh
    "
The PID 1 problem In a PID namespace, if PID 1 dies, the kernel SIGKILLs every process in that namespace. Your entrypoint is PID 1 automatically; no special init is required by the kernel. The reason docker run --init (tini) exists is different: a typical application does not reap orphaned zombies or forward signals the way an init should, so long-running containers that spawn children want a tiny real init as PID 1.

How Docker Actually Does It

Simplified flow of docker run. The namespace and pivot_root work happens inside runc, exactly as in the from-scratch demo, just in Go. Networking is set up by dockerd before the runtime starts.

docker run flow
1.  docker CLI --> dockerd (REST API call)
2.  dockerd / libnetwork prepares networking (CNM model):
       - veth pair, docker0 bridge attach, iptables NAT rules
3.  dockerd --> containerd (gRPC) --> containerd-shim-runc-v2 --> runc
4.  runc (OCI runtime):
    a. clone(CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS | ...)
    b. In child:
       - Set up cgroups
       - Set up rootfs (overlay mounts), mount /proc /sys /dev
       - pivot_root
       - Set hostname, join the prepared network namespace
       - Drop capabilities
       - Set seccomp filters
       - Set AppArmor/SELinux labels
       - execve(container entrypoint)
5.  Container process running, fully isolated
CNM, not CNI Plain Docker networking is libnetwork's Container Network Model, driven by dockerd. CNI (the plugin standard) is the Kubernetes path: kubelet's runtime (containerd/CRI-O) invokes CNI plugins instead. The two models solve the same problem and are not interchangeable.

Mount Propagation

Mounts can propagate between namespaces. This is often the source of subtle container bugs.

ModeBehaviorUse Case
private Changes stay in this namespace only Default for containers
shared Changes propagate to all peer mounts Host mounts visible in containers
slave Receives propagation but doesn't send One-way host-to-container sync
unbindable Can't be bind-mounted at all Prevent mount namespace escapes
check propagation
$ findmnt -o TARGET,PROPAGATION

# make a mount private (no propagation)
# mount --make-private /mnt/data

# make a mount shared (propagate everywhere)
# mount --make-shared /mnt/data

# recursively make everything private (container startup)
# mount --make-rprivate /

Recipes

Isolate network

no network
# run a process with zero network access
$ sudo unshare --net -- bash -c '
    curl google.com   # fails: no interfaces
    ping 8.8.8.8      # fails: no route
'

# useful for sandboxing builds, running untrusted code, etc.

Custom hostname

hostname
$ sudo unshare --uts -- bash -c '
    hostname devbox
    exec my-app
'

# my-app sees hostname "devbox"
# host hostname is untouched

Rootless container

rootless
# no sudo needed
$ unshare --user --map-root-user --pid --fork --mount-proc bash

# you're now "root" with isolated PIDs
# whoami
root
# ps aux
  PID  USER  COMMAND
    1  root  bash

Spy on Docker

docker namespaces
$ docker run -d --name test alpine sleep 3600
$ PID=$(docker inspect -f '{{.State.Pid}}' test)

# compare container vs host namespaces
$ for ns in /proc/$PID/ns/*; do
    type=$(basename $ns)
    cnt=$(readlink $ns)
    host=$(readlink /proc/1/ns/$type)
    [[ "$cnt" != "$host" ]] && echo "ISOLATED: $type"
done
ISOLATED: cgroup
ISOLATED: ipc
ISOLATED: mnt
ISOLATED: net
ISOLATED: pid
ISOLATED: uts

# note: default docker does NOT use user or time namespaces

Rescue enter (dockerd dead)

rescue
# enter a container when docker exec hangs or dockerd is dead
# containerd v2 shim keeps the pid here:
$ PID=$(cat /run/containerd/io.containerd.runtime.v2.task/moby/<container-id>/init.pid)
# or just find it: ps --forest, pgrep -f <entrypoint>
$ sudo nsenter --target $PID --all bash

# you're inside the container, full shell
# works even if the docker daemon is completely dead

Quick Reference

Key files

/proc/<pid>/ns/*Namespace symlinks
/proc/<pid>/uid_mapUID mapping
/proc/<pid>/gid_mapGID mapping
/proc/<pid>/setgroupsMust be "deny" before gid_map
/proc/<pid>/statusNSpid, NStgid, etc.
/var/run/netns/Named net namespaces

Key tools

unshareCreate new namespaces
nsenterEnter existing namespaces
lsnsList namespaces
ip netnsManage net namespaces
findmntMount propagation info
pstreePID namespace trees

Man pages

$ man 7 namespaces          # overview
$ man 7 user_namespaces     # UID mapping details
$ man 7 pid_namespaces      # PID 1 semantics
$ man 7 cgroups             # resource limits (the other half of containers)
$ man 2 unshare             # syscall
$ man 2 clone               # syscall
$ man 2 setns               # syscall
$ man 2 pivot_root          # the mount-point requirement lives here
// mnt · uts · ipc · pid · net · user · cgroup · time //