GoSuda

gvx: 1つのUnprivileged Processで Linux Containers、Docker、Kubernetes を実行する

By Lemon Mint views ...

はじめに: root もホストデーモンも使用しないセキュリティ強化コンテナ

開発マシン、CI ランナー、またはマルチテナントのクラウドノード内で Linux コンテナ、docker build、または Kubernetes を実行する場合、通常は次のような不都合なセキュリティ上のトレードオフを強いられる:

  • Linux では、標準的な OCI ランタイム(runc、crun、runsc)は、root 権限、ホストの /var/run/docker.sock の公開、--privileged(CAP_SYS_ADMIN)、またはホストのカーネル攻撃対象領域を拡大させるユーザー名前空間(unshare(CLONE_NEWUSER))のいずれかを必要とする。
  • Windows および macOS では、コンテナエンジン(Docker Desktop、Podman Machine、Lima、OrbStack)は、WSL2、Hyper-V、または Virtualization.framework 内で完全な Linux 仮想マシンカーネルを起動し、その VM 内で永続的なバックグラウンドデーモンを実行する。

gvx(rungv CLI および z.gfire.dev/gvx/pkg/gvx Go SDK)は、安全でゼロ特権の代替手段を提供する。Google の gVisor のユーザー空間 Linux カーネル(Sentry)およびユーザー空間ネットワークスタック(netstack)の、アップストリームに追従可能なフォークとして構築された gvx は、Windows、macOS、Linux 上の単一の非特権かつ自己ロックされたホストプロセス内で、変更されていない linux/amd64 および linux/arm64 の ELF64 バイナリおよび OCI コンテナを直接実行する。

セキュリティ保証(三重リング多層防御): gvx はホスト OS のセキュリティ境界を弱めることは決してなく、カーネルドライバをインストールすることもなく、ホスト上で root、sudo、--privileged、または CLONE_NEWUSER を必要とすることも決してない。ゲスト命令を1つも実行する前に、gvx は自身のホストプロセスを、Windows Restricted Tokens と Job Objects、macOS Seatbelt(deny default)、または Linux の PR_SET_NO_NEW_PRIVS と BPF seccomp を用いてロックダウンする。すべてのゲストシステムコール、仮想 UID 0 権限、マウント(VFS2)、およびネットワークテーブル(netstack)は、メモリ安全な Go 言語製の Sentry カーネル内に厳密に閉じ込められる。

ランタイムホスト OSコールドスタートメモリフットプリント管理者権限または sudo の必要性バックグラウンド VM デーモンOCI イメージの直接実行
gvx (whpx / hvf)Windows (amd64, arm64) & macOS (arm64)~20 ms~30 MiBいいえいいえはい
gvx (systrap)Windows (amd64, arm64) & Linux (amd64, arm64)~25 to 65 ms~31 MiBいいえいいえはい
WSL2 / Docker DesktopWindows & macOS800 to 2500 ms> 512 MiBはい(初回セットアップ時)はいデーモンが必要
QEMU (Full System)Windows & macOS1500 to 4000 ms> 256 MiBいいえいいえディスクイメージが必要

Windows および macOS 上で Linux VM カーネルは起動しない。ホスト上でバックグラウンドデーモンは実行されない。ホスト OS において、root、sudo、ユーザー名前空間(CLONE_NEWUSER)、mount(2)、TUN/TAP ドライバ、または CAP_NET_ADMIN 権限は一切必要としない。その非特権プロセス内で、gvx は Docker-in-Docker(docker:dind)、docker build、docker compose、および**多ノード構成の高可用性 Kubernetes(埋め込み etcd と Calico CNI を備えた k3s)**を実行する。


コンテナのライフサイクルと三重リングによるホストロックダウン

すべての gvx サンドボックスは1つの非特権ホストプロセス内で実行され、3段階のライフサイクルを適用する。すなわち、必要なすべてのホストファイルディスクリプタおよびソケットの事前オープン、pkg/hostlockdown によるホスト OS プロセスのロックダウン、そしてプロセス内の Sentry 実行ループへの移行である。

