Kurzfassung
Wir haben unsere Sandbox-VMs per SSH von der Control Plane aus gesteuert. Das hat funktioniert (so halbwegs), bis wir den Warm Pool aktiviert haben. Vorgebootete VMs konnten Umgebungsvariablen des Nutzers nicht ohne Reboot übernehmen, und SSH war das falsche Werkzeug, um das zu beheben. Wir haben dropbear komplett aus den Sandbox-Images entfernt und durch einen kleinen eigenen Daemon ersetzt: zwrm-sandboxd läuft in jeder Sandbox-VM, bietet eine typisierte ConnectRPC-Schnittstelle und holt sich beim Boot ein Bearer Token vom Metadata Service des Hosts. Env-Injection funktioniert jetzt in warm-geclaimten und kalt gebooteten Sandboxes über denselben Code-Pfad, das Trust-Modell hat die Hälfte seiner beweglichen Teile verloren, und Snapshot/Wake erhält den Daemon-Zustand ohne erneute Authentifizierung. Das war kein Glaubenswechsel. Es war das richtige Werkzeug für genau eine Aufgabe.
Der Bug, der uns dazu gezwungen hat
Unser Sandbox-System läuft auf Firecracker-MicroVMs. Ein Nutzer tippt:
zwrm sandbox create -t python --env GREETING=hello
zwrm sandbox exec <id> -- printenv GREETING
Er erwartet hello. Monatelang hat das funktioniert. Der Cold-Boot-Pfad war simpel: Die Control Plane setzte die Umgebungsvariablen vor dem Boot der VM, das Init-Skript exportierte sie, und SSH-rein-und-printenv lieferte die richtige Antwort.
Dann haben wir den Warm Pool ausgeliefert.
Der Warm Pool hält eine Handvoll vorgebooteter VMs bereit, damit ein sandbox create in der Größenordnung von ein paar Dutzend Millisekunden zurückkehren kann, statt auf einen vollen Cold Boot (~2 Sekunden) zu warten. Wunderbar, wenn es funktioniert. Nur: Eine Warm-Pool-VM ist bereits gebootet, wenn ein Nutzer sie anfordert. Ihre Umgebung wurde gesetzt, als der Replenisher sie erzeugt hat, meist auf nichts Bestimmtes. Das --env GREETING=hello des Nutzers kommt an, nachdem die VM schon existiert.
Wir haben zuerst das Naheliegende versucht: per SSH in die warm-geclaimte VM und export GREETING=hello. Aber das setzt die Variable in einem Shell-Prozess. Der nächste exec-Aufruf des Nutzers öffnet eine andere Shell. Seine Umgebung ist weg.
Wir haben versucht, in /etc/environment zu schreiben. Das funktioniert für neue Login-Shells, aber nicht für jeden Prozess, den ein späterer exec direkt startet. Wir haben versucht, jeden Exec in ein Wrapper-Skript zu packen, das eine Env-Datei pro Sandbox neu einliest. Das funktionierte, aber es fühlte sich an, als würden wir die falsche Schicht reparieren. Wir klebten Prozess-Zustand auf ein Protokoll, das sich weigerte, sich zwischen Aufrufen irgendetwas zu merken.
Die ehrliche Diagnose lag die ganze Zeit auf dem Tisch: SSH ist die falsche Abstraktion für das, was wir tun.
SSH ist gebaut für “ein Mensch loggt sich auf einem entfernten Host ein und führt Befehle in seiner Session aus”. Wir haben keinen Menschen. Wir haben keine Session. Wir haben einen Host, der RPCs gegen eine VM aufrufen will, mit Zustand, der Aufrufe überdauert. Jedes zwrm sandbox exec bezahlte für TCP-Setup, Key Exchange, Channel-Allokation und Shell-Parsing, also für etwas, das ein einziger Funktionsaufruf sein sollte.
Also haben wir es rausgeworfen.
Was an seine Stelle trat: zwrm-sandboxd
zwrm-sandboxd ist ein kleines, statisch gelinktes Go-Binary, das in jeder Sandbox-VM läuft. Es lauscht auf :9923 und bietet drei ConnectRPC-Services, definiert in proto/sandboxd/sandboxd.proto:
System:Ping(Readiness-Probe und Health Check),SetEnv(der RPC, der den Warm-Pool-Bug behebt)Process:Run(blockierender Exec mit erfasstem stdout/stderr/Exit-Code),Stream(reserviert für künftiges bidirektionales Streaming),Signal(Zustellung von POSIX-Signalen)Filesystem:Read,Write,Stat,List,Remove, mit Chunked Transfers für große Dateien
Alles spricht h2c, also reines HTTP/2 über TCP. Kein TLS innerhalb der Sandbox. Der Host redet mit der VM über einen privaten TAP-Link, den sonst nichts erreichen kann. Der Schutz auf der Leitung, der uns wirklich interessiert, ist das Bearer Token pro Sandbox, nicht eine Zertifikatskette.
Auf Host-Seite hält der Sandbox-Manager einen sandboxclient.Pool, indiziert nach Sandbox-ID. Der erste Aufruf, nachdem eine VM hochkommt, macht ein WaitReady: eine Ping-Schleife mit linearem Backoff, startend bei 50 ms, ansteigend bis zu einer Obergrenze von 2 s. Sobald der Daemon einmal geantwortet hat, ist jedes weitere zwrm sandbox exec ein einzelner HTTP/2-Request auf der gecachten Verbindung. Der Pool schließt den Client, wenn die Sandbox zerstört wird.
Der Held dieser Geschichte ist der SetEnv-RPC. Er merged den In-Memory-Env-Store des Daemons (oder ersetzt ihn mit replace=true). Jeder folgende Process.Run-Aufruf merged den Store mit etwaigen Overrides pro Aufruf über einen kleinen mergeEnv-Helper. Der Aufruf gewinnt, der Daemon-Zustand ist der Fallback. Wenn der Warm Pool eine VM claimt, ruft der Host SetEnv mit den --env-Variablen des Nutzers auf, bevor er die Sandbox zurückgibt. Wenn eine Cold-Boot-Sandbox startet, läuft derselbe Aufruf direkt nach UpdateSandboxRunning. Derselbe Helper, dieselbe Log-Zeile, dieselben Fehlermodi. Zwei Szenarien, ein Code-Pfad, null Kernel-Command-Line-Verrenkungen.
Die gesamte Host-zu-Daemon-Schnittstelle liegt in sandbox/sandboxclient/client.go. Der Execute-Pfad auf dem sandbox.Manager ist jetzt ein dünner Wrapper darum: Namen der Umgebungsvariablen validieren, eine daemonClientReady-Probe laufen lassen, client.Run mit sh -c <command> aufrufen, das erfasste Ergebnis zurückgeben. Die alte SSH-Dial-mit-Retry-Schleife ist weg.
Wie das Trust-Modell fast nebenbei sauberer wurde
Wir hatten nicht vor, das Sicherheitsmodell neu zu entwerfen. Aber SSH zu ersetzen hieß, dass wir einen Haufen beweglicher Teile loswerden konnten, die wir nur mitgeschleppt hatten, weil die alte Form des Problems sie verlangte.
Vorher (SSH):
- SSH-Host-Key pro App generieren und verteilen
authorized_keysaus dem Metadata Service ausliefern- dropbear in jedem Sandbox-Image behalten
- Trust-on-first-use für den Host-Key der VM bei jedem Dial
- Das meiste davon über Snapshot/Restore hinweg wiederholen
Nachher (Daemon):
- Bei
CreateSandbox32 zufällige Bytes erzeugen, hex-kodieren, alsBYTEAin der Spaltesandboxes.daemon_tokenspeichern - Beim Boot holt sich der Daemon das Token vom Metadata Service des Hosts unter
/daemon/token/<machine_id> - Der Metadata Server prüft die Quell-IP des Requests gegen die in der Machine-Zeile hinterlegte IP. Ein Prozess in Sandbox A kann also nicht das Token von Sandbox B anfordern, selbst wenn er Bs Machine-ID kennt
- Der Daemon hält das Token im Speicher und vergleicht eingehende
Authorization: Bearer <token>-Header mitsubtle.ConstantTimeCompare - Das Token berührt innerhalb der VM nie die Festplatte und taucht nie in JSON-API-Antworten auf (
json:"-"auf dem Struct-FeldDaemonToken)
Die interessante Pointe: Wir mussten dafür keinen neuen Metadata Service schreiben. Der vorhandene liefert bereits Secrets an App-VMs unter http://169.254.169.254:1338/secrets/<machine_id>, mit eingebautem Source-IP-Gating. /daemon/token/<machine_id> war ein neuer Handler, der die bestehenden /secrets/- und /ssh/*-Formen spiegelt: Pfad-Suffix extrahieren, Machine-Zeile nachschlagen, Client-IP mit machine.IPAddress vergleichen, Token als application/octet-stream zurückgeben. Sechs Unit-Tests in secrets/server_test.go decken den Handler ab: Happy Path, gefälschte Quell-IP, nicht existierende Machine, Sandbox-Zeile mit Null-Token (historische Migrationen), falsche HTTP-Methode, leere Machine-ID. Wenn eine App- oder Postgres-VM diesen Endpoint versehentlich trifft, liefert der Lookup 404 mit einer bewussten “daemon token not provisioned”-Meldung. Solche Fehler sollen laut sein, keine stille leere Antwort.
Das Token überlebt Suspend/Restore gratis. Wenn eine persistente Sandbox per Firecracker-Snapshot suspendiert und Stunden später aufwacht, wird der Daemon als Teil des Memory-Snapshots fortgesetzt, mit dem Token noch im Prozess-Heap. Kein erneutes Abholen, keine Re-Auth-Runde, kein zweiter Roundtrip zum Metadata Service. Der sandboxclient.Pool des Hosts verbindet sich über den wiederhergestellten TAP-Link neu, und der nächste exec landet bei einem Daemon, der schon weiß, wer er ist.
Was wir nicht gemacht haben
Zwei Dinge haben wir bewusst verschoben. Ohne sie wäre dieser Beitrag weder vollständig noch ehrlich.
Wir haben SSH auf App- und Postgres-VMs behalten. Apps führen Nutzer-Code aus, der für Debugging legitimerweise SSH-Zugriff brauchen kann. Postgres-VMs nutzen SSH für “Einloggen und Nachsehen”-Workflows von Operatoren und für Host-Key-basiertes Vertrauen, auf das sich das Replication-Tooling noch stützt. Hinter beidem steht ein echter menschlicher Anwendungsfall. Hinter Sandboxes nicht. Eine Sandbox ist ein API-Ziel, keine Kiste, in die Du Dich per ssh einloggst. Der Daemon-Tausch ist im Code sauber abgegrenzt: build.LegacyRootFSOptions() injiziert weiterhin dropbear, build.SandboxRootFSOptions() injiziert zwrm-sandboxd und setzt explizit IncludeSSH: false. Sandbox-Images enthalten überhaupt kein SSH-Binary mehr.
Wir haben kein Streaming-Exec ausgeliefert. Process.Stream ist im Proto mit dem vollen bidirektionalen Message-Set definiert, StreamStart, StreamStdin, StreamSignal, StreamData, StreamExit, und der Handler gibt Unimplemented zurück. Wenn wir irgendwann PTY-artiges Streaming für interaktive Coding-Agent-Sessions oder ein Browser-Terminal brauchen, liegt das Wire-Format schon bereit. Kein Service-Versions-Bump, keine Client-Migration, kein neuer RPC.
Was uns überrascht hat
Eine Handvoll Dinge, mit denen wir nicht gerechnet hatten.
Das Init-Skript hat sich kaum verändert. Wir waren auf einen invasiven Umbau des Boot-Pfads gefasst. Dropbear steckte tief in der Art, wie Sandbox-VMs hochkamen. Am Ende schrumpfte das Ganze auf zwei unabhängige, jeweils abgesicherte Blöcke im selben Init-Skript. Der SSH-Block läuft, wenn [ -x /usr/sbin/dropbear ]. Der sandboxd-Block läuft, wenn [ -x /usr/local/bin/zwrm-sandboxd ]. Die Build-Pipeline entscheidet über SandboxRootFSOptions versus LegacyRootFSOptions, welches Binary im Image landet, und das Init-Skript kennt den Unterschied nicht und muss ihn nicht kennen. Dasselbe Init-Skript für Apps, Sandboxes und Postgres, nur mit unterschiedlichen Binaries, die beim Image-Build ins Rootfs injiziert werden.
Der Boot-Fetch musste abbrechbar sein. Der Daemon holt sein Token beim Boot mit exponentiellem Backoff vom Metadata Service, gedeckelt auf 30 Sekunden pro Versuch und ~5 Minuten insgesamt. Die naive Version dieser Schleife blockiert SIGTERM für das volle Budget. Wenn eine VM mitten im Boot suspendiert wird (was Warm-Pool-VMs passieren kann, weil der Snapshot-Flow läuft, bevor die Sandbox geclaimt ist), sind das fünf Minuten Verzögerung beim Herunterfahren. Wir haben das Signal-Handling vor dem Start des Metadata-Fetch installiert und den Signal-Context in die Retry-Schleife gereicht. Das Erste, was die Schleife in jeder Iteration prüft, ist ctx.Err(). Bekommt der Daemon während des Boots ein SIGTERM, gibt fetchTokenWithRetry sofort ctx.Err() zurück. Im Nachhinein offensichtlich. Beim Schreiben nicht.
Das tolerante Boot-Config-Muster ist inzwischen Haus-Stil. Der Daemon liest seine Machine-ID und die Metadata-URL aus zwei Quellen: Umgebungsvariablen, die das Init-Skript setzt, und /proc/cmdline als Fallback. Die erste Fassung war strikt. Nimm die Umgebung, wenn beide Werte da sind, sonst die Cmdline, wenn beide Werte da sind, sonst Fehler. Eine unvollständige Umgebung (etwa MACHINE_ID gesetzt, aber METADATA_URL fehlt, weil der VM-Manager auf dem Host keine konfiguriert hatte und das Init-Skript auf gateway:1338 zurückfiel) galt als harter Fehler. Das ist falsch: Der Aufrufer hatte in beiden Quellen nützliche Informationen, wir haben sie nur nicht zusammengeführt. Die finale Fassung liest beide Quellen, merged Feld für Feld und validiert erst am Ende. Es ist eine Drei-Zeilen-Änderung, und es ist das Muster, zu dem wir jetzt greifen, wann immer ein Binary mehr als eine legitime Quelle für einen Config-Wert hat.
Solltest Du das auch tun?
Wenn Du eine Code-Execution-Sandbox baust, im Stil von Modal, E2B oder einem Code Interpreter, und SSH nutzt, weil es der Weg des geringsten Widerstands war, stell Dir diese zwei Fragen, bevor Du es rausreißt.
Muss der Host Zustand steuern, der Aufrufe überdauert? Umgebungsvariablen. Working Directory. Eine Interpreter-Session. Ein langlaufender Supervisor. Ein dict mit Metadaten pro Aufruf, das Du warm halten willst. Wenn ja, wird SSH Dich bei jedem einzelnen Punkt bekämpfen. Jede Session ist ihr eigener Shell-Prozess, und Zustand trägt nicht weiter. Du wirst am Ende ein Wrapper-Protokoll auf SSH schreiben oder Zustand durch /tmp schmuggeln. An dem Punkt baust Du bereits einen Daemon, also steh dazu.
Wenn nein, wenn jede Interaktion “schicke einen in sich geschlossenen Befehl, hole die Ausgabe, fertig” ist und sich nichts irgendetwas merken muss, ist SSH in Ordnung. Repariere nicht, was funktioniert.
Hast Du Warm Pools oder ein anderes Muster, bei dem die VM existiert, bevor der Nutzer von ihr weiß? Snapshot-Restore, vorprovisionierte Lease-Pools, Golden-Image-Wake-ups, alles, was die VM-Erzeugung von der Nutzerabsicht entkoppelt. Wenn ja, wirst Du den Env-Injection-Bug treffen. Wir haben ihn getroffen. Du wirst es auch. Die Daemon-mit-SetEnv-Form löst ihn sauber, weil der Daemon das eine Ding in der VM ist, das jeden einzelnen Shell-Prozess überlebt.
Wie es weitergeht
Drei Dinge auf der Roadmap, die dieser PR freischaltet:
- Streaming-Exec.
Process.Streamwird real: PTY-Support für interaktive Sessions, stdin-Streaming, Signal-Weiterleitung. Die Grundlage für ein Browser-Terminal gegen eine Sandbox. - Filesystem Watch. inotify auf Sandbox-Seite, verpackt in einen neuen RPC, damit der Host auf Dateiänderungen in der VM reagieren kann, ohne
Statzu pollen. - Daemon-vermittelte Metriken. CPU-, Speicher- und I/O-Statistiken aus dem Inneren der VM, verfügbar über einen
Stats-RPC. Keinensenter-Verrenkungen auf dem Host, keine cgroup-Scraping-Hacks.
Alle drei wären auf SSH aufgesetzt eine Qual. Mit einem Daemon, der uns gehört, sind sie schlichte Erweiterungen eines bestehenden Service: Methode ins Proto, Handler implementieren, Client generieren, ausliefern. Der harte Teil, also Identität, Transport, Connection Pool und Token-Verkabelung, ist schon erledigt.