gvx: Exécuter Linux Containers, Docker et Kubernetes dans un seul processus non privilégié

Introduction : conteneurs durcis en sécurité sans root ni daemons hôte
Exécuter des conteneurs Linux, docker build ou Kubernetes au sein d'une machine de développement, d'un runner CI ou d'un nœud cloud multi-locataire impose généralement un compromis de sécurité peu satisfaisant :
- Sur Linux, les runtimes OCI standard (
runc,crun,runsc) exigent soit des privilègesroot, soit l'exposition de/var/run/docker.sockde l'hôte, soit--privileged(CAP_SYS_ADMIN), soit des espaces de noms utilisateur (unshare(CLONE_NEWUSER)) qui élargissent la surface d'attaque du noyau hôte. - Sur Windows et macOS, les moteurs de conteneurs (
Docker Desktop,Podman Machine,Lima,OrbStack) démarrent un noyau Linux complet dans une machine virtuelle via WSL2, Hyper-V ouVirtualization.framework, et exécutent un daemon persistant en arrière-plan à l'intérieur de cette VM.
gvx (CLI rungv et SDK Go z.gfire.dev/gvx/pkg/gvx) offre une alternative sécurisée et sans privilège. Conçu comme une branche (fork) suivable en amont du noyau Linux en espace utilisateur de Google gVisor (Sentry) et de sa pile réseau en espace utilisateur (netstack), gvx exécute directement des binaires ELF64 linux/amd64 et linux/arm64 non modifiés ainsi que des conteneurs OCI au sein d'un unique processus hôte non privilégié et auto-verrouillé sur Windows, macOS et Linux.
Garantie de sécurité (défense en profondeur à trois anneaux) :
gvxn'affaiblit jamais les frontières de sécurité du système d'exploitation hôte, n'installe jamais de pilotes noyau et n'exige jamaisroot,sudo,--privilegedsur l'hôte, niCLONE_NEWUSER. Avant d'exécuter la moindre instruction invitée,gvxverrouille son propre processus hôte à l'aide de jetons restreints (Restricted Tokens) et de Job Objects Windows, de Seatbelt macOS (deny default), ou dePR_SET_NO_NEW_PRIVSet duseccompBPF sous Linux. Tous les appels système invités, les privilèges virtuelsUID 0, les montages (VFS2) et les tables réseau (netstack) sont strictement confinés au sein du noyauSentryen Go, sûr vis-à-vis de la mémoire.
| Runtime | OS hôte | Démarrage à froid | Empreinte mémoire | Administrateur ou sudo requis | Daemon VM en arrière-plan | Exécution directe d'image OCI |
|---|---|---|---|---|---|---|
gvx (whpx / hvf) | Windows (amd64, arm64) & macOS (arm64) | ~20 ms | ~30 MiB | Non | Non | Oui |
gvx (systrap) | Windows (amd64, arm64) & Linux (amd64, arm64) | ~25 à 65 ms | ~31 MiB | Non | Non | Oui |
| WSL2 / Docker Desktop | Windows & macOS | 800 à 2500 ms | > 512 MiB | Oui (configuration initiale) | Oui | Nécessite un daemon |
| QEMU (système complet) | Windows & macOS | 1500 à 4000 ms | > 256 MiB | Non | Non | Nécessite une image disque |
Aucun noyau de machine virtuelle Linux ne démarre sous Windows ou macOS. Aucun daemon en arrière-plan ne s'exécute sur l'hôte. Aucun privilège root, sudo, d'espaces de noms utilisateur (CLONE_NEWUSER), de mount(2), de pilotes TUN/TAP ni de CAP_NET_ADMIN n'est requis sur le système d'exploitation hôte. Au sein de ce processus non privilégié, gvx exécute Docker-in-Docker (docker:dind), docker build, docker compose et Kubernetes haute disponibilité multi-nœuds (k3s avec etcd embarqué et CNI Calico).
Cycle de vie du conteneur et verrouillage hôte à trois anneaux
Chaque bac à sable gvx s'exécute au sein d'un unique processus hôte non privilégié et applique un cycle de vie en trois phases : pré-ouverture de tous les descripteurs de fichiers et sockets hôte requis, verrouillage du processus du système d'exploitation hôte via pkg/hostlockdown, puis entrée dans la boucle d'exécution Sentry intégrée au processus.