gvx コンテナのライフサイクルとホスト自己ロックダウン

最初のゲスト命令へ制御を移す前に、gvx はキャッシュ済みの .erofs イメージレイヤーのディスクリプタ、.ext4 ボリュームのファイルディスクリプタ、/workspace の *os.Root ハンドル、およびホストのネットワークソケットを事前にオープンし、その後 hostlockdown.Apply() を呼び出す:

  1. Windows NT(lockdown_windows.go): AdjustTokenPrivileges を SE_PRIVILEGE_REMOVED とともに呼び出して権限をその場で剥奪し、Low 整合性レベル(S-1-16-4096)の DISABLE_MAX_PRIVILEGE 制限付き偽装トークンを割り当て、プロセスを JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1 を設定した無名の Job Object に割り当てて(子プロセスの生成をすべてブロックする)、SetProcessMitigationPolicy を介して DisallowWin32kSystemCalls = 1 を有効化する。
  2. macOS XNU(lockdown_darwin.go): RLIMIT_NPROC = 0 を設定し、(version 1)(deny default) から始まり process-fork、process-exec*、およびすべての新規ホストファイルオープンをブロックする Seatbelt プロファイル(sandbox_init_with_parameters)を適用する。
  3. Linux(lockdown_linux.go + pkg/gvx/syscallfilter_linux.go): PR_SET_NO_NEW_PRIVS を設定し、すべてのスレッドに対して(SECCOMP_FILTER_FLAG_TSYNC)Sentry の BPF seccomp 許可リストを導入し、execve、execveat、clone、openat、socket に対して明示的な EPERM ルールを設ける。

クロスプラットフォームのシステムコール傍受と 4 KB ページング(pkg/sentry/platform)

Linux の ELF64 バイナリは、mmap(MAP_FIXED) によるセグメントマッピングにおいて、4 KB(0x1000)のメモリページ粒度を前提とする。しかしながら、Windows の VirtualAlloc および MapViewOfFile3 は 64 KB(0x10000)のアライメントを強制し、Apple Silicon の macOS(darwin/arm64)は 16 KB(0x4000)のホストページを使用する。さらに、各 OS は、Linux VM を起動することなく、Linux の SYSCALL(amd64)および SVC #0(arm64)命令を傍受するために、それぞれ異なるメカニズムを必要とする。

gvx は、pkg/sentry/platform.Context を実装する4つの実行バックエンドを通じて、これら両方の問題を解決する:

gvx プラットフォームのシステムコール傍受と 4KB ページングの仕組み

1. Windows ハードウェアエンジン: platform/whpx(WinHvPlatform.dll)

windows/amd64 および windows/arm64 上では、platform/whpx がゲスト OS カーネルを起動することなく、WHvCreatePartition を介して軽量なハイパーバイザパーティションを作成する:

  • 64 KB から 4 KB へのページングデカップラ: whpx は、pgalloc.MemoryFile の各 64 MiB アリーナを、WHvMapGpaRange を介して一度だけパーティションに登録する。その後の MapFile および Unmap の呼び出しは、パーティション内のゲストページテーブルのみを変更し、Windows の 64 KB 割り当て粒度にもかかわらず、正確な 4 KB 単位の R/W/X 権限を強制する。
  • CommPage を介したゼロ API のレジスタ交換:
    • amd64 では、ゲストの Ring-3 SYSCALL がパーティション内の Ring-0 MSR_LSTAR スタブ(stubgen_x86.go)に入る。このスタブは高速パスのシステムコール用 GPR(RAX, RDI, RSI, RDX, R10, R8, R9, RIP, RSP)を共有の 4 KB CommPage に退避させ、HLT を実行して WHvRunVirtualProcessor から脱出する。
    • arm64 では、ゲストの EL0 SVC #0 が EL1 ベクタ(stubgen_a64.go)に入り、X0..X8, SP_EL0, ELR_EL1, PSTATE を CommPage に退避させ、バックされていない GPA ゼロからロードすることでメモリ例外を発生させ、Sentry へ戻る。

