gvx を用いた gVisor 内および入れ子の Sandboxes 内での Docker-in-Docker の実行

クイックソリューション: 制限されたgVisor (runsc) サンドボックス内でdocker:dindを安全に実行する
クラウドAIエージェントプラットフォーム、Kubernetesのagent-sandboxデプロイメント(RuntimeClass: gvisor)、および強化学習の評価ハーネスは、ホストカーネルを保護するために、信頼されていないワークロードをgVisor (runsc) コンテナ内に隔離する。デフォルトの非特権runscコンテナ内のワークロードが、apt-getでdocker.ioまたはdocker-ceをインストールし、dockerdを起動しようとすると、コンテナ内でroot(UID 0)権限を持っていても失敗する。
gvx (rungv) を使用すると、外側のRuntimeClassを弱めたり、ホストソケットを公開したり、--privilegedを付与したりすることなく、非特権gVisor (runsc) コンテナ内でdocker:dind、docker build、docker composeを安全に直接実行できる(またはgvx内でgvxをネストする)。
外側のサンドボックス隔離の維持と強化:
runsc内でrungvを実行しても、外側のgVisorサンドボックスによって課される制限は一つも回避されない。代わりにgvxは、独自のhostlockdown(PR_SET_NO_NEW_PRIVS+seccomp)、独自のインメモリnetstack、および単一ファイルのext4liteボリュームを備えた、第二のネストされたユーザー空間gVisorSentryカーネルを作成する。内部のdockerdとruncコンテナは二重にサンドボックス化されており、そのシステムコール、マウント、生パケットは内部のgvxユーザー空間カーネルの外に出ることがない。
1# 既存のgVisor (runsc) コンテナまたはKubernetes Pod内:
2# SLSAで証明されたGitHubリリースアーカイブからrungvをインストールするか、go installを使用する:
3CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest
4
5mkdir -p ~/.gvx
6rungv run \
7 -p unix:~/.gvx/docker.sock:2375 \
8 -v gvx-docker:/var/lib/docker \
9 -e DOCKER_TLS_CERTDIR= \
10 docker:dind --tls=false &
11
12# 内部のgvxソケットに対して標準的なDockerのビルドとコンテナを実行する
13export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
14until docker info >/dev/null 2>&1; do sleep 0.5; done
15docker info --format '{{.Driver}} {{.OperatingSystem}}'
16# 出力: overlayfs Alpine Linux ... (コンテナ化)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test
gVisor (runsc) 内で直接のDockerインストールが失敗する5つの理由
ユーザーまたはAIコーディングエージェントが標準のrunscコンテナ内でapt-get install -y docker.io(またはdocker-ce)を実行し、dockerdの起動を試みると、五つの連続した技術的障壁に直面する。
1. 仮想カーネル4.4.0に対するカーネルモジュールの読み込み(modprobe)
パッケージのインストールおよびdockerdの起動中、初期化スクリプトはoverlay、br_netfilter、ip_tables、nf_nat、xt_conntrackを読み込むためにmodprobeを実行する。外側のgVisor Sentryはinit_module(2)およびfinit_module(2)をブロックし、/lib/modules/4.4.0ディレクトリが存在しない仮想カーネルバージョン(4.4.0)を報告する:
1modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
2modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
3modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
4modprobe: FATAL: Module nf_nat not found in directory /lib/modules/4.4.0
2. iptables-nft (nf_tables Netlink) およびdocker0ブリッジの失敗
Ubuntu 22.04、Ubuntu 24.04、Debian 11/12では、apt-get install docker.ioは、デフォルトの/usr/sbin/iptablesの代替としてiptables-nft(nf_tables netlinkサブシステムをバックエンドとする)を選択する。ネットワーク管理権限またはnf_tables netlinkのサポートを持たない標準のgVisorコンテナでは、dockerdはDOCKER NATチェーンとdocker0ブリッジを初期化する際にクラッシュする:
1failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to create NAT chain DOCKER: iptables failed: iptables -t nat -N DOCKER
2iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
3iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
4iptables: No chain/target/match by that name.
5Unable to enable SKIP DNAT rule: (iptables failed: iptables --wait -t nat -I DOCKER -i docker0 -j RETURN: iptables: No chain/target/match by that name.)
6Error initializing network controller: error obtaining controller instance: failed to create bridge "docker0": operation not permitted
3. gofer / 9pルートファイルシステム上のoverlay2の失敗とvfsによるディスク枯渇
外側のrunsc管理者が/var/lib/dockerに専用のtmpfsまたはfilestoreマウントを明示的に事前構成していない限り、コンテナのルートファイルシステムはgVisorのgofer (9p/lisafs) またはネストされたoverlayマウントによってバックアップされる。/var/lib/dockerの上にoverlay2をマウントしようとすると失敗する:
1level=error msg="[graphdriver] prior storage driver overlay2 failed: driver not supported"
2failed to mount overlay: invalid argument
3Error starting daemon: error initializing graphdriver: driver not supported: overlay2
ユーザーまたはエージェントがdockerd --storage-driver=vfsで起動することによってこのエラーを回避しようとすると、Dockerはコピーオンライト(Copy-on-Write)によるレイヤー共有を完全に無効化する。Dockerfile内のすべてのRUN命令は、ルートファイルシステム全体の完全な再帰コピー(dirCopy)を実行し、10のビルドステップにわたる500 MBのイメージを5 GBの書き込みに増大させ、サンドボックスをno space left on deviceまたはI/Oタイムアウトで終了させる。
4. ネストされたruncのcgroupおよび/procマウントの拒否
dockerd --iptables=false --bridge=none --storage-driver=vfsを強制的に起動したとしても、docker runまたはdocker buildはruncを呼び出して内部コンテナを作成する。デフォルトの非特権gVisorコンテナでは、runcはcgroupのマウント、/procの再マウント、または内部seccompフィルタの読み込みに失敗する:
1docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process:
2error during container init: error mounting "cgroup" to rootfs at "/sys/fs/cgroup": mount cgroup:/sys/fs/cgroup/cpuset (via /proc/self/fd/6), flags: 0xf, data: cpuset: operation not permitted
3cgroups: cgroup mountpoint does not exist: unknown
4runc create failed: systemd not running on this host: cannot use systemd cgroup manager
5error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
6error during container init: error loading seccomp filter into kernel: operation not permitted
5. ネストされたrunscがSECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY) で失敗する
ユーザーがsystrapプラットフォームを使用して、runsc内で別のrunscインスタンスを実行しようとすると、初期化は直ちに中止される:
1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy
Linuxカーネルとgvisor Sentryの両方は、タスク階層ごとに最大一つのSECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) リスナーしか許可しない。外側のサンドボックスが既にそのリスナースロットを保持しているため、内部のseccomp(2)呼び出しはEBUSY (device or resource busy) を返す。
gvxがgVisor内のネストされた実行をどのように解決するか
gvxは、独自のユーザー空間カーネルおよびプラットフォーム層内で五つの障壁すべてに対処し、rungvが外側のgVisor (runsc) コンテナ内、さらには別のgvxコンテナ内で再帰的に(gvx内のgvxとして)実行できるようにする。

