GoSuda

gvxを用いたAI Agent RL Rollouts向けの高速で隔離された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. Host dockerd Lock Contention and State Leakage: ホストのdockerdにおけるロック競合と状態の漏えい: 数百の並行RLワーカーを単一のホストの/var/run/docker.sockに向けると、すべてのエージェントにホストデーモンへのroot相当のアクセス権が与えられ、イメージレイヤーの展開が直列化され、docker0サブネットの割り当てが枯渇し、ホストのiptablesでAnother app is currently holding the xtables lockエラーが発生し、エピソードをまたいで孤立したコンテナが漏えいする。
  2. Privileged DinD Container Escape Risk: 特権DinDコンテナからの脱出リスク: 標準のdocker:dindコンテナを実行するには--privileged(CAP_SYS_ADMIN)が必要であり、これにより信頼されていないRLロールアウトに対して、生のホストブロックデバイス、cgroupのrelease agent、カーネルモジュールが露出する。
  3. Hardware Virtualization (/dev/kvm) Constraints: ハードウェア仮想化(/dev/kvm)の制約: MicroVMランタイム(Firecracker、QEMU、Kata Containers)は内部カーネルを明確に隔離するが、/dev/kvmを必要とする。ネスト仮想化のない標準的なKubernetes Pod、KVMが無効化されたベアメタルクラウドインスタンス、および既存のgVisor(runsc)サンドボックスでは、KVM MicroVMを起動できない。
  4. Slow Episode Reset (env.reset()) Latency: エピソードリセット(env.reset())の遅延: RLエピソードごとに新しいdocker:dind環境を起動し、ベースイメージを取得または展開するには10~60秒かかり、環境のリセットを待つ間、GPUの学習ワーカーがアイドル状態になる。

gvx(rungv CLIおよびz.gfire.dev/gvx/pkg/gvx Go SDK)は、安全で低労力かつトークン効率の高いロールアウトランタイムを提供し、四つのボトルネックすべてを解決する。各ロールアウト環境は、単一の非特権の自己ロックされたユーザー空間プロセスとして実行される(Googleのgvisor Sentryカーネル、pkg/hostlockdown、インメモリのnetstackによって隔離される)。その/var/lib/dockerの全状態は、**単一のコピーオンライトな.ext4ファイル(ext4lite)**内に存在する。

エージェント向けの低労力・トークン効率の高い実行: すべてのエピソードでdocker buildやdocker composeがそのまま動作する場合(またはrungvを介してエージェントが3コマンドの1ターンでブートストラップできる場合)、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では制限付きトークン + Job Object)。RLエージェントがエピソード内でdocker run --privilegedや破壊的なrm -rf /コマンドを実行したとしても、それらの操作は厳密にそのエピソードのユーザー空間Sentryカーネルと一時的な.ext4ボリュームファイルの内部に閉じ込められる。


ロールアウトアーキテクチャ:並列非特権インスタンス + Reflinkスナップショット

gvxでは、各ロールアウトワーカーは通常の非特権OSプロセスであり、それぞれが独自のgVisor Sentryカーネル、独自のユーザー空間netstack(10.0.2.15/24)、独自のext4liteブロックボリューム(ep-N.ext4)を持つ。一方で、読み取り専用のコンテンツアドレス可能なerofs OCIイメージキャッシュは、ホストのページキャッシュ全体で共有される。

gvxを用いた並列エージェントRLロールアウトアーキテクチャ

単一ファイルのext4liteボリュームがRLリセットの経済性を変える理由

gvxのext4liteファイルシステムは、JBD2ジャーナリングされた完全なext4ボリューム(Dockerのoverlayfsレイヤーディレクトリ、trusted.overlay.*拡張属性、whiteoutデバイスノード、イメージメタデータを含む)を1つの通常のホストファイル内に格納する。そのため、エピソードのライフサイクル管理は単一のファイル操作となる:

  1. cp --reflink=alwaysによる100ミリ秒のenv.reset():
    • 事前にプルされたベースイメージ(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ミリ秒未満で独立したコピーオンライトのクローンが作成される。
    • そのエピソードの間に変更された4 KiBブロックのみが、追加のディスク容量を消費する。
  2. 残留物のないティアダウン(env.close()):
    • rungvプロセスを終了し(Goではsb.Close())、ep-0042.ext4のリンクを解除すると、そのエピソードのプロセス、ネットワークソケット、マウント、ストレージの100%が約10ミリ秒で回収される。ホストカーネルのマウントテーブルにマウントされたり、ホストのcgroupに登録されたりするものは一切ない。

手順ごとのワークフロー:ゴールデン.ext4スナップショットと高速エピソードリセット

各ロールアウトワーカーノードには、GitHub ReleasesからSLSA認証済みのビルド済みバイナリをダウンロードしてrungvをインストールする方法(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. golden-docker.ext4を基盤として、seed 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がイメージをフラッシュおよび圧縮できるよう、seedインスタンスを正常に停止する
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# コピーオンライトの瞬時クローン(XFS/Btrfs/APFSでは約100ミリ秒)
 5cp --reflink=always \
 6  /srv/rl-snapshots/golden-docker.ext4 \
 7  "/srv/rl-episodes/${EPISODE_ID}.ext4"
 8
 9# 4 GiB RAMと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# ロールアウトワーカー内では、イメージはネットワークからのプルなしで即座に利用可能である
22docker images
23
24# 報酬計算後のエピソードティアダウン(約10ミリ秒)
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. ゴールデンの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,    // 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}

エージェント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ミリ秒(VMスナップショット)5秒~20秒約100ミリ秒(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. gVisorおよびネストされたサンドボックス内でのDocker-in-Dockerの実行(gvx使用): docker.ioとネストされたrunscがgVisor(runsc)内でなぜ失敗するか、またgvxがEBUSY時に_RUN_SYSCALL_LOOP futexループへどうフォールバックするかについての詳細な解説。
  4. AIエージェントのRLロールアウトのための、gvxによる高速な隔離Docker環境(本記事)