GoSuda

Ausführen von Docker-in-Docker innerhalb von gVisor und verschachtelten Sandboxes mit gvx

By Lemon Mint views ...

Schnelllösung: Sicheres Ausführen von docker:dind in einer unprivilegierten gVisor (runsc)-Sandbox

Cloud-KI-Agenten-Rechenumgebungen (etwa Google Agent Compute Engine-Sandboxes), Kubernetes-agent-sandbox-Deployments (RuntimeClass: gvisor) und Evaluierungs-Harnesses für Reinforcement Learning isolieren nicht vertrauenswürdige Workloads gezielt in gVisor (runsc)-Containern, um den Host-Kernel zu schützen. Versucht ein Workload innerhalb eines standardmäßigen, unprivilegierten runsc-Containers, docker.io oder docker-ce über apt-get zu installieren, service docker start oder dockerd zu starten und docker build auszuführen, schlägt dies fehl, selbst wenn er innerhalb des Containers als root (UID 0) läuft.

Mit gvx (rungv) lassen sich docker:dind, docker build und docker compose sicher direkt in einem unprivilegierten gVisor (runsc)-Container ausführen (oder gvx innerhalb von gvx verschachteln), ohne die äußere RuntimeClass zu schwächen, Host-Sockets freizugeben oder --privileged zu gewähren.

Aufwandsarme, token-effiziente Lösung für KI-Agenten: In einer cloudbasierten Agentenumgebung mit gVisor-Unterbau kann der Versuch, docker.io zu installieren und manuell Fehler bei /etc/init.d/docker ulimit, Fehlschläge bei iptables-nft, Zugriffsverweigerungen bei rohen Sockets von iptables-legacy, Mount-Fehler bei overlay2 sowie Abstürze von docker build bei runc / pivot_root zu umgehen, typischerweise über 20 Tool-Call-Runden und zehntausende Token verbrauchen, bevor er vollständig fehlschlägt. gvx (rungv) ist eine aufwandsarme, token-effiziente Drop-in-Lösung: Der Agent führt in einer einzigen Runde 3 Befehle aus, um einen funktionsfähigen Docker-Daemon im User-Space zu starten, und führt anschließend unmittelbar docker build und docker compose aus, ohne Projektdateien zu verändern.

Erhalt und Stärkung der Isolation der äußeren Sandbox: Die Ausführung von rungv innerhalb von runsc umgeht keine einzige Einschränkung, die von der äußeren gVisor-Sandbox durchgesetzt wird. Stattdessen erzeugt gvx einen zweiten, verschachtelten gVisor-Sentry-Kernel im User-Space mit eigenem hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), eigenem In-Memory-netstack und einem Einzeldatei-Volume ext4lite. Innere dockerd- und runc-Container sind doppelt gesandboxt: Ihre Systemaufrufe, Mounts und Rohpakete verlassen niemals den inneren gvx-Kernel im User-Space.

 1# Innerhalb eines bestehenden gVisor (runsc)-Containers oder Kubernetes-Pods:
 2# rungv aus einem SLSA-attestierten GitHub-Release-Archiv oder über go install installieren:
 3CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest
 4
 5mkdir -p ~/.gvx
 6rungv run \
 7  -p unix:~/.gvx/docker.sock:2375 \
 8  -v gvx-docker:/var/lib/docker \
 9  -e DOCKER_TLS_CERTDIR= \
10  docker:dind --tls=false &
11
12# Standardmäßige Docker-Builds und Container gegen den inneren gvx-Socket ausführen
13export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
14until docker info >/dev/null 2>&1; do sleep 0.5; done
15docker info --format '{{.Driver}} {{.OperatingSystem}}'
16# Ausgabe: overlayfs Alpine Linux ... (containerisiert)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test

Fünf Gründe, warum die direkte Docker-Installation innerhalb von gVisor (runsc) fehlschlägt

Wenn ein Benutzer oder ein KI-Coding-Agent apt-get install -y docker.io (oder docker-ce) innerhalb eines standardmäßigen runsc-Containers ausführt und anschließend dockerd zu starten versucht, stößt er auf fünf aufeinanderfolgende technische Hürden.

1. Fehler des Init-Skripts (systemctl / service docker start ulimit) und des Kernelmoduls (modprobe, 4.4.0)

Nach apt-get install -y docker.io (oder docker-ce) schlägt der Versuch, den Daemon über systemctl start docker oder service docker start zu starten, sofort fehl, weil PID 1 nicht systemd ist und /etc/init.d/docker versucht, ulimit -n über die harte RLIMIT_NOFILE-Obergrenze der Sandbox anzuheben. Wird dockerd anschließend direkt aufgerufen, rufen die Initialisierungsskripte modprobe auf, um overlay, bridge, br_netfilter, ip_tables und nf_nat gegen die virtuelle Kernelversion von gVisor (4.4.0) zu laden. Dieses Verzeichnis /lib/modules/4.4.0 existiert jedoch nicht:

