CoursesDocker in depthHow isolation works: namespaces & cgroups

How isolation works: namespaces & cgroups

The kernel features under every container.

Intermediate14 min · lesson 8 of 30

Go looking for containers in the Linux kernel source and you will not find any. There is no container object and no container mode. What actually runs is an ordinary process that the kernel has fenced in using two features: namespaces, and control groups, which everyone shortens to cgroups. runc, the low-level runtime Docker calls under the hood, starts a process, drops it into a fresh set of namespaces, hangs some cgroup limits on it, and gets out of the way. That fenced-in process, living under those two constraints, is the container. Once that clicks, both the way isolation holds and the way it fails stop being mysterious.

Two features, two jobs

The split is clean. Namespaces control what a process can see. cgroups control what it can use. Renting a room in a shared building is a fair picture of it. The namespaces are the walls and the frosted glass, so from inside, your room looks like the whole place and you never see or bump into the other tenants. The cgroups are the meter on the wall: you get a set amount of power and water, and drawing past your share cuts you off. Neither half is isolation on its own. Put them together and a process gets a private view of the machine while being stopped from starving everyone else on the host.

Diagram
Namespaces: what it can SEE
pid
own process tree; entrypoint is PID 1
net
private interfaces and ports
mnt
own filesystem view
uts / ipc
own hostname and IPC
cgroups: what it can USE
memory
hard cap; kernel kills it on breach
cpu
capped share of the cores
pids
max process count; stops fork bombs

Proving the private view

Namespaces come in types, one per kind of resource. A process with its own PID (process ID) namespace sees only its own process tree, and whatever started it sits at PID 1. Its network namespace hands it a private set of interfaces and ports. Its mount namespace gives it its own view of the filesystem. You do not have to take any of that on trust. The lsns command (list namespaces) prints the namespaces on the host, so you can start a container and watch the kernel hand it a fresh set.

host shell
$ docker run -d --name app --memory 256m --cpus 1.5 --pids-limit 100 nginx:1.27
$ sudo lsns -p "$(docker inspect -f '{{.State.Pid}}' app)"
output
7c2e0b8f1a3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f
NS TYPE NPROCS PID USER COMMAND
4026531834 time 214 1 root /sbin/init
4026531837 user 214 1 root /sbin/init
4026532648 mnt 3 8123 root nginx: master process
4026532649 uts 3 8123 root nginx: master process
4026532650 ipc 3 8123 root nginx: master process
4026532651 pid 3 8123 root nginx: master process
4026532652 cgroup 3 8123 root nginx: master process
4026532653 net 3 8123 root nginx: master process

Read the NS column, which is the numeric ID of each namespace. The mount, UTS (Unix timesharing system, the namespace that owns the hostname), IPC (inter-process communication), PID, cgroup and network namespaces all carry fresh IDs that belong to this container's processes alone. The time and user rows are the odd ones out. They show the same IDs as /sbin/init, the host's own PID 1. That is the default. Docker does not remap user IDs unless you switch the user namespace on, so root inside the container is host root wearing a costume. Park that fact somewhere you will remember it, because it decides what a container could do if it ever broke out.

Seen from the inside, the same container looks like a machine with nothing else running on it.

inside the container
$ docker exec app ps -eo pid,comm
output
PID COMMAND
1 nginx
29 nginx
30 nginx
34 ps

nginx is PID 1, and apart from nginx and the ps you ran to look, the list is empty. The hundreds of processes on the host do not appear. This is not a filter you could switch off to reveal them. From in here, they were never in the table to begin with.

cgroups: the ceiling

Namespaces gave the container its private view. cgroups decide how much of the real machine that view is allowed to eat, so one workload cannot drag the whole node down with it. Every limit you pass to docker run lands in the container's cgroup, and you can read those numbers straight back from inside.

inside the container
$ docker exec app cat /proc/self/cgroup /sys/fs/cgroup/memory.max /sys/fs/cgroup/pids.max
output
0::/
268435456
100

/proc/self/cgroup reads 0::/ because the container has a cgroup namespace of its own, so it sees itself sitting at the root rather than buried somewhere in the host's tree. memory.max is 268435456 bytes, which is exactly the 256 MiB (mebibytes; one MiB is 1,048,576 bytes) you asked for, and pids.max is the 100-process cap. Those are not Docker's numbers being read back to you for show. They are the kernel's own accounting files, and they are what actually gets enforced. docker stats reads the live side of the same counters.

host shell
$ docker stats --no-stream app
output
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
7c2e0b8f1a3d app 0.03% 9.6MiB / 256MiB 3.75% 1.2kB / 0B 0B / 0B 3

Sharing a namespace on purpose

Isolation is the default, but now and then you knock a hole in the wall deliberately. --network=container:app drops a second container into the first one's network namespace, so the two of them reach each other on localhost. That is the sidecar pattern, and it is how a pause container works in Kubernetes: one container owns the network and the rest borrow it.

host shell
$ docker run --rm --network=container:app nicolaka/netshoot curl -s localhost:80 | head -n1
output
<!DOCTYPE html>

The netshoot container runs no web server of its own, yet curling localhost on port 80 reaches nginx and gets the home page back. They share one network namespace, so localhost means the same interface to both of them. --pid=host is the same trick aimed at processes. It lets a debugging container see and signal every process on the host, which is a gift at two in the morning and a liability if someone leaves it set in production.

When the ceiling does its job

