GoSuda

Eseguire Docker-in-Docker all'interno di gVisor e sandbox annidate con gvx

By Lemon Mint views ...

Soluzione rapida: esecuzione sicura di docker:dind all'interno di una sandbox gVisor (runsc) senza privilegi

Le piattaforme cloud per agenti di IA, le distribuzioni Kubernetes agent-sandbox (RuntimeClass: gvisor) e gli ambienti di valutazione per l'apprendimento per rinforzo isolano i carichi di lavoro non attendibili all'interno di container gVisor (runsc), con l'obiettivo specifico di proteggere il kernel dell'host. Quando un carico di lavoro all'interno di un container runsc predefinito e privo di privilegi tenta di installare docker.io o docker-ce tramite apt-get e di avviare dockerd, l'operazione fallisce anche se il processo dispone dell'utente root (UID 0) all'interno del container.

Con gvx (rungv) è possibile eseguire in modo sicuro docker:dind, docker build e docker compose direttamente all'interno di un container gVisor (runsc) privo di privilegi (oppure annidare gvx all'interno di gvx), senza indebolire il RuntimeClass esterno, esporre socket dell'host o concedere --privileged.

Preservazione e rafforzamento dell'isolamento della sandbox esterna: l'esecuzione di rungv all'interno di runsc non aggira alcuna restrizione imposta dalla sandbox gVisor esterna. Al contrario, gvx crea un secondo kernel Sentry di gVisor annidato, in user space, dotato di un proprio hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), di un proprio netstack in memoria e di un volume ext4lite costituito da un unico file. I processi dockerd e i container runc interni sono sottoposti a doppio sandboxing: le loro chiamate di sistema, i mount e i pacchetti grezzi non escono mai dal kernel user-space di gvx interno.

 1# All'interno di un container gVisor (runsc) esistente o di un Pod Kubernetes:
 2# Installare rungv da un archivio di release GitHub con attestazione SLSA oppure tramite 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# Eseguire build e container Docker standard tramite il 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# Output: overlayfs Alpine Linux ... (containerized)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test

Cinque motivi per cui l'installazione diretta di Docker non riesce all'interno di gVisor (runsc)

Quando un utente o un agente di codifica basato sull'IA esegue apt-get install -y docker.io (oppure docker-ce) all'interno di un container runsc standard e tenta di avviare dockerd, incontra cinque ostacoli tecnici in sequenza.

1. Caricamento dei moduli del kernel (modprobe) rispetto al kernel virtuale 4.4.0

Durante l'installazione dei pacchetti e l'avvio di dockerd, gli script di inizializzazione eseguono modprobe per caricare overlay, br_netfilter, ip_tables, nf_nat e xt_conntrack. Il Sentry di gVisor esterno blocca init_module(2) e finit_module(2) e riporta una versione virtuale del kernel (4.4.0) alla quale non corrisponde alcuna directory /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. Errori di iptables-nft (netlink nf_tables) e del bridge docker0

Su Ubuntu 22.04, Ubuntu 24.04 e Debian 11/12, apt-get install docker.io seleziona iptables-nft (basato sul sottosistema netlink nf_tables) come alternativa predefinita di /usr/sbin/iptables. In un container gVisor standard privo di privilegi di amministrazione di rete o di supporto netlink per nf_tables, dockerd si interrompe durante l'inizializzazione della catena NAT DOCKER e del bridge 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. Errore di overlay2 sul rootfs gofer / 9p e saturazione del disco con vfs

A meno che l'amministratore del runsc esterno non abbia configurato esplicitamente un mount tmpfs o filestore dedicato in /var/lib/docker, il rootfs del container è supportato dal gofer di gVisor (9p/lisafs) oppure da un mount overlay annidato. Il mount di overlay2 su /var/lib/docker fallisce:

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 utenti o agenti tentano di aggirare questo errore avviando dockerd --storage-driver=vfs, Docker disabilita completamente la condivisione dei layer tramite Copy-on-Write. Ogni istruzione RUN in un Dockerfile esegue una copia ricorsiva completa (dirCopy) dell'intero filesystem radice, trasformando un'immagine da 500 MB distribuita su 10 passaggi di build in 5 GB di scritture e causando la terminazione della sandbox con no space left on device oppure con un timeout di I/O.

