GoSuda

Uruchamianie Docker-in-Docker wewnątrz gVisor i zagnieżdżonych Sandboxes z użyciem gvx

By Lemon Mint views ...

Szybkie rozwiązanie: bezpieczne uruchamianie docker:dind w nieuprzywilejowanej piaskownicy gVisor (runsc)

Środowiska obliczeniowe agentów AI w chmurze (takie jak piaskownice Google Agent Compute Engine), wdrożenia agent-sandbox w Kubernetes (RuntimeClass: gvisor) oraz uprzęże ewaluacyjne uczenia ze wzmocnieniem izolują niezaufane obciążenia wewnątrz kontenerów gVisor (runsc), aby chronić jądro hosta. Gdy obciążenie działające w domyślnym, nieuprzywilejowanym kontenerze runsc próbuje zainstalować docker.io lub docker-ce za pomocą apt-get, uruchomić service docker start lub dockerd oraz wykonać docker build, kończy się to niepowodzeniem, nawet gdy ma root (UID 0) wewnątrz kontenera.

Dzięki gvx (rungv) można bezpiecznie uruchamiać docker:dind, docker build i docker compose bezpośrednio w nieuprzywilejowanym kontenerze gVisor (runsc) (lub zagnieżdżać gvx wewnątrz gvx) bez osłabiania zewnętrznego RuntimeClass, udostępniania gniazd hosta ani nadawania --privileged.

Rozwiązanie niskonakładowe i efektywne tokenowo dla agentów AI: w środowisku chmurowym agenta opartym na gVisor próba instalacji docker.io i ręcznego obchodzenia błędów ulimit w /etc/init.d/docker, awarii iptables-nft, odmów dostępu do surowych gniazd iptables-legacy, błędów montowania overlay2 oraz awarii runc / pivot_root podczas docker build zazwyczaj zużywa ponad 20 tur wywołań narzędzi i dziesiątki tysięcy tokenów, zanim całkowicie zawiedzie. gvx (rungv) to niskonakładowe, efektywne tokenowo rozwiązanie typu drop-in: agent wykonuje 3 polecenia w jednej turze, aby uruchomić działający demon Docker w przestrzeni użytkownika, a następnie natychmiast wykonuje docker build i docker compose bez modyfikowania plików projektu.

Zachowanie i wzmacnianie izolacji zewnętrznej piaskownicy: uruchomienie rungv wewnątrz runsc nie omija żadnego ograniczenia wymuszanego przez zewnętrzną piaskownicę gVisor. Zamiast tego gvx tworzy drugie, zagnieżdżone jądro gVisor Sentry działające w przestrzeni użytkownika, z własnym hostlockdown (PR_SET_NO_NEW_PRIVS + seccomp), własnym pamięciowym netstack oraz jednoplikowym wolumenem ext4lite. Wewnętrzne kontenery dockerd i runc są podwójnie izolowane: ich wywołania systemowe, montowania i surowe pakiety nigdy nie opuszczają wewnętrznego jądra gvx działającego w przestrzeni użytkownika.

 1# Wewnątrz istniejącego kontenera gVisor (runsc) lub Poda Kubernetes:
 2# Zainstaluj rungv z archiwum wydania GitHub z atestacją SLSA lub przez 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# Uruchom standardowe buildy i kontenery Docker względem wewnętrznego gniazda gvx
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# Wynik: overlayfs Alpine Linux ... (w kontenerze)
17
18docker build -t nested-app:test .
19docker run --rm nested-app:test

Pięć powodów, dla których bezpośrednia instalacja Dockera kończy się niepowodzeniem wewnątrz gVisor (runsc)

Gdy użytkownik lub agent kodujący AI uruchamia apt-get install -y docker.io (lub docker-ce) w standardowym kontenerze runsc i próbuje uruchomić dockerd, napotyka pięć kolejnych barier technicznych.

1. Skrypt inicjalizacyjny (systemctl / service docker start ulimit) oraz błędy modułów jądra (modprobe 4.4.0)

