Ejecutando Docker-in-Docker dentro de gVisor y sandboxes anidados con gvx

Solución rápida: ejecución segura de docker:dind dentro de un sandbox gVisor (runsc) sin privilegios
Los entornos de cómputo de agentes de IA en la nube (como los sandboxes de Google Agent Compute Engine), los despliegues de agent-sandbox en Kubernetes (RuntimeClass: gvisor) y los arneses de evaluación de aprendizaje por refuerzo aíslan las cargas de trabajo no confiables dentro de contenedores gVisor (runsc) específicamente para proteger el kernel del host. Cuando una carga de trabajo dentro de un contenedor runsc sin privilegios predeterminado intenta instalar docker.io o docker-ce mediante apt-get, iniciar service docker start o dockerd, y ejecutar docker build, falla incluso si tiene root (UID 0) dentro del contenedor.
Con gvx (rungv), puede ejecutar de forma segura docker:dind, docker build y docker compose directamente dentro de un contenedor gVisor (runsc) sin privilegios (o anidar gvx dentro de gvx) sin debilitar el RuntimeClass externo, exponer sockets del host ni conceder --privileged.
Solución de bajo esfuerzo y eficiente en tokens para agentes de IA: dentro de un entorno de agente en la nube respaldado por gVisor, intentar instalar
docker.ioy sortear manualmente los errores deulimiten/etc/init.d/docker, los fallos deiptables-nft, las denegaciones de sockets raw deiptables-legacy, los errores de montaje deoverlay2y los fallos derunc/pivot_rootdurantedocker buildsuele consumir más de 20 turnos de llamadas a herramientas y decenas de miles de tokens antes de fallar por completo.gvx(rungv) es una solución de reemplazo directo, de bajo esfuerzo y eficiente en tokens: el agente ejecuta 3 comandos en un solo turno para iniciar un daemon de Docker funcional en espacio de usuario y ejecuta inmediatamentedocker buildydocker composesin modificar los archivos del proyecto.
Preservación y refuerzo del aislamiento del sandbox externo: ejecutar
rungvdentro derunscno elude ni una sola restricción impuesta por el sandbox externo de gVisor. En cambio,gvxcrea un segundo kernelSentryde gVisor anidado en espacio de usuario, con su propiohostlockdown(PR_SET_NO_NEW_PRIVS+seccomp), su propionetstacken memoria y un volumenext4litede un único archivo. Los contenedores internosdockerdyruncestán doblemente aislados en un sandbox: sus llamadas al sistema, montajes y paquetes sin procesar nunca salen del kernel de espacio de usuario degvxinterno.
1# Dentro de un contenedor gVisor (runsc) existente o de un Pod de Kubernetes:
2# Instale rungv desde un archivo de versión de GitHub con atestación SLSA o mediante 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# Ejecute compilaciones y contenedores de Docker estándar contra el socket interno de 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# Salida: overlayfs Alpine Linux ... (containerized)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test
Cinco razones por las que la instalación directa de Docker falla dentro de gVisor (runsc)
Cuando un usuario o un agente de programación con IA ejecuta apt-get install -y docker.io (o docker-ce) dentro de un contenedor runsc estándar e intenta iniciar dockerd, encuentra cinco barreras técnicas secuenciales.
1. Fallos del script de inicio (systemctl / service docker start ulimit) y del módulo del kernel (modprobe 4.4.0)
Tras ejecutar apt-get install -y docker.io (o docker-ce), intentar iniciar el daemon mediante systemctl start docker o service docker start falla de inmediato, porque el PID 1 no es systemd y /etc/init.d/docker intenta elevar ulimit -n por encima del límite duro RLIMIT_NOFILE del sandbox. Cuando después se invoca dockerd directamente, los scripts de inicialización llaman a modprobe para cargar overlay, bridge, br_netfilter, ip_tables y nf_nat frente a la versión del kernel virtual de gVisor (4.4.0), que no tiene un directorio /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), denegaciones de sockets raw de iptables-legacy y fallos del puente docker0
En Ubuntu 22.04, Ubuntu 24.04 y Debian 11/12, apt-get install docker.io selecciona iptables-nft (respaldado por el subsistema netlink nf_tables) como alternativa predeterminada de /usr/sbin/iptables. Además, aunque el usuario o el agente cambie a iptables-legacy (update-alternatives --set iptables /usr/sbin/iptables-legacy), los sandboxes predeterminados de gVisor sin privilegios (--net-raw=false) rechazan los sockets AF_INET, SOCK_RAW con EPERM, lo que hace que iptables-legacy falle con Permission denied (you must be root) incluso cuando se ejecuta 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. Fallo de overlay2 sobre rootfs gofer / 9p y agotamiento de disco con vfs
A menos que el administrador externo de runsc haya preconfigurado explícitamente un montaje dedicado de tmpfs o de filestore en /var/lib/docker, el rootfs del contenedor está respaldado por el gofer de gVisor (9p/lisafs) o por un montaje overlay anidado. Montar overlay2 sobre /var/lib/docker falla:
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
Cuando usuarios o agentes intentan eludir este error iniciando dockerd --storage-driver=vfs, Docker desactiva por completo el uso compartido de capas Copy-on-Write. Cada instrucción RUN de un Dockerfile realiza una copia recursiva completa (dirCopy) de todo el sistema de archivos raíz, lo que multiplica una imagen de 500 MB a lo largo de 10 pasos de compilación en 5 GB de escrituras y termina el sandbox con no space left on device o con un timeout de E/S.
4. Fallos de docker build (BuildKit) y de runc anidado (pivot_root, /proc, cgroups y DNS)
Incluso si dockerd se fuerza a iniciarse con dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs, intentar ejecutar docker build -t app . o docker run falla de inmediato cuando BuildKit o runc crean el contenedor de compilación:
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 anidado falla con SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)
Si un usuario intenta ejecutar otra instancia de runsc dentro de runsc usando la plataforma systrap, la inicialización se aborta de inmediato:
1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy
Tanto el kernel de Linux como el Sentry de gVisor permiten como máximo un listener SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) por jerarquía de tareas. Como el sandbox externo ya ocupa esa ranura de listener, la llamada interna seccomp(2) devuelve EBUSY (device or resource busy).
Cómo gvx resuelve la ejecución anidada dentro de gVisor
gvx aborda las cinco barreras dentro de su propio kernel de espacio de usuario y de su capa de plataforma, lo que permite a rungv ejecutarse dentro de un contenedor externo gVisor (runsc) o incluso de forma recursiva dentro de otro contenedor gvx (gvx en gvx).

