GoSuda

Executando Docker-in-Docker Dentro do gVisor e de Sandboxes Aninhados com gvx

By Lemon Mint views ...

Solução Rápida: Executando docker:dind de Forma Segura Dentro de um Sandbox gVisor (runsc) Sem Privilégios

Plataformas de agentes de IA em nuvem, implantações agent-sandbox no Kubernetes (RuntimeClass: gvisor) e harnesses de avaliação de aprendizado por reforço isolam cargas de trabalho não confiáveis em contêineres gVisor (runsc) especificamente para proteger o kernel do hospedeiro. Quando uma carga de trabalho dentro de um contêiner runsc padrão, sem privilégios, tenta instalar docker.io ou docker-ce via apt-get e iniciar o dockerd, a operação falha, mesmo que o processo seja root (UID 0) dentro do contêiner.

Com o gvx (rungv), é possível executar de forma segura docker:dind, docker build e docker compose diretamente dentro de um contêiner gVisor (runsc) sem privilégios (ou aninhar gvx dentro de gvx), sem enfraquecer o RuntimeClass externo, expor sockets do hospedeiro ou conceder --privileged.

Preservação e Fortalecimento do Isolamento do Sandbox Externo: Executar rungv dentro de runsc não contorna nenhuma restrição imposta pelo sandbox gVisor externo. Em vez disso, o gvx cria um segundo kernel gVisor Sentry aninhado, em espaço do usuário, com seu próprio hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), seu próprio netstack em memória e um volume ext4lite de arquivo único. Os dockerd e os contêineres runc internos ficam em sandbox duplo: suas chamadas de sistema, montagens e pacotes brutos nunca saem do kernel do gvx interno, em espaço do usuário.

 1# Dentro de um contêiner gVisor (runsc) existente ou de um Pod do Kubernetes:
 2# Instale o rungv a partir de um arquivo de release do GitHub com atestação SLSA ou via 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# Execute builds e contêineres Docker padrão contra o socket interno do gvx
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# Saída: overlayfs Alpine Linux ... (containerized)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test

Cinco Motivos pelos Quais a Instalação Direta do Docker Falha Dentro do gVisor (runsc)

Quando um usuário ou um agente de codificação com IA executa apt-get install -y docker.io (ou docker-ce) dentro de um contêiner runsc padrão e tenta iniciar o dockerd, ele encontra cinco barreiras técnicas sequenciais.

1. Carregamento de Módulos do Kernel (modprobe) Contra o Kernel Virtual 4.4.0

Durante a instalação de pacotes e a inicialização do dockerd, os scripts de inicialização executam modprobe para carregar overlay, br_netfilter, ip_tables, nf_nat e xt_conntrack. O Sentry do gVisor externo bloqueia init_module(2) e finit_module(2) e informa uma versão virtual do kernel (4.4.0) que não possui o diretório /lib/modules/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. Falhas do iptables-nft (Netlink nf_tables) e da Ponte docker0

No Ubuntu 22.04, no Ubuntu 24.04 e no Debian 11/12, o apt-get install docker.io seleciona o iptables-nft (apoiado no subsistema netlink nf_tables) como a alternativa padrão /usr/sbin/iptables. Em um contêiner gVisor padrão, sem privilégios administrativos de rede ou suporte a netlink nf_tables, o dockerd falha ao inicializar a cadeia NAT DOCKER e a ponte 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. Falha do overlay2 sobre o Rootfs gofer / 9p e Esgotamento de Disco com vfs

A menos que o administrador do runsc externo tenha explicitamente pré-configurado uma montagem dedicada de tmpfs ou de filestore em /var/lib/docker, o rootfs do contêiner é apoiado pelo gofer do gVisor (9p/lisafs) ou por uma montagem overlay aninhada. Montar overlay2 sobre /var/lib/docker falha:

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

Quando usuários ou agentes tentam contornar esse erro iniciando dockerd --storage-driver=vfs, o Docker desabilita completamente o compartilhamento de camadas por Copy-on-Write. Cada instrução RUN em um Dockerfile realiza uma cópia recursiva completa (dirCopy) de todo o sistema de arquivos raiz, o que transforma uma imagem de 500 MB distribuída em 10 etapas de build em 5 GB de escritas e encerra o sandbox com no space left on device ou com um timeout de E/S.

4. Rejeições de Montagens de Cgroup e de /proc no runc Aninhado

Mesmo que o dockerd seja forçado a iniciar com dockerd --iptables=false --bridge=none --storage-driver=vfs, a execução de docker run ou docker build invoca o runc para criar o contêiner interno. Em um contêiner gVisor padrão, sem privilégios, o runc falha ao montar cgroups, ao remontar /proc ou ao carregar filtros seccomp internos:

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 Aninhado Falha com SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)

Se um usuário tenta executar outra instância de runsc dentro de runsc usando a plataforma systrap, a inicialização é abortada imediatamente:

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

Tanto o kernel Linux quanto o Sentry do gVisor permitem no máximo um listener SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) por hierarquia de tarefas. Como o sandbox externo já ocupa esse slot de listener, a chamada interna seccomp(2) retorna EBUSY (device or resource busy).


Como o gvx Resolve a Execução Aninhada Dentro do gVisor

O gvx aborda as cinco barreiras dentro de seu próprio kernel em espaço do usuário e de sua camada de plataforma, permitindo que o rungv execute dentro de um contêiner gVisor externo (runsc) ou até recursivamente dentro de outro contêiner gvx (gvx em gvx).

Execução aninhada do gvx dentro de um gVisor externo

1. Fallback Automático do systrap, de EBUSY para o Loop de Futex _RUN_SYSCALL_LOOP

No systrap do Linux, cada subprocesso stub executa dois tipos de threads:

  • Threads de execução do convidado (sysmsg): Interceptam chamadas de sistema do convidado via SECCOMP_RET_TRAP (SIGSYS) e trampolins jmp rel32 com patches binários (syshandler), trocando o estado de registradores com o Sentry por meio de memória compartilhada e SYS_FUTEX.
  • Uma syscallThread stub (syscall_thread.go): Executa chamadas de sistema do espaço de endereçamento (mmap, munmap, clone) dentro do subprocesso stub em nome do Sentry.

No gVisor upstream, a syscallThread oferece suporte a dois modos de despertar em stub_amd64.s e stub_arm64.s:

  1. Loop de User-Notify do Seccomp (_RUN_SECCOMP_LOOP = 6, quando t.seccompNotify != nil): Durante syscallThread.init(), t.installSeccompNotify() instala um listener SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)) para que o Sentry possa despachar requisições via ioctl(SECCOMP_IOCTL_NOTIF_SEND) e ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Loop de Chamadas de Sistema por Futex (_RUN_SYSCALL_LOOP = 5, quando t.seccompNotify == nil): A syscallThread executa syscall_loop (stub_amd64.s / stub_arm64.s), aguardando em SYS_FUTEX (FUTEX_WAIT) sobre a região compartilhada de duas páginas, composta por syscallSentryMessage (PROT_READ no stub) e syscallStubMessage (PROT_READ | PROT_WRITE no stub).

O gVisor upstream chama incondicionalmente t.installSeccompNotify() sempre que seccompNotifyIsSupported é verdadeiro e aborta a criação do stub se seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) retornar EBUSY.

No gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go e syscall_thread.go), syscallThread.init() chama 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}

Quando installSeccompNotify() retorna unix.EBUSY porque um sandbox gVisor (runsc) ou gvx externo já ocupa o slot único de listener da cadeia de filtros do processo:

  • installSeccompNotifyIfFree() define seccompNotifyBusy (atomic.Bool), registra um único aviso para o processo e mantém t.seccompNotify == nil para esse stub e para todos os stubs subsequentes.
  • Em syscallThread.detach() (syscall_thread_amd64.go e syscall_thread_arm64.go), t.seccompNotify == nil define regs.Rbx = _RUN_SYSCALL_LOOP (5) em amd64 (ou regs.Regs[9] = _RUN_SYSCALL_LOOP em arm64), em vez de _RUN_SECCOMP_LOOP (6).
  • Em syscallThread.syscall() (syscall_thread.go), o Sentry interno despacha as chamadas de sistema do stub exclusivamente por memória compartilhada e SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notifica a thread de chamadas de sistema sobre uma nova requisição de syscall.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Aguarda a resposta.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Como os filtros SECCOMP_RET_TRAP atuam sobre as pilhas das threads do convidado com profundidade arbitrária, e a syscallThread recorre a _RUN_SYSCALL_LOOP sem solicitar um segundo listener de notificação de usuário, o gvx executa de forma consistente dentro de runsc e oferece suporte a aninhamento recursivo (gvx dentro de gvx).

2. overlayfs Nativo sobre Volumes ext4lite de Arquivo Único

Para evitar a montagem de overlayfs sobre o sistema de arquivos raiz gofer (9p/lisafs) do contêiner gVisor externo, -v gvx-docker:/var/lib/docker sustenta /var/lib/docker com o sistema de arquivos interno ext4lite do gvx, armazenado em um único arquivo regular (gvx-docker.ext4).

Para o runsc externo, gvx-docker.ext4 é apenas um arquivo regular acessado via pread64(2) e pwrite64(2). Dentro do gvx, o ext4lite fornece um sistema de arquivos ext4 com journaling JBD2, com atributos estendidos trusted.overlay.* e security.capability, nós de dispositivo de caractere para whiteouts (0:0), entradas de diretório d_type e RENAME_EXCHANGE, permitindo que o dockerd interno utilize seu driver de armazenamento overlayfs nativo.

3. netstack Autocontido com docker0 e iptables-legacy

Dentro do gvx, o dockerd não interage com as interfaces de rede do contêiner runsc externo nem com suas tabelas netfilter. Em vez disso:

  • O dockerd cria sua ponte docker0 e os pares veth dentro do netstack próprio do gvx, em espaço do usuário.
  • Todas as regras iptables-legacy (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) são executadas na implementação interna de netfilter do gvx.
  • Os contêineres runc aninhados montam suas hierarquias /proc, /sys e /sys/fs/cgroup dentro da tabela de montagens VFS2 em memória do gvx, sem jamais invocar a syscall mount(2) do runsc externo.

Verificação do docker:dind Aninhado Dentro do gVisor (ou de gvx em gvx)

É possível verificar ambos os cenários (rungv dentro de runsc ou rungv dentro de rungv) com os seguintes comandos:

 1# Dentro de um contêiner gVisor (runsc) externo ou de um contêiner rungv externo:
 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# Verifique se o daemon Docker interno está usando overlayfs nativo (e não vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Saída: Storage Driver: overlayfs
16
17# Execute um build de múltiplas camadas e um contêiner aninhado
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# Saída: nested-build-ok
31
32kill -INT $DIND_PID

Leitura Complementar Nesta Série

  1. gvx: Executando Contêineres Linux, Docker e Kubernetes em um Único Processo sem Privilégios: Visão geral do gvx nas plataformas Windows (whpx, systrap), macOS (hvf) e Linux (systrap).
  2. Executando Docker, docker build e docker compose Sem Root ou User Namespaces: Como o gvx executa docker:dind quando root, sudo, unshare(CLONE_NEWUSER), mount(2) e /dev/fuse estão bloqueados.
  3. Executando Docker-in-Docker Dentro do gVisor e de Sandboxes Aninhados com o gvx (esta postagem)
  4. Ambientes Docker Isolados e Rápidos para Rollouts de RL de Agentes de IA com o gvx: Como usar o gvx e snapshots copy-on-write de arquivo único ext4lite para executar centenas de ambientes paralelos de SWE-bench e rollouts de RL com redefinições em 100 milissegundos.