Skip to content

Namespaces and cgroups🔗

Part of a deep dive and a pathway: System Isolation How Modern Software Really Runs on a CPU

Consult the map

Prerequisites

This article goes one layer beneath Processes. There you learned how to list and control running programs. Here you learn what the kernel uses to decide what each of those programs is allowed to see and use: the same two mechanisms that, stacked together, make a container a container.

Put two processes on the same machine. The first runs ps aux and sees every other process on the box, every mounted filesystem, every network interface. The second runs the same command and sees four processes, one filesystem, and a network interface it doesn't recognize. Same kernel, same hardware, no virtual machine anywhere.

The difference is two kernel features almost everyone uses without ever naming them: namespaces and cgroups, usually encountered secondhand through Docker, Podman, or systemd. That secondhand exposure is exactly where "a container is a lightweight VM" comes from: a plausible mental model that's wrong precisely because it skips these two primitives.

Isolation on Linux is two independent questions, answered by two different mechanisms:

  • What can this process see? Namespaces.
  • How much can this process consume? cgroups.

A process can sit in its own namespace with no resource limits, or be capped by a cgroup while sharing the host's full view of the system — they don't come bundled. Each gets its own walkthrough below, then what happens once you combine them.


Where You Might Have Seen This🔗

If you've ever run docker run -m 512m or set a memory limit on a Kubernetes Pod, you've configured a cgroup without knowing its name; if you've watched a container show its own hostname and private process list, you've watched namespaces at work. This article is the Linux machinery underneath those tools — the same primitives, without the wrapper.


Namespaces: Controlling What a Process Can See🔗

A namespace is a wrapper around a global system resource that makes the processes inside it believe they have their own private copy. The processes still run on the same kernel. They just get shown a curated view of the world.

Linux has eight namespace types, each covering one category of "what you can see":

Namespace What it isolates The illusion it creates
mount (mnt) The set of mounted filesystems Its own root filesystem and mount table
PID Process IDs Its own PID 1 and process tree
network (net) Interfaces, routes, firewall rules, ports Its own network stack
UTS Hostname and domain name Its own hostname
IPC Shared memory, semaphores, message queues Its own IPC objects
user User and group ID mappings Root inside, unprivileged outside
cgroup The cgroup hierarchy root Its own view of the resource tree
time The boot and monotonic clocks Its own uptime offset

Every process is in a namespace of each type. On a normal system they're all in the same namespaces, the host's, which is why everything can see everything. Create a new namespace and move a process into it, and that process's view narrows to whatever that namespace contains.

Seeing the namespaces a process is in🔗

Each process exposes its namespace membership under /proc:

A Process's Namespace Membership
ls -l /proc/self/ns/    # (1)!
  1. Output: one symlink per namespace type, each pointing at an inode like net:[4026531840]. Two processes with the same inode number share that namespace; different numbers mean they're isolated from each other on that axis.

Running ls -l /proc/self/ns/ to list this process's namespace membership, one symlink per namespace type

The lsns command reads the same information for the whole system and groups it:

List Every Network Namespace
lsns --type net    # (1)!
  1. Lists every network namespace, its inode, how many processes are in it, and the command that owns it. On a plain server you'll see one. On a host running containers you'll see one per container.

Creating one yourself🔗

You don't need any container tooling to make a namespace; unshare does it directly. This creates a new PID and mount namespace, then starts a shell inside them:

Enter a New PID + Mount Namespace
sudo unshare --pid --fork --mount-proc bash    # (1)!
  1. --pid gives a new PID namespace, --fork runs the shell as a child inside it (the namespace needs a PID 1), and --mount-proc remounts /proc so ps reflects the new view rather than the host's.

Inside that shell, run ps aux:

Inside the New Namespace
ps aux    # (1)!
  1. You'll see two processes: bash as PID 1 and the ps command itself. The hundreds of processes running on the host are still there; this shell simply can't see them, because a PID namespace only shows processes created within it.

That is the whole trick. Nothing was virtualised, nothing was copied. The kernel is scheduling the same processes on the same CPUs; it's just showing this shell a filtered process table. Exit the shell and the namespace is torn down, because the last process in it is gone.

The user namespace earns a special mention🔗

Most namespaces hide things. The user namespace does something more interesting: it remaps identity. A process can be UID 0, root, inside its user namespace while mapping to an ordinary unprivileged UID on the host. Root inside, nobody in particular outside.