Avant de transférer le contrôle à la première instruction invitée, gvx pré-ouvre ses descripteurs de couches d'image .erofs mises en cache, ses descripteurs de volumes .ext4, son handle *os.Root sur /workspace et ses sockets réseau hôte, puis invoque hostlockdown.Apply() :
- Windows NT (
lockdown_windows.go) : AppelleAdjustTokenPrivilegesavecSE_PRIVILEGE_REMOVEDpour retirer les privilèges sur place, lie un jeton d'usurpation restreintDISABLE_MAX_PRIVILEGEà l'intégrité Low (S-1-16-4096), affecte le processus à un Job Object anonyme avecJOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1(bloquant la création de tout processus enfant), et activeDisallowWin32kSystemCalls = 1viaSetProcessMitigationPolicy. - macOS XNU (
lockdown_darwin.go) : DéfinitRLIMIT_NPROC = 0et applique un profil Seatbelt (sandbox_init_with_parameters) débutant par(version 1)(deny default)qui bloqueprocess-fork,process-exec*et toute nouvelle ouverture de fichier hôte. - Linux (
lockdown_linux.go+pkg/gvx/syscallfilter_linux.go) : DéfinitPR_SET_NO_NEW_PRIVSet installe la liste blancheseccompBPF de Sentry sur l'ensemble des threads (SECCOMP_FILTER_FLAG_TSYNC), avec des règlesEPERMexplicites pourexecve,execveat,clone,openatetsocket.
Interception des appels système multiplateforme et pagination 4 Ko (pkg/sentry/platform)
Les binaires ELF64 Linux supposent une granularité de pages mémoire de 4 Ko (0x1000) pour les mappages de segments mmap(MAP_FIXED). Cependant, VirtualAlloc et MapViewOfFile3 de Windows imposent un alignement de 64 Ko (0x10000), et macOS sur Apple Silicon (darwin/arm64) utilise des pages hôte de 16 Ko (0x4000). De plus, chaque système d'exploitation requiert un mécanisme différent pour intercepter les instructions Linux SYSCALL (amd64) et SVC #0 (arm64) sans démarrer de VM Linux.
gvx résout ces deux problèmes à travers quatre backends d'exécution qui implémentent pkg/sentry/platform.Context :

