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

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:
- Sperrkonflikte und Zustandslecks bei
dockerdauf dem Host: Wenn Hunderte gleichzeitiger RL-Worker auf einen einzigen Host-/var/run/docker.sockzugreifen, erhält jeder Agent root-äquivalenten Zugriff auf den Host-Daemon, die Extraktion der Image-Layer wird serialisiert, der Subnetz-Adresspool vondocker0erschöpft sich, in den Host-iptablestreten Fehler vom TypAnother app is currently holding the xtables lockauf, und verwaiste Container lecken über Episoden hinweg. - 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. - 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 bestehendegVisor-(runsc)-Sandboxes können keine KVM-MicroVMs starten. - Hohe Latenz beim Zurücksetzen der Episode (
env.reset()): Das Starten einer neuendocker: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-seccompunter Linux, Seatbeltdeny defaultunter macOS, Restricted Token + Job Object unter Windows). Selbst wenn ein RL-Agent innerhalb seiner Episodedocker run --privilegedoder destruktiverm -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.

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:
- 100-Millisekunden-
env.reset()übercp --reflink=always:- Sie erstellen einmalig eine Datei
golden-docker.ext4, die vorab abgerufene Basis-Images enthält (python:3.11,postgres:16,redis:7oder aufgabenspezifischeSWE-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.
- Sie erstellen einmalig eine Datei
- Rückstandsfreier Abbau (
env.close()):- Das Beenden des
rungv-Prozesses (sb.Close()in Go) und das Entfernen vonep-0042.ext4gibt 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.
- Das Beenden des
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 / Anforderung | Gemeinsamer Host-dockerd | Privilegiertes docker:dind | Firecracker / KVM-MicroVM | Standard-gVisor (runsc) | gvx (rungv / pkg/gvx) |
|---|---|---|---|---|---|
Host-root oder --privileged erforderlich | Ja (docker.sock = root) | Ja (--privileged) | Benötigt /dev/kvm + TAP-Setup | Ja (oder userns + mount) | Nein (einzelner unprivilegierter Prozess) |
Funktioniert in unprivilegierten K8s-Pods / bestehenden runsc | Nein | Nein | Nein (kein /dev/kvm) | Schlägt fehl (EBUSY bei verschachteltem seccomp) | Ja (automatischer Fallback auf futex-Schleife) |
Unterstützt inneres docker build und docker compose | Teilt den Host-Daemon | Ja | Ja | Schlägt ohne Host-DinD-Feinabstimmung fehl | Ja (overlayfs auf ext4lite) |
| Netzwerkisolation pro Episode | Gemeinsames docker0 / xtables-Lock | Separater Namespace | Separate VM-NIC | Separater netns | Isoliertes 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-VM | Erfordert Linux-VM | Nur Linux-KVM | Nur Linux | Nativ unter Windows, macOS und Linux |
Weitere Lektüre in dieser Reihe
- gvx: Linux-Container, Docker und Kubernetes in einem unprivilegierten Prozess ausführen: Vollständiger architektonischer Überblick über
gvxunter Windows (whpx,systrap), macOS (hvf) und Linux (systrap). - 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/fuseblockiert sind. - Docker-in-Docker innerhalb von gVisor und verschachtelten Sandboxes mit gvx ausführen: Detaillierte Analyse, warum
docker.iound verschachteltesrunscinnerhalb vongVisor(runsc) fehlschlagen und wiegvxbeiEBUSYauf die futex-Schleife_RUN_SYSCALL_LOOPzurückfällt. - Schnelle isolierte Docker-Umgebungen für KI-Agent-RL-Rollouts mit gvx (dieser Beitrag)