2. macOS ハードウェアエンジン: platform/hvf(Hypervisor.framework)

Apple Silicon(darwin/arm64)上では、platform/hvf が Hypervisor.framework の vCPU(hv_vm_create、hv_vcpu_run)内でコンテナのスレッドを実行する:

  • 2段階の 4 KB over 16 KB MMU 変換: Stage-2(hv_vm_map)は、1 GiB のメモリファイルチャンクを、HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC を伴い、16 KB のホストページ粒度で中間物理アドレス(IPA)空間へ一度だけマップする。vCPU 内では、EL1 の bluepill スタブ(bluepill_arm64.go)が TTBR0_EL1 において TCR_EL1.TG0 = 0b00(4 KB granule)を設定し、正確な R-X または RW-(UXN=1)権限を持つ 4 KB の Stage-1 ページテーブルエントリを配置する。ARM64 MMU は Stage-1 と Stage-2 の権限の共通部分を強制するため、ハードウェアは正確な 4 KB 境界で権限フォルトをトラップする。
  • システムコールのトラップ: ゲストの EL0 SVC #0 は EL1 の VBAR_EL1 へトラップし、レジスタを共有 4 KB CommPage に退避させ、HVC #0 を実行して hv_vcpu_run から Go の Sentry スレッドへ戻る。

3. Windows ユーザーモードフォールバック: platform/systrap(winsystrap)

ハードウェア仮想化が利用できない場合(ネストされたクラウド VM 内など)、winsystrap は windows/amd64 および windows/arm64 上でユーザーモードにおいてゲストコードを実行する:

  • ハードウェアの CR3 / TTBR0_EL1 分離: ゲストスレッドは、生成された 1 KB の実行可能ファイル(stubimage_xplat.go)から起動された隔離されたスタブサブプロセス内で実行され、Low 整合性トークンおよび Job Object によって閉じ込められる。
  • その場でのバイナリパッチングと winSysmsg IPC: amd64 では、パッチャ(patcher_amd64_xplat.go)が2バイトの syscall(0f 05)命令を、5バイトの jmp rel32 ジャンプ(5バイトの領域が利用できない場合は CC 90 ブレークポイントトラップ)に置き換え、高速パスのアイランドへ飛ばす。arm64 では、svc #0 がアイランドスロットへの B 分岐にパッチされる。アイランドは共有 winSysmsg ページにレジスタを退避させ、WaitOnAddress および WakeByAddressSingle を介して Sentry と同期する。一方、パッチされていないトラップおよびページフォルトは Windows Debug Object(WaitForDebugEventEx)上で停止する。

4. Linux ユーザーモードエンジン: platform/systrap

linux/amd64 および linux/arm64 上では、systrap は、SECCOMP_RET_TRAP(SIGSYS)を介してゲストのシステムコールをトラップし、syscall 箇所を SYS_FUTEX によって同期される共有メモリのトランポリン(sysmsg)へバイナリパッチするゲストスレッド(sysmsg)を実行する。一方、スタブである syscallThread(syscall_thread.go)は、SECCOMP_RET_USER_NOTIF(_RUN_SECCOMP_LOOP)を介してアドレス空間の操作(mmap、munmap、clone)を実行する。gvx が、単一の SECCOMP_FILTER_FLAG_NEW_LISTENER スロットが EBUSY を返す外側の gVisor(runsc)サンドボックス内で実行される場合、gvx は自動的に syscallThread を共有メモリのフュテックスループ(_RUN_SYSCALL_LOOP)へ切り替える。詳細は本シリーズの第3部で述べる。


gvx がゲストの Linux システムコールを仮想サブシステムおよびホストハンドルへ対応付ける仕組み

