A container is a lie the kernel tells a process
There is no such thing as a container. That's the fact that took me the longest to accept. The kernel has no container object, no struct container, no container system call. What you call a container is an ordinary Linux process that has been lied to about the world. It thinks it's PID 1. It thinks it's alone. It thinks a small directory is the whole filesystem. Every one of those beliefs is a kernel feature you can turn on by hand.
In part one we built the filesystem from tar layers, and in part two we pulled it over HTTP. Now we run something inside it. This is the half py-docker does with Linux syscalls, and it's the half that can't run in a browser, because it is the kernel. So instead of simulating, I ran every command below inside a real privileged Linux container and captured the output. Where a figure shows a result, that result actually happened.
01 · Namespaces: what a process is allowed to see
The first lie is about visibility. A namespace wraps one kind of global resource, process IDs, mount points, network interfaces, the hostname, and gives a group of processes their own private copy. There are eight kinds, and a container is a process that has been placed inside several of them at once.
The clearest one to feel is the PID namespace. Toggle it below. On the left is the list of namespaces, hover any of them; on the right, watch what ps sees before and after the process enters a fresh PID namespace:
Before, the shell is one process among several. After unshare --fork --pid, the shell is PID 1, and ps shows nothing but itself. Same processes still running on the host, same kernel, but this process can no longer see them. Its view of "every process on the system" now contains one entry. The eight namespaces are listed as files under /proc/self/ns/, one symlink each, and two processes are in the same namespace exactly when those symlinks point at the same inode. That's the whole mechanism.
py-docker creates exactly one of these, the PID namespace, with a single line: libc.unshare(CLONE_NEWPID). That flag is the constant 0x20000000, and calling unshare with it is all it takes to get a private PID space. One syscall, one lie.
02 · Changing what "/" means
The second lie is about the filesystem. A container shouldn't see the host's root directory, it should see the image we unpacked. There are two ways to do this, and the difference between them is the difference between a demo and real isolation.
chroot is the old, simple one: it points / at a new directory. py-docker uses it. But chroot has a famous weakness, and the middle tab of the figure reproduces it. The catch is that chroot moves the root but not the working directory, and Linux never stops a root process from chdir-ing above its own root. So a process that gains root inside the jail can make a subdirectory, chroot into that (nesting the root one level deeper so its cwd is now above the root), then chdir("..") its way out to the real filesystem and chroot(".") there. I ran exactly that: ls / flips from a single lonely file to the whole host root, and the process reads /etc/hostname, a file the jail never contained. That's why chroot needs the CAP_SYS_CHROOT capability and why it is a fence, not a wall. pivot_root is what real runtimes use. It swaps the root mount and then unmounts the old one, so afterward there is no path, no descriptor, no mount that names anything outside the new root. In my capture, ls /oldroot after the unmount returns nothing: the host filesystem is simply gone from this process's universe. py-docker leaves pivot_root as a TODO in its source, which is honest, and a good marker of where a toy ends and runc begins.
03 · overlayfs: the layers, stacked by the kernel
Remember stacking tar layers by hand in part one? The kernel does that for real, continuously, with a filesystem called overlayfs. You give it a stack of read-only lower directories (the image layers) and one writable upper directory (the container's own scratch space), and it presents a single merged view. Reads fall through the stack top to bottom; writes land in the upper layer, leaving the shared image layers untouched.
Toggle the delete below and watch how overlayfs handles a removal:
The merged view shows greeting.txt as "layer2" because the upper lower shadows the one beneath it, and a.txt straight from the base. Now delete a.txt through the merged view. The kernel can't modify the read-only lowers, so it writes a whiteout into the upper layer, a character-device node with major and minor number 0:0, and the merged view stops showing the file. The base layer still has a.txt, untouched, because a thousand other containers might share it.
This is the same idea as the tar whiteout from part one, with a different mechanism, and the contrast is the fun part. An OCI layer on disk marks a deletion with a file named .wh.a.txt. The live kernel overlay marks it with a 0:0 device node. Two whiteout conventions for the same job, one for shipping layers around, one for running them.
04 · cgroups: how much a process may use
Namespaces control what a process can see. They say nothing about how much it can consume: a process in its own PID namespace can still eat all the RAM on the machine. That's the job of cgroups, the control groups that cap memory, CPU, and IO.
On cgroup v2, you make a directory under /sys/fs/cgroup, write a number into memory.max, and move a process in by writing its PID to cgroup.procs. From then on the kernel enforces the cap. There's a small, honest detail in the capture worth pointing at: I asked for a limit of 10,000,000 bytes and the kernel stored 9,998,336. It doesn't track bytes, it tracks whole memory pages, so it rounded my number down to the nearest 4096. The number you set is not always the number you get, and now you've seen why. py-docker sets no cgroups at all, so its "container" has no resource limits whatsoever.
05 · The whole fence, and what a real one adds
So a container is a process with several lies layered on: a private PID namespace so it thinks it's alone, a pivoted root so a directory looks like the whole disk, an overlay mount so the image layers stack read-only under a writable top, and a cgroup so it can't starve its neighbors. None of these is "containment" by itself. Stacked together, they are what we call a container.
Here's the honest accounting of what py-docker's 240 lines actually build, next to what runc adds to make something you'd trust in production:
py-docker is a wonderful teaching object precisely because it stops early. It does a chroot and one namespace, runs your command, and that's the demo. runc does pivot_root, all the namespaces, cgroups, the overlay mount, seccomp filters, and dropped capabilities, because the gap between "looks isolated" and "is isolated" is exactly that list. But the core idea, the thing that makes the whole edifice comprehensible, is the one you just watched go up syscall by syscall: a container is not a box the kernel gives you. It's a process the kernel has agreed to fool.
That closes the series. We started with an image as tar files named by digests, the same content-addressing idea as git; we pulled it over HTTP the way we read a git clone byte by byte; and we ran it by convincing one process it was alone in the world. No magic at any layer, just files, hashes, HTTP, and a handful of syscalls you can run yourself.