This is the single most important namespace for security, and it's the foundation of rootless containers. A process that believes it's root, can act like root against its own isolated resources, but holds no real privilege on the host, has a dramatically smaller blast radius if it's compromised. The privilege half of that story (what "acting like root" is actually made of) is Linux capabilities, which we cover next.


cgroups: Controlling What a Process Can Consume🔗

Namespaces answer "what can you see." They say nothing about "how much can you take." A process alone in every namespace can still consume all the CPU and every byte of RAM on the machine, starving everything else. That's the job of the second mechanism.

Control groups, or cgroups, organise processes into a hierarchy and enforce resource limits on each group. Where a namespace hides resources, a cgroup meters them. The two are orthogonal, which is exactly why they're separate features.

Modern systems use cgroup v2, a single unified hierarchy mounted at /sys/fs/cgroup. It's a real filesystem: directories are groups, and the files inside them are the knobs and gauges.

Available cgroup Controllers
cat /sys/fs/cgroup/cgroup.controllers    # (1)!
  1. Output: the available controllers, typically cpu io memory pids and a few more. Each controller governs one class of resource.

The controllers you'll reach for most:

  • memory — cap total memory with memory.max; the group is OOM-killed if it exceeds a hard limit.
  • cpu — set a ceiling with cpu.max (a quota-per-period pair) or a relative share with cpu.weight.
  • pids — cap the number of processes with pids.max, which stops a fork bomb from taking down the host.
  • io — throttle block-device bandwidth per group.

systemd is already doing this🔗

You almost never edit /sys/fs/cgroup by hand, because on any modern distribution systemd is the cgroup manager. Every service systemd starts gets its own cgroup automatically. This is why cgroups and services are so tightly linked — managing one is managing the other.

Inspect systemd's cgroup Tree
systemctl status sshd    # (1)!
systemd-cgls             # (2)!
systemd-cgtop            # (3)!
  1. Look for the CGroup: line in the output; it names the exact cgroup path this service's processes live in.
  2. Prints the full cgroup tree as a hierarchy, so you can see how systemd has grouped every service and user session.
  3. Live resource usage per cgroup, like top but grouped by service rather than by process.

Setting a limit on a service is a one-liner, and systemd writes the cgroup file for you:

Cap a Service's Memory
sudo systemctl set-property sshd.service MemoryMax=512M    # (1)!
  1. From now on the sshd service and everything it spawns are capped at 512 MB. Exceed it and the group is reclaimed or OOM-killed, but the rest of the system stays healthy. The equivalent by hand would be writing 536870912 into that group's memory.max.

Watching a limit bite🔗

You can prove the limit is real without any special tooling. Run a deliberately hungry command inside a temporary scope with a tight memory cap:

Enforce a Memory Cap on a One-Off Command
sudo systemd-run --scope -p MemoryMax=50M \
  bash -c 'a=(); while true; do a+=($(seq 1 100000)); done'    # (1)!
  1. The shell tries to grow an array without bound. Instead of consuming all system memory and dragging the whole box into swap, it hits the 50 MB ceiling and is OOM-killed, and only it is killed. That containment is the entire value proposition: one misbehaving workload can't take the others down with it.

Putting Them Together🔗

Now the two questions have two answers, and they compose cleanly:

graph TD
    P["A process"]
    NS["Namespaces\nwhat it can see"]
    CG["cgroups\nhow much it can use"]
    CAP["Capabilities\nwhat it's allowed to do"]

    P --> NS
    P --> CG
    P --> CAP

    NS --> V["Own PID tree, filesystem,\nnetwork, hostname"]
    CG --> R["Capped CPU, memory,\nprocess count, I/O"]
    CAP --> A["Least privilege,\nnot all-or-nothing root"]

Take an ordinary process. Give it its own namespaces so it sees a private filesystem, process tree, and network. Put it in a cgroup so it can't starve its neighbours. Strip it down to only the privileges it genuinely needs. What you've just described, feature by feature, is a container. There is no additional "container" object in the kernel — the word is shorthand for a process wearing this specific combination of namespaces, cgroups, and capabilities. Docker and Podman are automation that sets all of this up in one command; the isolation itself is pure Linux, exactly what you exercised by hand above. Exploring Containers picks up from exactly this point — What Is a Container, Really? covers the same idea from the container-tooling side, without repeating the kernel mechanics you just did by hand here.

