Exécuter Docker-in-Docker dans gVisor et des Nested Sandboxes avec gvx

Solution rapide : exécuter docker:dind de manière sécurisée dans un bac à sable gVisor (runsc) non privilégié
Les plateformes d'agents d'IA dans le cloud, les déploiements Kubernetes agent-sandbox (RuntimeClass: gvisor) et les harnais d'évaluation par apprentissage par renforcement isolent les charges de travail non fiables dans des conteneurs gVisor (runsc), spécifiquement pour protéger le noyau hôte. Lorsqu'une charge de travail s'exécutant dans un conteneur runsc non privilégié par défaut tente d'installer docker.io ou docker-ce via apt-get et de démarrer dockerd, elle échoue, même si elle dispose des droits root (UID 0) à l'intérieur du conteneur.
Avec gvx (rungv), vous pouvez exécuter de manière sécurisée docker:dind, docker build et docker compose directement à l'intérieur d'un conteneur gVisor (runsc) non privilégié (ou imbriquer gvx dans gvx) sans affaiblir le RuntimeClass externe, exposer les sockets de l'hôte ni accorder --privileged.
Préservation et renforcement de l'isolation du bac à sable externe : L'exécution de
rungvdansrunscne contourne aucune restriction appliquée par le bac à sablegVisorexterne. Au contraire,gvxcrée un second noyauSentrygVisor imbriqué, fonctionnant en espace utilisateur, doté de son proprehostlockdown(PR_SET_NO_NEW_PRIVS+seccomp), de sa proprenetstacken mémoire et d'un volumeext4litecontenu dans un fichier unique. Lesdockerdet les conteneursruncinternes sont doublement isolés : leurs appels système, leurs montages et leurs paquets bruts ne quittent jamais le noyaugvxinterne, qui s'exécute en espace utilisateur.
1# Dans un conteneur gVisor (runsc) existant ou un Pod Kubernetes :
2# Installer rungv depuis une archive de version GitHub attestée SLSA ou via 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# Exécuter les builds et conteneurs Docker standard via le socket gvx interne
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# Sortie : overlayfs Alpine Linux ... (conteneurisé)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test
Cinq raisons pour lesquelles l'installation directe de Docker échoue dans gVisor (runsc)
Lorsqu'un utilisateur ou un agent de codage IA exécute apt-get install -y docker.io (ou docker-ce) dans un conteneur runsc standard et tente de démarrer dockerd, il rencontre cinq obstacles techniques successifs.
1. Chargement des modules noyau (modprobe) face au noyau virtuel 4.4.0
Lors de l'installation des paquets et du démarrage de dockerd, les scripts d'initialisation exécutent modprobe pour charger overlay, br_netfilter, ip_tables, nf_nat et xt_conntrack. Le Sentry gVisor externe bloque init_module(2) et finit_module(2) et signale une version virtuelle du noyau (4.4.0) pour laquelle le répertoire /lib/modules/4.4.0 n'existe pas :
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. Échecs de iptables-nft (netlink nf_tables) et du pont docker0
Sur Ubuntu 22.04, Ubuntu 24.04 et Debian 11/12, apt-get install docker.io sélectionne iptables-nft (adossé au sous-système netlink nf_tables) comme alternative par défaut de /usr/sbin/iptables. Dans un conteneur gVisor standard, sans privilèges d'administration réseau ni prise en charge du netlink nf_tables, dockerd plante lors de l'initialisation de la chaîne NAT DOCKER et du pont 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. Échec de overlay2 sur un rootfs gofer / 9p et épuisement du disque avec vfs
Sauf si l'administrateur du runsc externe a explicitement préconfiguré un montage tmpfs ou filestore dédié sur /var/lib/docker, le rootfs du conteneur est adossé au gofer de gVisor (9p/lisafs) ou à un montage overlay imbriqué. Le montage de overlay2 par-dessus /var/lib/docker échoue :
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
Lorsque les utilisateurs ou les agents tentent de contourner cette erreur en démarrant dockerd --storage-driver=vfs, Docker désactive entièrement le partage des couches en Copy-on-Write. Chaque instruction RUN d'un Dockerfile effectue alors une copie récursive complète (dirCopy) de l'intégralité du système de fichiers racine, ce qui transforme une image de 500 Mo sur 10 étapes de build en 5 Go d'écritures et provoque la fin du bac à sable avec no space left on device ou un délai d'expiration d'E/S.
4. Rejets des cgroups et du montage /proc par le runc imbriqué
Même si dockerd est forcé de démarrer avec dockerd --iptables=false --bridge=none --storage-driver=vfs, l'exécution de docker run ou docker build invoque runc pour créer le conteneur interne. Dans un conteneur gVisor non privilégié par défaut, runc échoue lors du montage des cgroups, du remontage de /proc ou du chargement des filtres seccomp internes :
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. Échec de runsc imbriqué avec SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)
Si un utilisateur tente d'exécuter une autre instance de runsc à l'intérieur de runsc en utilisant la plateforme systrap, l'initialisation s'interrompt immédiatement :
1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy
Le noyau Linux comme le Sentry gVisor n'autorisent au plus qu'un seul écouteur SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) par hiérarchie de tâches. Comme le bac à sable externe occupe déjà cet emplacement d'écouteur, l'appel seccomp(2) interne renvoie EBUSY (device or resource busy).
Comment gvx résout l'exécution imbriquée dans gVisor
gvx traite les cinq obstacles au sein de son propre noyau en espace utilisateur et de sa couche de plateforme, ce qui permet à rungv de s'exécuter dans un conteneur gVisor externe (runsc), voire de manière récursive dans un autre conteneur gvx (gvx dans gvx).

1. Repli automatique de systrap depuis EBUSY vers la boucle à futex _RUN_SYSCALL_LOOP
Dans systrap sous Linux, chaque sous-processus stub exécute deux types de threads :
- Threads d'exécution invités (
sysmsg) : Interceptent les appels système invités viaSECCOMP_RET_TRAP(SIGSYS) et les trampolinesjmp rel32modifiés en binaire (syshandler), échangeant l'état des registres avec le Sentry via la mémoire partagée etSYS_FUTEX. - Un
syscallThreadde stub (syscall_thread.go) : Exécute, dans le sous-processus stub et pour le compte du Sentry, les appels système liés à l'espace d'adressage (mmap,munmap,clone).
Dans gVisor en amont, syscallThread prend en charge deux modes de réveil dans stub_amd64.s et stub_arm64.s :
- Boucle de notification utilisateur seccomp (
_RUN_SECCOMP_LOOP = 6, lorsquet.seccompNotify != nil) : Lors desyscallThread.init(),t.installSeccompNotify()installe un écouteurSECCOMP_RET_USER_NOTIF(seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)) afin que le Sentry puisse transmettre des requêtes viaioctl(SECCOMP_IOCTL_NOTIF_SEND)etioctl(SECCOMP_IOCTL_NOTIF_RECV). - Boucle d'appels système à futex (
_RUN_SYSCALL_LOOP = 5, lorsquet.seccompNotify == nil) : LesyscallThreadexécutesyscall_loop(stub_amd64.s/stub_arm64.s), en se mettant en sommeil surSYS_FUTEX(FUTEX_WAIT) au-dessus de la région partagée de 2 pagessyscallSentryMessage(PROT_READdans le stub) etsyscallStubMessage(PROT_READ | PROT_WRITEdans le stub).
gVisor en amont appelle inconditionnellement t.installSeccompNotify() dès que seccompNotifyIsSupported est vrai, et interrompt la création du stub si seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) renvoie EBUSY.
Dans gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go et syscall_thread.go), syscallThread.init() appelle 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}
Lorsque installSeccompNotify() renvoie unix.EBUSY parce qu'un bac à sable gVisor (runsc) ou gvx externe détient déjà l'emplacement unique d'écouteur de la chaîne de filtres du processus :
installSeccompNotifyIfFree()positionneseccompNotifyBusy(atomic.Bool), journalise un seul avertissement pour le processus, et laisset.seccompNotify == nilpour ce stub ainsi que pour tous les stubs suivants.- Dans
syscallThread.detach()(syscall_thread_amd64.goetsyscall_thread_arm64.go),t.seccompNotify == nilpositionneregs.Rbx = _RUN_SYSCALL_LOOP(5) suramd64(ouregs.Regs[9] = _RUN_SYSCALL_LOOPsurarm64) à la place de_RUN_SECCOMP_LOOP(6). - Dans
syscallThread.syscall()(syscall_thread.go), le Sentry interne achemine les appels système du stub uniquement via la mémoire partagée etSYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Notifier le thread système d'une nouvelle requête d'appel système.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Attendre la réponse.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))
Comme SECCOMP_RET_TRAP filtre les threads invités sur une pile d'une profondeur arbitraire et que syscallThread se replie sur _RUN_SYSCALL_LOOP sans demander un second écouteur de notification utilisateur, gvx s'exécute proprement dans runsc et prend en charge l'imbrication récursive (gvx dans gvx).
2. overlayfs natif sur des volumes ext4lite contenus dans un fichier unique
Pour éviter de monter overlayfs par-dessus le système de fichiers racine gofer (9p/lisafs) du conteneur gVisor externe, -v gvx-docker:/var/lib/docker adosse /var/lib/docker au système de fichiers interne ext4lite de gvx, stocké dans un fichier régulier unique (gvx-docker.ext4).
Pour le runsc externe, gvx-docker.ext4 n'est qu'un fichier régulier accessible via pread64(2) et pwrite64(2). À l'intérieur de gvx, ext4lite fournit un système de fichiers ext4 journalisé par JBD2, avec les attributs étendus trusted.overlay.* et security.capability, des nœuds de suppression (whiteout) de type périphérique caractère (0:0), des entrées de répertoire d_type et RENAME_EXCHANGE, ce qui permet au dockerd interne d'utiliser son pilote de stockage overlayfs natif.
3. netstack autonome avec docker0 et iptables-legacy
Dans gvx, dockerd n'interagit ni avec les interfaces réseau du conteneur runsc externe, ni avec ses tables netfilter. À la place :
dockerdcrée son pontdocker0et ses pairesvethau sein de lanetstacken espace utilisateur propre àgvx.- Toutes les règles
iptables-legacy(-t nat -N DOCKER,MASQUERADE,conntrack,addrtype) s'exécutent contre l'implémentation netfilter interne degvx. - Les conteneurs
runcimbriqués montent leurs hiérarchies/proc,/syset/sys/fs/cgroupdans la table de montageVFS2en mémoire degvx, sans jamais appeler l'appel systèmemount(2)durunscexterne.
Vérifier docker:dind imbriqué dans gVisor (ou gvx dans gvx)
Vous pouvez vérifier les deux scénarios (rungv dans runsc, ou rungv dans rungv) avec les commandes suivantes :
1# Dans un conteneur gVisor (runsc) externe ou un conteneur rungv externe :
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# Vérifier que le démon Docker interne utilise overlayfs natif (et non vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Sortie : Storage Driver: overlayfs
16
17# Exécuter un build multicouche et lancer un conteneur imbriqué
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# Sortie : nested-build-ok
31
32kill -INT $DIND_PID
Pour aller plus loin dans cette série
- gvx : exécuter des conteneurs Linux, Docker et Kubernetes dans un seul processus non privilégié : Présentation de
gvxsous Windows (whpx,systrap), macOS (hvf) et Linux (systrap). - Exécuter Docker, docker build et docker compose sans root ni espaces de noms utilisateur : Comment
gvxexécutedocker:dindlorsqueroot,sudo,unshare(CLONE_NEWUSER),mount(2)et/dev/fusesont bloqués. - Docker-in-Docker à l'intérieur de gVisor et de bacs à sable imbriqués avec gvx (cet article)
- Environnements Docker isolés et rapides pour les rollouts d'apprentissage par renforcement d'agents IA avec gvx : Comment utiliser
gvxet les snapshots copy-on-writeext4litecontenus dans un fichier unique pour exécuter des centaines d'environnements parallèlesSWE-benchet de rollouts RL avec des réinitialisations en 100 millisecondes.