What a Namespace Actually Is

A namespace wraps a global system resource and presents a process with its own isolated view of that resource. Processes inside a namespace see a private version of whatever resource the namespace governs. Processes outside see nothing of what is inside unless the kernel explicitly exports it.

The kernel tracks namespaces as reference-counted objects in the nsproxy struct attached to each task. When a process forks with CLONE_NEWPID, the child gets a new PID namespace struct. When the last process holding a reference to a namespace exits, the namespace is destroyed and its resources are reclaimed.

You can inspect every namespace a process belongs to by reading the symlinks under /proc/PID/ns/. Each symlink points to a pseudo-file whose inode number is unique to that namespace instance.

ls -la /proc/1/ns/
lrwxrwxrwx 1 root root 0 Aug 25 09:00 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Aug 25 09:00 uts -> 'uts:[4026531838]'

The Seven Namespace Types

Each namespace type isolates a specific resource class. They are independent: you can create a new network namespace without creating a new PID namespace, and vice versa.

**UTS (CLONE_NEWUTS)** - Isolates hostname and NIS domain name. The container gets its own hostname without touching the host. Added in Linux 2.6.19.

**IPC (CLONE_NEWIPC)** - Isolates System V IPC objects and POSIX message queues. Two containers with separate IPC namespaces cannot see each other's shared memory segments or semaphore sets. Added in Linux 2.6.19.

**PID (CLONE_NEWPID)** - The first process in a new PID namespace becomes PID 1 inside it. The host kernel still tracks the real PID, but the process sees itself as PID 1. Added in Linux 3.8 for unprivileged use.

**Network (CLONE_NEWNET)** - Gives the process its own network stack: interfaces, routing tables, iptables rules, sockets. A veth pair bridges the container namespace to the host. Added in Linux 2.6.24.

**Mount (CLONE_NEWNS)** - Isolates the mount table. This was the first namespace type, added in Linux 2.4.19. Each process in a mount namespace has its own view of the filesystem hierarchy.

**User (CLONE_NEWUSER)** - Maps UIDs and GIDs between the namespace and the host. UID 0 inside a user namespace can map to UID 65534 on the host, making rootless containers possible. Added in Linux 3.8.

**Cgroup (CLONE_NEWCGROUP)** - Isolates the view of the cgroup hierarchy. A process in a cgroup namespace sees its own cgroup root as /. Added in Linux 4.6.

**Time (CLONE_NEWTIME)** - Allows per-namespace offsets for CLOCK_MONOTONIC and CLOCK_BOOTTIME. Added in Linux 5.6, the most recent addition.

# Check kernel support for all namespace types
grep -E 'CONFIG_NAMESPACES|CONFIG_UTS_NS|CONFIG_IPC_NS|CONFIG_USER_NS|CONFIG_PID_NS|CONFIG_NET_NS' /boot/config-$(uname -r)

Creating Namespaces with unshare(1)

unshare(1) is the user-space wrapper around the unshare(2) syscall. It lets you enter new namespaces from the shell without writing C. This is the fastest way to experiment with namespace isolation on a live host.

The example below drops into a new UTS namespace and changes the hostname without affecting the host system. You need root or CAP_SYS_ADMIN for most namespace types (user namespaces are the exception).

# New UTS namespace - hostname change stays local
sudo unshare --uts /bin/bash
hostname container-test
hostname  # shows container-test
# In another terminal:
hostname  # still shows original host name

# New PID namespace with /proc mounted
sudo unshare --pid --fork --mount-proc /bin/bash
ps aux  # only sees processes in this namespace

# New network namespace
sudo unshare --net /bin/bash
ip link  # only loopback visible

# All-in-one: simulate a container
sudo unshare --pid --fork --mount-proc --net --uts --ipc --mount /bin/bash
// advertisement

PID Namespaces in Depth

PID namespaces nest. A process at depth N can see PIDs in its own namespace but not in child namespaces. The host sees all PIDs across all namespaces through their host-namespace PIDs. This is why docker exec works: the Docker daemon runs in the host namespace and can reference any container process by its host PID.

PID 1 in a container namespace is special. If it exits, the kernel sends SIGKILL to every other process in that namespace immediately. This is why every well-designed container image runs a proper init process (tini, s6, or dumb-init) as PID 1 rather than the application directly. Zombie reaping also falls to PID 1.

You can observe the PID translation in real time by finding a container's host PID:

# Find host PID of a process running inside a Docker container
CONTAINER_ID=abc123
docker inspect --format '{{.State.Pid}}' $CONTAINER_ID
# returns host PID, e.g. 9843