Seeing it this way retires a few persistent myths. A container is not a lightweight VM: there's no second kernel, no emulated hardware, just a filtered view and a resource cap. "It works on my machine but not in the container" is almost always a namespace difference (a different mount view, a different network stack), not sorcery. And a container that "ate all the RAM" was a container whose cgroup limits were never set.


Common Pitfalls🔗

Where isolation surprises people

  • A PID namespace needs a PID 1. If the process you run as PID 1 doesn't reap its children, zombie processes pile up inside the namespace. This is why container images often need a lightweight init.
  • cgroup v1 and v2 are different worlds. Older material describes v1's multiple separate hierarchies. Almost everything current is v2's single unified hierarchy. Check with stat -fc %T /sys/fs/cgroupcgroup2fs means v2.
  • Namespaces don't imply limits. A process can be fully namespace-isolated and still consume the whole machine. Visibility and consumption are separate; you need cgroups for the second.
  • Editing /sys/fs/cgroup by hand fights systemd. systemd owns the hierarchy on modern systems. Use systemctl set-property or a drop-in unit so your changes survive and don't get reverted.
  • Stopping a container isn't removing it. docker stop ends the process and tears down its namespaces (a namespace only exists while something references it), but the container's writable layer and metadata stay on disk — docker start resumes right where it left off. docker rm is the actual deletion: only then does the writable layer go away, unless a volume was backing it, which survives even that.

Quick Reference🔗

Task Command
List a process's namespaces ls -l /proc/<pid>/ns/
List all namespaces of a type lsns --type net
Enter a new PID + mount namespace sudo unshare --pid --fork --mount-proc bash
Show available cgroup controllers cat /sys/fs/cgroup/cgroup.controllers
View the cgroup tree systemd-cgls
Live per-cgroup resource usage systemd-cgtop
Cap a service's memory systemctl set-property NAME.service MemoryMax=512M
Run a one-off command in a capped scope systemd-run --scope -p MemoryMax=50M CMD
Check cgroup version stat -fc %T /sys/fs/cgroup

Practice Exercises🔗

Exercise 1: Watch a PID namespace lie to you

Enter a new PID namespace, note the PID your shell reports, then find that same shell's real PID from the host. What accounts for the two different numbers?

Solution
Compare the Two Views
sudo unshare --pid --fork --mount-proc bash    # (1)!
ps aux                                          # (2)!
# (from another terminal, on the host)
ps aux | grep bash                              # (3)!
  1. Start a shell in a fresh PID namespace.
  2. Inside it, your shell is PID 1 and the table is nearly empty.
  3. On the host, the same shell has an ordinary high PID among hundreds of others.

Explanation: It's one process, shown through two namespaces. A PID namespace only displays processes created within it, so inside it your shell is the first (and nearly only) process; the host sees the full table it's really part of.

Exercise 2: Prove a cgroup limit is enforced

Run a memory-hungry command inside a scope capped at 50 MB while watching per-cgroup usage in another terminal. Confirm that only that scope is affected when it hits the ceiling.

Solution
Cap and Observe
sudo systemd-run --scope -p MemoryMax=50M \
  bash -c 'a=(); while true; do a+=($(seq 1 100000)); done'    # (1)!
systemd-cgtop                                                   # (2)!
  1. Runs the memory-eater inside a 50 MB scope.
  2. In a second terminal, watch usage climb to the cap and get reclaimed.

Explanation: The scope's usage rises to roughly 50 MB and the process is OOM-killed, while every other cgroup is untouched. The limit contained the damage to exactly one group.


What's Next🔗

You've covered two of the three isolation primitives: what a process can see (namespaces) and how much it can use (cgroups). The third is what a process is allowed to do: the difference between a container that runs as harmless "root" and one that can compromise the host. Head to Linux Capabilities: Breaking Up Root to learn how the kernel splits the single all-powerful root account into around forty independent privileges you can grant one at a time.

To see these two mechanisms applied rather than explained in the abstract: What Is a Container, Really? picks up exactly here.

If you're following the How Modern Software Really Runs on a CPU pathway on bradpenney.io, the next step is Swap and the OOM Killer: what happens when a cgroup's memory limit isn't enough, and the kernel has to reclaim RAM for real.


Further Reading🔗

Command References🔗

  • man 7 namespaces — the authoritative overview of all namespace types
  • man 1 unshare and man 1 lsns — creating and inspecting namespaces
  • man 5 systemd.resource-control — every cgroup property systemd exposes (MemoryMax, CPUQuota, TasksMax, and more)

Deep Dives🔗

Official Documentation🔗