Szybkie izolowane środowiska Docker dla rolloutów RL agentów AI z gvx

Wąskie gardło bezpieczeństwa i infrastruktury w rolloutach RL agentów kodujących
Trenowanie i ewaluacja agentów inżynierii oprogramowania w ramach benchmarków oraz frameworków uczenia przez wzmacnianie (RL), takich jak SWE-bench, SWE-ReX, R2E-Gym, SWE-Gym, Verl i OpenHands, wymaga uruchamiania tysięcy równoległych rolloutów niezaufanego kodu generowanego przez LLM. W rzeczywistych zadaniach inżynierii oprogramowania agent nie tylko edytuje pliki tekstowe: uruchamia docker build, podnosi bazy danych i mikrousługi za pomocą docker compose, wykonuje zestawy testów integracyjnych i analizuje logi kontenerów.
Przy skali rolloutów (od 128 do 1 024 równoległych epizodów na klaster) istniejąca infrastruktura kontenerowa napotyka cztery systemowe wąskie gardła bezpieczeństwa i wydajności:
- Rywalizacja o blokady
dockerdhosta i wyciek stanu: Skierowanie setek równoległych workerów RL na pojedynczy host/var/run/docker.sockdaje każdemu agentowi uprawnienia równoważne root do demona hosta, serializuje wypakowywanie warstw obrazów, wyczerpuje pule podsiecidocker0, wywołuje błędyAnother app is currently holding the xtables lockwiptableshosta oraz pozostawia osierocone kontenery między epizodami. - Ryzyko ucieczki z kontenera w trybie uprzywilejowanym DinD: Uruchamianie standardowych kontenerów
docker:dindwymaga--privileged(CAP_SYS_ADMIN), co udostępnia niezaufanym rolloutom RL surowe urządzenia blokowe hosta, agenty zwalniania cgroup oraz moduły jądra. - Ograniczenia wirtualizacji sprzętowej (
/dev/kvm): Środowiska MicroVM (Firecracker,QEMU,Kata Containers) czysto izolują wewnętrzne jądra, ale wymagają/dev/kvm. Standardowe pody Kubernetes bez zagnieżdżonej wirtualizacji, instancje chmurowe bare-metal, na których KVM jest wyłączony, oraz istniejące piaskownicegVisor(runsc) nie mogą uruchamiać microVM KVM. - Wolny reset epizodu (
env.reset()): Uruchomienie świeżego środowiskadocker:dindoraz pobieranie lub rozpakowywanie obrazów bazowych dla każdego epizodu RL trwa od 10 do 60 sekund, pozostawiając workery treningowe GPU bezczynne podczas oczekiwania na reset środowiska.
gvx (CLI rungv oraz SDK Go z.gfire.dev/gvx/pkg/gvx) zapewnia bezpieczne, wielowarstwowe (defense-in-depth) środowisko uruchomieniowe rolloutów, które rozwiązuje wszystkie cztery wąskie gardła. Każde środowisko rolloutu działa jako pojedynczy, nieuprzywilejowany, samoblokujący się proces przestrzeni użytkownika (izolowany przez jądro Sentry gVisor firmy Google, pkg/hostlockdown oraz netstack działający w pamięci), którego cały stan /var/lib/docker znajduje się wewnątrz pojedynczego pliku .ext4 z kopiowaniem przy zapisie (ext4lite).
Bezpieczeństwo bez uprawnień dla niezaufanych rolloutów agentów: Każdy epizod
gvxblokuje własny proces systemu hosta przed uruchomieniem kodu gościa (PR_SET_NO_NEW_PRIVS+ BPFseccompw Linuksie, Seatbeltdeny defaultw macOS, Restricted Token + Job Object w Windows). Nawet jeśli agent RL wykonadocker run --privilegedlub destrukcyjne poleceniarm -rf /w ramach swojego epizodu, operacje te pozostają ściśle ograniczone do jądraSentryprzestrzeni użytkownika tego epizodu oraz jego efemerycznego pliku woluminu.ext4.
Architektura rolloutów: równoległe instancje bez uprawnień + migawki reflink
W gvx każdy worker rolloutu jest zwykłym nieuprzywilejowanym procesem systemu operacyjnego, z własnym jądrem gVisor Sentry, własnym netstack w przestrzeni użytkownika (10.0.2.15/24) oraz własnym blokowym woluminem ext4lite (ep-N.ext4), a jednocześnie współdzieli tylko do odczytu adresowaną zawartością pamięć podręczną obrazów OCI erofs w ramach pamięci podręcznej stron hosta.

