GoSuda

Быстрые изолированные среды Docker для развертывания траекторий обучения с подкреплением (RL) AI-агентов с помощью gvx

By Lemon Mint views ...

Узкое место безопасности и инфраструктуры в RL-развертываниях кодирующих агентов

Обучение и оценка агентов для разработки программного обеспечения на таких бенчмарках и фреймворках обучения с подкреплением (reinforcement learning, RL), как SWE-bench, SWE-ReX, R2E-Gym, SWE-Gym, Verl и OpenHands, требует выполнения тысяч параллельных развертываний (rollouts) недоверенного кода, сгенерированного LLM. В реальных задачах разработки программного обеспечения агент не просто редактирует текстовые файлы: он выполняет docker build, поднимает базы данных и микросервисы через docker compose, запускает наборы интеграционных тестов и анализирует логи контейнеров.

При масштабе развертываний (от 128 до 1,024 параллельных эпизодов на кластер) существующая контейнерная инфраструктура сталкивается с четырьмя системными проблемами безопасности и производительности:

  1. Блокировки и утечка состояния у хостового dockerd: Направление сотен параллельных RL-воркеров на один хостовый /var/run/docker.sock предоставляет каждому агенту доступ к демону хоста с правами, эквивалентными root. Это сериализует извлечение слоев образов, исчерпывает подсети docker0, вызывает ошибки Another app is currently holding the xtables lock в хостовом iptables и приводит к утечке осиротевших контейнеров между эпизодами.
  2. Риск выхода из контейнера при привилегированном DinD: Запуск стандартных контейнеров docker:dind требует --privileged (CAP_SYS_ADMIN), что открывает недоверенным RL-развертываниям прямой доступ к блочным устройствам хоста, release agent в cgroup и модулям ядра.
  3. Ограничения аппаратной виртуализации (/dev/kvm): MicroVM-рантаймы (Firecracker, QEMU, Kata Containers) надежно изолируют внутреннее ядро, но требуют /dev/kvm. Стандартные Kubernetes-поды без вложенной виртуализации, bare-metal облачные инстансы, где KVM отключен, и существующие песочницы gVisor (runsc) не могут запускать KVM microVM.
  4. Высокая задержка сброса эпизода (env.reset()): Запуск нового окружения docker:dind и загрузка или распаковка базовых образов для каждого RL-эпизода занимает от 10 до 60 секунд, в течение которых GPU-воркеры обучения простаивают в ожидании сброса окружения.

gvx (CLI rungv и Go SDK z.gfire.dev/gvx/pkg/gvx) предоставляет безопасный рантайм развертываний с многоуровневой защитой (defense-in-depth), который устраняет все четыре узких места. Каждое окружение развертывания работает как единственный непривилегированный самоблокирующийся процесс пользовательского пространства (изолированный ядром gVisor Sentry от Google, pkg/hostlockdown и встроенным в память netstack), а все его состояние /var/lib/docker находится внутри одного файла .ext4 с копированием при записи (ext4lite).

Безопасность без привилегий для недоверенных развертываний агентов: Каждый эпизод gvx блокирует собственный процесс хостовой ОС до запуска гостевого кода (PR_SET_NO_NEW_PRIVS и BPF seccomp в Linux, Seatbelt deny default в macOS, Restricted Token и Job Object в Windows). Даже если RL-агент выполнит docker run --privileged или деструктивную команду rm -rf / внутри своего эпизода, эти операции остаются строго ограниченными пользовательским ядром Sentry этого эпизода и его временным файлом тома .ext4.


Архитектура развертывания: параллельные непривилегированные экземпляры и снимки reflink

В gvx каждый воркер развертывания является обычным непривилегированным процессом ОС со своим ядром gVisor Sentry, собственным netstack пользовательского пространства (10.0.2.15/24) и собственным блочным томом ext4lite (ep-N.ext4), при этом все воркеры совместно используют доступный только для чтения адресуемый по содержимому кэш образов OCI erofs в страничном кэше хоста.

Архитектура параллельных RL-развертываний агентов с gvx

Почему тома ext4lite в одном файле меняют экономику сброса в RL

Поскольку файловая система ext4lite хранит целый том ext4 с журналом JBD2 (включая каталоги слоев overlayfs Docker, расширенные атрибуты trusted.overlay.*, узлы устройств whiteout и метаданные образов) внутри одного обычного файла хоста, управление жизненным циклом эпизода сводится к одной файловой операции:

  1. Сброс env.reset() за 100 миллисекунд через cp --reflink=always:
    • Однократно создайте файл golden-docker.ext4, содержащий предварительно загруженные базовые образы (python:3.11, postgres:16, redis:7 или специфичные для задачи базовые образы SWE-bench).
    • В начале каждого RL-эпизода выполните cp --reflink=always golden-docker.ext4 ep-0042.ext4 (на XFS, Btrfs, ZFS или macOS APFS). Это создает независимый клон с копированием при записи менее чем за 100 миллисекунд без копирования базовых блоков диска.
    • Дополнительное место на диске расходуют только блоки по 4 КиБ, изменённые в ходе данного эпизода.
  2. Очистка без остатков (env.close()):
    • Завершение процесса rungv (sb.Close() в Go) и удаление ep-0042.ext4 освобождает 100% процессов, сетевых сокетов, точек монтирования и хранилища эпизода примерно за 10 миллисекунд. Ничего никогда не монтируется в таблицу монтирования ядра хоста и не регистрируется в cgroup хоста.