4. Rifiuti di mount di cgroup e /proc nel runc annidato

Anche se dockerd viene forzato ad avviarsi con dockerd --iptables=false --bridge=none --storage-driver=vfs, l'esecuzione di docker run o docker build invoca runc per creare il container interno. In un container gVisor predefinito privo di privilegi, runc fallisce durante il mount dei cgroup, il rimontaggio di /proc o il caricamento dei filtri seccomp interni:

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 annidato fallisce con SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)

Se un utente tenta di eseguire un'altra istanza di runsc all'interno di runsc utilizzando la piattaforma systrap, l'inizializzazione si interrompe immediatamente:

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

Sia il kernel Linux sia il Sentry di gVisor consentono al massimo un listener SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) per gerarchia di task. Poiché la sandbox esterna occupa già lo slot del listener, la chiamata interna seccomp(2) restituisce EBUSY (device or resource busy).


Come gvx risolve l'esecuzione annidata all'interno di gVisor

gvx affronta tutti e cinque gli ostacoli all'interno del proprio kernel user-space e del proprio livello di piattaforma, consentendo a rungv di funzionare all'interno di un container gVisor esterno (runsc) o persino ricorsivamente all'interno di un altro container gvx (gvx in gvx).

Esecuzione annidata di gvx all'interno di gVisor esterno

1. Fallback automatico di systrap da EBUSY al ciclo futex _RUN_SYSCALL_LOOP

In systrap di Linux, ogni sottoprocesso stub esegue due tipi di thread:

  • Thread di esecuzione del guest (sysmsg): intercettano le chiamate di sistema del guest tramite SECCOMP_RET_TRAP (SIGSYS) e trampolini jmp rel32 modificati binariamente (syshandler), scambiando lo stato dei registri con il Sentry tramite memoria condivisa e SYS_FUTEX.
  • Un syscallThread stub (syscall_thread.go): esegue, per conto del Sentry, le chiamate di sistema dello spazio di indirizzi (mmap, munmap, clone) all'interno del sottoprocesso stub.

Nel gVisor upstream, syscallThread supporta due modalità di risveglio in stub_amd64.s e stub_arm64.s:

  1. Ciclo seccomp User-Notify (_RUN_SECCOMP_LOOP = 6, quando t.seccompNotify != nil): durante syscallThread.init(), t.installSeccompNotify() installa un listener SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)), in modo che il Sentry possa inviare richieste tramite ioctl(SECCOMP_IOCTL_NOTIF_SEND) e ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Ciclo di chiamate di sistema futex (_RUN_SYSCALL_LOOP = 5, quando t.seccompNotify == nil): il syscallThread esegue syscall_loop (stub_amd64.s / stub_arm64.s), mettendosi in attesa su SYS_FUTEX (FUTEX_WAIT) sulla regione condivisa di 2 pagine, composta da syscallSentryMessage (PROT_READ nello stub) e syscallStubMessage (PROT_READ | PROT_WRITE nello stub).

Il gVisor upstream invoca incondizionatamente t.installSeccompNotify() ogni volta che seccompNotifyIsSupported è vero, e interrompe la creazione dello stub se seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) restituisce EBUSY.

In gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go e syscall_thread.go), syscallThread.init() invoca 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() restituisce unix.EBUSY perché una sandbox gVisor (runsc) o gvx esterna occupa già l'unico slot listener della catena di filtri del processo:

  • installSeccompNotifyIfFree() imposta seccompNotifyBusy (atomic.Bool), registra un unico avviso per il processo e lascia t.seccompNotify == nil per quello stub e per tutti gli stub successivi.
  • In syscallThread.detach() (syscall_thread_amd64.go e syscall_thread_arm64.go), con t.seccompNotify == nil viene impostato regs.Rbx = _RUN_SYSCALL_LOOP (5) su amd64 (oppure regs.Regs[9] = _RUN_SYSCALL_LOOP su arm64), al posto di _RUN_SECCOMP_LOOP (6).
  • In syscallThread.syscall() (syscall_thread.go), il Sentry interno invia le chiamate di sistema dello stub esclusivamente tramite memoria condivisa e SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notifica al thread di sistema la presenza di una nuova richiesta di chiamata di sistema.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Attende la risposta.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Poiché i filtri SECCOMP_RET_TRAP sui thread del guest possono stratificarsi a qualsiasi profondità e syscallThread ricade su _RUN_SYSCALL_LOOP senza richiedere un secondo listener di user-notification, gvx viene eseguito correttamente all'interno di runsc e supporta l'annidamento ricorsivo (gvx in gvx).

2. overlayfs nativo su volumi ext4lite costituiti da un unico file

Per evitare di montare overlayfs sopra il filesystem radice gofer (9p/lisafs) del container gVisor esterno, -v gvx-docker:/var/lib/docker sostiene /var/lib/docker con il filesystem interno di gvx denominato ext4lite, memorizzato in un unico file regolare (gvx-docker.ext4).

Per runsc esterno, gvx-docker.ext4 è semplicemente un file regolare a cui si accede tramite pread64(2) e pwrite64(2). All'interno di gvx, ext4lite fornisce un filesystem ext4 con journaling JBD2, attributi estesi trusted.overlay.* e security.capability, nodi whiteout di tipo dispositivo a caratteri (0:0), voci di directory d_type e RENAME_EXCHANGE, consentendo al dockerd interno di utilizzare il proprio driver di storage nativo overlayfs.

3. netstack autocontenuto con docker0 e iptables-legacy

All'interno di gvx, dockerd non interagisce con le interfacce di rete né con le tabelle netfilter del container runsc esterno. Al contrario:

  • dockerd crea il proprio bridge docker0 e le proprie coppie veth all'interno del netstack user-space di gvx.
  • Tutte le regole iptables-legacy (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) vengono eseguite sull'implementazione netfilter interna di gvx.
  • I container runc annidati montano le proprie gerarchie /proc, /sys e /sys/fs/cgroup all'interno della tabella dei mount VFS2 in memoria di gvx, senza mai invocare la syscall mount(2) del runsc esterno.

Verifica di docker:dind annidato all'interno di gVisor (oppure di gvx in gvx)

È possibile verificare entrambi gli scenari (rungv all'interno di runsc, oppure rungv all'interno di rungv) con i seguenti comandi:

 1# All'interno di un container gVisor (runsc) esterno o di un container rungv esterno:
 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# Verificare che il daemon Docker interno utilizzi overlayfs nativo (e non vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Output: Storage Driver: overlayfs
16
17# Eseguire una build multi-layer e avviare un container annidato
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# Output: nested-build-ok
31
32kill -INT $DIND_PID

Ulteriori letture in questa serie

  1. gvx: esecuzione di container Linux, Docker e Kubernetes in un unico processo senza privilegi: panoramica di gvx su Windows (whpx, systrap), macOS (hvf) e Linux (systrap).
  2. Esecuzione di Docker, docker build e docker compose senza root né user namespace: come gvx esegue docker:dind quando root, sudo, unshare(CLONE_NEWUSER), mount(2) e /dev/fuse sono bloccati.
  3. Esecuzione di Docker-in-Docker all'interno di gVisor e di sandbox annidate con gvx (questo articolo)
  4. Ambienti Docker isolati e veloci per i rollout di RL di agenti IA con gvx: come utilizzare gvx e gli snapshot copy-on-write ext4lite costituiti da un unico file per eseguire centinaia di ambienti paralleli per SWE-bench e per i rollout di RL, con ripristini in 100 millisecondi.