---
id: 517bf76b20da9b1bac643f4bac1bfd6b
author: Lemon Mint
title: gvx를 활용한 AI Agent RL Rollouts용 빠르고 격리된 Docker 환경
description: SWE-bench 및 코딩 에이전트 강화 학습 롤아웃을 위해, 100밀리초 단위의 copy-on-write ext4lite 볼륨 초기화를 적용하여 수백 개의 병렬적이고 강하게 격리된 비특권 Docker 및 Kubernetes 환경을 안전하게 실행하는 방법
language: ko
date: 2026-11-06T12:02:55.95126412Z
path: /blog/posts/fast-docker-environments-for-agent-rl-rollouts-z48916d04
go_package: z.gfire.dev/gvx
go_repourl: https://github.com/gfire-sigs/gvx.git
category: ai
---

## 코딩 에이전트 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 파드, KVM이 비활성화된 베어메탈 클라우드 인스턴스, 기존 `gVisor`(`runsc`) 샌드박스에서는 KVM microVM을 실행할 수 없습니다.
4. **느린 에피소드 리셋(`env.reset()`) 지연**: RL 에피소드마다 새 `docker:dind` 환경을 시작하고 기본 이미지를 pull하거나 압축 해제하는 데 10초에서 60초가 걸리며, 그동안 GPU 학습 워커는 환경 리셋을 기다리며 유휴 상태가 됩니다.