Po wykonaniu apt-get install -y docker.io (lub docker-ce) próba uruchomienia demona przez systemctl start docker lub service docker start kończy się natychmiast niepowodzeniem, ponieważ PID 1 nie jest systemd, a /etc/init.d/docker próbuje podnieść ulimit -n powyżej twardego limitu RLIMIT_NOFILE piaskownicy. Gdy dockerd jest następnie wywoływany bezpośrednio, skrypty inicjalizacyjne wywołują modprobe, aby załadować overlay, bridge, br_netfilter, ip_tables i nf_nat względem wirtualnej wersji jądra gVisor (4.4.0), która nie ma katalogu /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), odmowy surowych gniazd iptables-legacy oraz awarie mostu docker0

W Ubuntu 22.04, Ubuntu 24.04 oraz Debian 11/12 polecenie apt-get install docker.io wybiera iptables-nft (oparte na podsystemie netlink nf_tables) jako domyślną alternatywę /usr/sbin/iptables. Co więcej, nawet jeśli użytkownik lub agent przełączy się na iptables-legacy (update-alternatives --set iptables /usr/sbin/iptables-legacy), domyślne nieuprzywilejowane piaskownice gVisor (--net-raw=false) odrzucają gniazda AF_INET, SOCK_RAW z błędem EPERM, powodując, że iptables-legacy kończy się niepowodzeniem z komunikatem Permission denied (you must be root), nawet gdy działa jako 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. Awaria overlay2 na rootfs gofer / 9p oraz wyczerpanie miejsca przy vfs

O ile administrator zewnętrznego runsc nie skonfigurował wcześniej jawnie dedykowanego montowania tmpfs lub filestore pod /var/lib/docker, rootfs kontenera opiera się na gofer (9p/lisafs) gVisor lub na zagnieżdżonym montowaniu overlay. Montowanie overlay2 na /var/lib/docker kończy się niepowodzeniem:

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

Gdy użytkownicy lub agenci próbują obejść ten błąd, uruchamiając dockerd --storage-driver=vfs, Docker całkowicie wyłącza współdzielenie warstw Copy-on-Write. Każda instrukcja RUN w Dockerfile wykonuje pełne rekurencyjne kopiowanie (dirCopy) całego systemu plików root, mnożąc obraz o rozmiarze 500 MB w 10 krokach budowania w 5 GB zapisów i kończąc działanie piaskownicy błędem no space left on device lub przekroczeniem czasu operacji we/wy.

4. Awarie docker build (BuildKit) i zagnieżdżonego runc (pivot_root, /proc, cgroups i DNS)

Nawet jeśli dockerd zostanie wymuszony do uruchomienia z dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs, próba wykonania docker build -t app . lub docker run kończy się natychmiast niepowodzeniem, gdy BuildKit lub runc uruchamia kontener budowania:

 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. Zagnieżdżony runsc kończy się błędem SECCOMP_FILTER_FLAG_NEW_LISTENER (EBUSY)

Jeśli użytkownik próbuje uruchomić kolejną instancję runsc wewnątrz runsc, używając platformy systrap, inicjalizacja natychmiast się przerywa:

1creating subprocess: sysmsg: failed to initialize thread: failed to install seccomp notify rules: device or resource busy

Zarówno jądro Linuksa, jak i gVisor Sentry dopuszczają co najwyżej jednego słuchacza SECCOMP_FILTER_FLAG_NEW_LISTENER (SECCOMP_RET_USER_NOTIF) w hierarchii zadań. Ponieważ zewnętrzna piaskownica już zajmuje ten slot słuchacza, wewnętrzne wywołanie seccomp(2) zwraca EBUSY (device or resource busy).


Jak gvx rozwiązuje zagnieżdżone wykonywanie wewnątrz gVisor

gvx usuwa wszystkie pięć barier wewnątrz własnego jądra w przestrzeni użytkownika oraz warstwy platformy, umożliwiając rungv działanie wewnątrz zewnętrznego kontenera gVisor (runsc), a nawet rekurencyjnie wewnątrz innego kontenera gvx (gvx w gvx).

