GoSuda

AI 에이전트 RL 롤아웃을 위한 gvx 기반의 빠르고 격리된 Docker 환경

By Lemon Mint views ...

코딩 에이전트 RL 롤아웃의 보안 및 인프라 병목 현상

SWE-bench, SWE-ReX, R2E-Gym, SWE-Gym, Verl, OpenHands와 같은 벤치마크 및 강화학습(RL) 프레임워크에서 소프트웨어 엔지니어링 에이전트를 학습하고 평가하려면, 신뢰할 수 없는 LLM 생성 코드를 수천 개의 동시 롤아웃으로 실행해야 합니다. 실제 소프트웨어 엔지니어링 작업에서 에이전트는 단순히 텍스트 파일을 편집하는 데 그치지 않습니다. 에이전트는 docker build를 실행하고, docker compose를 통해 데이터베이스와 마이크로서비스를 기동하며, 통합 테스트 스위트를 실행하고, 컨테이너 로그를 검사합니다.

롤아웃 규모(클러스터당 128개에서 1,024개의 동시 에피소드)에서 기존 컨테이너 인프라는 네 가지 체계적인 보안 및 성능 병목에 직면합니다.

  1. 호스트 dockerd 잠금 경합 및 상태 누출: 수백 개의 동시 RL 워커를 단일 호스트의 /var/run/docker.sock에 연결하면, 모든 에이전트가 호스트 데몬에 대한 root 동등 권한을 얻게 됩니다. 또한 이미지 레이어 추출이 직렬화되고, docker0 서브넷 할당이 고갈되며, 호스트 iptables에서 Another app is currently holding the xtables lock 오류가 발생하고, 에피소드 간에 고아 컨테이너가 누출됩니다.
  2. 특권 DinD 컨테이너 탈출 위험: 표준 docker:dind 컨테이너를 실행하려면 --privileged(CAP_SYS_ADMIN)가 필요합니다. 이로 인해 원시 호스트 블록 디바이스, cgroup release agent, 커널 모듈이 신뢰할 수 없는 RL 롤아웃에 노출됩니다.
  3. 하드웨어 가상화(/dev/kvm) 제약: MicroVM 런타임(Firecracker, QEMU, Kata Containers)은 내부 커널을 깔끔하게 격리하지만 /dev/kvm을 필요로 합니다. 중첩 가상화가 없는 표준 Kubernetes Pod, KVM이 비활성화된 베어메탈 클라우드 인스턴스, 그리고 기존 gVisor(runsc) 샌드박스에서는 KVM microVM을 시작할 수 없습니다.
  4. 느린 에피소드 리셋(env.reset()) 지연: RL 에피소드마다 새로운 docker:dind 환경을 시작하고 기본 이미지를 pull하거나 압축 해제하는 데 10초에서 60초가 소요되며, 그동안 GPU 학습 워커는 환경 리셋을 기다리며 유휴 상태가 됩니다.

gvx (rungv CLI 및 z.gfire.dev/gvx/pkg/gvx Go SDK)는 네 가지 병목을 모두 해결하는 안전하고, 낮은 노력으로 구현 가능하며, 토큰 효율적인 롤아웃 런타임을 제공합니다. 각 롤아웃 환경은 단일의 비특권 자가 잠금 사용자 공간 프로세스로 실행됩니다. 이 프로세스는 Google의 gVisor Sentry 커널, pkg/hostlockdown, 메모리 내 netstack에 의해 격리되며, 전체 /var/lib/docker 상태는 단일 copy-on-write .ext4 파일(ext4lite) 내부에 존재합니다.

에이전트를 위한 낮은 노력, 토큰 효율적 실행: 모든 에피소드 내부에서 docker build 또는 docker compose가 기본 설정으로 동작하거나(또는 rungv를 통해 에이전트가 3개의 명령으로 된 단일 턴에서 부트스트랩할 수 있으면), RL 롤아웃은 service docker start ulimit 실패, iptables-nft 오류, runc pivot_root 거부 문제를 해결하느라 컨텍스트 윈도우 토큰이나 단계 예산을 낭비하지 않습니다. 궤적의 모든 토큰이 실제 소프트웨어 엔지니어링 작업을 해결하는 데 사용됩니다.