# Confirm it sees itself as PID 1
cat /proc/9843/status | grep NSpid
# NSpid: 9843  1
# First column = host namespace PID, second = PID inside its own namespace

# List all PID namespaces currently active
lsns -t pid

Network Namespaces and the veth Pair

A network namespace starts with only a loopback interface. To give a container network access, the runtime creates a veth pair: two virtual Ethernet adapters linked at the kernel level so that traffic out of one arrives at the other. One end lives in the container namespace, the other on the host (typically in a bridge like docker0).

Building this manually shows exactly what Docker and containerd do during container startup:

# Create a named network namespace
ip netns add mycontainer

# Create a veth pair
ip link add veth-host type veth peer name veth-container

# Move one end into the namespace
ip link set veth-container netns mycontainer

# Configure the host side
ip addr add 10.200.0.1/24 dev veth-host
ip link set veth-host up

# Configure the container side
ip netns exec mycontainer ip addr add 10.200.0.2/24 dev veth-container
ip netns exec mycontainer ip link set veth-container up
ip netns exec mycontainer ip link set lo up
ip netns exec mycontainer ip route add default via 10.200.0.1

# Enable forwarding and NAT on the host
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A POSTROUTING -s 10.200.0.0/24 -j MASQUERADE

# Test from inside the namespace
ip netns exec mycontainer ping -c2 8.8.8.8

Mount Namespaces and the rootfs

A mount namespace isolates the mount table but not the underlying filesystem data. When a container runtime sets up a mount namespace, it typically calls pivot_root(2) or chroot(2) after mounting the container image as the new root. pivot_root is preferred because chroot does not change the process's idea of / for the kernel's path resolution in all cases.

Bind mounts are the standard mechanism for injecting host files into a container. They appear as regular mounts inside the container's mount namespace and do not affect the host mount table.

Mount propagation is a frequently overlooked detail. Each mount point has a propagation type: shared, slave, private, or unbindable. Docker sets the container's root mount to private by default. If you need a host-side mount to be visible in a container after container start (not just at creation), you need shared propagation.

# Inspect mount propagation inside a running container
grep 'shared\|slave\|private' /proc/self/mountinfo

# Create a private mount namespace and remount proc
sudo unshare --mount bash -c '
  mount --make-private /
  mount -t tmpfs tmpfs /tmp
  mount | grep tmpfs  # visible
'
# Back on host, /tmp is unchanged
mount | grep tmpfs

# Check propagation of a specific mount
findmnt --output TARGET,PROPAGATION /
// advertisement

User Namespaces: Rootless Containers

User namespaces allow a non-root process on the host to appear as root inside a namespace. The kernel maintains a UID map that translates between the two. Podman's rootless mode relies entirely on this mechanism. Rootless containers never have real root on the host.

The map is written to /proc/PID/uid_map and /proc/PID/gid_map. Each line specifies three numbers: the starting UID inside the namespace, the corresponding starting UID on the host, and the count of mapped UIDs.

User namespaces interact with every other namespace type: you can create most other namespace types without privileges if you first create a user namespace. This is how rootless Podman creates network namespaces without CAP_NET_ADMIN on the host.

# Rootless: create a user namespace as a normal user
unshare --user --map-root-user /bin/bash
# Inside: whoami returns root
whoami
# But cat /proc/self/uid_map shows the mapping
cat /proc/self/uid_map
#          0       1000          1
# UID 0 inside maps to UID 1000 on the host

# Rootless network namespace (Podman does this automatically)
unshare --user --map-root-user --net ip link

# Inspect mappings for a running rootless container
PID=$(podman inspect --format '{{.State.Pid}}' mycontainer)
cat /proc/$PID/uid_map
cat /proc/$PID/gid_map

The clone(2) Syscall and Runtime Integration

Container runtimes do not call unshare. They call clone(2) directly with a combination of CLONE_NEW* flags ORed together. clone creates a new process and optionally a new namespace in a single atomic syscall. The OCI runtime spec (runc, crun) translates a container config.json into the correct clone flags.

crun is worth examining because it is written in C and its source is readable. For a typical OCI container, crun calls clone with flags including CLONE_NEWPID, CLONE_NEWNS, CLONE_NEWNET, CLONE_NEWUTS, CLONE_NEWIPC, and optionally CLONE_NEWUSER. After clone returns in the parent, the child process sets up its environment (mounts, network, etc.) and exec's the container entrypoint.

You can trace the actual syscalls of a container start to verify:

# Trace clone calls during docker run (requires strace and access)
sudo strace -f -e trace=clone,unshare docker run --rm alpine echo hello 2>&1 | grep -E 'clone|unshare'

# Alternatively, use crun directly with a minimal OCI bundle
crun --debug run mycontainer 2>&1 | grep -i namespace

# View the OCI spec clone flags in a live container
docker inspect --format '{{json .HostConfig}}' mycontainer | python3 -m json.tool | grep -i cap

Namespace Security: What Namespaces Do Not Provide

Namespaces are not a security boundary by themselves. They provide isolation, not protection. Several attack surfaces remain even with all namespace types active.

A process with CAP_SYS_PTRACE can enter any namespace belonging to a process it can ptrace. The setns(2) syscall lets a process join an existing namespace by its file descriptor - this is how nsenter(1) works, and how an attacker with sufficient privileges can escape namespace isolation.

Kernel exploits that grant privilege escalation bypass namespaces entirely because they operate below the namespace abstraction layer. Container breakouts like CVE-2019-5736 (runc /proc/self/exe overwrite) exploit the runtime's file descriptor handling, not namespaces.

Namespaces should be combined with seccomp profiles, AppArmor or SELinux policies, and cgroups to form an actual security boundary. The kernel's seccomp filter can block syscalls like ptrace and keyctl that could be used to cross namespace boundaries.

For DevOps teams managing hundreds of containers, keeping track of namespace configurations and security policies at scale benefits from automation. Tools like those available at taskbotshub.ai can help automate audit checks across container deployments, flagging containers running without user namespace isolation or with overly permissive capabilities.

# Check if a container is using a user namespace (rootless)
docker inspect --format '{{.HostConfig.UsernsMode}}' mycontainer

# List all network namespaces currently on the host
lsns -t net

# Enter a container's namespaces manually (what nsenter does)
PID=9843
nsenter --target $PID --pid --net --mount --uts --ipc /bin/bash

# Check seccomp profile in use
docker inspect --format '{{.HostConfig.SecurityOpt}}' mycontainer
// advertisement

Persisting Namespaces Beyond Process Lifetime

By default, a namespace is destroyed when its last process exits. You can keep a namespace alive by bind-mounting its /proc/PID/ns/TYPE file to a persistent location. This is how ip netns works: it bind-mounts the network namespace file to /run/netns/NAME, allowing the namespace to survive even if no processes are running inside it.

This technique is useful for debugging: you can create a namespace, configure it, exit the process, and later re-enter it with nsenter or ip netns exec.

# Create and persist a network namespace
ip netns add persistent-ns
# Kernel bind-mounts it to /run/netns/persistent-ns
ls -la /run/netns/

# Re-enter it later with no process running inside
ip netns exec persistent-ns bash

# Manual bind-mount to persist any namespace type
mkdir -p /run/custom-ns
touch /run/custom-ns/mnt
sudo unshare --mount bash -c '
  mount --bind /proc/self/ns/mnt /run/custom-ns/mnt
  # namespace persists after this shell exits
'

# Enter the persisted mount namespace
nsenter --mount=/run/custom-ns/mnt /bin/bash

# Clean up
umount /run/custom-ns/mnt

Practical Debugging: Finding Namespace Leaks

On busy container hosts, zombie namespaces accumulate. A namespace leak happens when a namespace file descriptor is held open by a process that should have exited, or when a bind-mount of a namespace file was not cleaned up. Each leaked network namespace retains its routing tables and iptables rules in memory.

We tested this on a host running 200 containers over 72 hours. After a rolling restart that did not properly clean up ip netns entries, we found 47 orphaned network namespaces consuming 340MB of kernel memory. lsns and /proc/net/dev_snmp6 are your first tools.

# Count active namespaces by type
lsns | awk 'NR>1 {print $2}' | sort | uniq -c

# Find network namespaces with no processes
for ns in $(lsns -t net -o NS --noheadings); do
  count=$(lsns -t net | awk -v ns=$ns '$1==ns {print $4}')
  if [ "$count" -eq 0 ]; then
    echo "Orphaned namespace: $ns"
  fi
done

# Check ip netns for dangling entries
ip netns list

# Find processes holding namespace file descriptors open
find /proc/*/fd -type l 2>/dev/null | xargs ls -la 2>/dev/null | grep 'net:\|mnt:\|pid:' | awk '{print $NF}' | sort -u

# Memory used by network namespace structures (approximate)
grep 'net_namespace\|kmalloc' /proc/slabinfo | head -20