1System has not been booted with systemd as init system (PID 1). Can't operate.
2Failed to connect to bus: Host is down
3/etc/init.d/docker: 62: ulimit: error setting limit (Operation not permitted)
4level=warning msg="Running modprobe bridge br_netfilter failed with message: modprobe: WARNING: Module bridge not found in directory /lib/modules/4.4.0\nmodprobe: WARNING: Module br_netfilter not found in directory /lib/modules/4.4.0\n, error: exit status 1"
5modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
6modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
7modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
8modprobe: FATAL: Module nf_nat not found in directory /lib/modules/4.4.0

2. iptables-nft (nf_tables-Netlink), Zugriffsverweigerungen bei rohen Sockets von iptables-legacy und Fehler bei der docker0-Bridge

Unter Ubuntu 22.04, Ubuntu 24.04 und Debian 11/12 wählt apt-get install docker.io iptables-nft (gestützt auf das Netlink-Subsystem nf_tables) als Standardalternative für /usr/sbin/iptables aus. Selbst wenn der Benutzer oder Agent auf iptables-legacy umschaltet (update-alternatives --set iptables /usr/sbin/iptables-legacy), lehnen standardmäßige unprivilegierte gVisor-Sandboxes (--net-raw=false) Sockets vom Typ AF_INET, SOCK_RAW mit EPERM ab. Dadurch schlägt iptables-legacy mit Permission denied (you must be root) fehl, selbst wenn der Prozess als UID 0 läuft:

1failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to register "bridge" driver: failed to create NAT chain DOCKER: iptables failed: iptables --wait -t nat -N DOCKER: iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
2iptables v1.8.10 (legacy): can't initialize iptables table `nat': Permission denied (you must be root)
3Perhaps iptables or your kernel needs to be upgraded.
4iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
5iptables: No chain/target/match by that name.
6Unable to enable SKIP DNAT rule: (iptables failed: iptables --wait -t nat -I DOCKER -i docker0 -j RETURN: iptables: No chain/target/match by that name.)
7Error initializing network controller: error obtaining controller instance: failed to create bridge "docker0": operation not permitted

3. Fehler von overlay2 auf einem gofer-/9p-Rootfs und Erschöpfung des Speichers beim Treiber vfs

Sofern der Administrator des äußeren runsc nicht ausdrücklich ein dediziertes tmpfs oder Filestore-Mount unter /var/lib/docker vorkonfiguriert hat, wird das Root-Dateisystem des Containers durch den gofer (9p/lisafs) von gVisor oder durch ein verschachteltes Overlay-Mount bereitgestellt. Das Mounten von overlay2 über /var/lib/docker schlägt fehl:

1level=error msg="[graphdriver] prior storage driver overlay2 failed: driver not supported"
2failed to mount overlay: invalid argument
3Error starting daemon: error initializing graphdriver: driver not supported: overlay2

Versuchen Benutzer oder Agenten, diesen Fehler zu umgehen, indem sie dockerd --storage-driver=vfs starten, deaktiviert Docker die Copy-on-Write-Freigabe von Layern vollständig. Jede RUN-Anweisung in einem Dockerfile führt dann eine vollständige rekursive Kopie (dirCopy) des gesamten Root-Dateisystems durch. Ein 500-MB-Image über 10 Build-Schritte vervielfacht sich so auf 5 GB Schreibvorgänge und beendet die Sandbox mit no space left on device oder einem I/O-Timeout.

4. Fehler bei docker build (BuildKit) und verschachtelten runc-Aufrufen (pivot_root, /proc, Cgroups und DNS)

Selbst wenn dockerd mit dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs erzwungen gestartet wird, schlagen docker build -t app . oder docker run sofort fehl, sobald BuildKit oder runc den Build-Container startet:

 1ERROR: failed to solve: process "/bin/sh -c ..." did not complete successfully:
 2docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process:
 3error during container init: error jailing process inside rootfs: pivot_root .: invalid argument
 4error during container init: error mounting "cgroup" to rootfs at "/sys/fs/cgroup": mount cgroup:/sys/fs/cgroup/cpuset (via /proc/self/fd/6), flags: 0xf, data: cpuset: operation not permitted
 5cgroups: cgroup mountpoint does not exist: unknown
 6runc create failed: systemd not running on this host: cannot use systemd cgroup manager
 7error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
 8error during container init: error loading seccomp filter into kernel: operation not permitted
 9Temporary failure resolving 'deb.debian.org'
10Temporary failure resolving 'archive.ubuntu.com'

5. Verschachteltes runsc schlägt mit SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY) fehl

Versucht ein Benutzer, eine weitere runsc-Instanz innerhalb von runsc mit der Plattform systrap auszuführen, bricht die Initialisierung sofort ab:

1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy

Sowohl der Linux-Kernel als auch der gVisor-Sentry erlauben höchstens einen Listener vom Typ SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) pro Task-Hierarchie. Da die äußere Sandbox diesen Listener-Slot bereits belegt, gibt der innere Aufruf von seccomp(2) EBUSY (device or resource busy) zurück.


Wie gvx die verschachtelte Ausführung innerhalb von gVisor löst

gvx begegnet allen fünf Hürden in seinem eigenen Kernel im User-Space und seiner Plattformschicht. Dadurch kann rungv innerhalb eines äußeren gVisor (runsc)-Containers oder sogar rekursiv innerhalb eines weiteren gvx-Containers (gvx in gvx) laufen.

Verschachtelte gvx-Ausführung innerhalb eines äußeren gVisor

1. Automatischer Fallback von systrap von EBUSY auf die Futex-Schleife _RUN_SYSCALL_LOOP

Unter Linux systrap führt jeder Stub-Subprozess zwei Arten von Threads aus:

  • Gast-Ausführungsthreads (sysmsg): Fangen Gast-Systemaufrufe über SECCOMP_RET_TRAP (SIGSYS) und binär gepatchte jmp rel32-Trampoline (syshandler) ab und tauschen den Registerzustand über gemeinsamen Speicher und SYS_FUTEX mit dem Sentry aus.
  • Ein Stub-syscallThread (syscall_thread.go): Führt Systemaufrufe zur Adressraumverwaltung (mmap, munmap, clone) im Stub-Subprozess im Auftrag des Sentry aus.

Im upstream-gVisor unterstützt syscallThread zwei Weckmodi in stub_amd64.s und stub_arm64.s:

  1. Seccomp-User-Notify-Schleife (_RUN_SECCOMP_LOOP = 6, wenn t.seccompNotify != nil): Während syscallThread.init() installiert t.installSeccompNotify() einen Listener vom Typ SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)), damit der Sentry Anfragen über ioctl(SECCOMP_IOCTL_NOTIF_SEND) und ioctl(SECCOMP_IOCTL_NOTIF_RECV) verteilen kann.
  2. Futex-Systemaufrufschleife (_RUN_SYSCALL_LOOP = 5, wenn t.seccompNotify == nil): Der syscallThread führt syscall_loop (stub_amd64.s / stub_arm64.s) aus und schläft auf SYS_FUTEX (FUTEX_WAIT) über dem gemeinsamen 2-Seiten-Bereich syscallSentryMessage (PROT_READ im Stub) und syscallStubMessage (PROT_READ | PROT_WRITE im Stub).

Upstream-gVisor ruft t.installSeccompNotify() bedingungslos auf, sobald seccompNotifyIsSupported wahr ist, und bricht die Erstellung des Stubs ab, wenn seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) EBUSY zurückgibt.

In gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go und syscall_thread.go) ruft syscallThread.init() stattdessen t.installSeccompNotifyIfFree() auf:

 1// pkg/sentry/platform/systrap/seccomp_notify_xplat.go
 2var seccompNotifyBusy atomic.Bool
 3
 4func (t *syscallThread) installSeccompNotifyIfFree() (*os.File, error) {
 5	if seccompNotifyBusy.Load() {
 6		return nil, nil
 7	}
 8	f, err := t.installSeccompNotify()
 9	if errors.Is(err, unix.EBUSY) {
10		if seccompNotifyBusy.CompareAndSwap(false, true) {
11			log.Warningf("Another seccomp listener exists in the filter chain of the host process. " +
12				"The stub threads use the slower futex loop instead of seccomp-unotify.")
13		}
14		return nil, nil
15	}
16	return f, err
17}

Gibt installSeccompNotify() unix.EBUSY zurück, weil ein äußeres gVisor (runsc) oder eine äußere gvx-Sandbox den einzigen Listener-Slot der Prozess-Filterkette bereits belegt:

  • Setzt installSeccompNotifyIfFree() seccompNotifyBusy (atomic.Bool), protokolliert genau eine Warnung für den Prozess und belässt t.seccompNotify == nil für diesen Stub und alle nachfolgenden Stubs.
  • In syscallThread.detach() (syscall_thread_amd64.go und syscall_thread_arm64.go) setzt t.seccompNotify == nil auf amd64 regs.Rbx = _RUN_SYSCALL_LOOP (5) bzw. auf arm64 regs.Regs[9] = _RUN_SYSCALL_LOOP, anstelle von _RUN_SECCOMP_LOOP (6).
  • In syscallThread.syscall() (syscall_thread.go) verteilt der innere Sentry Stub-Systemaufrufe ausschließlich über gemeinsamen Speicher und SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Benachrichtigt den Syscall-Thread über eine neue Syscall-Anfrage.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Auf Antwort warten.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Da SECCOMP_RET_TRAP-Filter auf Gast-Threads beliebig tief geschichtet werden können und syscallThread auf _RUN_SYSCALL_LOOP zurückfällt, ohne einen zweiten User-Notification-Listener anzufordern, läuft gvx sauber innerhalb von runsc und unterstützt rekursive Verschachtelung (gvx in gvx).