Пошаговый процесс: золотой снимок .ext4 и быстрый сброс эпизода

Установить rungv на каждом узле воркеров можно двумя способами: загрузив бинарный файл с атестацией SLSA из 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) или выполнив CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest.

Шаг 1: Однократное создание золотого тома docker:dind

 1mkdir -p /srv/rl-snapshots /srv/rl-episodes
 2
 3# 1. Запустить исходный экземпляр docker:dind, использующий 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. Предварительно загрузить базовые образы и кэш сборки внутрь 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. Корректно остановить исходный экземпляр, чтобы ext4lite сбросил данные и уплотнил образ
20kill -INT $SEED_PID
21wait $SEED_PID

Шаг 2: Запуск параллельных RL-эпизодов за 100 миллисекунд

Каждый параллельный воркер развертывания клонирует golden-docker.ext4 через reflink и запускает изолированный экземпляр docker:dind, в котором все образы уже находятся в /var/lib/docker:

 1EPISODE_ID="ep-0042"
 2SOCK="/srv/rl-episodes/${EPISODE_ID}.sock"
 3
 4# Мгновенный клон с копированием при записи (~100 мс на XFS/Btrfs/APFS)
 5cp --reflink=always \
 6  /srv/rl-snapshots/golden-docker.ext4 \
 7  "/srv/rl-episodes/${EPISODE_ID}.ext4"
 8
 9# Запустить изолированный непривилегированный эпизод с 4 ГиБ ОЗУ и 2 ЦП
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# Внутри воркера развертывания образы доступны сразу, без загрузки из сети
22docker images
23
24# Завершение эпизода после расчета награды (~10 мс)
25kill -KILL $EP_PID
26rm -f "/srv/rl-episodes/${EPISODE_ID}.ext4" "${SOCK}"

Программный пул развертываний с использованием Go SDK z.gfire.dev/gvx/pkg/gvx

Если ваш сервер RL-окружений или harness оценки написан на Go (или обращается к демону развертываний на Go через gRPC), вы можете управлять параллельными контейнерными эпизодами напрямую через 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. Клонировать золотой том ext4lite через 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. Задать ограничение wall-clock времени для эпизода
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 ГиБ
55			CPUMillis:   2000,    // 2,0 ЦП
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}

Сравнение рантаймов песочниц для RL-агентов

Метрика / требованиеОбщий хостовый dockerdПривилегированный docker:dindFirecracker / KVM MicroVMСтандартный gVisor (runsc)gvx (rungv / pkg/gvx)
Требуется root хоста или --privilegedДа (docker.sock = root)Да (--privileged)Нужны /dev/kvm и настройка TAPДа (или userns + mount)Нет (единственный непривилегированный процесс)
Работает внутри непривилегированных K8s Pod / существующего runscНетНетНет (нет /dev/kvm)Ошибка (EBUSY при вложенном seccomp)Да (автоматический откат на цикл futex)
Поддерживает внутренние docker build и docker composeИспользует общий демон хостаДаДаОшибка без настройки host DinDДа (overlayfs на ext4lite)
Сетевая изоляция для каждого эпизодаОбщие docker0 / блокировка xtablesОтдельное пространство именОтдельный сетевой интерфейс VMОтдельный netnsИзолированный netstack внутри процесса
Задержка сброса теплого эпизода (env.reset())От 5 с до 30 с (очистка контейнеров/томов)От 10 с до 60 с (холодный демон)~150 мс (снимок VM)От 5 с до 20 с~100 мс (cp --reflink=always для .ext4)
Локальная разработка на разных платформах (Windows / macOS / Linux)Требуется VM с LinuxТребуется VM с LinuxТолько Linux KVMТолько LinuxНативно на Windows, macOS и Linux

Дополнительные материалы в этой серии

  1. gvx: запуск контейнеров Linux, Docker и Kubernetes в одном непривилегированном процессе: Полный архитектурный обзор gvx для Windows (whpx, systrap), macOS (hvf) и Linux (systrap).
  2. Запуск Docker, docker build и docker compose без root и пользовательских пространств имен: Краткое руководство из трех шагов по начальной настройке и устранению неполадок, когда заблокированы sudo, unshare(CLONE_NEWUSER), mount(2) и /dev/fuse.
  3. Запуск Docker-in-Docker внутри gVisor и вложенных песочниц с gvx: Подробный разбор причин, по которым docker.io и вложенный runsc не работают внутри gVisor (runsc), и того, как gvx переключается на цикл futex _RUN_SYSCALL_LOOP при EBUSY.
  4. Быстрые изолированные Docker-окружения для RL-развертываний AI-агентов с gvx (этот пост)