Zagnieżdżone wykonanie gvx wewnątrz zewnętrznego gVisor

1. Automatyczny fallback systrap z EBUSY do pętli futex _RUN_SYSCALL_LOOP

W systrap w Linuksie każdy podproces stub uruchamia dwa rodzaje wątków:

  • Wątki wykonania gościa (sysmsg): przechwytują wywołania systemowe gościa przez SECCOMP_RET_TRAP (SIGSYS) oraz binarnie łatane trampoliny jmp rel32 (syshandler), wymieniając stan rejestrów z Sentry przez pamięć współdzieloną i SYS_FUTEX.
  • Stub syscallThread (syscall_thread.go): wykonuje wywołania systemowe przestrzeni adresowej (mmap, munmap, clone) wewnątrz podprocesu stub w imieniu Sentry.

W upstreamowym gVisor syscallThread obsługuje dwa tryby wybudzania w stub_amd64.s i stub_arm64.s:

  1. Pętla powiadomień użytkownika seccomp (_RUN_SECCOMP_LOOP = 6, gdy t.seccompNotify != nil): podczas syscallThread.init() wywołanie t.installSeccompNotify() instaluje słuchacza SECCOMP_RET_USER_NOTIF (seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, stubSyscallRules)), aby Sentry mógł przekazywać żądania przez ioctl(SECCOMP_IOCTL_NOTIF_SEND) i ioctl(SECCOMP_IOCTL_NOTIF_RECV).
  2. Pętla wywołań systemowych futex (_RUN_SYSCALL_LOOP = 5, gdy t.seccompNotify == nil): syscallThread uruchamia syscall_loop (stub_amd64.s / stub_arm64.s), usypiając się na SYS_FUTEX (FUTEX_WAIT) nad wspólnym, dwustronicowym regionem syscallSentryMessage (PROT_READ w stubie) i syscallStubMessage (PROT_READ | PROT_WRITE w stubie).

Upstreamowy gVisor bezwarunkowo wywołuje t.installSeccompNotify() zawsze, gdy seccompNotifyIsSupported ma wartość true, i przerywa tworzenie stuba, jeśli seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) zwraca EBUSY.

W gvx (pkg/sentry/platform/systrap/seccomp_notify_xplat.go oraz syscall_thread.go) syscallThread.init() wywołuje 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}

Gdy installSeccompNotify() zwraca unix.EBUSY, ponieważ zewnętrzna piaskownica gVisor (runsc) lub zewnętrzne gvx już zajmuje jedyny slot słuchacza w łańcuchu filtrów procesu:

  • installSeccompNotifyIfFree() ustawia seccompNotifyBusy (atomic.Bool), zapisuje jedno ostrzeżenie dla procesu i pozostawia t.seccompNotify == nil dla tego stuba oraz wszystkich kolejnych stubów.
  • W syscallThread.detach() (syscall_thread_amd64.go i syscall_thread_arm64.go) warunek t.seccompNotify == nil ustawia regs.Rbx = _RUN_SYSCALL_LOOP (5) na amd64 (lub regs.Regs[9] = _RUN_SYSCALL_LOOP na arm64) zamiast _RUN_SECCOMP_LOOP (6).
  • W syscallThread.syscall() (syscall_thread.go) wewnętrzne Sentry wysyła wywołania stuba wyłącznie przez pamięć współdzieloną i SYS_FUTEX:
1// pkg/sentry/platform/systrap/syscall_thread.go
2// Powiadom wątek syscall o nowym żądaniu wywołania systemowego.
3atomic.AddUint32(&sentryMsg.state, 1)
4futexWakeUint32(&sentryMsg.state)
5
6// Czekaj na odpowiedź.
7futexWaitWake(&sentryMsg.state, atomic.LoadUint32(&sentryMsg.state))