**[gvx](https://github.com/gfire-sigs/gvx)**(`rungv` CLI 및 `z.gfire.dev/gvx/pkg/gvx` Go SDK)는 **보안이 강화된 심층 방어형 롤아웃 런타임**을 제공하여 네 가지 병목을 모두 해결합니다. 각 롤아웃 환경은 **단일의 비특권 자가 잠금(self-locked) 사용자 공간 프로세스**로 실행됩니다. 이 프로세스는 Google의 gVisor `Sentry` 커널, `pkg/hostlockdown`, 메모리 내 `netstack`에 의해 격리되며, 전체 `/var/lib/docker` 상태는 **단일 Copy-on-Write `.ext4` 파일(`ext4lite`)** 내부에 존재합니다.

> **신뢰할 수 없는 에이전트 롤아웃을 위한 무권한 보안**: 모든 `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 롤아웃 아키텍처](/assets/images/gvx/gvx-rl-rollout-snapshot.png)

### 단일 파일 `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` 스냅샷 및 빠른 에피소드 리셋

각 롤아웃 워커 노드에 `rungv`를 설치하는 방법은 두 가지입니다. SLSA 인증을 받은 사전 빌드 바이너리를 [GitHub Releases](https://github.com/gfire-sigs/gvx/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단계: Golden `docker:dind` 볼륨을 한 번만 생성

```sh
mkdir -p /srv/rl-snapshots /srv/rl-episodes

# 1. golden-docker.ext4를 백엔드로 사용하는 seed docker:dind 인스턴스 시작
rungv run \
  -p unix:/srv/rl-snapshots/seed.sock:2375 \
  --mount type=volume,src=/srv/rl-snapshots/golden-docker.ext4,dst=/var/lib/docker \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &
SEED_PID=$!

# 2. golden-docker.ext4 내부에 기본 이미지와 빌드 캐시를 미리 채움
export DOCKER_HOST="unix:///srv/rl-snapshots/seed.sock"
until docker info >/dev/null 2>&1; do sleep 0.5; done

docker pull python:3.11-slim
docker pull redis:7-alpine
docker pull postgres:16-alpine

# 3. ext4lite가 이미지를 플러시하고 압축할 수 있도록 seed 인스턴스를 정상 종료
kill -INT $SEED_PID
wait $SEED_PID
```

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

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

```sh
EPISODE_ID="ep-0042"
SOCK="/srv/rl-episodes/${EPISODE_ID}.sock"

# 즉시 수행되는 Copy-on-Write 복제 (XFS/Btrfs/APFS에서 약 100 ms)
cp --reflink=always \
  /srv/rl-snapshots/golden-docker.ext4 \
  "/srv/rl-episodes/${EPISODE_ID}.ext4"

# 4 GiB RAM과 CPU 2개를 갖춘 격리된 비특권 에피소드 시작
rungv run \
  -m 4g --cpus 2 \
  -p "unix:${SOCK}:2375" \
  --mount "type=volume,src=/srv/rl-episodes/${EPISODE_ID}.ext4,dst=/var/lib/docker" \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &
EP_PID=$!

export DOCKER_HOST="unix://${SOCK}"
until docker info >/dev/null 2>&1; do sleep 0.2; done

# 롤아웃 워커 내부에서는 네트워크 pull 없이 이미지를 즉시 사용할 수 있음
docker images

# 보상 계산 후 에피소드 종료 (약 10 ms)
kill -KILL $EP_PID
rm -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`를 통해 병렬 컨테이너 에피소드를 직접 관리할 수 있습니다.

```go
package main

import (
	"bytes"
	"context"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"time"

	"z.gfire.dev/gvx/pkg/gvx"
)

func runEpisode(ctx context.Context, goldenExt4, scratchDir string, epID int) error {
	epExt4 := filepath.Join(scratchDir, fmt.Sprintf("ep-%04d.ext4", epID))
	defer os.Remove(epExt4)

	// 1. golden ext4lite 볼륨을 reflink로 복제
	cpCmd := exec.CommandContext(ctx, "cp", "--reflink=always", goldenExt4, epExt4)
	if err := cpCmd.Run(); err != nil {
		return fmt.Errorf("clone golden volume: %w", err)
	}

	// 2. 에피소드별 벽시계 시간 제한 적용
	epCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
	defer cancel()

	var stdout, stderr bytes.Buffer
	sb, err := gvx.New(epCtx, gvx.Config{
		Platform:               gvx.PlatformAuto,
		NetworkMode:            gvx.NetModeNAT,
		NetRaw:                 true,
		AllowPacketSocketWrite: true,
		AllowSUID:              true,
		OCIImage:               "docker:dind",
		Entrypoint:             []string{"sh", "-c"},
		EntrypointSet:          true,
		Cmd: []string{
			"dockerd-entrypoint.sh --tls=false >/var/log/dockerd.log 2>&1 & " +
				"until docker info >/dev/null 2>&1; do sleep 0.2; done && " +
				"docker run --rm python:3.11-slim python -c 'print(6 * 7)'",
		},
		Envv: []string{"DOCKER_TLS_CERTDIR="},
		Mounts: []gvx.Mount{
			{
				Kind:      gvx.MountVolume,
				ImagePath: epExt4,
				GuestPath: "/var/lib/docker",
			},
		},
		Resources: gvx.Resources{
			MemoryBytes: 4 << 30, // 4 GiB 상한
			CPUMillis:   2000,    // CPU 2.0개
		},
		Stdout: &stdout,
		Stderr: &stderr,
	})
	if err != nil {
		return fmt.Errorf("init episode %d: %w", epID, err)
	}
	defer sb.Close()

	exitCode, err := sb.Run(epCtx)
	if err != nil {
		return fmt.Errorf("run episode %d: %w", epID, err)
	}

	log.Printf("episode %d finished: exit=%d stdout=%q", epID, exitCode, stdout.String())
	return nil
}

func main() {
	ctx := context.Background()
	if err := runEpisode(ctx, "/srv/rl-snapshots/golden-docker.ext4", "/srv/rl-episodes", 1); err != nil {
		log.Fatal(err)
	}
}
```

---

## 에이전트 RL 샌드박스 런타임 비교

| 지표 / 요구 사항                                          | 공유 호스트 `dockerd`             | 특권 `docker:dind`       | Firecracker / 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 실행하기](/blog/posts/gvx-cross-platform-linux-containers-z48916d01)**: Windows(`whpx`, `systrap`), macOS(`hvf`), Linux(`systrap`)에 걸친 `gvx`의 완전한 아키텍처 설명입니다.
2. **[루트 또는 사용자 네임스페이스 없이 Docker, docker build, docker compose 실행하기](/blog/posts/run-docker-without-root-or-userns-gvx-z48916d02)**: `sudo`, `unshare(CLONE_NEWUSER)`, `mount(2)`, `/dev/fuse`가 차단되었을 때를 위한 3단계 빠른 부트스트랩 및 문제 해결 가이드입니다.
3. **[gvx로 gVisor 및 중첩 샌드박스 내부에서 Docker-in-Docker 실행하기](/blog/posts/docker-in-docker-inside-gvisor-with-gvx-z48916d03)**: `docker.io`와 중첩 `runsc`가 `gVisor`(`runsc`) 내부에서 실패하는 이유와, `gvx`가 `EBUSY` 발생 시 `_RUN_SYSCALL_LOOP` futex 루프로 대체하는 방식에 대한 심층 설명입니다.
4. **[gvx로 AI 에이전트 RL 롤아웃을 위한 빠른 격리 Docker 환경 구축하기](/blog/posts/fast-docker-environments-for-agent-rl-rollouts-z48916d04)** (현재 글)