1. Fallback automático de systrap desde EBUSY al bucle futex _RUN_SYSCALL_LOOP
En systrap de Linux, cada subproceso stub ejecuta dos tipos de hilos:
- Hilos de ejecución del invitado (
sysmsg): interceptan las llamadas al sistema del invitado medianteSECCOMP_RET_TRAP(SIGSYS) y trampolinesjmp rel32parcheados en el binario (syshandler), e intercambian el estado de los registros con el Sentry mediante memoria compartida ySYS_FUTEX. - Un
syscallThreadstub (syscall_thread.go): ejecuta las llamadas al sistema del espacio de direcciones (mmap,munmap,clone) dentro del subproceso stub en nombre del Sentry.
En gVisor upstream, syscallThread admite dos modos de despertar en stub_amd64.s y stub_arm64.s:
- Bucle de user-notify de seccomp (
_RUN_SECCOMP_LOOP = 6, cuandot.seccompNotify != nil): durantesyscallThread.init(),t.installSeccompNotify()instala un listenerSECCOMP_RET_USER_NOTIF(seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)) para que el Sentry pueda despachar solicitudes medianteioctl(SECCOMP_IOCTL_NOTIF_SEND)eioctl(SECCOMP_IOCTL_NOTIF_RECV). - Bucle de llamadas al sistema con futex (
_RUN_SYSCALL_LOOP = 5, cuandot.seccompNotify == nil): elsyscallThreadejecutasyscall_loop(stub_amd64.s/stub_arm64.s), durmiendo enSYS_FUTEX(FUTEX_WAIT) sobre la región compartida de 2 páginassyscallSentryMessage(PROT_READen el stub) ysyscallStubMessage(PROT_READ | PROT_WRITEen el stub).
En gVisor upstream, installSeccompNotify() se invoca incondicionalmente siempre que seccompNotifyIsSupported sea verdadero, y aborta la creación del stub si seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) devuelve EBUSY.
En gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go y syscall_thread.go), syscallThread.init() llama a 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}
Cuando installSeccompNotify() devuelve unix.EBUSY porque un sandbox externo gVisor (runsc) o gvx ya ocupa la única ranura de listener de la cadena de filtros del proceso:
installSeccompNotifyIfFree()estableceseccompNotifyBusy(atomic.Bool), registra una única advertencia para el proceso y dejat.seccompNotify == nilpara ese stub y para todos los stubs posteriores.- En
syscallThread.detach()(syscall_thread_amd64.goysyscall_thread_arm64.go),t.seccompNotify == nilestableceregs.Rbx = _RUN_SYSCALL_LOOP(5) enamd64(oregs.Regs[9] = _RUN_SYSCALL_LOOPenarm64) en lugar de_RUN_SECCOMP_LOOP(6). - En
syscallThread.syscall()(syscall_thread.go), el Sentry interno despacha las llamadas al sistema del stub exclusivamente mediante memoria compartida ySYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notifica al hilo de llamadas al sistema sobre una nueva solicitud de llamada al sistema.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Espera la respuesta.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))
Como los filtros SECCOMP_RET_TRAP sobre los hilos del invitado se apilan a una profundidad arbitraria y syscallThread recurre a _RUN_SYSCALL_LOOP sin solicitar un segundo listener de notificación de usuario, gvx se ejecuta correctamente dentro de runsc y admite anidamiento recursivo (gvx dentro de gvx).
2. overlayfs nativo sobre volúmenes ext4lite de un único archivo
Para evitar montar overlayfs sobre el rootfs gofer (9p/lisafs) del contenedor gVisor externo, -v gvx-docker:/var/lib/docker respalda /var/lib/docker con el sistema de archivos interno de gvx, ext4lite, almacenado en un único archivo regular (gvx-docker.ext4).
Para runsc externo, gvx-docker.ext4 es simplemente un archivo regular al que se accede mediante pread64(2) y pwrite64(2). Dentro de gvx, ext4lite proporciona un sistema de archivos ext4 con journal JBD2, atributos extendidos trusted.overlay.* y security.capability, nodos whiteout de dispositivo de caracteres (0:0), entradas de directorio d_type y RENAME_EXCHANGE, lo que permite que el dockerd interno use su controlador de almacenamiento nativo overlayfs.
3. netstack autocontenido con docker0 e iptables-legacy
Dentro de gvx, dockerd no interactúa con las interfaces de red ni con las tablas de netfilter del contenedor externo runsc. En cambio:
dockerdcrea su puentedocker0y sus paresvethdentro delnetstackpropio degvxen espacio de usuario.- Todas las reglas de
iptables-legacy(-t nat -N DOCKER,MASQUERADE,conntrack,addrtype) se ejecutan contra la implementación interna de netfilter degvx. - Los contenedores
runcanidados montan sus jerarquías/proc,/sysy/sys/fs/cgroupdentro de la tabla de montajesVFS2en memoria degvx, sin invocar nunca la llamada al sistemamount(2)delrunscexterno.
Verificación de docker:dind anidado dentro de gVisor (o gvx dentro de gvx)
Puede verificar ambos escenarios (rungv dentro de runsc, o rungv dentro de rungv) con los siguientes comandos:
1# Dentro de un contenedor gVisor (runsc) externo o de un contenedor 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 que el daemon Docker interno usa overlayfs nativo (no vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Salida: Storage Driver: overlayfs
16
17# Ejecute una compilación multicapa y un contenedor anidado
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# Salida: nested-build-ok
31
32kill -INT $DIND_PID
Lectura adicional en esta serie
- gvx: Ejecución de contenedores Linux, Docker y Kubernetes en un único proceso sin privilegios: visión general de
gvxen Windows (whpx,systrap), macOS (hvf) y Linux (systrap). - Ejecución de Docker, docker build y docker compose sin root ni user namespaces: cómo
gvxejecutadocker:dindcuandoroot,sudo,unshare(CLONE_NEWUSER),mount(2)y/dev/fuseestán bloqueados. - Docker-in-Docker dentro de gVisor y sandboxes anidados con gvx (esta entrada)
- Entornos Docker rápidos y aislados para despliegues de RL de agentes de IA con gvx: cómo usar
gvxy snapshots copy-on-write deext4litede un único archivo para ejecutar cientos de entornos paralelos deSWE-benchy de despliegues de RL con reinicios de 100 milisegundos.