신뢰할 수 없는 에이전트 롤아웃을 위한 무특권 보안: 모든 gvx 에피소드는 게스트 코드를 실행하기 전에 자기 자신의 호스트 OS 프로세스를 잠급니다(Linux에서는 PR_SET_NO_NEW_PRIVS + BPF seccomp, macOS에서는 Seatbelt deny default, Windows에서는 Restricted Token + Job Object). RL 에이전트가 에피소드 내부에서 docker run --privileged나 파괴적인 rm -rf / 명령을 실행하더라도, 해당 작업은 그 에피소드의 사용자 공간 Sentry 커널과 일회성 .ext4 볼륨 파일 내부로 엄격하게 제한됩니다.


롤아웃 아키텍처: 병렬 비특권 인스턴스 + Reflink 스냅숏

gvx에서 모든 롤아웃 워커는 자체 gVisor Sentry 커널, 자체 사용자 공간 netstack(10.0.2.15/24), 자체 ext4lite 블록 볼륨(ep-N.ext4)을 가진 일반 비특권 OS 프로세스입니다. 동시에 모든 워커는 호스트 페이지 캐시를 공유하는 읽기 전용 내용 주소 지정 erofs OCI 이미지 캐시를 사용합니다.

gvx를 적용한 병렬 에이전트 RL 롤아웃 아키텍처

단일 파일 ext4lite 볼륨이 RL 리셋 경제성을 바꾸는 이유

gvx의 ext4lite 파일 시스템은 JBD2 저널링된 ext4 볼륨 전체(Docker의 overlayfs 레이어 디렉터리, trusted.overlay.* 확장 속성, whiteout 디바이스 노드, 이미지 메타데이터 포함)를 하나의 일반 호스트 파일 안에 저장합니다. 따라서 에피소드 수명 주기 관리가 단일 파일 연산이 됩니다.

  1. cp --reflink=always를 통한 100밀리초 env.reset():
    • 사전에 pull된 기본 이미지(python:3.11, postgres:16, redis:7, 또는 작업별 SWE-bench 기본 이미지)를 포함하는 golden-docker.ext4 파일을 한 번 빌드합니다.
    • 각 RL 에피소드를 시작할 때 cp --reflink=always golden-docker.ext4 ep-0042.ext4(XFS, Btrfs, ZFS 또는 macOS APFS)를 실행하면, 기초 디스크 블록을 복사하지 않고도 100밀리초 이내에 독립적인 Copy-on-Write 복제본이 생성됩니다.
    • 해당 에피소드 동안 더럽혀진(dirty) 4 KiB 블록만 추가 디스크 공간을 사용합니다.
  2. 오염 없는 종료(env.close()):
    • rungv 프로세스를 종료하고(Go에서는 sb.Close()), ep-0042.ext4의 링크를 해제하면, 약 10밀리초 안에 해당 에피소드의 프로세스, 네트워크 소켓, 마운트, 스토리지 100%가 회수됩니다. 호스트 커널의 마운트 테이블에 아무것도 마운트되지 않으며, 호스트 cgroup에 등록되는 것도 없습니다.

단계별 워크플로: Golden .ext4 스냅숏과 빠른 에피소드 리셋

