GoSuda

Ejecución de Docker-in-Docker dentro de gVisor y de Nested Sandboxes con gvx

By Lemon Mint views ...

Solución rápida: ejecutar docker:dind de forma segura dentro de un sandbox gVisor (runsc) sin privilegios

Las plataformas de agentes de IA en la nube, los despliegues agent-sandbox de 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 e iniciar dockerd, 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, sin exponer sockets del host y sin conceder --privileged.

Preservación y refuerzo del aislamiento del sandbox externo: Ejecutar rungv dentro de runsc no elude ninguna restricción aplicada por el sandbox externo de gVisor. En su lugar, 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 dockerd y los contenedores runc internos están doblemente aislados en 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 Docker estándar contra el socket de 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# 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 basado en 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. Carga de módulos del kernel (modprobe) frente al kernel virtual 4.4.0

Durante la instalación del paquete y el inicio de dockerd, los scripts de inicialización ejecutan modprobe para cargar overlay, br_netfilter, ip_tables, nf_nat y xt_conntrack. El Sentry de gVisor externo bloquea init_module(2) y finit_module(2) e informa de una versión virtual del kernel (4.4.0) que no tiene un directorio /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. Fallos de iptables-nft (netlink de nf_tables) y 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 /usr/sbin/iptables. En un contenedor gVisor estándar sin privilegios de administración de red ni soporte de netlink nf_tables, dockerd falla al inicializar la cadena NAT DOCKER y el puente 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. Fallo de overlay2 sobre el rootfs gofer / 9p y agotamiento del disco con vfs

A menos que el administrador externo de runsc haya preconfigurado explícitamente un montaje tmpfs o un filestore dedicado 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 la compartición 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 tiempo de espera de E/S.

4. Rechazos de montaje de cgroups y de /proc en runc anidado

Incluso si dockerd se fuerza a iniciar con dockerd --iptables=false --bridge=none --storage-driver=vfs, ejecutar docker run o docker build invoca runc para crear el contenedor interno. En un contenedor gVisor predeterminado sin privilegios, runc falla al montar cgroups, al volver a montar /proc o al cargar 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 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 interrumpe 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 que rungv se ejecute dentro de un contenedor externo de 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. Reserva automática de systrap desde EBUSY hacia el 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 el gVisor upstream, syscallThread admite dos modos de activación en stub_amd64.s y stub_arm64.s:

  1. Bucle de notificación de usuario 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) y ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Bucle de llamadas al sistema con futex (_RUN_SYSCALL_LOOP = 5, cuando t.seccompNotify == nil): 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).

El gVisor upstream llama incondicionalmente a t.installSeccompNotify() 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// Notificar al hilo de llamadas al sistema sobre una nueva solicitud.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Esperar la respuesta.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Como los filtros SECCOMP_RET_TRAP sobre los hilos invitados 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 sistema de archivos raíz 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 el 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 dispositivos de caracteres (0:0), entradas de directorio d_type y RENAME_EXCHANGE, lo que permite que el dockerd interno utilice 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 netfilter del contenedor runsc externo. En su lugar:

  • 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 utiliza overlayfs nativo (y 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

Lecturas adicionales de esta serie

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