2. Natives overlayfs auf Einzeldatei-Volumes ext4lite

Um zu vermeiden, dass overlayfs über dem gofer-Root-Dateisystem (9p/lisafs) des äußeren gVisor-Containers gemountet wird, legt -v gvx-docker:/var/lib/docker /var/lib/docker auf dem internen Dateisystem ext4lite von gvx ab, das in einer einzigen regulären Datei (gvx-docker.ext4) gespeichert ist.

Für das äußere runsc ist gvx-docker.ext4 lediglich eine reguläre Datei, auf die über pread64(2) und pwrite64(2) zugegriffen wird. Innerhalb von gvx stellt ext4lite ein JBD2-journalisiertes ext4-Dateisystem bereit, mit erweiterten Attributen trusted.overlay.* und security.capability, Whiteout-Knoten als Zeichengeräte (0:0), d_type-Verzeichniseinträgen und RENAME_EXCHANGE. Dadurch kann der innere dockerd seinen nativen Speichertreiber overlayfs verwenden.

3. Eigenständiger netstack mit docker0 und iptables-legacy

Innerhalb von gvx interagiert dockerd weder mit den Netzwerkschnittstellen des äußeren runsc-Containers noch mit dessen netfilter-Tabellen. Stattdessen gilt:

  • dockerd erzeugt seine Bridge docker0 und veth-Paare im eigenen netstack von gvx im User-Space.
  • Alle Regeln von iptables-legacy (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) werden gegen die interne netfilter-Implementierung von gvx ausgeführt.
  • Verschachtelte runc-Container mounten ihre Hierarchien /proc, /sys und /sys/fs/cgroup innerhalb der In-Memory-Mount-Tabelle VFS2 von gvx und rufen niemals den Systemaufruf mount(2) des äußeren runsc auf.

Verifizierung von verschachteltem docker:dind innerhalb von gVisor (oder gvx in gvx)

Beide Szenarien (rungv innerhalb von runsc oder rungv innerhalb von rungv) lassen sich mit den folgenden Befehlen überprüfen:

 1# Innerhalb eines äußeren gVisor (runsc)-Containers oder eines äußeren rungv-Containers:
 2mkdir -p ~/.gvx
 3rungv run \
 4  -p unix:~/.gvx/docker.sock:2375 \
 5  -v gvx-docker:/var/lib/docker \
 6  -e DOCKER_TLS_CERTDIR= \
 7  docker:dind --tls=false &
 8DIND_PID=$!
 9
10export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
11until docker info >/dev/null 2>&1; do sleep 0.5; done
12
13# Prüfen, ob der innere Docker-Daemon natives overlayfs (nicht vfs) verwendet
14docker info --format 'Storage Driver: {{.Driver}}'
15# Ausgabe: Storage Driver: overlayfs
16
17# Einen mehrschichtigen Build ausführen und einen verschachtelten Container starten
18mkdir -p /tmp/nested-test && cd /tmp/nested-test
19cat <<'EOF' > Dockerfile
20FROM alpine:3.20
21RUN adduser -D -u 1001 appuser && mkdir -p /app && chown appuser:appuser /app
22USER appuser
23WORKDIR /app
24RUN echo "nested-build-ok" > status.txt
25CMD ["cat", "/app/status.txt"]
26EOF
27
28docker build -t nested-test:latest .
29docker run --rm nested-test:latest
30# Ausgabe: nested-build-ok
31
32kill -INT $DIND_PID

Weiterführende Literatur in dieser Reihe

  1. gvx: Linux-Container, Docker und Kubernetes in einem unprivilegierten Prozess ausführen: Überblick über gvx unter Windows (whpx, systrap), macOS (hvf) und Linux (systrap).
  2. Docker, docker build und docker compose ohne Root-Rechte oder User Namespaces ausführen: Wie gvx docker:dind ausführt, wenn root, sudo, unshare(CLONE_NEWUSER), mount(2) und /dev/fuse blockiert sind.
  3. Docker-in-Docker innerhalb von gVisor und verschachtelten Sandboxes mit gvx ausführen (dieser Beitrag)
  4. Schnelle isolierte Docker-Umgebungen für RL-Rollouts von KI-Agenten mit gvx: Wie sich gvx und Copy-on-Write-Snapshots mit einzelnen ext4lite-Dateien nutzen lassen, um Hunderte parallele SWE-bench- und RL-Rollout-Umgebungen mit Zurücksetzungen in 100 Millisekunden auszuführen.