プラットフォームエンジンがゲストの SYSCALL(amd64)または SVC #0(arm64)命令をトラップし、ゲストのレジスタを 4 KB の共有ページ(CommPage、sysmsg、または winSysmsg)に書き込むと、制御は Go の Sentry タスクゴルーチン(pkg/sentry/kernel/task_syscall.go)へ戻る。Linux のシステムコールを基盤となるホスト OS カーネルへ転送する代わりに、Task.doSyscall() は元のレジスタ状態(SyscallSaveOrig())を保存し、ゲスト内の BPF seccomp フィルタおよび ptrace 停止を評価した上で、linux.AMD64 または linux.ARM64(pkg/sentry/syscalls/linux/linux64.go、約370個の Linux システムコールを網羅)を通じてシステムコール番号を発送し、4つの内部ドメインへ振り分ける:

gvx がゲストの Linux システムコールをサブシステムおよびホスト OS へ対応付ける仕組み

システムコールドメイン代表的なゲスト Linux システムコール内部 gvx サブシステム(pkg/sentry)ホスト OS 境界との相互作用(hostlockdown 適用後)
A. プロセス、名前空間、マウント、同期clone, execve, unshare, setns, mount, umount2, pivot_root, setuid, capset, futex, epoll_wait, pipe2kernel.Task、仮想資格情報、フュテックステーブル、およびインメモリの VFS2(procfs、sysfs、cgroupfs v2、tmpfs、overlayfs)ホストへのシステムコールはゼロ(100% インメモリの Go 状態であり、ホスト OS からは完全に不可視)
B. 仮想メモリ(MM)mmap, munmap, mremap, mprotect, brk, madvise, memfd_createmm.MemoryManager(4 KB VMA ツリー)および pgalloc.MemoryFileWHvMapGpaRange、hv_vm_map + TTBR0_EL1、またはスタブの mmap/mprotect を介した双方向の 4 KB ページテーブル更新
C. ファイルおよびストレージ(VFS2)openat, pread64, pwrite64, getdents64, statx, renameat2, setxattr, mknod, inotify_add_watchvfs.VirtualFilesystem のマウントテーブルルーター(fsimpl/erofs、fsimpl/ext4lite、fsimpl/hostfs v2)1つの固定された .erofs FD または1つの .ext4 FD に対する双方向の pread/pwrite、もしくは *os.Root 上の範囲限定された相対走査
D. ネットワークおよび Netfiltersocket, bind, listen, connect, sendmsg, recvmsg, netlink, setsockopt(IPT_SO_SET_REPLACE)ユーザー空間の pkg/tcpip(lo、eth0、docker0、veth、vxlan、wireguard、ipset、iptables-legacy)サンドボックス内のトラフィックはホストへのシステムコールをゼロとして処理され、外部トラフィックは net.Dial/net.Listen(-net=nat)または1つの UDP ソケット(-net=wg)に対応付けられる

ハンドラが (rval, ctrl, err) を返すと、Task.doSyscallInvoke() は任意のエラーを負の Linux errno(-ExtractErrno(err, sysno))に変換し、戻り値を共有レジスタページ(amd64 では RAX、arm64 では X0)に書き戻し、ゲストの実行を再開する。


ストレージ(VFS2)およびデュアルトラックのユーザー空間ネットワーク(netstack)

ゲストのすべてのファイルおよびネットワーク操作は、3つの VFS2 ファイルシステムと2つの pkg/netbackend トラックを通じて、Go の Sentry 内で処理される:

gvx VFS2 ストレージエンジンとデュアルトラック netstack

ストレージエンジンgvx におけるマウントの役割背後のホストリソース実行時のホストにおける openat / NtCreateFile 呼び出しWindows Defender(WdFilter.sys)によるファイル単位のスキャン
fsimpl/erofs読み取り専用の OCI イメージ下位レイヤーレイヤーごとに単一の <sha256>.erofs ファイル00
fsimpl/ext4lite名前付きボリューム(-v name:/path)ボリュームごとに単一の <name>.ext4 ファイル00
fsimpl/hostfs v2ホストのワークスペース(-workspace、-v /host:/guest)固定された *os.Root ホストディレクトリキャッシュされていない open ごとに O(depth)ホストファイルのクローズごとに 1

Docker、データベース、およびパッケージマネージャにおいて ext4lite が重要である理由