Dlaczego woluminy ext4lite w jednym pliku zmieniają ekonomię resetów RL
Ponieważ system plików ext4lite w gvx przechowuje cały wolumin ext4 z dziennikiem JBD2 (w tym katalogi warstw overlayfs Dockera, rozszerzone atrybuty trusted.overlay.*, węzły urządzeń whiteout oraz metadane obrazów) w jednym zwykłym pliku hosta, zarządzanie cyklem życia epizodu sprowadza się do jednej operacji na pliku:
- Reset
env.reset()w 100 milisekund za pomocącp --reflink=always:- Plik
golden-docker.ext4budujesz jednorazowo; zawiera on wstępnie pobrane obrazy bazowe (python:3.11,postgres:16,redis:7lub specyficzne dla zadania obrazy bazoweSWE-bench). - Na początku każdego epizodu RL polecenie
cp --reflink=always golden-docker.ext4 ep-0042.ext4(w systemach XFS, Btrfs, ZFS lub macOS APFS) tworzy niezależny klon Copy-on-Write w czasie krótszym niż 100 milisekund, bez kopiowania bazowych bloków dyskowych. - Dodatkowe miejsce na dysku zajmują wyłącznie bloki 4 KiB zmodyfikowane podczas tego konkretnego epizodu.
- Plik
- Zakończenie bez pozostałości (
env.close()):- Zakończenie procesu
rungv(sb.Close()w Go) oraz usunięcie dowiązaniaep-0042.ext4zwalnia 100% procesów, gniazd sieciowych, punktów montowania i pamięci masowej epizodu w ok. 10 milisekund. Nic nigdy nie jest montowane w tabeli montowań jądra hosta ani rejestrowane w cgroupach hosta.
- Zakończenie procesu
Procedura krok po kroku: złota migawka .ext4 i szybki reset epizodu
Program rungv można zainstalować na każdym węźle workera rolloutu, pobierając gotowy plik binarny z poświadczeniem SLSA z 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) albo uruchamiając CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest.
Krok 1: Jednorazowe utworzenie złotego woluminu docker:dind
1mkdir -p /srv/rl-snapshots /srv/rl-episodes
2
3# 1. Uruchom instancję bazową docker:dind opartą na golden-docker.ext4
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. Wstępnie wypełnij obrazy bazowe i pamięć podręczną budowania wewnątrz golden-docker.ext4
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. Łagodnie zatrzymaj instancję bazową, aby ext4lite opróżnił bufory i skompaktował obraz
20kill -INT $SEED_PID
21wait $SEED_PID
Krok 2: Uruchamianie równoległych epizodów RL w 100 milisekund
Każdy równoległy worker rolloutu klonuje golden-docker.ext4 za pomocą reflink i uruchamia odizolowaną instancję docker:dind ze wszystkimi obrazami już obecnymi w /var/lib/docker:
1EPISODE_ID="ep-0042"
2SOCK="/srv/rl-episodes/${EPISODE_ID}.sock"
3
4# Natychmiastowy klon Copy-on-Write (~100 ms na XFS/Btrfs/APFS)
5cp --reflink=always \
6 /srv/rl-snapshots/golden-docker.ext4 \
7 "/srv/rl-episodes/${EPISODE_ID}.ext4"
8
9# Uruchom odizolowany epizod bez uprawnień z 4 GiB RAM i 2 CPU
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# Wewnątrz workera rolloutu obrazy są od razu dostępne bez pobierania przez sieć
22docker images
23
24# Zakończenie epizodu po obliczeniu nagrody (~10 ms)
25kill -KILL $EP_PID
26rm -f "/srv/rl-episodes/${EPISODE_ID}.ext4" "${SOCK}"
Programowa pula rolloutów z użyciem SDK Go z.gfire.dev/gvx/pkg/gvx
Jeśli serwer środowiska RL lub harness ewaluacyjny jest napisany w Go (albo wywołuje demona rolloutów w Go przez gRPC), możesz zarządzać równoległymi epizodami kontenerów bezpośrednio za pomocą z.gfire.dev/gvx/pkg/gvx:
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. Sklonuj złoty wolumin ext4lite za pomocą reflink
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. Zastosuj limit czasu rzeczywistego dla epizodu
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, // limit 4 GiB
55 CPUMillis: 2000, // 2.0 CPU
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}
Porównanie środowisk uruchomieniowych piaskownic RL agentów
| Metryka / wymaganie | Współdzielony dockerd hosta | Uprzywilejowany docker:dind | Firecracker / KVM MicroVM | Standardowy gVisor (runsc) | gvx (rungv / pkg/gvx) |
|---|---|---|---|---|---|
Wymagany root hosta lub --privileged | Tak (docker.sock = root) | Tak (--privileged) | Wymaga /dev/kvm + konfiguracji TAP | Tak (lub userns + mount) | Nie (pojedynczy nieuprzywilejowany proces) |
Działa wewnątrz nieuprzywilejowanych podów K8s / istniejącego runsc | Nie | Nie | Nie (brak /dev/kvm) | Zawodzi (EBUSY przy zagnieżdżonym seccomp) | Tak (automatyczny awaryjny tryb pętli futex) |
Obsługuje wewnętrzne docker build i docker compose | Współdzieli demona hosta | Tak | Tak | Zawodzi bez strojenia DinD na hoście | Tak (overlayfs na ext4lite) |
| Izolacja sieci per epizod | Współdzielona blokada docker0 / xtables | Oddzielna przestrzeń nazw | Oddzielna karta sieciowa VM | Oddzielny netns | Odizolowany netstack w procesie |
Opóźnienie ciepłego resetu epizodu (env.reset()) | 5 s do 30 s (czyszczenie kontenera/woluminu) | 10 s do 60 s (zimny demon) | ~150 ms (migawka VM) | 5 s do 20 s | ~100 ms (cp --reflink=always .ext4) |
| Lokalny rozwój wieloplatformowy (Windows / macOS / Linux) | Wymaga maszyny wirtualnej Linux | Wymaga maszyny wirtualnej Linux | Tylko Linux KVM | Tylko Linux | Natywnie na Windows, macOS i Linux |
Dalsza lektura w tej serii
- gvx: uruchamianie kontenerów Linux, Dockera i Kubernetes w jednym nieuprzywilejowanym procesie: Kompletny przegląd architektury
gvxw systemach Windows (whpx,systrap), macOS (hvf) i Linux (systrap). - Uruchamianie Dockera, docker build i docker compose bez roota i przestrzeni nazw użytkownika: Szybki 3-etapowy przewodnik uruchomienia i rozwiązywania problemów, gdy zablokowane są
sudo,unshare(CLONE_NEWUSER),mount(2)oraz/dev/fuse. - Uruchamianie Docker-in-Docker wewnątrz gVisor i zagnieżdżonych piaskownic z gvx: Szczegółowe omówienie tego, dlaczego
docker.ioi zagnieżdżonyrunsczawodzą wewnątrzgVisor(runsc) oraz jakgvxprzechodzi na pętlę futex_RUN_SYSCALL_LOOPprzyEBUSY. - Szybkie odizolowane środowiska Docker dla rolloutów RL agentów AI z gvx (ten wpis)