---
id: 83fb2c108243a7df80db3a6e78290daf
author: Lemon Mint
title: Exécuter Docker-in-Docker dans gVisor et des Nested Sandboxes avec gvx
description: Pourquoi l'installation de docker.io ou l'imbrication de runsc dans un conteneur gVisor échoue avec modprobe, iptables-nft, overlay2, cgroups et seccomp EBUSY, et comment gvx exécute de manière sécurisée un docker:dind imbriqué au sein d'un noyau isolé en espace utilisateur (user-space kernel) sans --privileged.
language: fr
date: 2026-11-06T12:02:55.931762327Z
path: /blog/posts/docker-in-docker-inside-gvisor-with-gvx-z48916d03
go_package: z.gfire.dev/gvx
go_repourl: https://github.com/gfire-sigs/gvx.git
category: lowlevel
---

## 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](https://github.com/gfire-sigs/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.

```sh
# Dans un conteneur gVisor (runsc) existant ou un Pod Kubernetes :
# Installer rungv depuis une archive de version GitHub attestée SLSA ou via go install :
CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest

mkdir -p ~/.gvx
rungv run \
  -p unix:~/.gvx/docker.sock:2375 \
  -v gvx-docker:/var/lib/docker \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &

# Exécuter les builds et conteneurs Docker standard via le socket gvx interne
export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
until docker info >/dev/null 2>&1; do sleep 0.5; done
docker info --format '{{.Driver}} {{.OperatingSystem}}'
# Sortie : overlayfs Alpine Linux ... (conteneurisé)

docker build -t nested-app:test .
docker 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 :

```text
modprobe: FATAL: Module overlay not found in directory /lib/modules/4.4.0
modprobe: FATAL: Module br_netfilter not found in directory /lib/modules/4.4.0
modprobe: FATAL: Module ip_tables not found in directory /lib/modules/4.4.0
modprobe: 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` :

```text
failed to start daemon: Error initializing network controller: error obtaining controller instance: failed to create NAT chain DOCKER: iptables failed: iptables -t nat -N DOCKER
iptables v1.8.10 (nf_tables): Could not fetch rule set generation id: Invalid argument
iptables v1.8.10 (legacy): can't initialize iptables table 'nat': Table does not exist (do you need to insmod?)
iptables: No chain/target/match by that name.
Unable 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.)
Error 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 :

```text
level=error msg="[graphdriver] prior storage driver overlay2 failed: driver not supported"
failed to mount overlay: invalid argument
Error 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 :

```text
docker: 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:
error 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
cgroups: cgroup mountpoint does not exist: unknown
runc create failed: systemd not running on this host: cannot use systemd cgroup manager
error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/6), flags: 0xe: operation not permitted
error 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 :

```text
creating 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](/assets/images/gvx/gvx-nested-seccomp-futex.png)

### 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()` :

```go
// pkg/sentry/platform/systrap/seccomp_notify_xplat.go
var seccompNotifyBusy atomic.Bool

func (t *syscallThread) installSeccompNotifyIfFree() (*os.File, error) {
	if seccompNotifyBusy.Load() {
		return nil, nil
	}
	f, err := t.installSeccompNotify()
	if errors.Is(err, unix.EBUSY) {
		if seccompNotifyBusy.CompareAndSwap(false, true) {
			log.Warningf("Another seccomp listener exists in the filter chain of the host process. " +
				"The stub threads use the slower futex loop instead of seccomp-unotify.")
		}
		return nil, nil
	}
	return f, err
}
```

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` :

```go
// pkg/sentry/platform/systrap/syscall_thread.go
// Notifier le thread système d'une nouvelle requête d'appel système.
atomic.AddUint32(&sentryMsg.state, 1)
futexWakeUint32(&sentryMsg.state)

// Attendre la réponse.
futexWaitWake(&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 :

```sh
# Dans un conteneur gVisor (runsc) externe ou un conteneur rungv externe :
mkdir -p ~/.gvx
rungv run \
  -p unix:~/.gvx/docker.sock:2375 \
  -v gvx-docker:/var/lib/docker \
  -e DOCKER_TLS_CERTDIR= \
  docker:dind --tls=false &
DIND_PID=$!

export DOCKER_HOST="unix://$HOME/.gvx/docker.sock"
until docker info >/dev/null 2>&1; do sleep 0.5; done

# Vérifier que le démon Docker interne utilise overlayfs natif (et non vfs)
docker info --format 'Storage Driver: {{.Driver}}'
# Sortie : Storage Driver: overlayfs

# Exécuter un build multicouche et lancer un conteneur imbriqué
mkdir -p /tmp/nested-test && cd /tmp/nested-test
cat <<'EOF' > Dockerfile
FROM alpine:3.20
RUN adduser -D -u 1001 appuser && mkdir -p /app && chown appuser:appuser /app
USER appuser
WORKDIR /app
RUN echo "nested-build-ok" > status.txt
CMD ["cat", "/app/status.txt"]
EOF

docker build -t nested-test:latest .
docker run --rm nested-test:latest
# Sortie : nested-build-ok

kill -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é](/blog/posts/gvx-cross-platform-linux-containers-z48916d01)** : 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](/blog/posts/run-docker-without-root-or-userns-gvx-z48916d02)** : 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](/blog/posts/docker-in-docker-inside-gvisor-with-gvx-z48916d03)** (cet article)
4. **[Environnements Docker isolés et rapides pour les rollouts d'apprentissage par renforcement d'agents IA avec gvx](/blog/posts/fast-docker-environments-for-agent-rl-rollouts-z48916d04)** : 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.
