---
id: 83fb2c108243a7df80db3a6e78290daf
author: Lemon Mint
title: gVisor 및 중첩 샌드박스 내부에서 gvx로 Docker-in-Docker 실행
description: gVisor 컨테이너 내부에서 docker.io를 설치하거나 runsc를 중첩 실행하면 modprobe, iptables-nft, overlay2, cgroups, seccomp EBUSY 전반에서 실패하는 이유, 그리고 gvx가 --privileged 없이 격리된 user-space kernel 내부에서 중첩된 docker:dind를 안전하게 실행하는 방법
language: ko
date: 2026-11-06T12:02:55.931762327Z
path: /blog/posts/docker-in-docker-inside-gvisor-with-gvx-z48916d03
go_package: z.gfire.dev/gvx
go_repourl: https://github.com/gfire-sigs/gvx.git
category: lowlevel
---

## 빠른 해결책: 권한 없는 `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](https://github.com/gfire-sigs/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` 사용자 공간 커널 밖으로 절대 나가지 않습니다.

```sh
# 기존 gVisor (runsc) 컨테이너 또는 Kubernetes Pod 내부에서:
# SLSA 인증된 GitHub 릴리스 아카이브 또는 go install로 rungv를 설치합니다:
CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest

mkdir -p ~/.gvx
rungv run \
  -p unix:~/.gvx/docker.sock:2375 \
  -v gvx-docker:/var/lib/docker \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &

# 내부 gvx 소켓을 대상으로 표준 Docker 빌드 및 컨테이너를 실행합니다
export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
until docker info >/dev/null 2>&1; do sleep 0.5; done
docker info --format '{{.Driver}} {{.OperatingSystem}}'
# 출력: overlayfs Alpine Linux ... (containerized)

docker build -t nested-app:test .
docker 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`)을 보고합니다:

```text
modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
modprobe: 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` 브리지를 초기화하는 도중 충돌합니다:

```text
failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to create NAT chain DOCKER: iptables failed: iptables -t nat -N DOCKER
iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
iptables: No chain/target/match by that name.
Unable 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.)
Error 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`를 마운트하면 실패합니다:

```text
level=error msg="[graphdriver] prior storage driver overlay2 failed: driver not supported"
failed to mount overlay: invalid argument
Error 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 필터를 로드할 때 실패합니다:

```text
docker: 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:
error 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
cgroups: cgroup mountpoint does not exist: unknown
runc create failed: systemd not running on this host: cannot use systemd cgroup manager
error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
error during container init: error loading seccomp filter into kernel: operation not permitted
```

### 5. `SECCOMP_FILTER_FLAG_NEW_LISTENER`(`EBUSY`)로 인한 중첩 `runsc` 실패

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

```text
creating 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 실행](/assets/images/gvx/gvx-nested-seccomp-futex.png)

### 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()`를 호출합니다:

```go
// pkg/sentry/platform/systrap/seccomp_notify_xplat.go
var seccompNotifyBusy atomic.Bool

func (t *syscallThread) installSeccompNotifyIfFree() (*os.File, error) {
	if seccompNotifyBusy.Load() {
		return nil, nil
	}
	f, err := t.installSeccompNotify()
	if errors.Is(err, unix.EBUSY) {
		if seccompNotifyBusy.CompareAndSwap(false, true) {
			log.Warningf("Another seccomp listener exists in the filter chain of the host process. " +
				"The stub threads use the slower futex loop instead of seccomp-unotify.")
		}
		return nil, nil
	}
	return f, err
}
```

외부 `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`를 통해서만 전달합니다:

```go
// pkg/sentry/platform/systrap/syscall_thread.go
// 새로운 시스템 호출 요청을 syscall 스레드에 알립니다.
atomic.AddUint32(&sentryMsg.state, 1)
futexWakeUint32(&sentryMsg.state)

// 응답을 기다립니다.
futexWaitWake(&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`)를 모두 검증할 수 있습니다:

```sh
# 외부 gVisor (runsc) 컨테이너 또는 외부 rungv 컨테이너 내부에서:
mkdir -p ~/.gvx
rungv run \
  -p unix:~/.gvx/docker.sock:2375 \
  -v gvx-docker:/var/lib/docker \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &
DIND_PID=$!

export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
until docker info >/dev/null 2>&1; do sleep 0.5; done

# 내부 Docker 데몬이 vfs가 아닌 네이티브 overlayfs를 사용하는지 확인
docker info --format 'Storage Driver: {{.Driver}}'
# 출력: Storage Driver: overlayfs

# 다중 레이어 빌드를 실행하고 중첩 컨테이너를 실행
mkdir -p /tmp/nested-test && cd /tmp/nested-test
cat <<'EOF' > Dockerfile
FROM alpine:3.20
RUN adduser -D -u 1001 appuser && mkdir -p /app && chown appuser:appuser /app
USER appuser
WORKDIR /app
RUN echo "nested-build-ok" > status.txt
CMD ["cat", "/app/status.txt"]
EOF

docker build -t nested-test:latest .
docker run --rm nested-test:latest
# 출력: nested-build-ok

kill -INT $DIND_PID
```

---

## 이 시리즈의 추가 자료

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