1. Moteur matériel Windows : platform/whpx (WinHvPlatform.dll)
Sur windows/amd64 et windows/arm64, platform/whpx crée une partition hyperviseur légère via WHvCreatePartition sans démarrer de noyau de système d'exploitation invité :
- Découplage de pagination 64 Ko vers 4 Ko :
whpxenregistre chaque arène de 64 MiB depgalloc.MemoryFileauprès de la partition une seule fois viaWHvMapGpaRange. Les appelsMapFileetUnmapultérieurs ne modifient que les tables de pages invitées au sein de la partition, imposant des permissionsR/W/Xexactes à 4 Ko malgré la granularité d'allocation de 64 Ko de Windows. - Échange de registres sans API via
CommPage:- Sur
amd64, unSYSCALLinvité en Ring-3 entre dans un stubMSR_LSTARen Ring-0 au sein de la partition (stubgen_x86.go), qui écrit les GPR de l'appel système rapide (RAX, RDI, RSI, RDX, R10, R8, R9, RIP, RSP) dans uneCommPagepartagée de 4 Ko et exécuteHLTpour quitterWHvRunVirtualProcessor. - Sur
arm64, unSVC #0invité en EL0 entre dans les vecteurs EL1 (stubgen_a64.go), écritX0..X8, SP_EL0, ELR_EL1, PSTATEdansCommPage, puis charge depuis le GPA zéro non alloué afin de déclencher une sortie mémoire vers Sentry.
- Sur
2. Moteur matériel macOS : platform/hvf (Hypervisor.framework)
Sur Apple Silicon (darwin/arm64), platform/hvf exécute les threads de conteneurs au sein de vCPU Hypervisor.framework (hv_vm_create, hv_vcpu_run) :
- Traduction MMU à deux étages : 4 Ko sur 16 Ko : L'étage 2 (
hv_vm_map) mappe des fragments de 1 GiB du fichier mémoire dans l'espace d'adresses physiques intermédiaires (IPA) une seule fois, à la granularité de page hôte de 16 Ko, avecHV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC. Au sein du vCPU, le stub EL1bluepill(bluepill_arm64.go) configureTCR_EL1.TG0 = 0b00(granule de 4 Ko) dansTTBR0_EL1et remplit des entrées de tables de pages d'étage 1 de 4 Ko avec des permissions exactesR-XouRW-(UXN=1). Comme la MMU ARM64 applique l'intersection des permissions des étages 1 et 2, le matériel piège les fautes de permission à des frontières exactes de 4 Ko. - Piégeage d'appel système : Le
SVC #0invité en EL0 piège versVBAR_EL1en EL1, écrit les registres dans laCommPagepartagée de 4 Ko et exécuteHVC #0pour revenir dehv_vcpu_runvers le thread Go de Sentry.
3. Repli en mode utilisateur Windows : platform/systrap (winsystrap)
Lorsque la virtualisation matérielle est indisponible (par exemple au sein de VM cloud imbriquées), winsystrap exécute le code invité en mode utilisateur sur windows/amd64 et windows/arm64 :
- Isolation matérielle
CR3/TTBR0_EL1: Les threads invités s'exécutent au sein de sous-processus stub isolés, démarrés à partir d'un exécutable généré de 1 Ko (stubimage_xplat.go) confiné par un jeton d'intégrité Low et un Job Object. - Patch binaire sur place et IPC
winSysmsg: Suramd64, le patcher (patcher_amd64_xplat.go) remplace les instructionssyscall(0f 05) de 2 octets par des sautsjmp rel32de 5 octets (ou des pièges à points d'arrêtCC 90lorsqu'une fenêtre de 5 octets est indisponible) vers un îlot à chemin rapide. Surarm64,svc #0est patché par une brancheBvers un emplacement de l'îlot. L'îlot écrit les registres dans la page partagéewinSysmsget se synchronise avec Sentry viaWaitOnAddressetWakeByAddressSingle, tandis que les pièges non patchés et les fautes de page s'arrêtent sur un Windows Debug Object (WaitForDebugEventEx).
4. Moteur en mode utilisateur Linux : platform/systrap
Sur linux/amd64 et linux/arm64, systrap exécute des threads invités (sysmsg) qui interceptent les appels système invités via SECCOMP_RET_TRAP (SIGSYS) et patchent en binaire les sites syscall vers un trampoline en mémoire partagée (sysmsg) synchronisé via SYS_FUTEX, tandis qu'un stub syscallThread (syscall_thread.go) exécute les opérations d'espace d'adressage (mmap, munmap, clone) via SECCOMP_RET_USER_NOTIF (_RUN_SECCOMP_LOOP). Lorsque gvx s'exécute au sein d'un bac à sable gVisor externe (runsc) où l'unique emplacement SECCOMP_FILTER_FLAG_NEW_LISTENER renvoie EBUSY, gvx bascule automatiquement syscallThread vers une boucle futex en mémoire partagée (_RUN_SYSCALL_LOOP), comme détaillé dans la Partie 3 de cette série.
Comment gvx associe les appels système Linux invités aux sous-systèmes virtuels et aux handles hôte
Une fois qu'un moteur de plateforme intercepte un SYSCALL (amd64) ou un SVC #0 (arm64) invité et écrit les registres invités dans la page partagée de 4 Ko (CommPage, sysmsg ou winSysmsg), le contrôle revient à la goroutine de tâche Go de Sentry (pkg/sentry/kernel/task_syscall.go). Plutôt que de transmettre les appels système Linux au noyau du système d'exploitation hôte, Task.doSyscall() sauvegarde l'état original des registres (SyscallSaveOrig()), évalue les filtres BPF seccomp et les arrêts ptrace éventuels dans l'invité, puis répartit le numéro d'appel système via linux.AMD64 ou linux.ARM64 (pkg/sentry/syscalls/linux/linux64.go, couvrant environ 370 appels système Linux) vers quatre domaines internes :

