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-Agentenplattformen, Kubernetes-agent-sandbox-Deployments (RuntimeClass: gvisor) und Evaluationsumgebungen für Reinforcement Learning isolieren nicht vertrauenswürdige Workloads gezielt in gVisor (runsc)-Containern, um den Host-Kernel zu schützen. Wenn ein Workload in einem standardmäßig unprivilegierten runsc-Container versucht, docker.io oder docker-ce per apt-get zu installieren und dockerd zu starten, schlägt dies fehl, selbst wenn er innerhalb des Containers root (UID 0) ist.

Mit gvx (rungv) können Sie 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 offenzulegen oder --privileged zu gewähren.

Erhaltung und Stärkung der äußeren Sandbox-Isolation: Das Ausführen von rungv innerhalb von runsc umgeht keine einzige Einschränkung, die durch die äußere gVisor-Sandbox erzwungen wird. Stattdessen erzeugt gvx einen zweiten, verschachtelten gVisor-Sentry-Kernel im Benutzerraum mit eigenem hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), eigenem In-Memory-netstack und einem ext4lite-Volume aus einer einzelnen Datei. Innere dockerd- und runc-Container sind doppelt gesandboxt: Ihre Systemaufrufe, Mounts und Rohpakete verlassen den inneren gvx-Benutzerraum-Kernel niemals.

 1# Innerhalb eines bestehenden gVisor (runsc)-Containers oder Kubernetes-Pods:
 2# Installieren Sie rungv aus einem SLSA-attestierten GitHub-Release-Archiv oder über go install:
 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# Standard-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 ... (containerized)
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) in einem Standard-runsc-Container ausführt und versucht, dockerd zu starten, stößt er auf fünf aufeinanderfolgende technische Hürden.

1. Laden von Kernelmodulen (modprobe) gegen den virtuellen Kernel 4.4.0

Während der Paketinstallation und des dockerd-Starts führen Initialisierungsskripte modprobe aus, um overlay, br_netfilter, ip_tables, nf_nat und xt_conntrack zu laden. Der äußere gVisor-Sentry blockiert init_module(2) und finit_module(2) und meldet eine virtuelle Kernelversion (4.4.0), für die kein Verzeichnis /lib/modules/4.4.0 existiert:

1modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
2modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
3modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
4modprobe: FATAL: Module nf_nat not found in directory /lib/modules/4.4.0

2. iptables-nft- (nf_tables Netlink) und docker0-Bridge-Fehler

Auf Ubuntu 22.04, Ubuntu 24.04 sowie 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. In einem Standard-gVisor-Container ohne Netzwerkadministrationsrechte oder nf_tables-Netlink-Unterstützung stürzt dockerd bei der Initialisierung der NAT-Kette DOCKER und der Bridge docker0 ab:

1failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to create NAT chain DOCKER: iptables failed: iptables -t nat -N DOCKER
2iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
3iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
4iptables: No chain/target/match by that name.
5Unable 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.)
6Error initializing network controller: error obtaining controller instance: failed to create bridge "docker0": operation not permitted

3. overlay2-Fehler auf gofer- / 9p-Rootfs und vfs-Speichererschöpfung

Sofern der äußere runsc-Administrator nicht explizit ein dediziertes tmpfs- oder Filestore-Mount unter /var/lib/docker vorkonfiguriert hat, wird das Container-Rootfs durch den gofer (9p/lisafs) von gVisor oder ein verschachteltes Overlay-Mount gestützt. Das Mounten von overlay2 auf /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

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

4. Ablehnung verschachtelter runc-Cgroup- und /proc-Mounts

Selbst wenn dockerd mit dockerd --iptables=false --bridge=none --storage-driver=vfs gezwungen wird zu starten, ruft docker run oder docker build runc auf, um den inneren Container zu erstellen. In einem standardmäßig unprivilegierten gVisor-Container schlägt runc beim Mounten von Cgroups, beim erneuten Mounten von /proc oder beim Laden innerer seccomp-Filter fehl:

1docker: 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:
2error 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
3cgroups: cgroup mountpoint does not exist: unknown
4runc create failed: systemd not running on this host: cannot use systemd cgroup manager
5error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
6error during container init: error loading seccomp filter into kernel: operation not permitted

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

Wenn ein Benutzer versucht, eine weitere runsc-Instanz innerhalb von runsc mit der systrap-Plattform 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 SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) pro Task-Hierarchie. Da die äußere Sandbox diesen Listener-Slot bereits belegt, liefert der innere seccomp(2)-Aufruf EBUSY (device or resource busy) zurück.


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

gvx adressiert alle fünf Hürden in seinem eigenen Benutzerraum-Kernel und seiner Plattformschicht. Dadurch kann rungv innerhalb eines äußeren gVisor-Containers (runsc) oder sogar rekursiv innerhalb eines weiteren gvx-Containers (gvx in gvx) ausgeführt werden.

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

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

In 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 Registerzustände mit dem Sentry über gemeinsamen Speicher und SYS_FUTEX aus.
  • Ein Stub-syscallThread (syscall_thread.go): Führt Systemaufrufe des Adressraums (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 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 wartet auf SYS_FUTEX (FUTEX_WAIT) über dem gemeinsamen Speicherbereich aus zwei Seiten, 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() 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}

Wenn installSeccompNotify() unix.EBUSY zurückgibt, weil eine äußere gVisor- (runsc) oder gvx-Sandbox bereits den einzigen Listener-Slot der Filterkette des Prozesses belegt:

  • setzt installSeccompNotifyIfFree() seccompNotifyBusy (atomic.Bool), protokolliert eine Warnung für den Prozess und lässt t.seccompNotify == nil für diesen Stub sowie für alle nachfolgenden Stubs.
  • setzt in syscallThread.detach() (syscall_thread_amd64.go und syscall_thread_arm64.go) bei t.seccompNotify == nil den Wert regs.Rbx = _RUN_SYSCALL_LOOP (5) auf amd64 (bzw. regs.Regs[9] = _RUN_SYSCALL_LOOP auf arm64) anstelle von _RUN_SECCOMP_LOOP (6).
  • verteilt der innere Sentry in syscallThread.syscall() (syscall_thread.go) Stub-Systemaufrufe ausschließlich über gemeinsamen Speicher und SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Benachrichtige den Syscall-Thread über eine neue Systemaufrufanfrage.
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 im Stack wirken und syscallThread ohne Anforderung eines zweiten User-Notification-Listeners auf _RUN_SYSCALL_LOOP zurückfällt, läuft gvx sauber innerhalb von runsc und unterstützt rekursive Verschachtelung (gvx in gvx).

2. Natives overlayfs auf ext4lite-Volumes aus einer einzelnen Datei

Um zu vermeiden, overlayfs auf das Root-Dateisystem des äußeren gVisor-Containers zu mounten, das vom gofer (9p/lisafs) bereitgestellt wird, stützt -v gvx-docker:/var/lib/docker /var/lib/docker auf das interne Dateisystem ext4lite von gvx, das in einer einzelnen 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 mit den erweiterten Attributen trusted.overlay.* und security.capability, Whiteout-Knoten als Zeichengeräte (0:0), d_type-Verzeichniseinträgen und RENAME_EXCHANGE bereit. Dadurch kann der innere dockerd seinen nativen Speichertreiber overlayfs verwenden.

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

Innerhalb von gvx interagiert dockerd nicht mit den Netzwerkschnittstellen oder den netfilter-Tabellen des äußeren runsc-Containers. Stattdessen:

  • erstellt dockerd seine Bridge docker0 und seine veth-Paare innerhalb des eigenen netstack von gvx im Benutzerraum.
  • werden alle iptables-legacy-Regeln (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) gegen die interne netfilter-Implementierung von gvx ausgeführt.
  • mounten verschachtelte runc-Container ihre Hierarchien /proc, /sys und /sys/fs/cgroup in 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)

Sie können beide Szenarien (rungv in runsc oder rungv in rungv) 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 den nativen 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 Lektüre in dieser Reihe

  1. gvx: Ausführung von Linux-Containern, Docker und Kubernetes in einem einzigen unprivilegierten Prozess: Überblick über gvx unter Windows (whpx, systrap), macOS (hvf) und Linux (systrap).
  2. Docker, docker build und docker compose ohne Root 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 Sie gvx und Copy-on-Write-Snapshots aus einzelnen ext4lite-Dateien verwenden, um Hunderte parallele SWE-bench- und RL-Rollout-Umgebungen mit 100-Millisekunden-Resets auszuführen.