A container that keeps restarting with nothing useful in its logs is usually hitting its memory limit. The evidence sits in the container's state, not in the application's output.

host shell
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' worker
output
OOMKilled=true exit=137

OOMKilled=true is the kernel's OOM (out of memory) killer telling you the container went past its cap and got shut down. Exit 137 is 128 plus 9, and signal 9 is SIGKILL, the kill a process cannot catch, block, or clean up after. Read together, the two turn "it keeps dying" into one specific answer: raise --memory, or fix the leak. Nobody is guessing at that point.

--memory is a softer ceiling than it looks
Pass --memory 256m and Docker quietly grants the container a matching 256 MiB of swap on top, so it can reach 512 MiB in total before the kernel steps in. On any host with swap turned on, your "256m" container can sit at 400 MiB and you will swear the limit is broken. For a genuine hard cap, use --memory 256m --memory-swap 256m, which leaves swap no extra room.

Namespaces and cgroups, plus Linux capabilities (root's powers chopped into separate switches) and seccomp (secure computing mode, which filters the system calls a process may make), are the entire wall between a container and the kernel it shares with the host. The wall is real and it holds for normal workloads. It is also thinner than a virtual machine's, and every shared namespace or dropped limit shaves it thinner still. Build a container out of these pieces by hand once, and the escapes that abuse them stop looking like magic.

What isolation promises, and what it does not

The promise is narrower than most people assume. One kernel serves the host and every container on it, so a privilege bug in that kernel is a bug in all of them at once. A virtual machine gets its own kernel with a hypervisor underneath, which is why the two boundaries are not in the same weight class. --privileged is the quickest way to hand the promise back: one flag gives the container almost every capability, exposes the host's devices, and lifts the default seccomp and AppArmor (application armor, a Linux access control system) confinement. Capabilities, seccomp, AppArmor, SELinux (security-enhanced Linux) and user namespaces add gates on top of the two features here, and the advanced security course works through each of them. The mental model starts with vision versus budget.

When a container can see host processes (--pid=host) or the host's network stack (--network=host), you collapsed a namespace on purpose. Some monitoring agents genuinely need that. Treat it the way you would treat any raised privilege: written down, scoped to the workloads that need it, and obvious in a review. Missing limits fail more quietly. One Java service with no memory limit can push a node into OOM kills that land on other containers and look random for a week. Put explicit memory and CPU limits on every service definition, including the small ones nobody worries about.

If you change an isolation setting on a running system, leave a short trail. Record which flag you added or removed, the container and the host it ran on, the docker inspect output before and after, and the one command that puts it back. "Added --pid=host to the agent on node 7 on Tuesday; drop the flag and recreate the container to undo it" is the whole note. It takes a minute, and it is what stops a namespace you opened for one afternoon of debugging from quietly living in production for a year.

Try this

Run these on a lab engine; Docker 24 or newer is fine. Read the sample output first so you know what a healthy result looks like before you lean on the command anywhere that matters.

terminal
$ docker run --rm --memory=64m --cpus=0.5 alpine:3.20 sh -c 'echo cgroup limits active; cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes'
cgroup limits active
67108864
$ docker run --rm alpine:3.20 ls /proc/1/ns
cgroup ipc mnt net pid user uts
# STATUS: READY — namespaces present; memory.max shows the 64MiB cap

Takeaway

Containers are namespaced, cgrouped processes sharing the host's kernel, so treat every shared namespace as a privilege you handed out and every missing limit as an outage waiting for a busy afternoon. When something looks isolated, go and check: lsns tells you which namespaces are genuinely fresh, and the files under /sys/fs/cgroup tell you the numbers the kernel will really enforce.

Quick check
01A container keeps restarting, and docker inspect reports OOMKilled=true with exit code 137. What happened to it?
Incorrect — Exit 137 is not an application crash. It is 128 plus signal 9 (SIGKILL), a kill the process cannot catch, and OOMKilled=true says the kernel sent it rather than the app falling over.
Correct — OOMKilled=true is the memory controller enforcing the cap, and 137 is 128 plus SIGKILL (9). Raise --memory or fix the leak.
Incorrect — Disk pressure shows up as write errors and 'no space left on device'. It does not set OOMKilled or produce exit 137.
Incorrect — A daemon restart would not set OOMKilled or give you exit 137. That field is written only when the memory controller kills the process.
02In the lsns output, mnt, uts, ipc, pid, cgroup and net all carry fresh IDs, but user and time show the same IDs as the host's /sbin/init. What does that tell you?
Incorrect — No. Sharing the user and time namespaces is the normal default, not a sign that anything failed to start.
Incorrect — No. user and time are namespaces, which govern what a process can see. cgroup controllers govern what it can use.
Incorrect — It works the other way round. The user namespace is off by default, which is exactly why container root maps to host root.
Correct — With no user namespace, UID 0 inside the container is UID 0 outside it, so an escape keeps root.
03You start a container with --memory 256m on a host that has swap enabled, and docker stats shows it sitting happily at 400 MiB. Why is it still alive, and how do you get a true 256 MiB hard cap?
Incorrect — No. docker stats reads the kernel's own counters, so the extra usage is real, and swap is what explains it.
Correct — --memory on its own comes with an equal swap allowance, and pinning --memory-swap to the same value takes it away.
Incorrect — No. The memory limit applies from the first byte. There is no warm-up grace period.
Incorrect — No. --cpus caps CPU time and has nothing to do with whether the memory limit works.

Related