GoSuda

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

By Lemon Mint views ...

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.io et de contourner manuellement les erreurs ulimit de /etc/init.d/docker, les échecs de iptables-nft, les refus de sockets bruts d'iptables-legacy, les erreurs de montage overlay2 et les plantages de runc / pivot_root lors de docker build consomme 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édiatement docker build et docker compose sans modifier les fichiers du projet.

Préservation et renforcement de l'isolation du bac à sable externe : exécuter rungv à l'intérieur de runsc ne contourne aucune restriction imposée par le bac à sable gVisor externe. Au contraire, gvx crée un second noyau Sentry gVisor imbriqué, en espace utilisateur, doté de son propre hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), de sa propre pile réseau netstack en mémoire et d'un volume ext4lite constitué d'un fichier unique. Les démons dockerd et les conteneurs runc internes sont doublement isolés : leurs appels système, leurs montages et leurs paquets bruts ne quittent jamais le noyau en espace utilisateur de gvx interne.

 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).

Exécution imbriquée de gvx dans gVisor externe

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 via SECCOMP_RET_TRAP (SIGSYS) et des trampolines jmp rel32 patchés en binaire (syshandler), en échangeant l'état des registres avec le Sentry via la mémoire partagée et SYS_FUTEX.
  • Un syscallThread stub (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 :

  1. Boucle Seccomp User-Notify (_RUN_SECCOMP_LOOP = 6, lorsque t.seccompNotify != nil) : pendant syscallThread.init(), t.installSeccompNotify() installe un écouteur SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)) afin que le Sentry puisse distribuer les requêtes via ioctl(SECCOMP_IOCTL_NOTIF_SEND) et ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Boucle d'appels système Futex (_RUN_SYSCALL_LOOP = 5, lorsque t.seccompNotify == nil) : le syscallThread exécute syscall_loop (stub_amd64.s / stub_arm64.s), en se mettant en sommeil sur SYS_FUTEX (FUTEX_WAIT) sur la région partagée de 2 pages syscallSentryMessage (PROT_READ dans le stub) et syscallStubMessage (PROT_READ | PROT_WRITE dans 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() positionne seccompNotifyBusy (atomic.Bool), journalise un seul avertissement pour le processus, et laisse t.seccompNotify == nil pour ce stub ainsi que pour tous les stubs suivants.
  • Dans syscallThread.detach() (syscall_thread_amd64.go et syscall_thread_arm64.go), t.seccompNotify == nil positionne regs.Rbx = _RUN_SYSCALL_LOOP (5) sur amd64 (ou regs.Regs[9] = _RUN_SYSCALL_LOOP sur arm64) à 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 et SYS_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 :

  • dockerd crée son pont docker0 et ses paires veth au sein du netstack en 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 de gvx.
  • Les conteneurs runc imbriqués montent leurs hiérarchies /proc, /sys et /sys/fs/cgroup au sein de la table de montage VFS2 en mémoire de gvx, sans jamais appeler l'appel système mount(2) de runsc externe.

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

  1. gvx : exécuter des conteneurs Linux, Docker et Kubernetes dans un seul processus non privilégié : présentation de gvx sous Windows (whpx, systrap), macOS (hvf) et Linux (systrap).
  2. Exécuter Docker, docker build et docker compose sans root ni user namespaces : comment gvx exécute docker:dind lorsque root, sudo, unshare(CLONE_NEWUSER), mount(2) et /dev/fuse sont bloqués.
  3. Docker-in-Docker à l'intérieur de gVisor et de bacs à sable imbriqués avec gvx (cet article)
  4. Environnements Docker isolés et rapides pour les rollouts d'apprentissage par renforcement d'agents d'IA avec gvx : comment utiliser gvx et des snapshots copy-on-write ext4lite constitués d'un fichier unique pour exécuter des centaines d'environnements parallèles de rollouts SWE-bench et RL avec des réinitialisations en 100 millisecondes.