Linux Namespaces and Cgroups Explained: How Containers Work
Learn how Linux namespaces and cgroups v2 isolate and limit containers, with hands-on unshare, nsenter and Docker examples, cgroup files explained, and security caveats.
Linux Namespaces and Cgroups: The Foundations of Container Isolation
A container is often described as a "lightweight virtual machine", but that description hides what is really going on. There is no separate kernel and no hardware emulation. A container is an ordinary Linux process that the kernel has been told to treat differently: it sees its own process list, its own network, its own filesystem and its own hostname, and it can use only a limited share of CPU, memory and other resources.
Two Linux kernel features make this possible. Namespaces control what a process can see. Control groups (cgroups) control how much a process can use. Docker, Podman, containerd and Kubernetes all build on these same two features.
In this guide you will learn how each namespace and each important cgroup controller works, try them by hand with unshare and nsenter, see exactly how Docker flags such as --memory and --cpus map to kernel files, and understand where the isolation stops. Examples are written for Docker Engine 29 on a modern Linux distribution with cgroup v2, as of October 2026.
Quick Answer
Figure 1: Namespaces control what a container sees; cgroups control what it can use
Containers Are Just Processes
Start a container and look for it from the host. You will find it in the host's ordinary process list:
docker run -d --name web nginx:1.29
ps -eo pid,user,cmd | grep "nginx: master"
Inside the container, the same Nginx process believes it is PID 1. On the host, it has an ordinary PID such as 48213. Nothing is emulated: the kernel simply presents a different view to that process (namespaces) and enforces limits on it (cgroups). Ask Docker for the host PID directly:
docker inspect -f "{{.State.Pid}}" web
We will use this PID throughout the guide to explore the container from the host.
Part 1: Linux Namespaces
A namespace wraps a global system resource so that processes inside the namespace get their own isolated instance of it. Processes in different namespaces of the same type cannot see each other's instance. Linux currently has eight namespace types:
Namespaces are created and joined with three system calls: clone() creates a new process in new namespaces, unshare() moves the calling process into new namespaces, and setns() joins an existing namespace. Container runtimes such as runc use exactly these calls.
PID Namespace: A Private Process Tree
In a new PID namespace, the first process gets PID 1 and sees only its own descendants. Processes inside cannot see or signal processes outside. The host, however, can see every container process, each with its host PID.
docker run --rm alpine ps
The output lists only ps itself as PID 1. Because the first process becomes PID 1, it inherits PID 1's special responsibilities: signal handling and reaping zombie processes. Our tutorial Graceful Shutdown in Docker: Signals, PID 1 and tini covers the consequences in detail.
Network Namespace: A Private Network Stack
A new network namespace starts with only a loopback interface. It has its own interfaces, IP addresses, routing table, firewall rules and port numbers, which is why two containers can both listen on port 80. Docker connects each container to a bridge network using a virtual Ethernet (veth) pair: one end inside the container as eth0, the other end attached to the docker0 bridge on the host.
docker run --rm --network none alpine ip addr
docker run --rm alpine ip addr
The first command shows only lo; the second also shows eth0 with a private address on the bridge network. For a full explanation, see our tutorial Docker Network Architecture.
Mount Namespace: A Private Filesystem View
A mount namespace gives the process its own list of mount points. Docker uses it to mount the image's layered filesystem as the container's root (/), plus volumes, bind mounts and special filesystems such as /proc and /dev. Mounts made inside the container do not appear on the host, and the host's mounts are not visible inside unless Docker deliberately mounts them.
UTS Namespace: A Private Hostname
UTS stands for "UNIX Time-sharing System", a historical name. It isolates the hostname, so each container can have its own:
docker run --rm --hostname api-01 alpine hostname
IPC Namespace: Private Shared Memory
The IPC namespace isolates System V shared memory, semaphores and message queues, and POSIX message queues. Processes in different containers therefore cannot accidentally (or deliberately) use each other's shared memory segments. Containers that genuinely need to share memory can opt in with --ipc container:<name> or --ipc shareable.
User Namespace: Root Inside, Unprivileged Outside
A user namespace maps user and group IDs inside the namespace to different IDs outside. A process can be UID 0 (root) inside while being an unprivileged UID on the host. If it escapes the container, it has no special power on the host.
Important: Docker does not enable user namespaces for containers by default. With a default installation, root in a container is UID 0 on the host, restricted only by capabilities, seccomp and other security features. Enable user namespaces with the daemon's userns-remap option, or run the whole daemon in rootless mode; see our tutorial Rootless Docker: Setup and Limitations.
Cgroup Namespace: A Private View of Cgroups
The cgroup namespace virtualises the view of /sys/fs/cgroup, so a container sees its own cgroup as the root of the hierarchy instead of the host's full tree and its paths. On cgroup v2 hosts, Docker gives each container a private cgroup namespace by default (--cgroupns private).
Time Namespace: Private Clock Offsets
The newest namespace lets a process see different values for the monotonic and boot-time clocks. It exists mainly for checkpoint and restore, so a migrated process does not see time jump backwards. It does not change the wall-clock time, and Docker does not use it by default.
How Docker Uses Namespaces by Default
Security: Every "host" option above removes a layer of isolation. --pid host lets a container see and signal every host process, and --network host gives it direct access to the host's network stack. Use them only for trusted system tools that genuinely need them, such as some monitoring agents.
Hands-On: Explore Namespaces
Create Namespaces Yourself With unshare
You can build a basic container-like environment with one command, without Docker. Run this on a Linux machine (not inside a container):
sudo unshare --pid --fork --mount-proc --uts --net --ipc bash
Inside the new shell, try these commands:
hostname demo-box && hostname # changes only in this UTS namespace
ps aux # bash is PID 1, nothing else is visible
ip link # only the loopback interface exists
Open another terminal: the host's hostname has not changed. Type exit to leave. You have just created PID, mount, UTS, network and IPC isolation by hand. This is the core of what a container runtime does, before it adds a root filesystem, cgroups and security settings.
Inspect a Container's Namespaces
Every process's namespaces appear as symbolic links in /proc/<pid>/ns/. Two processes are in the same namespace when the links point to the same inode number:
PID=$(docker inspect -f "{{.State.Pid}}" web)
sudo ls -l /proc/$PID/ns/
sudo ls -l /proc/1/ns/
Compare the numbers: the container's pid, net, mnt, uts, ipc and cgroup links differ from the host's (PID 1), while its user and time links are the same, confirming the defaults in the table above. The lsns command lists all namespaces on the system:
sudo lsns -p $PID
Enter a Container's Namespaces With nsenter
nsenter runs a command inside an existing process's namespaces. This is extremely useful for debugging minimal images that have no shell or networking tools, because you can use the host's tools inside the container's network namespace:
sudo nsenter -t $PID -n ip addr # container network, host tools
sudo nsenter -t $PID -n ss -tlnp # listening ports inside the container
sudo nsenter -t $PID -u hostname # container hostname
docker exec does the same thing internally: it starts a new process that joins all of the container's namespaces.
Part 2: Control Groups (Cgroups)
Namespaces isolate what a process sees, but they do not stop it from using all the memory or CPU on the machine. Control groups organise processes into a hierarchy of groups and let the kernel limit, prioritise and account for the resources each group uses.
Cgroups are managed through a special filesystem mounted at /sys/fs/cgroup. Each group is a directory, and each setting is a file. Writing a value to a file such as memory.max sets a limit; reading a file such as memory.current reports usage.
Cgroups v1 vs Cgroups v2
Ubuntu 21.10 and later, Debian 11 and later, RHEL 9 and later, and Fedora 31 and later use cgroup v2 by default. Check your system:
stat -fc %T /sys/fs/cgroup/
docker info --format "{{.CgroupVersion}} {{.CgroupDriver}}"
The first command prints cgroup2fs on cgroup v2. The second typically prints 2 systemd, meaning Docker uses cgroup v2 and lets systemd manage the container cgroups (the recommended driver).
The Important Cgroup v2 Controllers
How Docker Flags Map to Cgroup Files
When you pass a resource flag to docker run, Docker (through containerd and runc) writes the corresponding value into the container's cgroup:
cpu.max works as a quota per period: 150000 100000 means the container may use 150,000 microseconds of CPU time in every 100,000-microsecond period, which equals one and a half CPU cores. When it uses up its quota, it is throttled until the next period.
Docker defaults: Without resource flags, a container has no memory or CPU limit: it can use everything the host has. If you set --memory but not --memory-swap, the container may additionally use swap equal to its memory limit (if the host has swap). Always set explicit limits in production.
Hands-On: Watch Cgroups Work
Verify a Limit From Inside the Container
Because Docker gives each container a private cgroup namespace, the container sees its own cgroup at the root of /sys/fs/cgroup:
docker run --rm --memory 256m --cpus 0.5 --pids-limit 100 alpine \
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/pids.max
Expected output:
268435456
50000 100000
100
Find a Container's Cgroup From the Host
With the systemd cgroup driver, each container gets a systemd scope unit named after its full ID:
ID=$(docker inspect -f "{{.Id}}" web)
ls /sys/fs/cgroup/system.slice/docker-$ID.scope/
cat /sys/fs/cgroup/system.slice/docker-$ID.scope/memory.current
memory.current shows the container's current memory usage in bytes, which is where docker stats gets its numbers.
See the OOM Killer in Action
When a container exceeds memory.max, the kernel's out-of-memory (OOM) killer terminates a process inside that cgroup. Allocate more memory than the limit allows and watch what happens:
docker run --name oom-demo --memory 100m --memory-swap 100m python:3.13-slim \
python -c "data = bytearray(300 * 1024 * 1024)"
docker inspect -f "ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}" oom-demo
docker rm oom-demo
The output shows ExitCode=137 OOMKilled=true. Exit code 137 means the process was killed with SIGKILL (128 + 9), and OOMKilled=true confirms the cgroup memory limit was the cause. Setting --memory-swap equal to --memory disables swap, so the limit is hit immediately. For a full troubleshooting guide, see our tutorial How to Fix Docker Exit Code 137.
Watch CPU Throttling
docker run -d --name cpu-demo --cpus 0.5 alpine sh -c "while true; do :; done"
docker stats --no-stream cpu-demo
docker exec cpu-demo cat /sys/fs/cgroup/cpu.stat
docker rm -f cpu-demo
docker stats shows CPU usage capped at about 50%. In cpu.stat, the nr_throttled and throttled_usec values keep growing, showing how often and for how long the kernel paused the container because it had used up its quota.
Beyond Limits: Pressure Stall Information
Cgroup v2 provides Pressure Stall Information (PSI) in the files cpu.pressure, memory.pressure and io.pressure. They show the percentage of time processes in the cgroup were stalled waiting for that resource over the last 10, 60 and 300 seconds.
cat /sys/fs/cgroup/system.slice/docker-$ID.scope/memory.pressure
PSI is a far better early-warning signal than raw usage: a container at 90% of its memory limit with zero pressure is healthy, while one at 60% with rising memory pressure is already slowing down. Modern monitoring tools and Kubernetes increasingly use these metrics.
Namespaces and Cgroups Are Not the Whole Story
Namespaces and cgroups are the foundation of container isolation, but they are not a complete security boundary on their own. All containers share the host's kernel, so a kernel vulnerability can affect every container. Docker adds several more layers:
Warning about --privileged: The --privileged flag disables most of these protections: it grants all capabilities, disables seccomp and AppArmor confinement and gives access to host devices. A privileged container is only lightly separated from the host. Avoid it unless you fully trust the workload.
For workloads that need stronger isolation, such as untrusted multi-tenant code, sandboxed runtimes such as gVisor (which intercepts system calls in a user-space kernel) or Kata Containers (which runs each container in a lightweight virtual machine) add a further boundary while keeping the container workflow.
Namespaces and Cgroups in Kubernetes
Kubernetes uses the same kernel features, organised around pods:
All containers in a pod share the network namespace (they reach each other on localhost) and the IPC namespace. Sharing the PID namespace is optional (shareProcessNamespace: true).
Pod resource requests and limits become cgroup settings: limits.memory sets memory.max, limits.cpu sets cpu.max, and requests.cpu sets cpu.weight.
Pods with hostNetwork, hostPID or hostIPC set to true share the node's namespaces, with the same risks as Docker's host options.
Kubernetes supports user namespaces for pods (hostUsers: false), applying the same root-inside, unprivileged-outside mapping.
Our tutorials Kubernetes Resource Quotas and LimitRanges and Kubernetes SecurityContext Explained build on these foundations.
Quick Reference: Commands Used in This Guide
Frequently Asked Questions
What is the difference between namespaces and cgroups?
Namespaces control what a process can see: its process list, network, filesystem, hostname and so on. Cgroups control how much a process can use: CPU, memory, number of processes and I/O. Containers need both, because isolation without limits lets one container starve the rest, and limits without isolation let containers interfere with each other.
How many namespaces does Linux have?
Eight: mount, UTS, IPC, PID, network, user, cgroup and time. Docker creates new mount, UTS, IPC, PID, network and cgroup namespaces for each container by default; it does not use user or time namespaces by default.
Are containers as secure as virtual machines?
Not by default. Virtual machines each have their own kernel, while containers share the host kernel, so the attack surface is larger. Capabilities, seccomp, AppArmor or SELinux, user namespaces and rootless mode narrow the gap, and sandboxed runtimes such as gVisor or Kata Containers can close it further for untrusted workloads.
Does Docker still support cgroup v1?
Docker Engine 29 still runs on cgroup v1 hosts, but cgroup v1 support was deprecated in Docker Engine 29.0, with no removal date set yet. All current major Linux distributions default to cgroup v2, and new deployments should use it.
Why does my container show the host's total memory in free or top?
Tools such as free read /proc/meminfo, which is not namespaced and reports the host's memory. The real limit is in /sys/fs/cgroup/memory.max. Modern language runtimes (recent JVMs, Node.js and Go) read cgroup limits directly, but older software may size itself based on the host's memory and get OOM-killed.
What happens when a container reaches its CPU limit?
It is throttled, not killed. The kernel pauses the container's processes until the next scheduling period. This shows up as higher latency and growing nr_throttled values in cpu.stat, rather than as an error.
Conclusion and Next Steps
Every container you run is the combination of an ordinary Linux process, a set of namespaces that give it a private view of the system, and a cgroup that limits what it can consume. Once you understand these two features, a lot of Docker behaviour stops being mysterious: why the main process is PID 1, why two containers can use the same port, why free shows the wrong memory, and why a container exits with code 137.
Use /proc/<pid>/ns, nsenter and the files in /sys/fs/cgroup to look beneath Docker when debugging, always set explicit resource limits in production, and remember that namespaces and cgroups are the foundation of container security, not the whole building.
Continue the Docker course with these tutorials on BitCodeMatrix:
Docker Architecture Explained — how the client, daemon, containerd and runc use these kernel features
Container Isolation and Linux Namespaces — another walkthrough of namespace isolation
Rootless Docker: Setup and Limitations — user namespaces put into practice
Docker Security Fundamentals — capabilities, seccomp and the other layers on top