Exécuter Docker-in-Docker à l'intérieur de gVisor et de sandboxes imbriquées avec gvx

Solution rapide : exécuter docker:dind de manière sécurisée dans un bac à sable gVisor (runsc) non privilégié
Les environnements de calcul pour agents d'IA dans le cloud (tels que les bacs à sable Google Agent Compute Engine), les déploiements Kubernetes agent-sandbox (RuntimeClass: gvisor) et les harnais d'évaluation d'apprentissage par renforcement isolent les charges de travail non fiables à l'intérieur de conteneurs gVisor (runsc), afin de protéger spécifiquement 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, de lancer service docker start ou dockerd, puis d'exécuter docker build, 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 dans un conteneur gVisor (runsc) non privilégié (ou imbriquer gvx dans gvx), sans affaiblir le RuntimeClass externe, sans exposer de sockets hôte et sans accorder --privileged.
Solution peu coûteuse et économe en tokens pour les agents d'IA : dans un environnement d'agent cloud reposant sur gVisor, tenter d'installer
docker.ioet de contourner manuellement les erreursulimitde/etc/init.d/docker, les échecs deiptables-nft, les refus de sockets bruts d'iptables-legacy, les erreurs de montageoverlay2et les plantages derunc/pivot_rootlors dedocker buildconsomme généralement plus de 20 tours d'appels d'outils et des dizaines de milliers de tokens avant un échec complet.gvx(rungv) constitue une solution de remplacement directe, peu coûteuse en effort et économe en tokens : l'agent exécute 3 commandes en un seul tour pour démarrer un démon Docker fonctionnel en espace utilisateur, puis exécute immédiatementdocker buildetdocker composesans modifier les fichiers du projet.
Préservation et renforcement de l'isolation du bac à sable externe : exécuter
rungvà l'intérieur derunscne contourne aucune restriction imposée par le bac à sablegVisorexterne. Au contraire,gvxcrée un second noyauSentrygVisor imbriqué, en espace utilisateur, doté de son proprehostlockdown(PR_SET_NO_NEW_PRIVS+seccomp), de sa propre pile réseaunetstacken mémoire et d'un volumeext4liteconstitué d'un fichier unique. Les démonsdockerdet les conteneursruncinternes sont doublement isolés : leurs appels système, leurs montages et leurs paquets bruts ne quittent jamais le noyau en espace utilisateur degvxinterne.
1# À l'intérieur d'un conteneur gVisor (runsc) existant ou d'un Pod Kubernetes :
2# Installer rungv depuis une archive de release 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 des builds et des conteneurs Docker standard via la 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 ... (containerized)
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 lancer dockerd, il se heurte à cinq obstacles techniques successifs.
1. Échecs du script d'initialisation (systemctl / service docker start ulimit) et du module noyau (modprobe 4.4.0)
Après l'exécution de apt-get install -y docker.io (ou docker-ce), la tentative de démarrer le démon via systemctl start docker ou service docker start échoue immédiatement, car le PID 1 n'est pas systemd et /etc/init.d/docker tente d'augmenter ulimit -n au-delà de la limite stricte RLIMIT_NOFILE du bac à sable. Lorsque dockerd est ensuite invoqué directement, les scripts d'initialisation appellent modprobe pour charger overlay, bridge, br_netfilter, ip_tables et nf_nat par rapport à la version du noyau virtuel de gVisor (4.4.0), laquelle ne dispose d'aucun répertoire /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), refus de sockets bruts d'iptables-legacy et échecs 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. De plus, même si l'utilisateur ou l'agent bascule vers iptables-legacy (update-alternatives --set iptables /usr/sbin/iptables-legacy), les bacs à sable gVisor non privilégiés par défaut (--net-raw=false) rejettent les sockets AF_INET, SOCK_RAW avec EPERM, ce qui fait échouer iptables-legacy avec Permission denied (you must be root), même lorsqu'il s'exécute avec 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. Échec de overlay2 sur un rootfs gofer / 9p et épuisement du disque avec vfs
Sauf si l'administrateur externe de runsc a explicitement préconfiguré un montage tmpfs ou filestore dédié sur /var/lib/docker, le rootfs du conteneur repose sur le gofer de gVisor (9p/lisafs) ou sur 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 lançant 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 répartie sur 10 étapes de build en 5 Go d'écritures, et provoque l'arrêt du bac à sable avec no space left on device ou un dépassement de délai d'E/S.
4. Échecs de docker build (BuildKit) et de runc imbriqué (pivot_root, /proc, cgroups et DNS)
Même si dockerd est forcé de démarrer avec dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs, la tentative d'exécuter docker build -t app . ou docker run échoue immédiatement lorsque BuildKit ou runc lance le conteneur de 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 imbriqué échoue 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 de gVisor n'autorisent au plus un écouteur SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) par hiérarchie de tâches. Comme le bac à sable externe détient déjà cet emplacement d'écoute, l'appel interne seccomp(2) renvoie EBUSY (device or resource busy).
Comment gvx résout l'exécution imbriquée à l'intérieur de 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 à l'intérieur d'un conteneur gVisor externe (runsc), voire de manière récursive à l'intérieur d'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 des trampolinesjmp rel32patchés en binaire (syshandler), en échangeant l'état des registres avec le Sentry via la mémoire partagée etSYS_FUTEX. - Un
syscallThreadstub (syscall_thread.go) : exécute, pour le compte du Sentry, les appels système liés à l'espace d'adressage (mmap,munmap,clone) à l'intérieur du sous-processus stub.
Dans le gVisor en amont, syscallThread prend en charge deux modes de réveil dans stub_amd64.s et stub_arm64.s :
- Boucle Seccomp User-Notify (
_RUN_SECCOMP_LOOP = 6, lorsquet.seccompNotify != nil) : pendantsyscallThread.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 distribuer les 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) sur la région partagée de 2 pagessyscallSentryMessage(PROT_READdans le stub) etsyscallStubMessage(PROT_READ | PROT_WRITEdans le stub).
Le gVisor en amont appelle inconditionnellement t.installSeccompNotify() dès que seccompNotifyIsSupported vaut 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 externe gVisor (runsc) ou gvx détient déjà l'unique emplacement 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 distribue 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 de syscall 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 les filtres SECCOMP_RET_TRAP sur les threads invités peuvent s'empiler à une profondeur arbitraire et que syscallThread revient à _RUN_SYSCALL_LOOP sans demander un second écouteur de notification utilisateur, gvx s'exécute proprement à l'intérieur de runsc et prend en charge l'imbrication récursive (gvx dans gvx).
2. overlayfs natif sur des volumes ext4lite constitués d'un fichier unique
Afin d'é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 runsc externe, gvx-docker.ext4 n'est qu'un fichier régulier accédé 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 dunetstacken espace utilisateur propre àgvx.- Toutes les règles
iptables-legacy(-t nat -N DOCKER,MASQUERADE,conntrack,addrtype) s'exécutent sur l'implémentation netfilter interne degvx. - Les conteneurs
runcimbriqués montent leurs hiérarchies/proc,/syset/sys/fs/cgroupau sein de la table de montageVFS2en mémoire degvx, sans jamais appeler l'appel systèmemount(2)derunscexterne.
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# À l'intérieur d'un conteneur gVisor (runsc) externe ou d'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 l'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
Lectures complémentaires 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 user namespaces : 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 d'IA avec gvx : comment utiliser
gvxet des snapshots copy-on-writeext4liteconstitués d'un fichier unique pour exécuter des centaines d'environnements parallèles de rolloutsSWE-benchet RL avec des réinitialisations en 100 millisecondes.