数万にのぼるコンテナファイルを Windows NTFS または macOS APFS へ直接展開または書き込むと、アンチウイルスのミニフィルタによるスキャン(WdFilter.sys)、大文字小文字の区別による衝突、および POSIX 拡張属性の欠落が発生する。

pkg/sentry/fsimpl/ext4lite は、事前にオープンされた1つのホストファイル(Windows では %LOCALAPPDATA%\gvx\cache\volumes\<name>.ext4、macOS では ~/Library/Caches/gvx/volumes/<name>.ext4、Linux では ~/.cache/gvx/volumes/<name>.ext4)内に、純粋な Go 言語による ext4 ファイルシステムを実装し、以下の機能を備える:

  • 4 KiB ブロックおよび 16 MiB の動的ブロックグループ(meta_bg): スパースファイルの割り当て停止を伴わずに動的に拡張し(Linux では fallocate、macOS では fcntl(F_PREALLOCATE)、Windows では FileAllocationInfo)、クリーンなアンマウント時に未使用の末尾グループを圧縮する。
  • JBD2 ordered-mode ジャーナル(inode 8): CRC-32 チェックサムおよび revoke ブロックを伴う原子的なトランザクションのコミットに加え、fsckLite による回復を備える。
  • overlayfs のアッパーレイヤー対応: trusted.overlay.* および security.capability の拡張属性、キャラクタデバイスのホワイトアウト(0:0)、d_type によるディレクトリエントリの報告、および RENAME_EXCHANGE を実装する。

実用的なワークロード:Docker Engine、Kubernetes(k3s + Calico)、および Go SDK

rungv のインストール:SLSA 認証済みのビルド済みリリースまたは go install

linux/amd64、linux/arm64、darwin/arm64、windows/amd64、または windows/arm64 向けのビルド済み rungv アーカイブは GitHub Releases からダウンロードできる(各アーカイブは SLSA リリースパイプラインによってビルドされ、Sigstore の provenance バンドルおよび SHA256SUMS を伴う)。あるいは、go install によってソースから直接インストールすることもできる:

1# オプション A:ビルド済みリリースをダウンロードし、SLSA ビルドの provenance を検証する
2gh attestation verify rungv-v0.2.0-linux-amd64.tar.gz --repo gfire-sigs/gvx \
3  --signer-workflow gfire-sigs/gvx/.github/workflows/release.yml \
4  --source-ref refs/tags/v0.2.0 --deny-self-hosted-runners
5shasum -a 256 -c SHA256SUMS --ignore-missing
6tar -xzf rungv-v0.2.0-linux-amd64.tar.gz
7
8# オプション B:Go を介して直接インストールする(Linux / Windows)
9CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest

macOS(darwin/arm64)では、ビルド済みリリースのアーカイブは既に com.apple.security.hypervisor エンタイトルメントで署名されている(xattr -d com.apple.quarantine rungv によりブラウザの隔離属性が解除される)。macOS 上で go install または go build によってローカルでビルドする場合は、codesign --sign - --entitlements entitlements.plist --force $(which rungv) でバイナリに署名すること。

1. 非特権 Docker Engine(docker:dind)の実行

docs/docker-engine.md に従うことで、Windows、macOS、または Linux 上の rungv 内で変更されていない docker:dind コンテナを実行し、ホストの docker CLI から制御できる:

 1# 1. 転送される Unix ソケット用のホストディレクトリを作成する
 2mkdir -p ~/.gvx
 3
 4# 2. ext4lite ボリュームを伴う gvx サンドボックス内で docker:dind を起動する
 5rungv run \
 6  -p unix:~/.gvx/docker.sock:2375 \
 7  -p 127.0.0.1:9000-9019:9000-9019 \
 8  -v gvx-docker:/var/lib/docker \
 9  -e DOCKER_TLS_CERTDIR= \
10  docker:dind --tls=false

2つ目のターミナルで、ホストの docker CLI をサンドボックスのソケットに向ける:

