You probably type docker run every day. But have you ever wondered what the kernel is actually doing behind that one command?
A container has none of that virtual-machine hardware emulation, no separate kernel — it runs on the same ordinary Linux kernel as the host. So how does it manage to "look like a standalone machine"?
The answer is a combination of three things: namespaces + cgroups + a constrained filesystem. That's it.
That conclusion sounds too neat. So I decided to verify it by hand — writing a working container runtime in Rust from scratch, stripping away all of Docker's engineering packaging, to see how long the core path of a container really is.
The project is called capsule-box (command cb), code on GitHub. It's not production-grade, and it never will be. It's a learning project built to answer one question: what does Docker actually do?
What Is a Container, Really
One sentence: a container is an ordinary process whose field of view and resources have been carefully restricted.
container = process + restricted view (namespaces) + restricted resources (cgroups) + isolated filesystem (chroot/OverlayFS)
Break these three things apart —
Restricted view comes from namespaces. The PID namespace stops a process inside the container from seeing other processes on the host, the Mount namespace gives it its own mount points, the UTS namespace gives it its own hostname, and the Network namespace gives it its own network interfaces and routing table.
Restricted resources come from cgroups. They cap how much memory the process can consume, how much CPU it can use, how many child processes it can spawn.
Isolated filesystem comes from chroot or OverlayFS. It redirects the container's root directory / to an isolated directory, so the process believes it lives in a complete world of its own.
That is Docker's entire secret. Everything else — image formats, layered storage, Compose orchestration, image registries — is engineering packaging layered on top of these three things.
What capsule-box Does
capsule-box implements this whole mechanism end to end, plus a daemon/client architecture that wires up command dispatch and lifecycle management. The whole thing looks like this:
client
|
| Unix Socket: /run/cb/ipc.sock
v
daemon
|
+-- container manager container state, IP allocation, lifecycle
+-- sandbox namespaces / cgroups / OverlayFS / networking
+-- storage JSON metadata persistenceThe architecture is plain, but each block maps to a real responsibility of a container runtime. cb daemon runs in the background listening on a Unix Socket; cb run is a lightweight client that serializes the request into JSON and sends it over. This daemon/client split isn't arbitrary — Docker itself is a dockerd + docker CLI structure, and containerd is a daemon through and through. A container runtime naturally needs a persistent process to manage the state of a bunch of containers.
The First Piece: Process Isolation via Namespaces
A namespace is a "view boundary" the Linux kernel draws around a process. Processes inside the same namespace can see each other; those across namespaces cannot.
capsule-box uses four kinds of namespaces:
PID namespace — makes the container process become PID 1 in its own world. Without isolation, ps aux inside the container would see the host's systemd, nginx, your shell, everything. After isolation, the container only sees itself.
UTS namespace — an independent hostname. The container can call itself container-abc123 without affecting the host.
Mount namespace — an independent mount tree. The procfs the container mounts on /proc is only visible inside the container; it never pollutes the host.
Network namespace — independent network interfaces, routing tables, and iptables rules. This is the foundation of container network isolation.
There's a counterintuitive trap here: unshare(CLONE_NEWPID) creates a new PID namespace, but the caller's own PID doesn't change. You must fork again; only the newly born child will be PID 1 in that namespace. This is the so-called double fork.
Another thing: PID 1 is special in Linux. It is the "adoptive parent" of all orphaned processes, and by default it cannot be killed by SIGTERM. That's why many container images use tini or dumb-init as PID 1 — to properly reap zombie processes. capsule-box keeps this part fairly simplified, but the knowledge point itself is well worth remembering.
The Second Piece: Resource Limits via cgroups
Namespaces govern "what can be seen"; cgroups govern "how much can be used".
The cgroup v2 interface is dead simple — it's a virtual filesystem mounted at /sys/fs/cgroup/. You don't need to call any special API; configuring resource limits is just writing ordinary files:
# Create a cgroup (mkdir creates it)
mkdir /sys/fs/cgroup/cb/my_container
# Limit to 256M memory (writing a file configures it)
echo "268435456" > /sys/fs/cgroup/cb/my_container/memory.max
# Add a process to it (write the PID)
echo $PID > /sys/fs/cgroup/cb/my_container/cgroup.procsThat's it. No config file, no daemon, no RPC. Read and write files, done. A process that exceeds its memory limit gets SIGKILLed outright by the kernel's OOM Killer.
capsule-box supports syntax like cb run /bin/sh 256M, and also accepts 1G, 512K, and max (unlimited). It parses the string, converts it to bytes, and writes it into memory.max.
There's a subtlety to the timing of cgroup setup: the parent process must create the cgroup directory and set the limit before the fork, then immediately write the child's PID into cgroup.procs right after the fork. Otherwise the child runs "bare" — unconstrained — for those few milliseconds.
The Third Piece: Filesystem via OverlayFS
What happens if every container chroots into the same rootfs? Container A writes a file, and container B can see it; when container A exits, the rootfs is polluted and the next container gets dirty data.
OverlayFS solves this. Its approach is "copy-on-write" — three directories stacked into one view:
lowerdir (read-only) shared base image, used by all containers, never modified
upperdir (read-write) one layer per container
workdir internal OverlayFS use
↓ merged
merged (view) what the container sees as /On read, it checks upperdir first, then falls back to lowerdir; on write, it only writes to upperdir, leaving lowerdir untouched forever; on delete, it drops a whiteout marker in upperdir to mask the corresponding file in lowerdir.
This is the basis of Docker's layered images. Every layer you docker pull is essentially a lowerdir; multiple layers stacked together form the complete filesystem. capsule-box may not have image management, but this OverlayFS layer gets the "multiple containers sharing one base rootfs, each independently writable" job done.
The Hardest Piece: Networking
So far the container is still an "island" — after adding a Network namespace it has only a lo loopback interface. It can't reach the outside world, and the host can't reach it either.
To get the container online, you have to manually pull a "network cable". Specifically:
1. Create a veth pair — a virtual Ethernet cable, a network interface on each end. One stays on the host called veth0, the other goes into the container's network namespace called eth0.
2. Build a bridge — set up a virtual switch cb0 on the host and connect the host-side ends of all the veth pairs to it. Now multiple containers live in the same layer-2 network.
3. Assign IPs — configure an IP for each container's eth0 and the host-side veth, and set up routing.
4. Configure NAT — write iptables masquerade rules on the host to rewrite the source address of outbound packets from the container to the host IP. Also enable IP forwarding (net.ipv4.ip_forward).
5. Configure DNS — write a DNS server into the container's /etc/resolv.conf, otherwise name resolution won't work.
This entire flow is exactly what Docker's default bridge network mode does. When you tap docker run and the container just connects to the network, the kernel is actually doing all of this for you behind the scenes. capsule-box hand-writes every one of these steps — true manual transmission.
Interactive Terminal: PTY Forwarding
cb run -it /bin/sh 256M gives you an interactive shell, powered by PTY (pseudo-terminal).
The principle isn't complicated: the daemon allocates a PTY for the container process and forwards the client's stdin/stdout/stderr to this PTY over the Unix Socket. Every character you type travels client → Unix Socket → daemon → the container's PTY, and the container's output comes back the same way.
It sounds simple, but PTY window sizing, signal forwarding (Ctrl+C producing SIGINT), and terminal mode switching are all traps.
Lifecycle: From run to remove
From birth to death, a container requires capsule-box to maintain its full state:
cb run create cgroup → mount OverlayFS → fork+unshare → configure network → exec
cb list view all containers
cb stop stop a running container
cb remove clean up OverlayFS → delete cgroup → delete network interface → delete iptables → delete metadataThe hard part isn't "getting it running" — it's "cleaning up cleanly". Unmounting OverlayFS requires lazy umount (MNT_DETACH); deleting a network interface means first finding which namespace it's in; cleaning iptables rules means matching precisely without accidentally deleting the host's own rules. The "leftover containers" and "zombie network interfaces" that make Docker painful in production all stem from these cleanup routines not running to completion along various exception paths.
Why Rust
For systems programming, Rust has a few natural advantages:
Seamless C ABI interop. fork, unshare, mount, chroot are all libc system calls. Rust calls them via the nix crate — type-safe and zero-overhead. Writing in C invites memory bugs; writing in Go drags in runtime and GC baggage (forking a GC'd process is notoriously dangerous).
Clear unsafe boundaries. fork() is extremely dangerous in a multi-threaded environment — the child inherits all of the parent's locks, but the other threads vanish, so locks may never unlock. Rust forces you to annotate these operations with unsafe blocks, pushing you to exec immediately after fork and do nothing complex. That constraint is itself a reminder of correctness.
Async-friendly. The daemon uses tokio to handle multiple client connections and multiple container lifecycles at once; Rust's async is far more comfortable than hand-rolling epoll in systems programming scenarios.
What I Learned from This Project
Containers aren't mysterious at all. Once you personally unshare a PID namespace, write to memory.max once, and mount OverlayFS once, Docker's sprawling ecosystem stops being a black box. You know what each layer does, and when something breaks you know where to look.
Linux's design philosophy is elegant. cgroups are a virtual filesystem, namespaces are a flag on fork, networking is a pile of manually configurable virtual devices. Nothing is "magic" — it's all files and system calls.
Cleanup is ten times harder than creation. "Getting a container running" is maybe two hundred lines of code; "deleting a container cleanly and thoroughly" means handling countless edge cases.
Tutorials are the best way to learn. capsule-box ships with a 9-part, ~23-round tutorial series, each round introducing exactly one new concept. Writing the tutorials forced me to think through the "why" of every system call — a depth of understanding that writing code alone can't reach.
Try It
If you also want to truly understand container internals, I highly recommend starting from capsule-box's tutorial, reading through to round 23. You'll come away with a tiny container runtime that you wrote yourself and that actually runs.
"You can't truly understand a tool until you've built it yourself."
Next time you type docker run, I hope you know how much the kernel is doing for you behind that one line.
Login to Comment
You need to log in to leave a comment.
Login