각 RL 롤아웃 워커 노드에 rungv를 설치할 수 있습니다. 설치 방법은 GitHub 릴리스에서 SLSA 증명을 받은 사전 빌드 바이너리를 다운로드하는 것(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단계: Golden docker:dind 볼륨을 한 번 생성

 1mkdir -p /srv/rl-snapshots /srv/rl-episodes
 2
 3# 1. golden-docker.ext4를 백엔드로 사용하는 시드 docker:dind 인스턴스를 시작합니다
 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가 이미지를 flush 및 compact할 수 있도록 시드 인스턴스를 정상 종료합니다
20kill -INT $SEED_PID
21wait $SEED_PID

2단계: 100밀리초 안에 병렬 RL 에피소드 생성

각 병렬 롤아웃 워커는 reflink를 통해 golden-docker.ext4를 복제하고, 모든 이미지가 이미 /var/lib/docker에 존재하는 격리된 docker:dind 인스턴스를 부팅합니다.

 1EPISODE_ID="ep-0042"
 2SOCK="/srv/rl-episodes/${EPISODE_ID}.sock"
 3
 4# 즉시 적용되는 Copy-on-Write 복제 (XFS/Btrfs/APFS에서 약 100 ms)
 5cp --reflink=always \
 6  /srv/rl-snapshots/golden-docker.ext4 \
 7  "/srv/rl-episodes/${EPISODE_ID}.ext4"
 8
 9# 4 GiB RAM과 CPU 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# 롤아웃 워커 내부에서는 네트워크 pull 없이 이미지를 즉시 사용할 수 있습니다
22docker images
23
24# 보상 계산 후 에피소드를 종료합니다 (약 10 ms)
25kill -KILL $EP_PID
26rm -f "/srv/rl-episodes/${EPISODE_ID}.ext4" "${SOCK}"

z.gfire.dev/gvx/pkg/gvx Go SDK를 사용한 프로그래밍 방식 롤아웃 풀

RL 환경 서버나 평가 하네스가 Go로 작성되었거나 gRPC를 통해 Go 롤아웃 데몬을 호출하는 경우, 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. golden 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. 에피소드별 실제 경과 시간 제한을 적용합니다
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 상한
55			CPUMillis:   2000,    // CPU 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 없음)실패 (중첩 seccomp에서 EBUSY)예 (자동 futex 루프 대체 경로)
내부 docker build 및 docker compose 지원호스트 데몬 공유예예호스트 DinD 튜닝 없이는 실패예 (ext4lite 위의 overlayfs)
에피소드별 네트워크 격리공유 docker0 / xtables 잠금별도 네임스페이스별도 VM NIC별도 netns프로세스 내부의 격리된 netstack
웜 에피소드 리셋 지연 시간 (env.reset())5초 ~ 30초 (컨테이너/볼륨 정리)10초 ~ 60초 (콜드 데몬)약 150 ms (VM 스냅숏)5초 ~ 20초약 100 ms (cp --reflink=always .ext4)
크로스 플랫폼 로컬 개발 (Windows / macOS / Linux)Linux VM 필요Linux VM 필요Linux KVM만 지원Linux만 지원Windows, macOS, Linux에서 네이티브 동작

이 시리즈의 추가 자료

  1. gvx: 하나의 비특권 프로세스에서 Linux 컨테이너, Docker, Kubernetes 실행하기: Windows(whpx, systrap), macOS(hvf), Linux(systrap)에 걸친 gvx의 전체 아키텍처 설명입니다.
  2. root 또는 사용자 네임스페이스 없이 Docker, docker build, docker compose 실행하기: sudo, unshare(CLONE_NEWUSER), mount(2), /dev/fuse가 차단된 경우를 위한 빠른 3단계 부트스트랩 및 문제 해결 가이드입니다.
  3. gvx로 gVisor 및 중첩 샌드박스 내부에서 Docker-in-Docker 실행하기: docker.io와 중첩 runsc가 gVisor(runsc) 내부에서 실패하는 이유와, gvx가 EBUSY 발생 시 _RUN_SYSCALL_LOOP futex 루프로 대체하는 방식에 대한 심층 분석입니다.
  4. gvx로 AI 에이전트 RL 롤아웃을 위한 빠른 격리 Docker 환경 구축하기 (이 게시물)