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

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
rungvall'interno dirunscnon aggira alcuna restrizione imposta dalla sandbox gVisor esterna. Al contrario,gvxcrea un secondo kernelSentrydi gVisor annidato, in user space, dotato di un propriohostlockdown(PR_SET_NO_NEW_PRIVS+seccomp), di un proprionetstackin memoria e di un volumeext4litecostituito da un unico file. I processidockerde i containerruncinterni sono sottoposti a doppio sandboxing: le loro chiamate di sistema, i mount e i pacchetti grezzi non escono mai dal kernel user-space digvxinterno.
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).

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 tramiteSECCOMP_RET_TRAP(SIGSYS) e trampolinijmp rel32modificati binariamente (syshandler), scambiando lo stato dei registri con il Sentry tramite memoria condivisa eSYS_FUTEX. - Un
syscallThreadstub (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:
- Ciclo seccomp User-Notify (
_RUN_SECCOMP_LOOP = 6, quandot.seccompNotify != nil): durantesyscallThread.init(),t.installSeccompNotify()installa un listenerSECCOMP_RET_USER_NOTIF(seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)), in modo che il Sentry possa inviare richieste tramiteioctl(SECCOMP_IOCTL_NOTIF_SEND)eioctl(SECCOMP_IOCTL_NOTIF_RECV). - Ciclo di chiamate di sistema futex (
_RUN_SYSCALL_LOOP = 5, quandot.seccompNotify == nil): ilsyscallThreadeseguesyscall_loop(stub_amd64.s/stub_arm64.s), mettendosi in attesa suSYS_FUTEX(FUTEX_WAIT) sulla regione condivisa di 2 pagine, composta dasyscallSentryMessage(PROT_READnello stub) esyscallStubMessage(PROT_READ | PROT_WRITEnello 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()impostaseccompNotifyBusy(atomic.Bool), registra un unico avviso per il processo e lasciat.seccompNotify == nilper quello stub e per tutti gli stub successivi.- In
syscallThread.detach()(syscall_thread_amd64.goesyscall_thread_arm64.go), cont.seccompNotify == nilviene impostatoregs.Rbx = _RUN_SYSCALL_LOOP(5) suamd64(oppureregs.Regs[9] = _RUN_SYSCALL_LOOPsuarm64), 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 eSYS_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:
dockerdcrea il proprio bridgedocker0e le proprie coppievethall'interno delnetstackuser-space digvx.- Tutte le regole
iptables-legacy(-t nat -N DOCKER,MASQUERADE,conntrack,addrtype) vengono eseguite sull'implementazione netfilter interna digvx. - I container
runcannidati montano le proprie gerarchie/proc,/syse/sys/fs/cgroupall'interno della tabella dei mountVFS2in memoria digvx, senza mai invocare la syscallmount(2)delrunscesterno.
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
- gvx: esecuzione di container Linux, Docker e Kubernetes in un unico processo senza privilegi: panoramica di
gvxsu Windows (whpx,systrap), macOS (hvf) e Linux (systrap). - Esecuzione di Docker, docker build e docker compose senza root né user namespace: come
gvxeseguedocker:dindquandoroot,sudo,unshare(CLONE_NEWUSER),mount(2)e/dev/fusesono bloccati. - Esecuzione di Docker-in-Docker all'interno di gVisor e di sandbox annidate con gvx (questo articolo)
- Ambienti Docker isolati e veloci per i rollout di RL di agenti IA con gvx: come utilizzare
gvxe gli snapshot copy-on-writeext4litecostituiti da un unico file per eseguire centinaia di ambienti paralleli perSWE-benche per i rollout di RL, con ripristini in 100 millisecondi.