How isolation works: namespaces & cgroups
The kernel features under every container.
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.
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.
$ 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)"
7c2e0b8f1a3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9fNS TYPE NPROCS PID USER COMMAND4026531834 time 214 1 root /sbin/init4026531837 user 214 1 root /sbin/init4026532648 mnt 3 8123 root nginx: master process4026532649 uts 3 8123 root nginx: master process4026532650 ipc 3 8123 root nginx: master process4026532651 pid 3 8123 root nginx: master process4026532652 cgroup 3 8123 root nginx: master process4026532653 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.
$ docker exec app ps -eo pid,comm
PID COMMAND1 nginx29 nginx30 nginx34 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.
$ docker exec app cat /proc/self/cgroup /sys/fs/cgroup/memory.max /sys/fs/cgroup/pids.max
0::/268435456100
/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.
$ docker stats --no-stream app
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS7c2e0b8f1a3d 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.
$ docker run --rm --network=container:app nicolaka/netshoot curl -s localhost:80 | head -n1
<!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.
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' worker
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.
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.
$ 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 active67108864$ docker run --rm alpine:3.20 ls /proc/1/nscgroup 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.
user and time show the same IDs as the host's /sbin/init. What does that tell you?--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?