GoSuda

Schnelle isolierte Docker-Umgebungen für AI-Agenten-RL-Rollouts mit gvx

By Lemon Mint views ...

Der Sicherheits- und Infrastrukturengpass bei Coding-Agent-RL-Rollouts

Das Training und die Evaluierung von Software-Engineering-Agenten über Benchmarks und Reinforcement-Learning-(RL)-Frameworks wie SWE-bench, SWE-ReX, R2E-Gym, SWE-Gym, Verl und OpenHands erfordern die Ausführung Tausender gleichzeitiger Rollouts von nicht vertrauenswürdigem, durch LLMs generiertem Code. Bei realen Software-Engineering-Aufgaben bearbeitet ein Agent nicht lediglich Textdateien: Er führt docker build aus, startet Datenbanken und Microservices über docker compose, führt Integrationstestsuiten aus und untersucht Container-Logs.

Im Rollout-Maßstab (128 bis 1.024 gleichzeitige Episoden pro Cluster) stößt die bestehende Container-Infrastruktur auf vier systemische Sicherheits- und Leistungsengpässe:

  1. Sperrkonflikte und Zustandslecks bei dockerd auf dem Host: Wenn Hunderte gleichzeitiger RL-Worker auf einen einzigen Host-/var/run/docker.sock zugreifen, erhält jeder Agent root-äquivalenten Zugriff auf den Host-Daemon, die Extraktion der Image-Layer wird serialisiert, der Subnetz-Adresspool von docker0 erschöpft sich, in den Host-iptables treten Fehler vom Typ Another app is currently holding the xtables lock auf, und verwaiste Container lecken über Episoden hinweg.
  2. Container-Escape-Risiko durch privilegiertes DinD: Der Betrieb standardmäßiger docker:dind-Container erfordert --privileged (CAP_SYS_ADMIN), wodurch rohe Host-Blockgeräte, cgroup-Release-Agents und Kernelmodule für nicht vertrauenswürdige RL-Rollouts offengelegt werden.
  3. Einschränkungen der Hardwarevirtualisierung (/dev/kvm): MicroVM-Laufzeitumgebungen (Firecracker, QEMU, Kata Containers) isolieren innere Kernel sauber, benötigen jedoch /dev/kvm. Standard-Kubernetes-Pods ohne verschachtelte Virtualisierung, Bare-Metal-Cloud-Instanzen, auf denen KVM deaktiviert ist, und bestehende gVisor-(runsc)-Sandboxes können keine KVM-MicroVMs starten.
  4. Hohe Latenz beim Zurücksetzen der Episode (env.reset()): Das Starten einer neuen docker:dind-Umgebung sowie das Abrufen und Entpacken von Basis-Images für jede RL-Episode dauert 10 bis 60 Sekunden, sodass GPU-Trainingsworker untätig bleiben, während sie auf das Zurücksetzen der Umgebungen warten.

gvx (rungv-CLI und z.gfire.dev/gvx/pkg/gvx Go-SDK) stellt eine sichere Rollout-Laufzeitumgebung mit mehrschichtiger Verteidigung (defense-in-depth) bereit, die alle vier Engpässe löst. Jede Rollout-Umgebung läuft als einziger, unprivilegierter, selbst gesperrter User-Space-Prozess (isoliert durch den gVisor-Sentry-Kernel von Google, pkg/hostlockdown und einen speicherinternen netstack), dessen gesamter /var/lib/docker-Zustand in einer einzigen Copy-on-Write-.ext4-Datei (ext4lite) liegt.

Null-Privilegien-Sicherheit für nicht vertrauenswürdige Agent-Rollouts: Jede gvx-Episode sperrt ihren eigenen Host-OS-Prozess, bevor sie Gastcode ausführt (PR_SET_NO_NEW_PRIVS + BPF-seccomp unter Linux, Seatbelt deny default unter macOS, Restricted Token + Job Object unter Windows). Selbst wenn ein RL-Agent innerhalb seiner Episode docker run --privileged oder destruktive rm -rf /-Befehle ausführt, bleiben diese Operationen strikt auf den User-Space-Sentry-Kernel dieser Episode und ihre flüchtige .ext4-Volume-Datei beschränkt.


