Skip to content
sadisticbrewPublic

About

minimal OCI-ish container runtime in Go — namespaces, pivot_root, supervising init, from scratch

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

35 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

burg

a container runtime in ~1,200 lines of Go. no container libraries — just syscalls, a lot of man pages, and the OCI spec for company.

demo

why

I didn't build this because the world needed another runtime. runc exists, it's battle-tested, and it runs most of the internet's containers.

I built it because I wanted to know what Docker actually does, and reading only gets you so far — at some point you have to hold the knife.

There's also a second half I haven't built yet. the plan was always jail + warden: burg is the jail (namespaces, mounts, pivot_root, eventually cgroups), and the warden is a BPF-LSM policy engine that enforces per-container rules at runtime — a mini Tetragon. that's v2, and it's the actual reason burg exists.

the part that bit me

The first surprise came early. I had a running container, I sent it SIGTERM, and — nothing. not an error, not a delay, nothing. SIGKILL worked fine. so somewhere between TERM and KILL, the kernel was quietly eating my signal.

Turns out the kernel protects PID 1 — the init process of any PID namespace — from every signal it hasn't explicitly installed a handler for. My child process became PID 1 by exec'ing straight into the user's program, so every polite signal was discarded, and only SIGKILL (which ignores everyone's feelings) got through.

The fix is the same one runc uses: never let the user's program be PID 1. burg runs a small supervisor as PID 1 that catches every signal and forwards it, and launches your program as PID 2 — a normal citizen that dies when asked. You can see it from inside the container:

/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /proc/self/exe child-init /home/.../demo-bundle
    8 root      0:00 sh
   11 root      0:00 ps

what works

bundle parsing config.json parsed and validated against the required fields
namespaces pid, mount, uts, ipc, network, cgroup — via clone flags from the spec
mounts proc, tmpfs, devpts, sysfs, mqueue, bind — spec-driven, in order
rootfs pivot_root, put_old unmounted and removed, read-only root when root.readonly is set
lifecycle create / start / run / state / kill / delete — all real, no stubs
supervising init signal forwarding to the container process
readiness pipe the parent won't write running to the state file until the child confirms it's actually up — a container that died during setup never gets a lying state file
exit codes the container's exit code becomes burg's exit code (exit 42 inside → echo $? → 42; killed → 128+N)
burg spec drops a runnable default config.json so you don't hand-write one

what's missing (on purpose)

  • cgroups v2 — the hierarchy code exists and is tested, it's just not wired into create/start yet. v1.1.
  • capabilities, rlimits, maskedPaths, readonlyPaths — parsed by the spec types, silently ignored by burg.
  • networking — a new network namespace with nothing inside it. no veth, no bridge.
  • pty — terminal: true means inherited stdio, not a real pty. no resize events, and Ctrl-C hits burg too.
  • hooks, seccomp, apparmor, rootless, kill --all — not started.

and it all runs as root with full capabilities. don't put anything you like near it.

quick start

mkdir demo && cd demo && mkdir rootfs
tar -xzf ~/Downloads/alpine-minirootfs-*.tar.gz -C rootfs   # or busybox
burg spec                    # writes config.json — edit hostname/args if you like
sudo burg run mybox -b .

inside: hostname, ps, cat /proc/mounts, ls / — then from another terminal:

sudo burg state mybox        # the OCI state, as JSON
sudo burg kill mybox KILL    # TERM is forwarded, but shells may ignore it — KILL never fails
sudo burg delete mybox

how a start actually works

sequenceDiagram
    participant P as burg (parent)
    participant S as supervisor — PID 1
    participant U as program — PID 2

    P->>S: fork into fresh namespaces
    S->>S: hostname, mounts, pivot_root, ro remount
    S->>U: fork
    S-->>P: "1" on the pipe (fd 3) — I'm up
    P->>P: save state: running
    Note over S: burg kill TERM arrives
    S->>U: forward signal
    U-->>S: exit (own code, or 128+N)
    S-->>P: exit with the same code
    P->>P: save state: stopped
Loading

the numbers

  • ~5 ms cold start — full lifecycle of an exit-0 container, 5.3 ms ± 0.6 over 30 runs (hyperfine, shell-free), CPU pinned at 3.4 GHz on two P-cores, kernel 7.2.0. reproduced with scripts/bench.sh
  • 1,238 lines of runtime Go (scc, tests excluded)
  • 2,168 lines of tests — the suite outweighs the runtime ~1.75x, which is how I like it
  • dependencies: cobra (CLI), runtime-spec (JSON types only), x/sys (raw syscalls). nothing that knows what a container is

roadmap

  • v1.1 — wire cgroup v2 limits into create/start/delete
  • v2 — the warden: a BPF-LSM policy engine enforcing per-container syscall/file/network rules at runtime

not on the roadmap: being runc. see the missing table.

license: GPL-3.0. it's a learning project — take it apart, that's the point.

About

minimal OCI-ish container runtime in Go — namespaces, pivot_root, supervising init, from scratch

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages