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

Soluzione rapida: esecuzione sicura di docker:dind all'interno di una sandbox gVisor (runsc) non privilegiata
Gli ambienti di calcolo per agenti AI in cloud (come le sandbox di Google Agent Compute Engine), le distribuzioni Kubernetes agent-sandbox (RuntimeClass: gvisor) e gli harness di valutazione per il reinforcement learning isolano i carichi di lavoro non attendibili all'interno di container gVisor (runsc), con lo scopo specifico di proteggere il kernel dell'host. Quando un carico di lavoro all'interno di un container runsc non privilegiato predefinito tenta di installare docker.io o docker-ce tramite apt-get, di avviare service docker start o dockerd e di eseguire docker build, l'operazione fallisce anche se il processo dispone dei privilegi di 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) non privilegiato (oppure annidare gvx dentro gvx) senza indebolire il RuntimeClass esterno, esporre socket dell'host o concedere --privileged.
Soluzione a basso sforzo e ad alta efficienza in token per agenti AI: in un ambiente agente in cloud basato su gVisor, il tentativo di installare
docker.ioe di aggirare manualmente gli errori diulimitin/etc/init.d/docker, i fallimenti diiptables-nft, i dinieghi sui raw socket diiptables-legacy, gli errori di mount dioverlay2e i crash dirunc/pivot_rootdurantedocker buildrichiede tipicamente oltre 20 turni di chiamate a strumenti e decine di migliaia di token prima di fallire completamente.gvx(rungv) è una soluzione drop-in a basso sforzo e ad alta efficienza in token: l'agente esegue 3 comandi in un solo turno per avviare un daemon Docker funzionante in user space e subito dopo eseguedocker buildedocker composesenza modificare i file del progetto.
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 kernelSentrygVisor annidato in user space, con il propriohostlockdown(PR_SET_NO_NEW_PRIVS+seccomp), il proprionetstackin memoria e un volumeext4litea singolo file. Idockerde i containerruncinterni sono sottoposti a doppio sandboxing: le loro system call, 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# Installa 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# Esegui 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 fallisce all'interno di gVisor (runsc)
Quando un utente o un agente di codifica basato su AI esegue apt-get install -y docker.io (oppure docker-ce) all'interno di un container runsc standard e tenta di avviare dockerd, incontra cinque barriere tecniche sequenziali.
1. Errori dello script di init (systemctl / service docker start ulimit) e del modulo del kernel (modprobe 4.4.0)
Dopo aver eseguito apt-get install -y docker.io (oppure docker-ce), il tentativo di avviare il daemon tramite systemctl start docker o service docker start fallisce immediatamente, perché il PID 1 non è systemd e /etc/init.d/docker tenta di innalzare ulimit -n oltre il limite massimo RLIMIT_NOFILE della sandbox. Quando dockerd viene invocato direttamente, gli script di inizializzazione chiamano modprobe per caricare overlay, bridge, br_netfilter, ip_tables e nf_nat rispetto alla versione virtuale del kernel di gVisor (4.4.0), per la quale non esiste la directory /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), dinieghi sui raw socket di iptables-legacy e fallimenti 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. Inoltre, anche se l'utente o l'agente passa a iptables-legacy (update-alternatives --set iptables /usr/sbin/iptables-legacy), le sandbox gVisor non privilegiate predefinite (--net-raw=false) rifiutano i socket AF_INET, SOCK_RAW con EPERM, causando il fallimento di iptables-legacy con Permission denied (you must be root) anche quando il processo viene eseguito come 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. Fallimento di overlay2 su rootfs gofer / 9p ed esaurimento del disco con vfs
A meno che l'amministratore esterno di runsc non abbia preconfigurato esplicitamente un mount dedicato tmpfs o filestore su /var/lib/docker, il rootfs del container è supportato dal gofer di gVisor (9p/lisafs) oppure da un mount overlay annidato. Il mount di overlay2 sopra /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. Fallimenti di docker build (BuildKit) e dei runc annidati (pivot_root, /proc, cgroup e DNS)
Anche se dockerd viene forzato ad avviarsi con dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs, il tentativo di eseguire docker build -t app . oppure docker run fallisce immediatamente quando BuildKit o runc avviano il container di build:
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 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 viene interrotta 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 seccomp(2) interna restituisce EBUSY (device or resource busy).
Come gvx risolve l'esecuzione annidata all'interno di gVisor
gvx affronta tutte e cinque le barriere all'interno del proprio kernel user-space e del proprio livello di piattaforma, consentendo a rungv di eseguire all'interno di un container gVisor (runsc) esterno oppure ricorsivamente all'interno di un altro container gvx (gvx in gvx).

1. Passaggio automatico dal systrap con EBUSY al ciclo futex _RUN_SYSCALL_LOOP
In systrap di Linux, ogni subprocess stub esegue due tipi di thread:
- Thread di esecuzione del guest (
sysmsg): intercettano le system call del guest tramiteSECCOMP_RET_TRAP(SIGSYS) e tramite trampolinijmp rel32modificati nel binario (syshandler), scambiando lo stato dei registri con il Sentry tramite memoria condivisa eSYS_FUTEX. - Un
syscallThreadstub (syscall_thread.go): esegue le system call relative allo spazio degli indirizzi (mmap,munmap,clone) all'interno del subprocess stub per conto del Sentry.
Nella versione upstream di gVisor, 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)) affinché il Sentry possa inviare le richieste tramiteioctl(SECCOMP_IOCTL_NOTIF_SEND)eioctl(SECCOMP_IOCTL_NOTIF_RECV). - Ciclo di system call 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).
Upstream gVisor 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), quandot.seccompNotify == nil, viene 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 system call dello stub esclusivamente tramite memoria condivisa eSYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notifica al syscall thread una nuova richiesta di system call.
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 essere stratificati in modo arbitrariamente profondo 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 a singolo file
Per evitare il mount di overlayfs sopra il filesystem radice gofer (9p/lisafs) del container gVisor esterno, -v gvx-docker:/var/lib/docker supporta /var/lib/docker con il filesystem interno ext4lite di gvx, memorizzato in un unico file regolare (gvx-docker.ext4).
Per runsc esterno, gvx-docker.ext4 è semplicemente un file regolare acceduto tramite pread64(2) e pwrite64(2). All'interno di gvx, ext4lite fornisce un filesystem ext4 con journal JBD2, con gli attributi estesi trusted.overlay.* e security.capability, nodi whiteout di tipo character device (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 autonomo 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 coppievethall'interno delnetstackuser-space digvx.- Tutte le regole di
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 system callmount(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# Verifica che il daemon Docker interno utilizzi overlayfs nativo (e non vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Output: Storage Driver: overlayfs
16
17# Esegui una build multi-layer ed esegui 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 non privilegiato: panoramica di
gvxsu Windows (whpx,systrap), macOS (hvf) e Linux (systrap). - Esecuzione di Docker, docker build e docker compose senza root o 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 AI con gvx: come usare
gvxe gli snapshot copy-on-write a singolo fileext4liteper eseguire centinaia di ambienti paralleli perSWE-benche rollout di RL con reset in 100 millisecondi.