Rollout-Architektur: Parallele unprivilegierte Instanzen + Reflink-Snapshots

In gvx ist jeder Rollout-Worker ein gewöhnlicher unprivilegierter OS-Prozess mit eigenem gVisor-Sentry-Kernel, eigenem User-Space-netstack (10.0.2.15/24) und eigenem ext4lite-Blockvolume (ep-N.ext4). Alle Worker teilen sich dabei einen schreibgeschützten, inhaltsadressierbaren erofs-OCI-Image-Cache im Host-Page-Cache.

Architektur paralleler Agent-RL-Rollouts mit gvx

Warum einzelne ext4lite-Volumes die Ökonomie des RL-Resets verändern

Da das ext4lite-Dateisystem ein vollständiges JBD2-journalisiertes ext4-Volume (einschließlich der overlayfs-Layer-Verzeichnisse von Docker, der erweiterten Attribute trusted.overlay.*, der Whiteout-Gerätknoten und der Image-Metadaten) in einer einzigen regulären Host-Datei speichert, wird die Verwaltung des Episodenlebenszyklus zu einer einzigen Dateioperation:

  1. 100-Millisekunden-env.reset() über cp --reflink=always:
    • Sie erstellen einmalig eine Datei golden-docker.ext4, die vorab abgerufene Basis-Images enthält (python:3.11, postgres:16, redis:7 oder aufgabenspezifische SWE-bench-Basis-Images).
    • Zu Beginn jeder RL-Episode erzeugt cp --reflink=always golden-docker.ext4 ep-0042.ext4 (auf XFS, Btrfs, ZFS oder macOS APFS) einen unabhängigen Copy-on-Write-Klon in weniger als 100 Millisekunden, ohne die zugrunde liegenden Plattenblöcke zu kopieren.
    • Nur die während dieser spezifischen Episode geänderten 4-KiB-Blöcke beanspruchen zusätzlichen Speicherplatz.
  2. Rückstandsfreier Abbau (env.close()):
    • Das Beenden des rungv-Prozesses (sb.Close() in Go) und das Entfernen von ep-0042.ext4 gibt 100 % der Prozesse, Netzwerk-Sockets, Mounts und Speicherressourcen der Episode in etwa 10 Millisekunden frei. Nichts wird jemals in der Mount-Tabelle des Host-Kernels eingetragen oder in den cgroups des Hosts registriert.

Schritt-für-Schritt-Workflow: Goldener .ext4-Snapshot und schnelles Zurücksetzen der Episode

Sie können rungv auf jedem Rollout-Worker-Knoten entweder durch Herunterladen des SLSA-attestierten, vorkompilierten Binärpakets aus den GitHub Releases (gh attestation verify rungv-v0.2.0-linux-amd64.tar.gz --repo gfire-sigs/gvx --signer-workflow gfire-sigs/gvx/.github/workflows/release.yml --deny-self-hosted-runners) oder durch Ausführen von CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest installieren.

Schritt 1: Das goldene docker:dind-Volume einmalig erstellen

 1mkdir -p /srv/rl-snapshots /srv/rl-episodes
 2
 3# 1. Eine Seed-docker:dind-Instanz starten, die von golden-docker.ext4 gestützt wird
 4rungv run \
 5  -p unix:/srv/rl-snapshots/seed.sock:2375 \
 6  --mount type=volume,src=/srv/rl-snapshots/golden-docker.ext4,dst=/var/lib/docker \
 7  -e DOCKER_TLS_CERTDIR= \
 8  docker:dind --tls=false &
 9SEED_PID=$!
