Namespaces and cgroups — the container primitives
Namespaces isolate global kernel resources per process group (pid, net, mnt, uts, ipc, user, cgroup). Combined with cgroups for resource limits and a root filesystem, they form the complete container primitive. No magic — this is where the Docker track picks up.
Every senior engineer who has ever debugged a container networking problem, a runaway container eating host CPU, or a privilege escalation inside a container has eventually hit the same realisation: a container is not a virtual machine, not a sandbox, and not magic. It is a process (or a process tree) that the kernel has been told to show a different view of the world. The process thinks it is alone on the machine, has PID 1, owns its own network stack, and can only see a specific directory tree as its root. The kernel achieves this through two mechanisms: namespaces (which slice global resources into isolated views) and cgroups (which enforce resource limits). A root filesystem completes the picture. These three things together are exactly what Docker, containerd, and every other container runtime constructs when you run a container. After this lesson there is no mystery left — and the Docker track picks up exactly here.
After this lesson you can name the seven namespace types and what each isolates, use unshare to create a new namespace and nsenter to enter one, explain how cgroups v2 limits a process’s CPU and memory, and describe a container as namespaces + cgroups + a root filesystem — with no remaining black boxes.
The seven namespace types. A namespace wraps a global kernel resource so that processes inside the namespace see their own isolated copy. The Linux kernel currently provides seven namespace types.
# List namespaces for the current shell process:
ls -la /proc/$$/ns/
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 cgroup -> cgroup:[4026531835]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 ipc -> ipc:[4026531839]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 mnt -> mnt:[4026531840]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 net -> net:[4026531992]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 pid -> pid:[4026531836]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 user -> user:[4026531837]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 uts -> uts:[4026531838]
# Two processes sharing the same inode number for a namespace type
# are in the SAME namespace. Different inodes = different namespaces.
| Namespace | Flag | What it isolates |
|---|---|---|
| pid | CLONE_NEWPID | Process ID space — PID 1 inside can be any process |
| net | CLONE_NEWNET | Network interfaces, routing table, iptables rules |
| mnt | CLONE_NEWNS | Mount table — what filesystems are visible |
| uts | CLONE_NEWUTS | Hostname and NIS domain name |
| ipc | CLONE_NEWIPC | System V IPC (semaphores, message queues, shared memory) |
| user | CLONE_NEWUSER | UID/GID mapping — root inside maps to an unprivileged UID outside |
| cgroup | CLONE_NEWCGROUP | The process’s view of the cgroup hierarchy |
Creating namespaces with unshare. unshare starts a new process in a new namespace without any container runtime involvement. This is the raw kernel feature.
# Create a new UTS namespace and change the hostname inside it:
sudo unshare --uts /bin/bash
# Inside the new namespace:
hostname container-test
hostname
# container-test
# Open another terminal on the host and check:
hostname
# myserver ← host hostname is unchanged
# Create a new PID namespace — the shell becomes PID 1 inside:
sudo unshare --pid --fork --mount-proc /bin/bash
# Inside:
echo $$
# 1 ← this shell is PID 1 in the new namespace
ps aux
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1 0.0 0.0 4236 3492 pts/1 S 09:00 0:00 /bin/bash
# root 2 0.0 0.0 7484 2992 pts/1 R+ 09:00 0:00 ps aux
# Only two processes visible — the host's 200+ are hidden
# Create a new network namespace (isolated network stack):
sudo unshare --net /bin/bash
# Inside:
ip link
# 1: lo: <LOOPBACK> mtu 65536 ... ← only loopback, no eth0, no docker0unshare with multiple flags combines namespaces: --pid --net --uts --ipc --mount is approximately what a container runtime does when starting a container, minus the rootfs pivot.
Entering existing namespaces with nsenter. nsenter attaches a process to the namespaces of an already-running process. This is how you debug inside a container without docker exec — directly from the host.
# Find the PID of a running container on the host:
# (In Docker: docker inspect <container> --format '{{.State.Pid}}')
CPID=12345
# Enter the container's network namespace to inspect it from outside:
sudo nsenter --net --target $CPID ip addr
# 1: lo: <LOOPBACK,UP>
# 15: eth0@if16: <BROADCAST,MULTICAST,UP> ...
# inet 172.17.0.3/16
# Enter the container's PID namespace to see its process tree:
sudo nsenter --pid --target $CPID ps aux
# Enter ALL namespaces of the container (equivalent to a raw exec):
sudo nsenter --target $CPID --mount --uts --ipc --net --pid -- /bin/bash
# You are now inside the container's full environment without Docker
# This is exactly how 'docker exec' works under the hood —
# it calls nsenter into the container's namespaces.nsenter is invaluable when a container has no shell (FROM scratch images, distroless) — you enter its network namespace from the host but run tools from the host filesystem.
cgroups v2: resource limits per process group. Namespaces control what a process can see. cgroups control how much of the host resources it can use. Unit 08 covered cgroups in depth; here we connect them to the container picture.
# The cgroup v2 hierarchy lives at /sys/fs/cgroup (unified hierarchy):
ls /sys/fs/cgroup/
# cgroup.controllers cgroup.max.depth cgroup.procs cpu.pressure ...
# memory.current memory.max system.slice user.slice
# Find what cgroup a container's PID belongs to:
cat /proc/$CPID/cgroup
# 0::/system.slice/docker-abc123.scope
# Inspect memory limit for that cgroup:
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
# 536870912 ← 512 MB limit set by Docker
# Inspect CPU limit:
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
# 100000 1000000 ← 100ms quota per 1000ms period = 10% of one CPU
# Create a cgroup manually and limit it (shows the raw mechanism Docker uses):
sudo mkdir /sys/fs/cgroup/demo
echo 268435456 | sudo tee /sys/fs/cgroup/demo/memory.max # 256 MB
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs # add this shell
# This shell now cannot allocate more than 256 MB total.
# The OOM killer (from unit 08) fires if it tries.Docker’s --memory, --cpus, and --cpu-shares flags write exactly these cgroup files. There is no other mechanism.
The container equation: namespaces + cgroups + rootfs. A container runtime does three things in sequence to start a container:
# 1. NAMESPACES — create isolated views of the kernel:
# --pid: new PID space (container process is PID 1)
# --net: new network stack (veth pair to host bridge)
# --mnt: new mount table
# --uts: new hostname
# --ipc: new IPC space
# --user: (optional) UID remapping for rootless containers
# 2. CGROUPS — enforce resource limits:
# memory.max → --memory in Docker
# cpu.max → --cpus in Docker
# pids.max → limit fork bombs
# 3. ROOTFS — present a different filesystem as root:
# pivot_root() or chroot() into the container image's layer stack
# The container image is just a directory tree (tar layers)
# overlayfs merges the read-only image layers with a writable top layer
# Assemble a minimal container manually (educational — not production):
sudo unshare --pid --fork --mount-proc --net --uts \
chroot /path/to/rootfs /bin/sh
# This is a container. No Docker daemon, no containerd, no magic.
# The only difference from a real container runtime:
# - no cgroup assignment (no resource limits)
# - no veth pair (no network connectivity)
# - no image pull / layer management
# A real runtime automates these three steps, adds lifecycle management,
# image distribution, and a control API — but the kernel primitives are identical.
# Verify from the host that the "container" has its own PID namespace:
ls -la /proc/<container-pid>/ns/pid
# pid:[4026532345] ← different inode = different namespace from host
ls -la /proc/$$/ns/pid
# pid:[4026531836] ← host PID namespaceThis is the bridge: the Docker track begins with docker run, but you now know that under the hood it is calling clone() with namespace flags, writing cgroup limits, and calling pivot_root() into the image’s layer stack. Every container debugging session — docker exec, nsenter, cgroup memory limits, network namespace inspection — makes sense once you see the three primitives.
Debugging: a container has no internet but the host does. Diagnose from the host using namespace tools.
# The container is running but curl inside it fails:
# docker exec myapp curl https://example.com
# curl: (6) Could not resolve host: example.com
# Step 1: get the container's host PID:
CPID=$(docker inspect myapp --format '{{.State.Pid}}')
echo $CPID
# 8821
# Step 2: enter the container's network namespace and inspect from there:
sudo nsenter --net --target $CPID ip route
# default via 172.17.0.1 dev eth0
# 172.17.0.0/16 dev eth0 proto kernel
# Step 3: check if the default gateway is reachable:
sudo nsenter --net --target $CPID ping -c1 172.17.0.1
# PING 172.17.0.1 (172.17.0.1): 56 data bytes
# 64 bytes from 172.17.0.1: icmp_seq=0 ttl=64 time=0.15 ms
# Gateway is reachable — DNS is the suspect
# Step 4: check DNS resolution from inside the namespace:
sudo nsenter --net --target $CPID cat /etc/resolv.conf
# nameserver 127.0.0.11 ← Docker's embedded DNS resolver
sudo nsenter --net --target $CPID nslookup example.com 127.0.0.11
# ;; connection timed out; no servers could be reached
# Step 5: check ip_forward on the HOST:
sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 0 ← found it
# The host lost ip_forward (reboot without persistent sysctl).
# Docker's embedded DNS at 127.0.0.11 forwards upstream queries
# through the host's NAT — which requires ip_forward.
# Fix:
sudo sysctl -w net.ipv4.ip_forward=1
sudo tee /etc/sysctl.d/99-docker-forwarding.conf <<'EOF'
net.ipv4.ip_forward = 1
EOF
# Retry from the container:
# docker exec myapp curl https://example.com
# <!DOCTYPE html>... ← workingRoot cause: two lessons converge — ip_forward (sysctl lesson) lost on reboot, diagnosed using nsenter to enter the container’s network namespace directly from the host. This is the standard playbook for container networking problems.
▸Why this works
macOS has no Linux namespaces — Docker Desktop runs a Linux VM. When you run docker run on macOS, Docker Desktop starts a lightweight Linux VM (using Apple’s Hypervisor framework). The Linux kernel inside that VM provides the namespaces and cgroups. The docker CLI on macOS is just a remote client talking to the daemon inside the VM. This means: nsenter from the macOS host does not work (there are no Linux namespaces on macOS), /proc does not exist, and the sysctl parameters for Docker (net.ipv4.ip_forward, etc.) live inside the VM, not on the Mac. The Docker track works entirely with the VM; this distinction matters when debugging production Linux hosts vs. your local macOS dev environment.
▸Common mistake
Assuming user namespace root equals host root. When a container uses a user namespace (rootless containers, Podman’s default), UID 0 inside the container maps to an unprivileged UID on the host (e.g. UID 100000). A process that escapes the container’s mount namespace but stays in its user namespace has host-level UID 100000 — not root. This is the security value of user namespaces. However, many container runtimes (classic Docker) do NOT use user namespaces by default — UID 0 inside maps directly to UID 0 on the host. In those deployments, a container escape gives host root access. Always check whether your runtime uses user namespaces if security posture matters.
You run `sudo unshare --pid --fork --mount-proc /bin/bash` and inside you see only two processes when running `ps aux`. Meanwhile the host has 200 running processes. Which kernel mechanism produces this difference, and what would you add to also give this shell its own hostname separate from the host?
Linux containers are built from three kernel primitives with no additional magic. Namespaces give a process group an isolated view of seven global resources: pid (process IDs), net (network stack), mnt (mount table), uts (hostname), ipc (IPC objects), user (UID/GID mapping), and cgroup (cgroup hierarchy view). unshare creates a new namespace; nsenter enters an existing one — useful for debugging containers from the host without docker exec. cgroups v2 enforce resource limits on a process group via files in /sys/fs/cgroup/: memory.max, cpu.max, pids.max are exactly what Docker’s --memory, --cpus flags write. A root filesystem (an image layer stack mounted via overlayfs with pivot_root) completes the container. Every container runtime — Docker, containerd, Podman — is an API and lifecycle manager on top of these three primitives. The Docker track starts here: docker run calls clone() with namespace flags, writes cgroup files, and pivots into the image. There is nothing else underneath.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.