| Domaine d'appel système | Appels système Linux invités représentatifs | Sous-système interne gvx (pkg/sentry) | Interaction avec la frontière du système d'exploitation hôte (après hostlockdown) |
|---|---|---|---|
| A. Processus, espace de noms, montage et synchronisation | clone, execve, unshare, setns, mount, umount2, pivot_root, setuid, capset, futex, epoll_wait, pipe2 | kernel.Task, identifiants virtuels, table des futex et VFS2 en mémoire (procfs, sysfs, cgroupfs v2, tmpfs, overlayfs) | Zéro appel système hôte (état Go à 100 % en mémoire ; totalement invisible pour le système d'exploitation hôte) |
B. Mémoire virtuelle (MM) | mmap, munmap, mremap, mprotect, brk, madvise, memfd_create | mm.MemoryManager (arbre de VMA à 4 Ko) et pgalloc.MemoryFile | Mises à jour bidirectionnelles des tables de pages de 4 Ko via WHvMapGpaRange, hv_vm_map + TTBR0_EL1, ou mmap/mprotect du stub |
C. Fichiers et stockage (VFS2) | openat, pread64, pwrite64, getdents64, statx, renameat2, setxattr, mknod, inotify_add_watch | Routeur de table de montage vfs.VirtualFilesystem (fsimpl/erofs, fsimpl/ext4lite, fsimpl/hostfs v2) | pread/pwrite bidirectionnels sur 1 FD .erofs épinglé ou 1 FD .ext4, ou parcours relatifs délimités sur *os.Root |
| D. Réseau et Netfilter | socket, bind, listen, connect, sendmsg, recvmsg, netlink, setsockopt(IPT_SO_SET_REPLACE) | pkg/tcpip en espace utilisateur (lo, eth0, docker0, veth, vxlan, wireguard, ipset, iptables-legacy) | Le trafic intra-bac à sable utilise zéro appel système hôte ; le trafic externe correspond à net.Dial/net.Listen (-net=nat) ou à 1 socket UDP (-net=wg) |
Lorsque le gestionnaire renvoie (rval, ctrl, err), Task.doSyscallInvoke() convertit toute erreur en errno Linux négatif (-ExtractErrno(err, sysno)), réécrit la valeur de retour dans la page de registres partagée (RAX sur amd64, X0 sur arm64) et reprend l'exécution invitée.
Stockage (VFS2) et réseau en espace utilisateur à double piste (netstack)
Toutes les opérations de fichiers et réseau invitées sont traitées au sein du Sentry en Go par l'intermédiaire de trois systèmes de fichiers VFS2 et de deux pistes pkg/netbackend :

| Moteur de stockage | Rôle de montage dans gvx | Ressource hôte sous-jacente | Appels hôte openat / NtCreateFile à l'exécution | Analyses par fichier de Windows Defender (WdFilter.sys) |
|---|---|---|---|---|
fsimpl/erofs | Couches inférieures d'image OCI en lecture seule | Un seul fichier <sha256>.erofs par couche | 0 | 0 |
fsimpl/ext4lite | Volumes nommés (-v name:/path) | Un seul fichier <name>.ext4 par volume | 0 | 0 |
fsimpl/hostfs v2 | Espace de travail hôte (-workspace, -v /host:/guest) | Répertoire hôte *os.Root épinglé | O(depth) par ouverture non mise en cache | 1 par fermeture de fichier hôte |
Pourquoi ext4lite est important pour Docker, les bases de données et les gestionnaires de paquets
L'extraction ou l'écriture de dizaines de milliers de fichiers de conteneur directement sur NTFS (Windows) ou APFS (macOS) déclenche des analyses par les minifiltres antivirus (WdFilter.sys), des collisions de sensibilité à la casse et l'absence des attributs étendus POSIX.
pkg/sentry/fsimpl/ext4lite implémente un système de fichiers ext4 en Go pur au sein d'un unique fichier hôte pré-ouvert (%LOCALAPPDATA%\gvx\cache\volumes\<name>.ext4 sous Windows, ~/Library/Caches/gvx/volumes/<name>.ext4 sous macOS, ou ~/.cache/gvx/volumes/<name>.ext4 sous Linux) avec :
- Blocs de 4 KiB et groupes de blocs dynamiques de 16 MiB (
meta_bg) : Croît dynamiquement sans blocages d'allocation de fichiers creux (fallocatesous Linux,fcntl(F_PREALLOCATE)sous macOS,FileAllocationInfosous Windows) et compacte les groupes finaux inutilisés lors d'un démontage propre. - Journal JBD2 en mode ordonné (inode 8) : Validations de transactions atomiques avec sommes de contrôle CRC-32 et blocs de révocation, ainsi qu'une récupération
fsckLite. - Prise en charge de la couche supérieure
overlayfs: Implémente les attributs étendustrusted.overlay.*etsecurity.capability, les whiteouts de périphériques caractères (0:0), le rapportd_typedes entrées de répertoire etRENAME_EXCHANGE.
Charges de travail pratiques : moteur Docker, Kubernetes (k3s + Calico) et SDK Go
Installer rungv : versions préconstruites attestées SLSA ou go install
Vous pouvez télécharger une archive rungv préconstruite pour linux/amd64, linux/arm64, darwin/arm64, windows/amd64 ou windows/arm64 depuis GitHub Releases (chacune construite par le pipeline de publication SLSA avec un bundle de provenance Sigstore et un SHA256SUMS), ou installer directement depuis les sources avec go install :
1# Option A : Télécharger une version préconstruite et vérifier la provenance de build SLSA
2gh attestation verify rungv-v0.2.0-linux-amd64.tar.gz --repo gfire-sigs/gvx \
3 --signer-workflow gfire-sigs/gvx/.github/workflows/release.yml \
4 --source-ref refs/tags/v0.2.0 --deny-self-hosted-runners
5shasum -a 256 -c SHA256SUMS --ignore-missing
6tar -xzf rungv-v0.2.0-linux-amd64.tar.gz
7
8# Option B : Installer directement via Go (Linux / Windows)
9CGO_ENABLED=0 go install z.gfire.dev/gvx/cmd/rungv@latest
Sur macOS (
darwin/arm64), l'archive de version préconstruite est déjà signée avec le droitcom.apple.security.hypervisor(xattr -d com.apple.quarantine rungvsupprime la mise en quarantaine du navigateur). Si vous compilez localement avecgo installougo buildsur macOS, signez le binaire aveccodesign --sign - --entitlements entitlements.plist --force $(which rungv).
1. Exécuter un moteur Docker non privilégié (docker:dind)
Conformément à docs/docker-engine.md, vous pouvez exécuter un conteneur docker:dind non modifié au sein de rungv sous Windows, macOS ou Linux et le piloter avec votre CLI docker hôte :
1# 1. Créer le répertoire hôte pour le socket Unix redirigé
2mkdir -p ~/.gvx
3
4# 2. Démarrer docker:dind dans un bac à sable gvx avec un volume ext4lite
5rungv run \
6 -p unix:~/.gvx/docker.sock:2375 \
7 -p 127.0.0.1:9000-9019:9000-9019 \
8 -v gvx-docker:/var/lib/docker \
9 -e DOCKER_TLS_CERTDIR= \
10 docker:dind --tls=false
Dans un second terminal, pointez votre CLI docker hôte vers le socket du bac à sable :
1docker context create gvx --docker host=unix://$HOME/.gvx/docker.sock
2docker context use gvx
3
4docker info --format '{{.Driver}} {{.OperatingSystem}}'
5# Sortie : overlayfs Alpine Linux ... (conteneurisé)
6
7docker build -t myapp ./app
8docker compose up -d
2. Exécuter Kubernetes (k3s + Calico) dans des bacs à sable non privilégiés
Comme gvx implémente /dev/kmsg, cgroup v2 (tueur OOM memory.max et limitation de bande passante cpu.max), /proc/diskstats, les périphériques netlink wireguard et vxlan, ipset et iptables-legacy (addrtype, conntrack, statistic, recent), une image rancher/k3s non modifiée atteint Ready au sein d'un unique processus rungv :
1mkdir -p ~/.gvx/k3s
2
3rungv run \
4 -e IPTABLES_MODE=legacy \
5 -p 127.0.0.1:6443:6443 \
6 -p 127.0.0.1:30000-30019:30000-30019 \
7 -v k3s:/var/lib/rancher \
8 --mount type=bind,src=$HOME/.gvx/k3s,dst=/output \
9 rancher/k3s:v1.31.4-k3s1 server \
10 --disable=traefik,servicelb \
11 --write-kubeconfig=/output/kubeconfig.yaml \
12 --write-kubeconfig-mode=644
Vous pouvez également joindre plusieurs nœuds gvx via WireGuard (flannel-backend=wireguard-native ou un maillage wg0) avec une haute disponibilité etcd embarquée, ou exécuter Calico (calico-vxlan.yaml avec FELIX_IPTABLESBACKEND=Legacy) pour appliquer les NetworkPolicy Kubernetes entièrement en espace utilisateur.
3. Intégrer des conteneurs Linux en Go via z.gfire.dev/gvx/pkg/gvx
Les applications Go peuvent intégrer directement des conteneurs Linux sans démon de conteneurs externe :
1package main
2
3import (
4 "context"
5 "fmt"
6 "os"
7
8 "z.gfire.dev/gvx/pkg/gvx"
9)
10
11func main() {
12 ctx := context.Background()
13 sb, err := gvx.New(ctx, gvx.Config{
14 Platform: gvx.PlatformAuto,
15 NetworkMode: gvx.NetModeNAT,
16 OCIImage: "alpine:3.20",
17 WorkspacePath: "./repo",
18 Argv: []string{"/usr/bin/git", "status"},
19 Stdout: os.Stdout,
20 Stderr: os.Stderr,
21 })
22 if err != nil {
23 fmt.Fprintf(os.Stderr, "init sandbox: %v\n", err)
24 os.Exit(1)
25 }
26 defer sb.Close()
27
28 exitCode, err := sb.Run(ctx)
29 if err != nil {
30 fmt.Fprintf(os.Stderr, "run sandbox: %v\n", err)
31 os.Exit(1)
32 }
33 os.Exit(exitCode)
34}
Autres lectures dans cette série
- gvx : exécuter des conteneurs Linux, Docker et Kubernetes dans un seul processus non privilégié (cet article)
- Exécuter Docker, docker build et docker compose sans root ni espaces de noms utilisateur : Comment amorcer
docker:dinddans des bacs à sable verrouillés oùsudo,unshare(CLONE_NEWUSER),mount(2)et/dev/fusesont bloqués. - Exécuter Docker-in-Docker au sein de gVisor et de bacs à sable imbriqués avec gvx : Pourquoi
apt-get install docker.ioetrunscimbriqué échouent au sein degVisorà travers cinq barrières noyau, et commentgvxexécutedocker:dindimbriqué à l'aide d'une boucle futex en mémoire partagée. - Environnements Docker isolés et rapides pour les rollouts RL d'agents IA avec gvx : Mise à l'échelle de
SWE-benchet des rollouts d'apprentissage par renforcement d'agents avec des centaines d'instancesdocker:dindnon privilégiées en parallèle et des réinitialisations de volumes.ext4en copie sur écriture en 100 millisecondes.