10
11# 2. Basis-Images und Build-Cache innerhalb von golden-docker.ext4 vorab befüllen
12export DOCKER_HOST="unix:///srv/rl-snapshots/seed.sock"
13until docker info >/dev/null 2>&1; do sleep 0.5; done
14
15docker pull python:3.11-slim
16docker pull redis:7-alpine
17docker pull postgres:16-alpine
18
19# 3. Die Seed-Instanz kontrolliert beenden, damit ext4lite die Puffer leert und das Image komprimiert
20kill -INT $SEED_PID
21wait $SEED_PID

Schritt 2: Parallele RL-Episoden in 100 Millisekunden starten

Jeder parallele Rollout-Worker klont golden-docker.ext4 per Reflink und startet eine isolierte docker:dind-Instanz, in der alle Images bereits in /var/lib/docker vorhanden sind:

 1EPISODE_ID="ep-0042"
 2SOCK="/srv/rl-episodes/${EPISODE_ID}.sock"
 3
 4# Sofortiger Copy-on-Write-Klon (~100 ms auf XFS/Btrfs/APFS)
 5cp --reflink=always \
 6  /srv/rl-snapshots/golden-docker.ext4 \
 7  "/srv/rl-episodes/${EPISODE_ID}.ext4"
 8
 9# Isolierte unprivilegierte Episode mit 4 GiB RAM und 2 CPUs starten
10rungv run \
11  -m 4g --cpus 2 \
12  -p "unix:${SOCK}:2375" \
13  --mount "type=volume,src=/srv/rl-episodes/${EPISODE_ID}.ext4,dst=/var/lib/docker" \
14  -e DOCKER_TLS_CERTDIR= \
15  docker:dind --tls=false &
16EP_PID=$!
17
18export DOCKER_HOST="unix://${SOCK}"
19until docker info >/dev/null 2>&1; do sleep 0.2; done
20
21# Innerhalb des Rollout-Workers sind Images sofort ohne Netzwerk-Pull verfügbar
22docker images
23
24# Abbau der Episode nach der Berechnung der Belohnung (~10 ms)
25kill -KILL $EP_PID
26rm -f "/srv/rl-episodes/${EPISODE_ID}.ext4" "${SOCK}"

Programmatischer Rollout-Pool mit dem z.gfire.dev/gvx/pkg/gvx Go-SDK

Wenn Ihr RL-Umgebungsserver oder Ihr Evaluierungsharness in Go geschrieben ist (oder einen Go-Rollout-Daemon über gRPC aufruft), können Sie parallele Container-Episoden direkt über z.gfire.dev/gvx/pkg/gvx verwalten:

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"fmt"
 7	"log"
 8	"os"
 9	"os/exec"
