Chapter 80 -- A container runtime in 150 lines of Go
In one sentence
A container is a process started with new namespaces (its own hostname, PID numbering, mounts, IPC, and network), a different root filesystem, and a cgroup that limits its resources. A short Go program can do all of it.
Why this matters
Chapter 44 said "a container is namespaces + cgroups + a filesystem". Building
one makes that concrete, and it explains real behaviour: why PID 1 inside a
container is special (Chapter 74), why a container's localhost isn't the
host's, why ps inside shows so little, and why fork bombs are contained. This
lab follows the well-known "containers from scratch" exercise popularised by
Liz Rice.
How it actually works
run (parent, on the host)
├─ mkdir /sys/fs/cgroup/minicontainer-<pid>; write pids.max, memory.max
└─ re-exec /proc/self/exe "child" with:
Cloneflags: NEWUTS | NEWPID | NEWNS | NEWIPC | NEWNET
CgroupFD: the new cgroup (Go 1.20+: born inside it, no race)
child (PID 1 in its own PID namespace)
├─ sethostname("minicontainer")
├─ chroot(rootfs); chdir("/")
├─ mount proc on /proc (so ps sees only this namespace)
└─ run the user's command
Real runtimes (runc, crun) add pivot_root instead of chroot (which can be
escaped by a root process), user namespaces, seccomp filters, capability
dropping, a network setup (veth pair + bridge, as in the
TCP/IP guide's Chapter 52), and OCI image unpacking.
Build it in Go (45 min) -- minicontainer
//go:build linux
// minicontainer: a container runtime in ~150 lines. Namespaces give the
// process its own view of the system; chroot gives it its own filesystem;
// a cgroup limits what it can consume. That's all a container is.
//
// Run as root on a Linux machine/VM (cgroup v2), with a root filesystem:
//
// mkdir -p /tmp/rootfs && docker export $(docker create busybox) | tar -x -C /tmp/rootfs
// go build -o minicontainer ./minicontainer
// sudo ./minicontainer run /tmp/rootfs /bin/sh
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("usage: minicontainer run <rootfs> <command> [args...]")
os.Exit(2)
}
switch os.Args[1] {
case "run":
run(os.Args[2], os.Args[3:])
case "child":
child(os.Args[2], os.Args[3:])
}
}
// run is the "runtime": it re-executes this same binary as `child` inside new
// namespaces and a new cgroup, and waits for it.
func run(rootfs string, argv []string) {
cg, err := makeCgroup()
must(err)
defer os.Remove(cg) // rmdir: only works once every process in it has exited
cgfd, err := syscall.Open(cg, syscall.O_DIRECTORY|syscall.O_RDONLY, 0)
must(err)
defer syscall.Close(cgfd)
cmd := exec.Command("/proc/self/exe", append([]string{"child", rootfs}, argv...)...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | // own hostname
syscall.CLONE_NEWPID | // own PID numbering: the child is PID 1
syscall.CLONE_NEWNS | // own mount table
syscall.CLONE_NEWIPC | // own System V IPC / message queues
syscall.CLONE_NEWNET, // own network stack: only a loopback, and it's down
Unshareflags: syscall.CLONE_NEWNS, // don't propagate our mounts back to the host
UseCgroupFD: true, // Go 1.20+: start the child directly inside the cgroup
CgroupFD: cgfd,
}
if err := cmd.Run(); err != nil {
fmt.Println("container exited:", err)
}
if b, err := os.ReadFile(filepath.Join(cg, "pids.events")); err == nil {
fmt.Printf("cgroup pids.events: %s", b) // "max N" = N forks refused by the limit
}
}
// child runs INSIDE the new namespaces, as PID 1 of its own PID namespace.
func child(rootfs string, argv []string) {
must(syscall.Sethostname([]byte("minicontainer")))
must(syscall.Chroot(rootfs)) // production runtimes use pivot_root, which can't be escaped like chroot
must(os.Chdir("/"))
must(syscall.Mount("proc", "/proc", "proc", 0, "")) // a /proc that only shows OUR PID namespace
defer syscall.Unmount("/proc", 0)
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Env = []string{"PATH=/bin:/usr/bin:/sbin:/usr/sbin", "PS1=container# "}
if err := cmd.Run(); err != nil {
os.Exit(1)
}
}
// makeCgroup creates a cgroup v2 group with a process limit (a fork-bomb
// guard) and a memory limit. Requires root and the pids + memory controllers
// enabled in the parent's cgroup.subtree_control.
func makeCgroup() (string, error) {
cg := filepath.Join("/sys/fs/cgroup", "minicontainer-"+strconv.Itoa(os.Getpid()))
if err := os.Mkdir(cg, 0o755); err != nil {
return "", fmt.Errorf("create cgroup (are you root, on cgroup v2?): %w", err)
}
for file, value := range map[string]string{
"pids.max": "20", // at most 20 processes
"memory.max": "64000000", // 64 MB
} {
if err := os.WriteFile(filepath.Join(cg, file), []byte(value), 0o644); err != nil {
os.Remove(cg)
return "", fmt.Errorf("%s: %w (enable the controller in the parent's cgroup.subtree_control)", file, err)
}
}
return cg, nil
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Run it as root on a Linux VM, or inside a privileged container as below:
mkdir -p rootfs && docker export $(docker create busybox) | tar -x -C rootfs
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/minicontainer ./minicontainer
docker run --rm -it --privileged --cgroupns=private -v "$PWD":/w debian:bookworm sh -c '
mkdir /sys/fs/cgroup/init && echo $$ > /sys/fs/cgroup/init/cgroup.procs # cgroup v2: move ourselves
echo "+pids +memory" > /sys/fs/cgroup/cgroup.subtree_control # into a leaf, enable controllers
/w/bin/minicontainer run /w/rootfs /bin/sh'
(The two cgroup lines are needed only inside a container. cgroup v2 doesn't
let a group both contain processes and delegate controllers, so the shell moves
itself into a leaf group first. On a systemd host, run as root and skip them.)
Real output from a scripted run:
== host view: hostname=3b127053433e pid=1
inside: hostname=minicontainer my-pid=7
PID USER TIME COMMAND
1 root 0:00 /proc/self/exe child /b/rootfs /bin/sh -c ...
7 root 0:00 /bin/sh -c ...
9 root 0:00 ps
1: lo: <LOOPBACK> mtu 65536 qdisc noop qlen 1000
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop qlen 1000
... (every interface DOWN: "noop")
fork test:
/bin/sh: line 0: can't fork: Resource temporarily unavailable
cgroup pids.events: max 1
What to notice:
- Our
childis PID 1, andpssees only three processes, because the freshly mounted/procbelongs to the new PID namespace. - The new network namespace has only a loopback, and it's down. (The
tunl0,gre0... devices appear automatically in every network namespace when those tunnel modules are loaded on the host, also down.) The container has no network until something creates a veth pair for it: Docker's job, built by hand in the TCP/IP guide. - The fork bomb stopped at 20 processes, and
pids.eventsrecorded it. That one line is why a container can't take down its host by forking. UseCgroupFD/CgroupFDstart the child directly inside the cgroup, with no window in which it runs unlimited.
Exercises:
- Inside the container run
hostname foo, then check the host's hostname. The UTS namespace kept them apart. - Add
CLONE_NEWUSERwith a UID mapping so it runs without root: a rootless container. - Add networking: create a veth pair, move one end into the child's network
namespace (
ip link set veth1 netns <pid>), and give both ends addresses. - Copy Chapter 76's
memlimitbinary into the rootfs and run it with-live 100. The 64 MBmemory.maxkills it. Then readmemory.eventsin the cgroup directory to see theoom_killcount.
Common mistakes
- Thinking
chrootalone is isolation. Without namespaces and cgroups it's a different view of the same system, and a root process can escape it. - Forgetting to mount a fresh
/proc, sopsshows the host's processes. - Running untrusted code in a home-made runtime. This is for learning. Use runc, crun, or gVisor for real isolation.
Check yourself
- Which namespace makes the child PID 1, and which makes its hostname private?
- Why is a new
/procmount needed inside the container? - What stops the fork bomb, and where would you see that it happened?
Further reading
- Liz Rice, "Containers From Scratch" (talk and code), the classic version of this lab.
- Container Security (Liz Rice, O'Reilly), chapters on namespaces, cgroups, and what real runtimes add.
- Chapters 44–46 of this guide.