1docker context create gvx --docker host=unix://$HOME/.gvx/docker.sock
2docker context use gvx
3
4docker info --format '{{.Driver}} {{.OperatingSystem}}'
5# 出力: overlayfs Alpine Linux ... (containerized)
6
7docker build -t myapp ./app
8docker compose up -d

2. 非特権サンドボックスにおける Kubernetes(k3s + Calico)の実行

gvx が /dev/kmsg、cgroup v2(memory.max による OOM killer および cpu.max による帯域制限)、/proc/diskstats、wireguard および vxlan の netlink デバイス、ipset、ならびに iptables-legacy(addrtype、conntrack、statistic、recent)を実装しているため、変更されていない rancher/k3s イメージは、単一の rungv プロセス内で Ready に到達する:

 1mkdir -p ~/.gvx/k3s
 2
 3rungv run \
 4  -e IPTABLES_MODE=legacy \
 5  -p 127.0.0.1:6443:6443 \
 6  -p 127.0.0.1:30000-30019:30000-30019 \
 7  -v k3s:/var/lib/rancher \
 8  --mount type=bind,src=$HOME/.gvx/k3s,dst=/output \
 9  rancher/k3s:v1.31.4-k3s1 server \
10  --disable=traefik,servicelb \
11  --write-kubeconfig=/output/kubeconfig.yaml \
12  --write-kubeconfig-mode=644

埋め込み etcd による高可用性構成で、複数の gvx ノードを WireGuard(flannel-backend=wireguard-native または wg0 メッシュ)を介して結合することもできる。あるいは、Calico(FELIX_IPTABLESBACKEND=Legacy を伴う calico-vxlan.yaml)を実行し、Kubernetes の NetworkPolicy をユーザー空間内で完全に適用することもできる。

3. z.gfire.dev/gvx/pkg/gvx を介した Go への Linux コンテナの埋め込み

Go アプリケーションは、外部のコンテナデーモンを必要とせずに、Linux コンテナを直接埋め込むことができる:

 1package main
 2
 3import (
 4	"context"
 5	"fmt"
 6	"os"
 7
 8	"z.gfire.dev/gvx/pkg/gvx"
 9)
10
11func main() {
12	ctx := context.Background()
13	sb, err := gvx.New(ctx, gvx.Config{
14		Platform:      gvx.PlatformAuto,
15		NetworkMode:   gvx.NetModeNAT,
16		OCIImage:      "alpine:3.20",
17		WorkspacePath: "./repo",
18		Argv:          []string{"/usr/bin/git", "status"},
19		Stdout:        os.Stdout,
20		Stderr:        os.Stderr,
21	})
22	if err != nil {
23		fmt.Fprintf(os.Stderr, "init sandbox: %v\n", err)
24		os.Exit(1)
25	}
26	defer sb.Close()
27
28	exitCode, err := sb.Run(ctx)
29	if err != nil {
30		fmt.Fprintf(os.Stderr, "run sandbox: %v\n", err)
31		os.Exit(1)
32	}
33	os.Exit(exitCode)
34}

本シリーズのさらなる読書案内

  1. gvx: 1つの非特権プロセスで Linux コンテナ、Docker、および Kubernetes を実行する(本記事)
  2. root またはユーザー名前空間を用いずに Docker、docker build、および docker compose を実行する: sudo、unshare(CLONE_NEWUSER)、mount(2)、および /dev/fuse がブロックされたロックダウン済みのサンドボックスにおいて、docker:dind をブートストラップする方法。
  3. gvx を用いた gVisor およびネストされたサンドボックス内での Docker-in-Docker の実行: apt-get install docker.io およびネストされた runsc が、gVisor 内で5つのカーネル上の障壁によりなぜ失敗するのか、また gvx がどのようにして共有メモリのフュテックスループを用いてネストされた docker:dind を実行するのかについて。
  4. gvx を用いた AI エージェントの RL ロールアウトのための高速な隔離 Docker 環境: 数百の並列な非特権 docker:dind インスタンスおよび100ミリ秒のコピーオンライト .ext4 ボリュームリセットを用いて、SWE-bench およびエージェントの強化学習ロールアウトを拡張する。