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 com Segurança Dentro de um Sandbox gVisor (runsc) Não Privilegiado

Ambientes de computação para agentes de IA em nuvem (como os sandboxes do Google Agent Compute Engine), implantações agent-sandbox no Kubernetes (RuntimeClass: gvisor) e arcabouços 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 não privilegiado padrão tenta instalar docker.io ou docker-ce via apt-get, iniciar service docker start ou dockerd e executar docker build, a operação falha mesmo que o processo tenha root (UID 0) dentro do contêiner.

Com o gvx (rungv), é possível executar com segurança docker:dind, docker build e docker compose diretamente dentro de um contêiner gVisor (runsc) não privilegiado (ou aninhar gvx dentro de gvx) sem enfraquecer o RuntimeClass externo, expor sockets do hospedeiro ou conceder --privileged.

Solução de Baixo Esforço e Eficiente em Tokens para Agentes de IA: Dentro de um ambiente de agente em nuvem com suporte do gVisor, a tentativa de instalar docker.io e contornar manualmente erros de ulimit em /etc/init.d/docker, falhas de iptables-nft, negações de raw socket do iptables-legacy, erros de montagem do overlay2 e falhas de runc / pivot_root durante o docker build normalmente consome mais de 20 turnos de chamadas de ferramentas e dezenas de milhares de tokens antes de falhar por completo. O gvx (rungv) é uma solução de substituição direta, de baixo esforço e eficiente em tokens: o agente executa 3 comandos em um único turno para iniciar um daemon Docker funcional no espaço do usuário e executa imediatamente docker build e docker compose sem modificar os arquivos do projeto.

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 Sentry do gVisor aninhado, no espaço do usuário, com seu próprio hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), sua própria netstack em memória e um volume ext4lite de arquivo único. Os daemons dockerd e os contêineres runc internos ficam em duplo sandbox: suas chamadas de sistema, montagens e pacotes brutos nunca saem do kernel interno do gvx no 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 gvx interno
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. Falhas do Script de Inicialização (systemctl / service docker start ulimit) e do Módulo do Kernel (modprobe 4.4.0)

Após executar apt-get install -y docker.io (ou docker-ce), a tentativa de iniciar o daemon via systemctl start docker ou service docker start falha imediatamente, porque o PID 1 não é o systemd e o /etc/init.d/docker tenta elevar o ulimit -n acima do limite rígido RLIMIT_NOFILE do sandbox. Quando o dockerd é então invocado diretamente, os scripts de inicialização chamam o modprobe para carregar overlay, bridge, br_netfilter, ip_tables e nf_nat contra a versão virtual do kernel do gVisor (4.4.0), que não possui o diretório /lib/modules/4.4.0:

1System has not been booted with systemd as init system (PID 1). Can't operate.
2Failed to connect to bus: Host is down
3/etc/init.d/docker: 62: ulimit: error setting limit (Operation not permitted)
4level=warning msg="Running modprobe bridge br_netfilter failed with message: modprobe: WARNING: Module bridge not found in directory /lib/modules/4.4.0\nmodprobe: WARNING: Module br_netfilter not found in directory /lib/modules/4.4.0\n, error: exit status 1"
5modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
6modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
7modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
8modprobe: FATAL: Module nf_nat not found in directory /lib/modules/4.4.0

2. iptables-nft (Netlink nf_tables), Negações de Raw Socket do iptables-legacy e Falhas 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 (baseado no subsistema netlink nf_tables) como alternativa padrão de /usr/sbin/iptables. Além disso, mesmo que o usuário ou o agente alterne para o iptables-legacy (update-alternatives --set iptables /usr/sbin/iptables-legacy), os sandboxes gVisor não privilegiados padrão (--net-raw=false) rejeitam sockets AF_INET, SOCK_RAW com EPERM, fazendo com que o iptables-legacy falhe com Permission denied (you must be root) mesmo quando executado como UID 0:

1failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to register "bridge" driver: failed to create NAT chain DOCKER: iptables failed: iptables --wait -t nat -N DOCKER: iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
2iptables v1.8.10 (legacy): can't initialize iptables table `nat': Permission denied (you must be root)
3Perhaps iptables or your kernel needs to be upgraded.
4iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
5iptables: No chain/target/match by that name.
6Unable 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.)
7Error initializing network controller: error obtaining controller instance: failed to create bridge "docker0": operation not permitted

3. Falha do overlay2 no Rootfs gofer / 9p e Esgotamento de Disco com vfs

A menos que o administrador externo do runsc tenha configurado previamente um mount dedicado de tmpfs ou de filestore em /var/lib/docker, o rootfs do contêiner é sustentado pelo gofer do gVisor (9p/lisafs) ou por um mount de overlay aninhado. A montagem do 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 o dockerd --storage-driver=vfs, o Docker desativa completamente o compartilhamento de camadas Copy-on-Write. Cada instrução RUN em um Dockerfile executa uma cópia recursiva completa (dirCopy) de todo o sistema de arquivos raiz, multiplicando uma imagem de 500 MB ao longo de 10 etapas de build em 5 GB de gravações e encerrando o sandbox com no space left on device ou com um timeout de I/O.

4. Falhas do docker build (BuildKit) e do runc Aninhado (pivot_root, /proc, Cgroups e DNS)

Mesmo que o dockerd seja forçado a iniciar com dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs, a tentativa de executar docker build -t app . ou docker run falha imediatamente quando o BuildKit ou o runc cria o contêiner de build:

 1ERROR: failed to solve: process "/bin/sh -c ..." did not complete successfully:
 2docker: 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:
 3error during container init: error jailing process inside rootfs: pivot_root .: invalid argument
 4error 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
 5cgroups: cgroup mountpoint does not exist: unknown
 6runc create failed: systemd not running on this host: cannot use systemd cgroup manager
 7error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
 8error during container init: error loading seccomp filter into kernel: operation not permitted
 9Temporary failure resolving 'deb.debian.org'
10Temporary failure resolving 'archive.ubuntu.com'

5. runsc Aninhado Falha com SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)

Se um usuário tentar executar outra instância do runsc dentro de um 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 trata todas as cinco barreiras dentro de seu próprio kernel no espaço do usuário e de sua camada de plataforma, permitindo que o rungv seja executado dentro de um contêiner gVisor (runsc) externo ou, 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): capturam as chamadas de sistema do convidado via SECCOMP_RET_TRAP (SIGSYS) e trampolins jmp rel32 com patch binário (syshandler), trocando o estado dos 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 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 via 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 2 páginas 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 deixa 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 dos stubs exclusivamente por memória compartilhada e SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notify the syscall thread about a new syscall request.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Wait for reply.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Como o SECCOMP_RET_TRAP filtra threads do convidado em pilhas arbitrariamente profundas e a syscallThread recorre a _RUN_SYSCALL_LOOP sem solicitar um segundo listener de notificação de usuário, o gvx é executado corretamente dentro de runsc e oferece suporte a aninhamento recursivo (gvx dentro de gvx).

2. overlayfs Nativo sobre Volumes ext4lite de Arquivo Único

Para evitar montar um 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 do gvx, o ext4lite, 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, atributos estendidos trusted.overlay.* e security.capability, nós whiteout de dispositivo de caracteres (0:0), entradas de diretório d_type e RENAME_EXCHANGE, o que permite que o dockerd interno use seu driver de armazenamento overlayfs nativo.

3. netstack Autocontida com docker0 e iptables-legacy

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

  • O dockerd cria sua ponte docker0 e os pares veth dentro da netstack própria do gvx, no espaço do usuário.
  • Todas as regras de iptables-legacy (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) são executadas contra a 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 chamada de sistema mount(2) do runsc externo.

Verificando o docker:dind Aninhado Dentro do gVisor (ou 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 Não Privilegiado: Visão geral do gvx em 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 Aprendizado por Reforço de Agentes de IA com o gvx: Como usar o gvx e snapshots copy-on-write de ext4lite em arquivo único para executar centenas de ambientes paralelos de SWE-bench e de rollouts de RL com reinicializações de 100 milissegundos.