open atlas
↑ Back to track
Linux, the operating system LIN · 12 · 03

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.

LIN Senior ◷ 24 min
Level
FoundationsJuniorMiddleSenior

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.

Goal

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.

1

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.
NamespaceFlagWhat it isolates
pidCLONE_NEWPIDProcess ID space — PID 1 inside can be any process
netCLONE_NEWNETNetwork interfaces, routing table, iptables rules
mntCLONE_NEWNSMount table — what filesystems are visible
utsCLONE_NEWUTSHostname and NIS domain name
ipcCLONE_NEWIPCSystem V IPC (semaphores, message queues, shared memory)
userCLONE_NEWUSERUID/GID mapping — root inside maps to an unprivileged UID outside
cgroupCLONE_NEWCGROUPThe process’s view of the cgroup hierarchy
2

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 docker0

unshare 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.

3

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.

4

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.

5

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 namespace

This 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.

Worked example

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>...   ← working

Root 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.

Check yourself
Quiz

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?

Recap

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.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.