GoSuda

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

By Lemon Mint views ...

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.io e di aggirare manualmente gli errori di ulimit in /etc/init.d/docker, i fallimenti di iptables-nft, i dinieghi sui raw socket di iptables-legacy, gli errori di mount di overlay2 e i crash di runc / pivot_root durante docker build richiede 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 esegue docker build e docker compose senza modificare i file del progetto.

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 gVisor annidato in user space, con il proprio hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), il proprio netstack in memoria e un volume ext4lite a singolo file. I dockerd e i container runc interni sono sottoposti a doppio sandboxing: le loro system call, 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# 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).

Esecuzione annidata di gvx all'interno di gVisor esterno

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 tramite SECCOMP_RET_TRAP (SIGSYS) e tramite trampolini jmp rel32 modificati nel binario (syshandler), scambiando lo stato dei registri con il Sentry tramite memoria condivisa e SYS_FUTEX.
  • Un syscallThread stub (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:

  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)) affinché il Sentry possa inviare le richieste tramite ioctl(SECCOMP_IOCTL_NOTIF_SEND) e ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Ciclo di system call 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).

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() 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), quando 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 system call dello stub esclusivamente tramite memoria condivisa e SYS_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:

  • dockerd crea il proprio bridge docker0 e le coppie veth all'interno del netstack user-space di gvx.
  • Tutte le regole di 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 system call 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# 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

  1. gvx: Esecuzione di container Linux, Docker e Kubernetes in un unico processo non privilegiato: panoramica di gvx su Windows (whpx, systrap), macOS (hvf) e Linux (systrap).
  2. Esecuzione di Docker, docker build e docker compose senza root o 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 AI con gvx: come usare gvx e gli snapshot copy-on-write a singolo file ext4lite per eseguire centinaia di ambienti paralleli per SWE-bench e rollout di RL con reset in 100 millisecondi.