Ponieważ filtry SECCOMP_RET_TRAP działają na wątkach gościa o dowolnie dużej głębokości stosu, a syscallThread przełącza się na _RUN_SYSCALL_LOOP bez żądania drugiego słuchacza powiadomień użytkownika, gvx działa poprawnie wewnątrz runsc i obsługuje rekurencyjne zagnieżdżanie (gvx wewnątrz gvx).

2. Natywny overlayfs na jednoplikowych wolumenach ext4lite

Aby uniknąć montowania overlayfs na korzeniowym systemie plików kontenera zewnętrznego gVisor (gofer, 9p/lisafs), -v gvx-docker:/var/lib/docker udostępnia /var/lib/docker z wewnętrznego systemu plików ext4lite gvx, przechowywanego w pojedynczym zwykłym pliku (gvx-docker.ext4).

Dla zewnętrznego runsc plik gvx-docker.ext4 jest po prostu jednym zwykłym plikiem, do którego dostęp odbywa się przez pread64(2) i pwrite64(2). Wewnątrz gvx ext4lite zapewnia system plików ext4 z dziennikiem JBD2, atrybuty rozszerzone trusted.overlay.* i security.capability, węzły whiteout w postaci urządzeń znakowych (0:0), wpisy katalogów d_type oraz RENAME_EXCHANGE, co pozwala wewnętrznemu dockerd używać natywnego sterownika pamięci masowej overlayfs.

3. Samodzielny netstack z docker0 i iptables-legacy

Wewnątrz gvx dockerd nie współdziała z interfejsami sieciowymi ani tabelami netfilter zewnętrznego kontenera runsc. Zamiast tego:

  • dockerd tworzy swój most docker0 oraz pary veth wewnątrz własnego netstack gvx działającego w przestrzeni użytkownika.
  • Wszystkie reguły iptables-legacy (-t nat -N DOCKER, MASQUERADE, conntrack, addrtype) są wykonywane względem wewnętrznej implementacji netfilter gvx.
  • Wewnętrzne kontenery runc montują swoje hierarchie /proc, /sys i /sys/fs/cgroup wewnątrz tabeli montowań VFS2 gvx działającej w pamięci, nigdy nie wywołując zewnętrznego wywołania systemowego mount(2) z runsc.

Weryfikacja zagnieżdżonego docker:dind wewnątrz gVisor (lub gvx w gvx)

Oba scenariusze (rungv wewnątrz runsc lub rungv wewnątrz rungv) można zweryfikować za pomocą następujących poleceń:

 1# Wewnątrz zewnętrznego kontenera gVisor (runsc) lub zewnętrznego kontenera rungv:
 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# Zweryfikuj, czy wewnętrzny demon Docker używa natywnego overlayfs (a nie vfs)
14docker info --format 'Storage Driver: {{.Driver}}'
15# Wynik: Storage Driver: overlayfs
16
17# Uruchom wielowarstwowy build i wykonaj zagnieżdżony kontener
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# Wynik: nested-build-ok
31
32kill -INT $DIND_PID

Dalsza lektura w tej serii

  1. gvx: uruchamianie kontenerów Linux, Docker i Kubernetes w jednym nieuprzywilejowanym procesie: przegląd gvx w systemach Windows (whpx, systrap), macOS (hvf) oraz Linux (systrap).
  2. Uruchamianie Dockera, docker build i docker compose bez roota ani przestrzeni nazw użytkownika: jak gvx uruchamia docker:dind, gdy root, sudo, unshare(CLONE_NEWUSER), mount(2) i /dev/fuse są zablokowane.
  3. Docker-in-Docker wewnątrz gVisor i zagnieżdżonych piaskownic z gvx (ten wpis)
  4. Szybkie izolowane środowiska Docker dla rolloutów RL agentów AI z gvx: jak używać gvx i jednoplikowych migawek copy-on-write ext4lite do uruchamiania setek równoległych środowisk SWE-bench i rolloutów RL z resetami trwającymi 100 milisekund.