GoSuda

Exécuter Docker-in-Docker dans gVisor et des Nested Sandboxes 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 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 rungv dans runsc ne contourne aucune restriction appliquée par le bac à sable gVisor externe. Au contraire, gvx crée un second noyau Sentry gVisor imbriqué, fonctionnant en espace utilisateur, doté de son propre hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), de sa propre netstack en mémoire et d'un volume ext4lite contenu dans un fichier unique. Les 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 gvx interne, 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).

Exécution gvx imbriquée dans un 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 les trampolines jmp rel32 modifiés en binaire (syshandler), échangeant l'état des registres avec le Sentry via la mémoire partagée et SYS_FUTEX.
  • Un syscallThread de 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 :

  1. Boucle de notification utilisateur seccomp (_RUN_SECCOMP_LOOP = 6, lorsque t.seccompNotify != nil) : Lors de 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 transmettre des 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) au-dessus de la région partagée de 2 pages syscallSentryMessage (PROT_READ dans le stub) et syscallStubMessage (PROT_READ | PROT_WRITE dans 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() 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 achemine 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 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 :

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

  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 espaces de noms utilisateur : 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 IA avec gvx : Comment utiliser gvx et les snapshots copy-on-write ext4lite contenus dans un fichier unique pour exécuter des centaines d'environnements parallèles SWE-bench et de rollouts RL avec des réinitialisations en 100 millisecondes.