GoSuda

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

By Lemon Mint views ...

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:

  1. Rywalizacja o blokady dockerd hosta i wyciek stanu: Skierowanie setek równoległych workerów RL na pojedynczy host /var/run/docker.sock daje każdemu agentowi uprawnienia równoważne root do demona hosta, serializuje wypakowywanie warstw obrazów, wyczerpuje pule podsieci docker0, wywołuje błędy Another app is currently holding the xtables lock w iptables hosta oraz pozostawia osierocone kontenery między epizodami.
  2. Ryzyko ucieczki z kontenera w trybie uprzywilejowanym DinD: Uruchamianie standardowych kontenerów docker:dind wymaga --privileged (CAP_SYS_ADMIN), co udostępnia niezaufanym rolloutom RL surowe urządzenia blokowe hosta, agenty zwalniania cgroup oraz moduły jądra.
  3. 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 piaskownice gVisor (runsc) nie mogą uruchamiać microVM KVM.
  4. Wolny reset epizodu (env.reset()): Uruchomienie świeżego środowiska docker:dind oraz 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 gvx blokuje własny proces systemu hosta przed uruchomieniem kodu gościa (PR_SET_NO_NEW_PRIVS + BPF seccomp w Linuksie, Seatbelt deny default w macOS, Restricted Token + Job Object w Windows). Nawet jeśli agent RL wykona docker run --privileged lub destrukcyjne polecenia rm -rf / w ramach swojego epizodu, operacje te pozostają ściśle ograniczone do jądra Sentry przestrzeni 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.

Równoległa architektura rolloutów RL agentów z gvx

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:

  1. Reset env.reset() w 100 milisekund za pomocą cp --reflink=always:
    • Plik golden-docker.ext4 budujesz jednorazowo; zawiera on wstępnie pobrane obrazy bazowe (python:3.11, postgres:16, redis:7 lub specyficzne dla zadania obrazy bazowe SWE-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.
  2. Zakończenie bez pozostałości (env.close()):
    • Zakończenie procesu rungv (sb.Close() w Go) oraz usunięcie dowiązania ep-0042.ext4 zwalnia 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.

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 / wymaganieWspółdzielony dockerd hostaUprzywilejowany docker:dindFirecracker / KVM MicroVMStandardowy gVisor (runsc)gvx (rungv / pkg/gvx)
Wymagany root hosta lub --privilegedTak (docker.sock = root)Tak (--privileged)Wymaga /dev/kvm + konfiguracji TAPTak (lub userns + mount)Nie (pojedynczy nieuprzywilejowany proces)
Działa wewnątrz nieuprzywilejowanych podów K8s / istniejącego runscNieNieNie (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 composeWspółdzieli demona hostaTakTakZawodzi bez strojenia DinD na hościeTak (overlayfs na ext4lite)
Izolacja sieci per epizodWspółdzielona blokada docker0 / xtablesOddzielna przestrzeń nazwOddzielna karta sieciowa VMOddzielny netnsOdizolowany 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 LinuxWymaga maszyny wirtualnej LinuxTylko Linux KVMTylko LinuxNatywnie na Windows, macOS i Linux

Dalsza lektura w tej serii

  1. gvx: uruchamianie kontenerów Linux, Dockera i Kubernetes w jednym nieuprzywilejowanym procesie: Kompletny przegląd architektury gvx w systemach Windows (whpx, systrap), macOS (hvf) i Linux (systrap).
  2. 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.
  3. Uruchamianie Docker-in-Docker wewnątrz gVisor i zagnieżdżonych piaskownic z gvx: Szczegółowe omówienie tego, dlaczego docker.io i zagnieżdżony runsc zawodzą wewnątrz gVisor (runsc) oraz jak gvx przechodzi na pętlę futex _RUN_SYSCALL_LOOP przy EBUSY.
  4. Szybkie odizolowane środowiska Docker dla rolloutów RL agentów AI z gvx (ten wpis)