10	"path/filepath"
11	"time"
12
13	"z.gfire.dev/gvx/pkg/gvx"
14)
15
16func runEpisode(ctx context.Context, goldenExt4, scratchDir string, epID int) error {
17	epExt4 := filepath.Join(scratchDir, fmt.Sprintf("ep-%04d.ext4", epID))
18	defer os.Remove(epExt4)
19
20	// 1. Golden-ext4lite-Volume per Reflink klonen
21	cpCmd := exec.CommandContext(ctx, "cp", "--reflink=always", goldenExt4, epExt4)
22	if err := cpCmd.Run(); err != nil {
23		return fmt.Errorf("clone golden volume: %w", err)
24	}
25
26	// 2. Ein Wanduhr-Zeitlimit pro Episode anwenden
27	epCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
28	defer cancel()
29
30	var stdout, stderr bytes.Buffer
31	sb, err := gvx.New(epCtx, gvx.Config{
32		Platform:               gvx.PlatformAuto,
33		NetworkMode:            gvx.NetModeNAT,
34		NetRaw:                 true,
35		AllowPacketSocketWrite: true,
36		AllowSUID:              true,
37		OCIImage:               "docker:dind",
38		Entrypoint:             []string{"sh", "-c"},
39		EntrypointSet:          true,
40		Cmd: []string{
41			"dockerd-entrypoint.sh --tls=false >/var/log/dockerd.log 2>&1 & " +
42				"until docker info >/dev/null 2>&1; do sleep 0.2; done && " +
43				"docker run --rm python:3.11-slim python -c 'print(6 * 7)'",
44		},
45		Envv: []string{"DOCKER_TLS_CERTDIR="},
46		Mounts: []gvx.Mount{
47			{
48				Kind:      gvx.MountVolume,
49				ImagePath: epExt4,
50				GuestPath: "/var/lib/docker",
51			},
52		},
53		Resources: gvx.Resources{
54			MemoryBytes: 4 << 30, // 4 GiB Obergrenze
55			CPUMillis:   2000,    // 2.0 CPUs
56		},
57		Stdout: &stdout,
58		Stderr: &stderr,
59	})
60	if err != nil {
61		return fmt.Errorf("init episode %d: %w", epID, err)
62	}
63	defer sb.Close()
64
65	exitCode, err := sb.Run(epCtx)
66	if err != nil {
67		return fmt.Errorf("run episode %d: %w", epID, err)
68	}
69
70	log.Printf("episode %d finished: exit=%d stdout=%q", epID, exitCode, stdout.String())
71	return nil
72}
73
74func main() {
75	ctx := context.Background()
76	if err := runEpisode(ctx, "/srv/rl-snapshots/golden-docker.ext4", "/srv/rl-episodes", 1); err != nil {
77		log.Fatal(err)
78	}
79}

Vergleich von Laufzeitumgebungen für Agent-RL-Sandboxes

Metrik / AnforderungGemeinsamer Host-dockerdPrivilegiertes docker:dindFirecracker / KVM-MicroVMStandard-gVisor (runsc)gvx (rungv / pkg/gvx)
Host-root oder --privileged erforderlichJa (docker.sock = root)Ja (--privileged)Benötigt /dev/kvm + TAP-SetupJa (oder userns + mount)Nein (einzelner unprivilegierter Prozess)
Funktioniert in unprivilegierten K8s-Pods / bestehenden runscNeinNeinNein (kein /dev/kvm)Schlägt fehl (EBUSY bei verschachteltem seccomp)Ja (automatischer Fallback auf futex-Schleife)
Unterstützt inneres docker build und docker composeTeilt den Host-DaemonJaJaSchlägt ohne Host-DinD-Feinabstimmung fehlJa (overlayfs auf ext4lite)
Netzwerkisolation pro EpisodeGemeinsames docker0 / xtables-LockSeparater NamespaceSeparate VM-NICSeparater netnsIsoliertes In-Prozess-netstack
Latenz beim Zurücksetzen der Warm-Episode (env.reset())5 s bis 30 s (Container-/Volume-Bereinigung)10 s bis 60 s (kalter Daemon)~150 ms (VM-Snapshot)5 s bis 20 s~100 ms (cp --reflink=always .ext4)
Plattformübergreifende lokale Entwicklung (Windows / macOS / Linux)Erfordert Linux-VMErfordert Linux-VMNur Linux-KVMNur LinuxNativ unter Windows, macOS und Linux

Weitere Lektüre in dieser Reihe

  1. gvx: Linux-Container, Docker und Kubernetes in einem unprivilegierten Prozess ausführen: Vollständiger architektonischer Ü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: Kurze Anleitung in drei Schritten zur Einrichtung und Fehlerbehebung, wenn 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: Detaillierte Analyse, warum docker.io und verschachteltes runsc innerhalb von gVisor (runsc) fehlschlagen und wie gvx bei EBUSY auf die futex-Schleife _RUN_SYSCALL_LOOP zurückfällt.
  4. Schnelle isolierte Docker-Umgebungen für KI-Agent-RL-Rollouts mit gvx (dieser Beitrag)