1. EBUSYから_RUN_SYSCALL_LOOPのfutexループへの自動systrapフォールバック
Linuxのsystrapでは、各スタブサブプロセスは二種類のスレッドを実行する:
- ゲスト実行スレッド(
sysmsg):SECCOMP_RET_TRAP(SIGSYS) およびバイナリパッチされたjmp rel32トランポリン(syshandler)を介してゲストのシステムコールを捕捉し、共有メモリおよびSYS_FUTEXを介してレジスタの状態をSentryと交換する。 - スタブの
syscallThread(syscall_thread.go): Sentryに代わって、スタブサブプロセス内でアドレス空間のシステムコール(mmap、munmap、clone)を実行する。
アップストリームのgVisorでは、syscallThreadはstub_amd64.sおよびstub_arm64.sにおいて二つの起床モードをサポートしている:
- Seccomp User-Notifyループ(
_RUN_SECCOMP_LOOP = 6、t.seccompNotify != nilの場合):syscallThread.init()の間に、t.installSeccompNotify()がSECCOMP_RET_USER_NOTIFリスナー(seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules))をインストールし、Sentryがioctl(SECCOMP_IOCTL_NOTIF_SEND)およびioctl(SECCOMP_IOCTL_NOTIF_RECV)を介してリクエストを送信できるようにする。 - Futexシステムコールループ(
_RUN_SYSCALL_LOOP = 5、t.seccompNotify == nilの場合):syscallThreadはsyscall_loop(stub_amd64.s/stub_arm64.s)を実行し、共有の2ページsyscallSentryMessage(スタブ内ではPROT_READ)およびsyscallStubMessage(スタブ内ではPROT_READ | PROT_WRITE)領域に対してSYS_FUTEX(FUTEX_WAIT)でスリープする。
アップストリームのgVisorは、seccompNotifyIsSupportedが真である場合は無条件にt.installSeccompNotify()を呼び出し、seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...)がEBUSYを返すとスタブの作成を中止する。
gvx(pkg/sentry/platform/systrap/seccomp_notify_xplat.goおよびsyscall_thread.go)では、syscallThread.init()がt.installSeccompNotifyIfFree()を呼び出す:
1// pkg/sentry/platform/systrap/seccomp_notify_xplat.go
2var seccompNotifyBusy atomic.Bool
3
4func (t *syscallThread) installSeccompNotifyIfFree() (*os.File, error) {
5 if seccompNotifyBusy.Load() {
6 return nil, nil
7 }
8 f, err := t.installSeccompNotify()
9 if errors.Is(err, unix.EBUSY) {
10 if seccompNotifyBusy.CompareAndSwap(false, true) {
11 log.Warningf("Another seccomp listener exists in the filter chain of the host process. " +
12 "The stub threads use the slower futex loop instead of seccomp-unotify.")
13 }
14 return nil, nil
15 }
16 return f, err
17}
installSeccompNotify()がunix.EBUSYを返すのは、外側のgVisor (runsc) または外側のgvxサンドボックスが、プロセスフィルタチェーンの単一のリスナースロットを既に保持しているためであり、その場合:
installSeccompNotifyIfFree()はseccompNotifyBusy(atomic.Bool) を設定し、プロセスごとに一つの警告をログに記録し、そのスタブおよびその後のすべてのスタブに対してt.seccompNotify == nilのままにする。syscallThread.detach()(syscall_thread_amd64.goおよびsyscall_thread_arm64.go)では、t.seccompNotify == nilにより、_RUN_SECCOMP_LOOP(6)の代わりに、amd64ではregs.Rbx = _RUN_SYSCALL_LOOP(5)が、arm64ではregs.Regs[9] = _RUN_SYSCALL_LOOPが設定される。syscallThread.syscall()(syscall_thread.go)では、内部のSentryが共有メモリとSYS_FUTEXのみを介してスタブのシステムコールを振り分ける:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// 新しいシステムコールリクエストについて、syscallスレッドに通知する。
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// 応答を待機する。
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))
SECCOMP_RET_TRAPフィルタは任意の深さまでゲストスレッドのスタック上で機能し、syscallThreadは二つ目のユーザー通知リスナーを要求することなく_RUN_SYSCALL_LOOPにフォールバックするため、gvxはrunsc内でも問題なく動作し、再帰的なネスト(gvx内のgvx)をサポートする。
2. 単一ファイルext4liteボリューム上のネイティブoverlayfs
外側のgVisorコンテナのgofer (9p/lisafs) ルートファイルシステムの上にoverlayfsをマウントすることを避けるため、-v gvx-docker:/var/lib/dockerは、/var/lib/dockerを、単一の通常ファイル(gvx-docker.ext4)に格納されたgvxの内部**ext4lite**ファイルシステムによってバックアップする。
外側のrunscにとって、gvx-docker.ext4はpread64(2)およびpwrite64(2)を介してアクセスされる単なる一つの通常ファイルである。gvxの内部では、ext4liteはJBD2でジャーナリングされたext4ファイルシステムを提供し、trusted.overlay.*およびsecurity.capabilityの拡張属性、文字デバイスのwhiteoutノード(0:0)、d_typeディレクトリエントリ、およびRENAME_EXCHANGEをサポートする。これにより、内部のdockerdはネイティブのoverlayfsストレージドライバを使用できる。
3. docker0およびiptables-legacyを備えた自己完結型netstack
gvxの内部では、dockerdは外側のrunscコンテナのネットワークインターフェースやnetfilterテーブルとやり取りしない。代わりに:
dockerdはgvx独自のユーザー空間netstack内に、docker0ブリッジとvethペアを作成する。- すべての
iptables-legacyルール(-t nat -N DOCKER、MASQUERADE、conntrack、addrtype)は、gvxの内部netfilter実装に対して実行される。 - ネストされた
runcコンテナは、gvxのインメモリVFS2マウントテーブル内で/proc、/sys、/sys/fs/cgroupの階層をマウントし、外側のrunscのmount(2)システムコールを決して呼び出さない。
gVisor内(またはgvx内のgvx)でのネストされたdocker:dindの検証
以下のコマンドを使用して、両方のシナリオ(runsc内のrungv、またはrungv内のrungv)を検証できる:
1# 外側のgVisor (runsc) コンテナまたは外側のrungvコンテナ内:
2mkdir -p ~/.gvx
3rungv run \
4 -p unix:~/.gvx/docker.sock:2375 \
5 -v gvx-docker:/var/lib/docker \
6 -e DOCKER_TLS_CERTDIR= \
7 docker:dind --tls=false &
8DIND_PID=$!
9
10export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
11until docker info >/dev/null 2>&1; do sleep 0.5; done
12
13# 内部のDockerデーモンがvfsではなくネイティブのoverlayfsを使用していることを確認する
14docker info --format 'Storage Driver: {{.Driver}}'
15# 出力: Storage Driver: overlayfs
16
17# 複数レイヤーのビルドを実行し、ネストされたコンテナを実行する
18mkdir -p /tmp/nested-test && cd /tmp/nested-test
19cat <<'EOF' > Dockerfile
20FROM alpine:3.20
21RUN adduser -D -u 1001 appuser && mkdir -p /app && chown appuser:appuser /app
22USER appuser
23WORKDIR /app
24RUN echo "nested-build-ok" > status.txt
25CMD ["cat", "/app/status.txt"]
26EOF
27
28docker build -t nested-test:latest .
29docker run --rm nested-test:latest
30# 出力: nested-build-ok
31
32kill -INT $DIND_PID
このシリーズの関連記事
- gvx: 一つの非特権プロセスでLinuxコンテナ、Docker、Kubernetesを実行する: Windows(
whpx、systrap)、macOS(hvf)、Linux(systrap)にわたるgvxの概要。 - rootやユーザー名前空間なしでDocker、docker build、docker composeを実行する:
root、sudo、unshare(CLONE_NEWUSER)、mount(2)、/dev/fuseがブロックされている場合に、gvxがdocker:dindをどのように実行するか。 - gvxによるgVisorおよびネストされたサンドボックス内でのDocker-in-Docker実行(本記事)
- gvxによるAIエージェントRLロールアウトのための高速な隔離Docker環境:
gvxと単一ファイルのext4liteコピーオンライトスナップショットを使用して、100ミリ秒のリセットで数百の並列SWE-benchおよびRLロールアウト環境を実行する方法。