Building a container runtime from raw syscalls in Go
There is no such thing as a container in the Linux kernel. There are namespaces, cgroups, and a root filesystem you swap out from under a process. The only way I truly believed that was to build a runtime with nothing but the standard library and the syscalls themselves.
A container is not a thing
When you run docker run, it feels like you are starting a small, sealed machine. But the kernel has no concept of a “container.” What it has is a handful of unrelated isolation mechanisms — namespaces that hide part of the system, control groups that cap resources, and mount operations that replace the root filesystem — and a container is just what you call the result when you compose all of them around one process. Docker hides that composition behind a daemon. I wanted to remove the daemon and write each mechanism by hand, in Go, with zero third-party dependencies, so that nothing interesting was delegated to a library.
The rule about dependencies sounds like an aesthetic choice, but it is really a learning device. The moment you allow yourself to import a netlink library or shell out to ip, the part you were trying to understand disappears into someone else’s code. Forbidding that forces every hard edge to stay in view.
Six namespaces, all at once
The first thing the runtime does is launch the target process into six namespaces simultaneously — user, PID, mount, UTS, IPC, and network. Each one virtualizes a different slice of the system: the PID namespace makes the process believe it is PID 1, the UTS namespace lets it have its own hostname, the network namespace gives it its own empty stack, and so on. They are independent — you can create any subset — but a useful container wants them together, so they are combined into a single set of clone flags and applied in one shot.
What surprised me is how little “container” there is here. Six flags, ORed together. The difficulty is never the flags; it is everything you have to set up correctly inside each namespace before the isolation is actually real.
Why you re-exec your own binary
The natural instinct is to call clone() with the namespace flags right where you are. In Go you cannot, and the reason is a genuinely good lesson about the runtime. Go programs are multithreaded from the start, but creating namespaces — especially a user namespace — requires a single-threaded context. So the runtime uses the pattern the real ones use: the parent sets the clone flags in SysProcAttr and then re-executes its own binary with an init argument.
The child wakes up as a fresh process, already inside the new namespaces, and finishes the setup — mounts, hostname, loopback — before handing control to the real entrypoint. The first time you hit the “operation not permitted” error and work out why, the re-exec dance stops looking like a hack and starts looking inevitable.
Swapping the world with pivot_root
Isolating processes is pointless if they can still see the host’s files, so the runtime replaces the root filesystem. The obvious tool is chroot, and the obvious tool is the wrong one: chroot can be escaped through an open file descriptor and fchdir. The runtime uses pivot_root(2) instead, and the order of operations is the whole game.
First it makes the entire mount tree private with MS_REC | MS_PRIVATE, so nothing it does propagates back to the host. Then it bind-mounts the new root onto itself — pivot_root insists the new root be a mount point — pivots into it, and finally unmounts the old root with MNT_DETACH and removes it. Skip the private step and your container’s mounts leak straight into the host namespace. I know because I skipped it.
cgroups v2: the kernel filesystem is the API
Namespaces control what a process can see; cgroups control what it can consume. The runtime speaks cgroup v2 only — the unified hierarchy — with no systemd and no v1 fallback. It creates a group for the container under /sys/fs/cgroup, turns on the controllers it needs by writing to cgroup.subtree_control, and then sets limits by writing plain text to plain files: a CPU quota to cpu.max, a memory ceiling to memory.max, a process cap to pids.max.
There is no library and no abstraction — the kernel’s filesystem interface is the API, and using it directly is clarifying. The manager also reads back memory.current and the OOM-kill count from memory.events, and it deliberately refuses to delete a cgroup that still has live processes listed in cgroup.procs, because tearing down a group out from under running tasks is exactly the kind of quiet bug that a “probably fine” runtime ships.
Talking to the kernel by hand: rtnetlink
Inside a fresh network namespace, even the loopback interface starts down. Bringing it up is where the no-dependencies rule earned its keep. Configuring a link means talking to the kernel over netlink, and rather than import a library I built the message by hand: an RTM_NEWLINK request assembled byte by byte with unsafe for the struct sizing, sent over a raw AF_NETLINK socket, with the kernel’s NLMSG_ERROR acknowledgement read back and checked.
It is the most involved code in the project and the part I am most glad I didn’t outsource. Netlink is usually a black box behind ip link set lo up; writing the bytes myself turned it into something I actually understand. That is the entire argument for building from scratch, compressed into one file.
What works, and what is still scaffolding
I want to be precise about where this project stands, because a runtime that overstates its isolation is worse than one that is honest about its limits. The primitives are real: the six-namespace launch, the re-exec bootstrap, pivot_root isolation, the cgroup v2 manager, and the loopback netlink path all work, and they are covered by unit tests plus root-gated integration tests that assert a process really does get its own PID view, hostname, IPC, and mount tree.
The layers that would turn those primitives into a product — pulling and unpacking OCI images, overlay filesystems, bridge-and-veth networking between containers, and the full create/start/stop/delete lifecycle — are scaffolded behind typed interfaces and marked as not-yet-implemented in the code and in a status table in the docs. I also wrote six architecture decision records and a threat model, partly because the reasoning is the point of a project like this, and partly to keep myself honest about the difference between “isolated” and “looks isolated.”
The lesson
The thing that stayed with me is that isolation is not a feature you switch on; it is a sequence of independent kernel mechanisms that only add up to a container when every single one is configured correctly and in the right order. Forget the private mount propagation and pivot_root leaks. Forget to write the UID map and your container’s “root” is really nobody. Set the memory limit but never attach the process to the cgroup and nothing is actually capped. Every one of those is a one-line mistake with a system-sized consequence — and you only really learn to respect that by wiring the syscalls together yourself.
Read the Container Runtime case study or explore the source on GitHub ↗.