English | 中文
goroot is a rootless, lightweight, single-process container runtime written in
Go. It boots a rootfs (such as an Alpine minirootfs) directly, with no root
privileges required. It is meant as a modern alternative to
proot: instead of intercepting syscalls with
ptrace, it uses the Linux user namespace to acquire a "fake root" as an
ordinary user — which is faster and more robust than proot.
$ go build -o goroot .
$ ./goroot run -r rootfs -- /bin/sh
/ # id
uid=0(root) gid=0(root) groups=65534(nobody),0(root)
- Works out of the box — an Alpine minirootfs is embedded in the binary and
used when no
--rootis given. - Sane defaults — shares the host network, auto-binds the host
/etc/resolv.confread-only and auto-selects a shell, sogoroot runjust works (andapk/curlwork immediately). - No root required — an unprivileged user namespace maps your user to root inside the container.
- Real isolation — user / mount / pid / uts / ipc / net namespaces (each can be turned off individually).
- Background tasks — a
server/clientdaemon pair (unix socket under~/.goroot) runs detached containers and can stream their logs. - Go SDK — import the
github.com/startvibecoding/goroot/sandboxpackage to run sandboxes from your own Go program (see Go SDK). - Single process — by default the target command is
exec'd as PID 1 of the container; there is no per-container supervisor. - Complete mount view —
/proc, read-only/sys,/dev(device nodes,/dev/pts,/dev/shm) and/tmp. - pivot_root to switch the root filesystem (falls back to
chroot). - Bind mounts (read-only or read-write), extra tmpfs, environment variables and working directory.
- Optional tiny init (
--init): a PID 1 that reaps zombies and forwards signals, suited to running services. - Built-in rootfs unpacking (local file or
http(s)://URL;.tar,.tar.gz,.tar.bz2). - Interactive terminal: automatically takes over the foreground process group, so Ctrl-C and job control work.
- Kernel capability self-check (
goroot doctor) and graceful degradation: on an older kernel or a restricted host, missing namespaces are dropped with a warning instead of crashing.
- Linux kernel ≥ 3.8 (user namespaces), with
/proc/sys/kernel/unprivileged_userns_clone = 1; - a private network namespace must be creatable to mount
sysfs(goroot creates one by default); - Go ≥ 1.21.
Missing capabilities are detected and degraded automatically (see Compatibility & graceful degradation). Start with a self-check:
goroot doctormake build # produces ./goroot
make install # installs to /usr/local/binThe built-in rootfs lives in assets/ (alpine-minirootfs-3.20.0-x86_64.tar.gz)
and is compiled into the binary with go:embed. To refresh it, drop a new tarball
in assets/ and update the constants in assets.go.
A minimal Alpine rootfs is embedded in the binary, so you can start immediately — with no arguments you drop straight into a shell:
./goroot run -- /bin/sh # or just: ./goroot runBy default the container shares the host network and the host resolver is available, so this works out of the box:
./goroot run -- /bin/sh -c 'apk add --no-cache curl && curl -s https://example.com | head -1'On first use the built-in rootfs is unpacked to
~/.cache/goroot/alpine-3.20.0-x86_64 and reused from then on. To use your own
rootfs instead, pass -r DIR:
# unpack a rootfs you downloaded (no root; files end up owned by you)
./goroot extract \
https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.3-x86_64.tar.gz \
rootfs
./goroot run -r rootfs -- /bin/shBy default the container shares the host network and the host's
/etc/resolv.conf is bind-mounted read-only, so package managers and curl work
with no extra flags:
./goroot run -r rootfs -- /bin/sh -c 'apk add --no-cache curl && curl -s https://example.com | head -1'For a private network namespace (only lo), pass --isolate-net. Use
--no-resolv to stop binding the host resolver, and --share-net (redundant with
the default) to force sharing.
goroot run [options] [--] <command> [args...]
goroot extract [-c N] <tarball|url> <dest>
goroot server [-d|--daemon]
goroot client <subcommand> # run | ps | logs | stop | rm | status | shutdown
goroot doctor
goroot version
run options:
| Option | Description |
|---|---|
-r, --root DIR |
rootfs directory (default: built-in Alpine minirootfs) |
--hostname NAME |
container hostname (default goroot) |
-b, --bind SRC[:DST][,ro] |
bind-mount a host path (repeatable) |
--ro-bind SRC[:DST] |
read-only bind mount (repeatable) |
--tmpfs DST |
mount a tmpfs at DST (repeatable) |
-e, --env KEY=VAL |
set an environment variable (repeatable) |
-w, --cwd DIR |
working directory inside the container (default /) |
--share-net |
share the host network namespace (default) |
--isolate-net |
use a private network namespace (only lo) |
--no-resolv |
do not auto-bind the host /etc/resolv.conf |
--no-pid / --no-ipc / --no-uts |
do not create that namespace |
-u, --uid N / -g, --gid N |
run as that identity inside (see limitations) |
--memory SIZE |
hard memory ceiling (e.g. 512m, 1g) |
--memory-high SIZE |
soft memory ceiling that only throttles reclaim (cgroup only) |
--cpus N |
CPU bandwidth in cores (e.g. 0.5) |
--cpu-time N |
kill after N seconds of CPU time (RLIMIT_CPU) |
--pids N |
max number of tasks/threads |
--no-cgroup |
force the rlimit fallback |
--init |
run a tiny init as PID 1 (reaps zombies, forwards signals) |
--keep-env |
keep the host environment variables |
extract options: -c N / --strip N removes N leading path components.
Defaults: --root = built-in Alpine minirootfs, shared host network, host
/etc/resolv.conf bound read-only, and a shell auto-selected from
/bin/sh, /bin/bash, /bin/zsh, /bin/fish (in that order).
Limits are enforced with cgroup v2 when the host delegates a writable cgroup
subtree to the user (systemd's user@.service does this by default via
Delegate=) — the precise path: memory.max, memory.high, cpu.max,
pids.max, with the container started inside its own cgroup via
CLONE_INTO_CGROUP so the whole process tree is contained from birth.
When no delegated cgroup is available (e.g. inside many CI containers), the
runtime degrades to rlimits and warns: --memory becomes RLIMIT_AS and
--cpu-time is RLIMIT_CPU. --cpus (a bandwidth quota) and --pids cannot
be expressed as rlimits and are skipped with a warning; --memory-high is
cgroup-only. Pass --no-cgroup to force the rlimit path. goroot doctor
reports which path this host uses.
The parent process creates the namespaces in one clone(2) (via Go's
SysProcAttr) and maps the current user/group to uid/gid 0 inside the container:
uid_map: 0 -> <your host uid> (size 1)
gid_map: 0 -> <your host gid> (size 1)
It then fork/execs itself, entering the hidden __init stage, which inside the
namespaces:
mount --make-rprivate /so mount events never leak back to the host;- bind-mounts the rootfs onto itself to give
pivot_roota mount point; - mounts
/proc, read-onlysysfs,/dev(tmpfs + device nodes + devpts//dev/shm) and/tmp; pivot_roots into the new root and unmounts the old one;- sets the hostname, brings up
lo, applies bind/tmpfs mounts, switches uid/gid,chdirs; - finally
execves the target command (or enters the tiny init).
proot normally does all of this by intercepting every syscall with ptrace and rewriting paths. goroot lets the kernel do it natively, so there is no interpreter overhead and behaviour is much closer to a real container.
goroot server runs a small daemon (listening on ~/.goroot/goroot.sock) that can
launch detached containers; goroot client talks to it. The daemon is started
automatically the first time a client needs it (set GOROOT_NO_AUTOSTART=1 to
forbid that).
goroot server -d # start the daemon in the background
goroot client status # is it up?
id=$(goroot client run -- /bin/sh -c 'while :; do date; sleep 5; done')
goroot client ps # list tasks
goroot client logs -f $id # follow the task's logs
goroot client stop $id # SIGTERM, escalating to SIGKILL
goroot client rm $id # drop a finished task
goroot client shutdown # stop the daemon and its tasksclient run accepts the same options as run. Detached tasks are launched with
the tiny init as PID 1, so stop delivers a graceful SIGTERM (exit code 143) and
orphaned children are reaped. Task state is in-memory: if the daemon stops, its
The container engine is a reusable package, github.com/startvibecoding/goroot/sandbox, so other Go programs
can embed container sandboxing:
package main
import (
"context"
"os"
"github.com/startvibecoding/goroot/sandbox"
)
func main() {
sandbox.Init() // MUST be the first statement of main (see below)
code, err := sandbox.Run(context.Background(), &sandbox.Spec{
Rootfs: "/path/to/rootfs",
Hostname: "demo",
Argv: []string{"/bin/sh", "-c", "id; echo hi"},
}, sandbox.Stdio{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr})
if err != nil {
panic(err)
}
os.Exit(code)
}API: Spec, Stdio, Start (returns a *Sandbox), Run, Sandbox.Wait,
Sandbox.Signal, Sandbox.Kill, Sandbox.Pid, Extract, Detect.
sandbox.Init()must be the very first statement of yourmain. The container init runs as a re-exec of your binary withGOROOT_INIT=1set;Initdetects that and runs the init stage (it never returns in that case, and returns immediately otherwise). Because your binary is re-executed, keep your package-levelinit()functions free of side effects.Stdioaccepts anyio.Reader/io.Writer, so you can capture output into a buffer or a pipe. TTY is auto-detected for terminal*os.Files.- The embedded Alpine rootfs lives in a separate package,
github.com/startvibecoding/goroot/assets(opt-in, ~3.5 MB); combined withsandbox.Extractyou can unpack it as your default rootfs. Seeexamples/sdk.
Build the example: go build -o /tmp/goroot-sdk ./examples/sdk && /tmp/goroot-sdk.
tasks are killed and the list is lost. Logs live in ~/.goroot/logs/<id>.log.
goroot does not assume every namespace exists. Before launching it probes
each capability with clone(2) (note: it cannot call unshare() directly from the
Go process — Go is multithreaded and the kernel refuses it; it must probe via
fork+exec, exactly like the real container does), then degrades item by item in
priority order, warning on stderr rather than failing outright.
goroot doctor # print what this host supports and what will be degradedkernel : 7.0.10-1-liquorix-amd64 (x86_64)
euid : 1000 (unprivileged)
namespaces :
user ok
mount ok
pid ok
uts ok
ipc ok
net ok
verdict :
can run: full isolation
| Capability | Minimum kernel | Behaviour when missing |
|---|---|---|
| user namespace | 3.8 (and not disabled by the distro) | unprivileged: error out with guidance; root: skip it, run as a full real-root container |
| mount ns + pivot_root | 2.6.x | required (no container without it) |
| pid namespace | 2.6.24 | dropped; bind the host /proc |
| uts / ipc / net ns | 2.6.19 / 3.0 / 2.6.24 | dropped one by one with a warning (never touches the host hostname / host lo) |
| mounting procfs in userns | 3.8 | fall back to binding the host /proc |
| mounting sysfs in userns | 3.8–4.x | fall back to a read-only bind of the host /sys; under --share-net only the host /sys can be bound |
devpts newinstance |
4.7 | fall back to binding the host /dev/pts |
| creating regular files in userns/tmpfs | 3.8 | required (/dev device nodes are bind-mounted, never mknoded) |
Key safety point: when a namespace probe fails and it is dropped, the flags passed
to the child are updated in lockstep so that no side effect lands on the host — for
example, if UTS is unavailable goroot does not call sethostname (which would
change the host hostname), and if the network namespace is unavailable it does
not bring up the host lo.
If the kernel is too old, with neither user namespaces nor root, real isolation is
simply impossible — this is exactly the niche proot fills (faking it in userspace
with ptrace). goroot then tells you to enable userns or use proot/bwrap, instead of
surfacing an obscure EPERM.
Debugging / forcing degradation: set GOROOT_DISABLE_NS=pid,uts,net
(comma-separated) to make goroot pretend those namespaces are unavailable, which is
handy for exercising the degradation paths.
- Single-id mapping: an unprivileged user namespace can only map
current user ↔ container rootone-to-one. Therefore only uid/gid 0 exists inside; passing a non-zero-u/--gidis an error. Multi-user support would need/etc/subuid+newuidmap(not implemented yet). - Supplementary groups: because
setgroupsis denied inside a user namespace, the host's supplementary groups show up asnobody(65534)(cosmetic only; file permissions are unaffected). /sysunder--share-net: sysfs is tied to the network namespace, so when sharing the host netns only the host/syscan be bind-mounted (read-only), bringing along the host's submounts.- No seccomp filtering yet: CPU, memory and pids limits are supported (see Resource limits), but syscall filtering is not.
- Files in the rootfs are best owned by your user (unpacking with
goroot extractdoes this); otherwise files that aren't yours appear asnobodyand may be unreadable inside.
make fmt vet # format and static checks
make test # smoke test (prepares an alpine rootfs; needs network)
ROOTFS=./myrootfs ./scripts/smoke.sh # reuse an existing rootfs.github/workflows/ci.yml builds and runs the smoke test on every push and pull
request.
.github/workflows/release.yml triggers on v* tags: it cross-compiles
Linux binaries, creates the GitHub Release and publishes the npm package to the
GitHub Packages registry.
git tag v0.1.0 && git push origin v0.1.0Artifacts (also buildable locally with make dist):
goroot_<ver>_linux_{amd64,arm64}.tar.gz, matching raw binaries (downloaded by
the npm package on install) and checksums.txt.
# GitHub Packages requires a token even to install; export GITHUB_TOKEN first.
echo "@startvibecoding:registry=https://npm.pkg.github.com" >> ~/.npmrc
echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc
npm install -g @startvibecoding/goroot-installer # installs the `goroot` commandPublishing uses the built-in GITHUB_TOKEN (packages: write) — no extra secret,
and it skips a version that is already published.
Code layout:
Three packages: the root github.com/startvibecoding/goroot (CLI + daemon),
.../sandbox (engine + SDK), .../assets (embedded rootfs).
| File | Responsibility |
|---|---|
main.go |
CLI parsing and dispatch; sandbox.Init(), buildRunSpec |
run.go |
foreground run (wires stdio + signals into sandbox.Run) |
doctor.go |
doctor self-check (uses sandbox.Detect) |
extract.go |
extract command (URL/local fetch) |
assets.go |
resolve the built-in rootfs into ~/.cache/goroot |
protocol.go |
daemon wire protocol + ~/.goroot path helpers |
daemon.go |
server: task manager, unix-socket listener, log streaming |
client.go |
client: run/ps/logs/stop/rm/status/shutdown |
sandbox/*.go |
the engine / SDK (Spec, Start/Run/Wait/Signal, Init, Extract, Detect) |
assets/assets.go |
go:embed of the Alpine tarball |