GoSuda

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

By Lemon Mint views ...

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.io y sortear manualmente los errores de ulimit en /etc/init.d/docker, los fallos de iptables-nft, las denegaciones de sockets raw de iptables-legacy, los errores de montaje de overlay2 y los fallos de runc / pivot_root durante docker build suele 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 inmediatamente docker build y docker compose sin modificar los archivos del proyecto.

Preservación y refuerzo del aislamiento del sandbox externo: ejecutar rungv dentro de runsc no elude ni una sola restricción impuesta por el sandbox externo de gVisor. En cambio, gvx crea un segundo kernel Sentry de gVisor anidado en espacio de usuario, con su propio hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), su propio netstack en memoria y un volumen ext4lite de un único archivo. Los contenedores internos dockerd y runc están doblemente aislados en un sandbox: sus llamadas al sistema, montajes y paquetes sin procesar nunca salen del kernel de espacio de usuario de gvx interno.

 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).

Ejecución anidada de gvx dentro de gVisor externo

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 mediante SECCOMP_RET_TRAP (SIGSYS) y trampolines jmp rel32 parcheados en el binario (syshandler), e intercambian el estado de los registros con el Sentry mediante memoria compartida y SYS_FUTEX.
  • Un syscallThread stub (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:

  1. Bucle de user-notify de seccomp (_RUN_SECCOMP_LOOP = 6, cuando t.seccompNotify != nil): durante syscallThread.init(), t.installSeccompNotify() instala un listener SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)) para que el Sentry pueda despachar solicitudes mediante ioctl(SECCOMP_IOCTL_NOTIF_SEND) e ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Bucle de llamadas al sistema con futex (_RUN_SYSCALL_LOOP = 5, cuando t.seccompNotify == nil): el syscallThread ejecuta syscall_loop (stub_amd64.s / stub_arm64.s), durmiendo en SYS_FUTEX (FUTEX_WAIT) sobre la región compartida de 2 páginas syscallSentryMessage (PROT_READ en el stub) y syscallStubMessage (PROT_READ | PROT_WRITE en 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() establece seccompNotifyBusy (atomic.Bool), registra una única advertencia para el proceso y deja t.seccompNotify == nil para ese stub y para todos los stubs posteriores.
  • En syscallThread.detach() (syscall_thread_amd64.go y syscall_thread_arm64.go), t.seccompNotify == nil establece regs.Rbx = _RUN_SYSCALL_LOOP (5) en amd64 (o regs.Regs[9] = _RUN_SYSCALL_LOOP en arm64) 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 y SYS_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:

  • dockerd crea su puente docker0 y sus pares veth dentro del netstack propio de gvx en 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 de gvx.
  • Los contenedores runc anidados montan sus jerarquías /proc, /sys y /sys/fs/cgroup dentro de la tabla de montajes VFS2 en memoria de gvx, sin invocar nunca la llamada al sistema mount(2) del runsc externo.

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

  1. gvx: Ejecución de contenedores Linux, Docker y Kubernetes en un único proceso sin privilegios: visión general de gvx en Windows (whpx, systrap), macOS (hvf) y Linux (systrap).
  2. Ejecución de Docker, docker build y docker compose sin root ni user namespaces: cómo gvx ejecuta docker:dind cuando root, sudo, unshare(CLONE_NEWUSER), mount(2) y /dev/fuse están bloqueados.
  3. Docker-in-Docker dentro de gVisor y sandboxes anidados con gvx (esta entrada)
  4. Entornos Docker rápidos y aislados para despliegues de RL de agentes de IA con gvx: cómo usar gvx y snapshots copy-on-write de ext4lite de un único archivo para ejecutar cientos de entornos paralelos de SWE-bench y de despliegues de RL con reinicios de 100 milisegundos.