GoSuda

gVisor 및 중첩 샌드박스 내부에서 gvx로 Docker-in-Docker 실행

By Lemon Mint views ...

빠른 해결책: 권한 없는 gVisor (runsc) 샌드박스 내부에서 docker:dind를 안전하게 실행하기

클라우드 AI 에이전트 플랫폼, Kubernetes agent-sandbox 배포(RuntimeClass: gvisor), 그리고 강화학습 평가 하네스(harness)는 호스트 커널을 보호하기 위해 신뢰할 수 없는 워크로드를 gVisor (runsc) 컨테이너 내부에서 격리합니다. 기본 설정의 권한 없는 runsc 컨테이너 내부 워크로드가 apt-get으로 docker.io 또는 docker-ce를 설치하고 dockerd를 시작하려 하면, 컨테이너 내부에서 root(UID 0) 권한을 가지고 있더라도 실패합니다.

gvx (rungv)를 사용하면, 외부 gVisor (runsc) 컨테이너 내부에서 바로(또는 gvx 안에 gvx를 중첩하여) 외부 RuntimeClass를 약화시키지 않고, 호스트 소켓을 노출하지 않으며, --privileged를 부여하지 않고 docker:dind, docker build, docker compose를 안전하게 실행할 수 있습니다.

외부 샌드박스 격리의 보존 및 강화: runsc 내부에서 rungv를 실행하더라도 외부 gVisor 샌드박스가 적용하는 제한을 단 하나도 우회하지 않습니다. 대신 gvx는 자체 hostlockdown(PR_SET_NO_NEW_PRIVS + seccomp), 자체 인메모리 netstack, 그리고 단일 파일 ext4lite 볼륨을 갖춘 두 번째 중첩 사용자 공간 gVisor Sentry 커널을 생성합니다. 내부 dockerd와 runc 컨테이너는 이중으로 샌드박싱되며, 시스템 호출, 마운트, 원시 패킷(raw packet)은 내부 gvx 사용자 공간 커널 밖으로 절대 나가지 않습니다.

 1# 기존 gVisor (runsc) 컨테이너 또는 Kubernetes Pod 내부에서:
 2# SLSA 인증된 GitHub 릴리스 아카이브 또는 go install로 rungv를 설치합니다:
 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 ... (containerized)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test

gVisor (runsc) 내부에서 Docker를 직접 설치하면 실패하는 다섯 가지 이유

사용자 또는 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 대체(alternative)로 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 마운트를 명시적으로 미리 구성하지 않았다면, 컨테이너 rootfs는 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를 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. SECCOMP_FILTER_FLAG_NEW_LISTENER(EBUSY)로 인한 중첩 runsc 실패

사용자가 systrap 플랫폼을 사용하여 runsc 내부에서 또 다른 runsc 인스턴스를 실행하려 하면, 초기화가 즉시 중단됩니다:

1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy

Linux 커널과 gVisor Sentry는 모두 태스크 계층(task hierarchy)당 최대 하나의 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) 실행될 수 있도록 합니다.

외부 gVisor 내부의 중첩 gvx 실행

1. EBUSY에서 _RUN_SYSCALL_LOOP futex 루프로의 자동 systrap 폴백

Linux systrap에서 각 stub 서브프로세스는 두 종류의 스레드를 실행합니다:

  • 게스트 실행 스레드(sysmsg): SECCOMP_RET_TRAP(SIGSYS)과 바이너리 패치된 jmp rel32 트램펄린(syshandler)을 통해 게스트 시스템 호출을 가로채고, 공유 메모리와 SYS_FUTEX를 통해 Sentry와 레지스터 상태를 교환합니다.
  • stub syscallThread(syscall_thread.go): Sentry를 대신하여 stub 서브프로세스 내부에서 주소 공간 시스템 호출(mmap, munmap, clone)을 실행합니다.

upstream gVisor에서 syscallThread는 stub_amd64.s와 stub_arm64.s에서 두 가지 깨우기 모드를 지원합니다:

  1. 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)를 통해 요청을 전달할 수 있게 합니다.
  2. Futex 시스템 호출 루프(_RUN_SYSCALL_LOOP = 5, t.seccompNotify == nil인 경우): syscallThread은 syscall_loop(stub_amd64.s / stub_arm64.s)을 실행하며, 2페이지 크기의 공유 syscallSentryMessage(stub에서 PROT_READ)와 syscallStubMessage(stub에서 PROT_READ | PROT_WRITE) 영역에 대해 SYS_FUTEX(FUTEX_WAIT)에서 대기합니다.

upstream gVisor는 seccompNotifyIsSupported가 true일 때마다 t.installSeccompNotify()를 무조건 호출하며, seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...)가 EBUSY를 반환하면 stub 생성을 중단합니다.

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}

외부 gVisor (runsc) 또는 외부 gvx 샌드박스가 이미 프로세스 필터 체인의 단일 리스너 슬롯을 점유하고 있어 installSeccompNotify()가 unix.EBUSY를 반환하는 경우:

  • installSeccompNotifyIfFree()는 seccompNotifyBusy(atomic.Bool)를 설정하고, 프로세스당 경고를 한 번 기록하며, 해당 stub과 이후의 모든 stub에 대해 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는 stub 시스템 호출을 오직 공유 메모리와 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는 단일 일반 파일(gvx-docker.ext4)에 저장된 gvx 내부 ext4lite 파일시스템으로 /var/lib/docker를 뒷받침합니다.

외부 runsc에게 gvx-docker.ext4는 pread64(2)와 pwrite64(2)로 접근되는 일반 파일 하나에 불과합니다. gvx 내부에서 ext4lite는 trusted.overlay.* 및 security.capability 확장 속성, 문자 장치 whiteout 노드(0:0), d_type 디렉터리 엔트리, RENAME_EXCHANGE를 갖춘 JBD2 저널링 ext4 파일시스템을 제공하여, 내부 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 컨테이너는 /proc, /sys, /sys/fs/cgroup 계층을 gvx의 인메모리 VFS2 마운트 테이블 내부에 마운트하며, 외부 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

이 시리즈의 추가 자료

  1. gvx: 하나의 권한 없는 프로세스에서 Linux 컨테이너, Docker, Kubernetes 실행하기: Windows(whpx, systrap), macOS(hvf), Linux(systrap)에 걸친 gvx의 개요.
  2. root 또는 사용자 네임스페이스 없이 Docker, docker build, docker compose 실행하기: root, sudo, unshare(CLONE_NEWUSER), mount(2), /dev/fuse가 차단된 상황에서 gvx가 docker:dind를 실행하는 방법.
  3. gvx로 gVisor 및 중첩 샌드박스 내부에서 Docker-in-Docker 실행하기 (이 글)
  4. gvx로 구현하는 AI 에이전트 RL 롤아웃용 빠른 격리 Docker 환경: gvx와 단일 파일 ext4lite copy-on-write 스냅샷을 사용하여 100밀리초 리셋으로 수백 개의 병렬 SWE-bench 및 RL 롤아웃 환경을 실행하는 방법.