A minimal container runtime CLI in Go — the core primitives real runtimes (runc) use, without image management, overlayfs, a daemon, or networking:
- Namespace isolation —
CLONE_NEWPID(container sees itself as PID 1),CLONE_NEWNS(isolated mount table),CLONE_NEWUTS(custom hostname),CLONE_NEWIPC(isolated IPC), viasyscall.SysProcAttr.Cloneflags. - Filesystem jailing via
pivot_root— notchroot: the old root is fully unmounted and detached, preventing container breakout. - cgroups v2 resource limits — per-container memory ceiling and CPU
quota under
/sys/fs/cgroup/mini-run/<id>/. - Interactive PTY passthrough — stdio is wired straight through so
/bin/sh,top, etc. work interactively.
It is a stateless CLI, not a daemon: sudo mini-run run <rootfs> <cmd> [args...]
against a rootfs directory you've already unpacked yourself (e.g. Alpine's
minirootfs tarball, extracted).
.
├── cmd/mini-run/ CLI entrypoint
│ ├── main.go arg dispatch: run | child (hidden) | -h
│ ├── run.go `run` subcommand: flags -> container.RunOptions
│ └── child.go hidden `child` subcommand -> container.RunChild
├── internal/
│ ├── container/
│ │ ├── types.go RunOptions
│ │ ├── run_linux.go parent-side orchestration (namespaces, cgroups, wait)
│ │ ├── child_linux.go PID-1 child logic: hostname, pivot_root, exec
│ │ ├── rootfs_linux.go pivot_root + synthetic /dev implementation
│ │ ├── id.go random 12-hex-char container ID
│ │ ├── id_test.go
│ │ └── *_other.go non-Linux build-tag stubs
│ └── cgroups/
│ ├── cgroups_linux.go cgroups v2 Setup / AddProcess / Cleanup
│ ├── limits.go ParseMemory / ParseCPUs (pure, cross-platform)
│ ├── limits_test.go
│ └── cgroups_other.go non-Linux build-tag stub
├── test/integration/
│ └── mini_run_test.go black-box, root+Linux-gated integration suite
│ (automates the Verification checklist below)
├── scripts/
│ ├── fetch-rootfs.sh downloads + extracts Alpine's minirootfs
│ ├── bench.sh startup-latency benchmark harness (hyperfine)
│ └── plot_bench.py renders the benchmark bar chart (matplotlib)
├── docs/
│ └── benchmark.png chart embedded in this README
├── lima.yaml disposable Ubuntu VM config for macOS dev/test
└── go.mod zero external dependencies
mini-run has no long-running daemon and no supervisor process tree: the
parent CLI invocation sets up resource limits, re-execs itself into new
namespaces, and then gets out of the way — the containerized command
becomes a real PID 1, not a child of some Go supervisor loop.
sequenceDiagram
participant Host as Host shell
participant Parent as mini-run run (parent)
participant CG as cgroups v2 (/sys/fs/cgroup/mini-run/<id>)
participant Child as mini-run child (becomes PID 1)
participant Target as exec'd command
Host->>Parent: sudo mini-run run --memory 128m ./rootfs /bin/sh
Parent->>Parent: NewID(), resolve rootfs, ParseLimits()
Parent->>CG: mkdir <id>/, write memory.max / cpu.max
Parent->>Child: re-exec self as "child" with Cloneflags<br/>NEWNS|NEWUTS|NEWIPC|NEWPID, Pdeathsig=SIGKILL
Note over Child: new process, born as PID 1 inside<br/>its own PID/mount/UTS/IPC namespaces
Parent->>CG: write cgroup.procs (attach child's real host PID)
Child->>Child: Sethostname("mini-run-<id[:8]>")
Child->>Child: pivotRoot(rootfs) — see filesystem diagram below
Child->>Target: syscall.Exec(resolved cmd) — replaces its own process image
Note over Target: target command IS the container's PID 1 now;<br/>stdio was wired straight through from the host shell
Target-->>Parent: process exits
Parent->>CG: rmdir <id>/ (Cleanup(), retries up to ~500ms)
Parent-->>Host: propagate the container's exit code
Two design choices worth calling out:
syscall.Exec, not a subprocess — the child process replaces its own image with the target command instead of spawning it as a child. This means the container's PID 1 is/bin/sh(or whatever was requested), with correct init/signal/reaping semantics, rather than a Go process that has to babysit a subprocess.- cgroup limits are created before the container exists —
memory.maxandcpu.maxdon't require a PID to be written, so the cgroup is fully configured first and the container's host PID is attached to it right after the namespaced process starts, closing the window where an unconstrained process could run free.
chroot only changes what path resolution treats as /; the old root
filesystem is still mounted and reachable through tricks like relative
.. traversal via an open file descriptor. pivot_root swaps the entire
mount tree's root and lets the old root be fully unmounted and deleted, so
there is nothing left to break out into:
Before pivot_root After pivot_root
(inside the new mount namespace) (inside the new mount namespace)
---------------------------------- ----------------------------------
/ / (== former rootfs,
├── proc/, sys/, dev/, ... ├── bin/, etc/, usr/, ... now the real root)
├── home/.../container-runtime/ ├── dev/ (fresh tmpfs: null, zero,
│ └── rootfs/ <- bind-mounted │ full, random, urandom,
│ onto itself so pivot_root(2) │ tty, fd -> /proc/self/fd)
│ accepts it as the new root ├── proc/ (freshly mounted here,
└── ...rest of the host filesystem... │ reflects the new PID ns)
└── .old_root unmounted (MNT_DETACH) and
os.RemoveAll'd -- gone
The sequence, from rootfs_linux.go:
make all mounts private+recursive (so pivot_root doesn't EINVAL on
distros with shared mount propagation) → bind-mount rootfs onto itself
(a mount-point requirement of pivot_root(2)) → build a minimal synthetic
/dev via tmpfs + hand-rolled mknod (never bind-mount the host's real
/dev) → pivot_root → chdir("/") → mount a fresh /proc (only valid
after the pivot, since it must reflect the new PID namespace) → unmount
and recursively delete .old_root.
Namespaces, pivot_root, and cgroups v2 are Linux kernel features and do
not exist on macOS/Windows. Building the CLI works anywhere (Go's build
tags isolate the Linux-only code), but actually running a container
requires Linux, root, and a cgroups v2 unified hierarchy mounted at
/sys/fs/cgroup.
A Lima config is included for a disposable Ubuntu VM:
brew install lima
limactl start --name mini-run ./lima.yaml
limactl shell mini-run
cd container-runtime
go build -o mini-run ./cmd/mini-run
./scripts/fetch-rootfs.sh
sudo ./mini-run run --memory 128m --cpus 0.5 ./rootfs /bin/sh
(Ubuntu/systemd is used rather than Alpine/OpenRC because systemd keeps the
root cgroup free of directly-attached processes — otherwise enabling the
cpu/memory controllers on /sys/fs/cgroup fails with EBUSY.)
If you already have a Linux box, skip Lima and just go build +
sudo ./mini-run run ... there directly.
sudo mini-run run [--memory 512m] [--cpus 0.5] <rootfs-path> <cmd> [args...]
--memory— e.g.512m,1.5g(default: unlimited)--cpus— fraction of one core, e.g.0.5,2(default: unlimited)
Flags must precede <rootfs-path>; everything after the command is passed
through untouched (so sudo mini-run run ./rootfs /bin/sh -c "ls -la" works
as expected).
Run inside the Lima VM (or any Linux box):
- PID namespace:
echo $$inside the container prints1. - UTS namespace:
hostnameprints a generatedmini-run-xxxxxxxxname, distinct from the host's. - Mount namespace:
mountinside the container shows only/,/proc, and the synthetic/devtmpfs — compare against a secondlimactl shell mini-runwindow on the host. - Process visibility:
ps auxinside the container shows only the container's own processes, not the host's (/procreflects the new PID namespace). - Breakout prevention:
ls /.old_rootinside the container fails with "No such file or directory";ls /shows only the rootfs's own directories, never host paths like/homeor the project directory. - cgroup limits applied: from the host,
cat /sys/fs/cgroup/mini-run/<id>/memory.maxand.../cpu.maxmatch what was requested;cgroup.procsshows the real host-namespace PID (vs.1seen inside the container). - Memory limit enforced: inside the container, run a memory bomb
(
sh -c 'a=AAAA; while true; do a="$a$a"; done') — it gets OOM-killed by the cgroup, without affecting the host VM. - CPU limit enforced:
yes > /dev/nullinside the container while watching host-sidetop— usage caps near the configured fraction of one core. - Cleanup: after
exit(or Ctrl-C mid-session),/sys/fs/cgroup/mini-run/<id>is gone. - Exit code propagation:
sudo ./mini-run run ./rootfs /bin/sh -c "exit 42"; echo $?prints42.
The portable pieces (flag parsing, ID generation, memory/CPU string parsing) build and vet directly on macOS without Lima:
go build ./... && go vet ./...
Unit tests cover the pure, cross-platform logic (ID generation, memory/CPU parsing) and run anywhere, no Lima or root needed:
go test ./...
Everything else — namespace isolation, pivot_root, cgroup enforcement,
exit-code propagation, cleanup — is covered by an integration suite that
automates the 10 verification steps above. It's black-box (drives the built
binary as a real user would) and requires Linux, root, and a fetched rootfs,
so it's gated behind the integration build tag and skips itself with a
clear message if either is missing. Run it inside the Lima VM:
go build -o mini-run ./cmd/mini-run
./scripts/fetch-rootfs.sh
sudo go test -tags integration ./test/integration/... -v
scripts/bench.sh measures container startup latency and compares it
against a staircase of baselines to show what each isolation layer costs:
raw exec, chroot alone, chroot + namespaces (via unshare), and
mini-run itself (+ cgroups v2 setup/cleanup and pivot_root). It uses
hyperfine for statistically sound
timing and, if matplotlib is available, renders bench-results.png (via
scripts/plot_bench.py) as a labeled bar chart of the four variants. Run
inside the Lima VM (both hyperfine and matplotlib are preinstalled by
lima.yaml's provisioning script):
sudo ./scripts/bench.sh
Measured on an aarch64 Lima VM (2 vCPUs, 4GiB RAM, Ubuntu 24.04) running on a macOS host — absolute numbers will vary by machine, but the shape of the staircase (what each layer adds) is the interesting part:
| Command | Mean | Min | Max | Slowdown vs. raw exec |
|---|---|---|---|---|
| a: raw exec | 1.1 µs | 0.0 µs | 1027.1 µs | 1× |
| b: chroot only | 253.4 µs | 166.1 µs | 5011.2 µs | ~238× |
| c: chroot + namespaces | 519.8 µs | 452.4 µs | 1132.3 µs | ~488× |
| d: mini-run | 3.36 ms | 2.16 ms | 17.98 ms | ~3160× |
Reading the staircase:
- (a) raw exec —
true, no isolation at all. Hyperfine's own warning ("command took less than 5 ms") is the tell that this number is really measuring hyperfine's shell-invocation floor, not a meaningful process cost — treat it as "approximately zero," the baseline everything else is relative to, not a precise figure. - (a) → (b) chroot only, +252 µs — this jump is not the cost of the
chroot(2)syscall itself (that's sub-microsecond). It's the cost of spawning the externalchrootbinary as its own process before it execs/bin/true— i.e. this measures "one extra fork+exec plus achroot()andchdir()call," which is representative of what a shell-script-based jail would actually cost, not a syscall microbenchmark in isolation. - (b) → (c) + namespaces via
unshare, +266 µs — on top of another process spawn (theunsharebinary itself), the kernel now allocates and populates a new PID, mount, UTS, and IPC namespace (CLONE_NEWPID| CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC). Namespace creation has real kernel-side cost — the mount namespace in particular requires copying the process's mount table — which is why this step costs meaningfully more than the chroot-only step even though both are dominated by process-spawn overhead. - (c) → (d) mini-run, +2.84 ms — this is where mini-run's extra work
over the
unshare+chrootequivalent shows up, and it's the sum of several things the manual baseline doesn't do at all:- Two full process launches, not one
clone(2)— the parent (mini-run run) re-execs itself asmini-run childviaos/exec.Commandrather than callingclone(2)directly in-process. That's two separate ~2.8MB Go binary startups (runtime init, GC init, etc.) chained together, versusunshare's singleclone()+execve(). - cgroups v2 setup and teardown —
mkdir, twosubtree_controlwrites (root andmini-run/),memory.max/cpu.maxwrites, acgroup.procsattach, and on the way out, anrmdirthat retries up to 10×50ms if kernel accounting hasn't caught up yet. Each is a synchronous sysfs write with kernel-side validation —unshare/chrootdo none of this. - The full
pivot_rootsequence — making mounts private+recursive, a recursive bind-mount of the rootfs onto itself, a tmpfs mount plus sixmknodcalls for/dev,pivot_root(2)itself, and finally unmounting (MNT_DETACH) and recursively deleting the old root.chrootis a single syscall by comparison; this is roughly a dozen. - A fresh
/procmount after the pivot (namespace-aware, so it can't happen before).
- The wide variance (±1.86 ms, up to 18 ms) is consistent with this: it's a long chain of syscalls in a nested-virtualization guest (Lima VM on top of macOS), plus the cgroup cleanup retry loop occasionally kicking in if a previous container's accounting hadn't settled yet.
- Two full process launches, not one
The takeaway: mini-run's overhead over hand-rolled namespaces is almost
entirely cgroups v2 bookkeeping and the pivot_root//dev setup, not the
namespaces themselves — namespace creation (b→c) is cheap relative to
what a real container runtime does around it (c→d). This roughly tracks
how runc and similar runtimes spend their startup budget too.
Primitives this project implements:
namespaces(7),pid_namespaces(7),mount_namespaces(7)— Linux manual pages for the isolation primitives behindCLONE_NEWPID/CLONE_NEWNS/CLONE_NEWUTS/CLONE_NEWIPC.pivot_root(2)— why it's used instead ofchroot(2)for filesystem jailing.- cgroups v2 (kernel docs) —
the unified hierarchy,
subtree_control,memory.max,cpu.max. - OCI Runtime Specification — the spec real container runtimes (runc, crun) implement; mini-run deliberately implements a subset of the same underlying primitives without the spec's config/bundle format.
- runc — the reference OCI
runtime implementation; mini-run's
pivot_rootsequence follows the same shape (private+recursive remount, self bind-mount, pivot, detach).
Tooling used in this repo:
- Lima — the Linux VM used for macOS development
(
lima.yaml). - hyperfine — the benchmarking tool
used by
scripts/bench.sh. - Alpine Linux
minirootfs— the rootfs fetched byscripts/fetch-rootfs.sh.
This project is licensed under the MIT License. See LICENSE for details.
