Site
Alle Artikel

Veröffentlicht 21. Sept. 202619 Min. Lesezeit

Zero Cold Starts für nicht vertrauenswürdigen Code: Wie Vinkius MCP-Server in V8-Isolaten betreibt

Warum V8-Isolates Container für Multi-Tenant-Edge-Code schlagen: Snapshot-Restore in ~15 ms, harte 128-MB-Deckel pro Tenant, SSRF-gesicherter fetch und ein vierstufenes Integritätsmodell für Snapshots, bei dem ein defekter Eintrag ein Cache Miss ist, kein Crash, im Vinkius-Runtime.

Renato Marinho

Von Renato Marinho

Founder · Vinkius

One Node host process: three per-tenant V8 isolates side by side, host-owned effects (fetch, timers, crypto, console) below the bridge, and a four-layer snapshot integrity model.

Jeder MCP-Server, den ein Kunde auf Vinkius Cloud betreibt, ist früher oder später Drittcode, der auf unserer Infrastruktur läuft. Nicht unser Code. Keine Bibliothek, die wir kontrollieren. Der des Kunden. Eine Cloud für AI-Agenten ist nur dann nützlich, wenn Kunden ihre eigene Logik mitbringen können, ihre eigenen Connectors, ihr eigenes Bindeglied zwischen einem LLM und ihren Systemen. Sobald diese Möglichkeit existiert, wird die Frage, auf die kein Produktdeck eine Antwort hat, zum gesamten Engineering-Problem: Wie führt man unvertrauenswürdiges JavaScript für Tausende Tenant auf einer kleinen Zahl von Servern, ohne dass einer von ihnen einen Weg hat, die anderen, die Maschine oder die Nachbarn zu schädigen?

Das ist die Frage hinter der V8-Isolate-Ebene der Vinkius-Runtime, und der Teil der Architektur, über den ich öffentlich am seltensten rede; deshalb schreibe ich ihn hier auf. Im Folgenden geht es darum, wie das System heute funktioniert. Die Zahlen sind die, die der Code durchsetzt, nicht die, wie ich sie mir wünsche.

Warum nicht ein Container pro Server

Die erste Antwort, nach der Multi-Tenant-Plattformen greifen, ist ein Container pro Server. Sie ist sauber, sie ist verständlich, und sie liefert echte Isolation auf Kernel-Ebene. Genau dafür habe ich einen großen Teil der Designzeit verbracht.

Sie ist an der Dichte gestorben. Vinkius-Cloud-Kunden verbinden MCP-Server so, wie sie in jeder SaaS Integrationen verbinden: Dutzende pro Workspace, billig im Anlegen, billiger im Löschen, die meisten davon Tage lang idle. Ein Node.js-Prozess, der eine echte Runtime hochgefahren hat (Framework, SDK, Polyfills, Connection-Pool), trägt eine feste Kostenlast von mehreren Dutzend Megabyte, bevor er nützliche Arbeit leistet. Multipliziert man das mit einigen hundert Servern auf einer einzigen Task, kämpft man gegen die Memory-Obergrenze der Task, bevor ein einziger Tool-Call stattgefunden hat. Multipliziert man das mit Cold Starts, hat man eine Plattform gebaut, auf der das Hochfahren des eigenen Servers Hunderte von Millisekunden dauert.

Die zweite naheliegende Antwort sind viele Prozesse in einem Container, ein Fork pro Tenant. Das ist schlechter. Man tauscht die Kernel-Grenze gegen nichts: Prozesse haben weiterhin je einen eigenen Heap, eigene Pagetabellen und einen eigenen Garbage Collector, und ein Crash in einem Prozess kann über geteilte File-Deskriptoren und den Init-Prozess immer noch die Geschwister mitreißen. Man hat genau so viel Speicher ausgegeben wie bei der Container-Antwort und erhält im Gegenzug ein schlechteres Versagensmuster.

Auch den Mittelweg habe ich durchgerechnet: ein Node-Prozess pro Workspace, geteilt über die Server eines Tenants. Er stößt an dieselben zwei Wände. Die fixe Footprint einer hochgefahrenen Node-Runtime ist eine Kostenstelle pro Tenant, ob man sie teilt oder nicht, und ein Memory-Leak oder eine Fork-Bomb eines Workspaces nimmt alle Server dieses Workspaces mit sich. Der Schadensradius lokalisiert sich nicht.

Was Cloudflare mit Workers gezeigt hat, ist die dritte Option: kein Prozess pro Tenant, kein Container pro Tenant, sondern ein Isolate. V8 wurde gebaut, um in einem einzigen Browser-Prozess viele Programme auszuführen, denen man nicht vertraut; das ist exakt die Form unseres Problems, denn ein Browser-Tab ist ein Tenant. Die Workers-Runtime stützt sich auf dieselbe Primitve: ein Isolate pro Request, wiederhergestellt aus einem V8-Snapshot; der Cold Start, über den man sich beschwert, ist in Wirklichkeit nur ein Heap-Restore. Die Plattform von dort habe ich nicht übernommen, und ich kann es auch nicht: Unser Gateway lebt lange und ist stateful auf eine Weise, die eine Page Function nicht ist, und wir betreiben unsere eigene Fleet auf AWS, wo ich den Upgrade-Takt kontrolliere. Die Idee habe ich übernommen und die Host-Seite davon auf Node.js mit isolated-vm gebaut, dem C++-Binding, mit dem ein Node-Prozess ein rohes V8-Isolate besitzen kann.

Diese eine Entscheidung („ein Tenant ist ein Isolate, kein Prozess“) ist der Ursprung für den Rest der Runtime. Was folgt, ist eine Konsequenz daraus.

Was ein Isolate tatsächlich liefert

Ein Isolate ist ein Heap: eine von einem Garbage Collector verwaltete Speicherregion mit eigener Microtask-Queue, eigenen Globals und einer harten Obergrenze, die man bei der Erstellung festlegt. Das Isolate, das isolated-vm für einen Tenant anlegt, erhält 128 MB. Das ist keine weiche Quote, die wir in Application-Code durchsetzen und als Warnung loggen; das ist eine memoryLimit, die der Engine übergeben wird. Trifft der Heap eines Tenants die Grenze, wird das Isolate dieses Tenants nicht mehr bedient. Die anderen Tenant laufen weiter. Das globalThis eines Tenants ist aus dem Isolate eines anderen nicht erreichbar, denn es gibt gar keine gemeinsame JavaScript-Grenze. Der einzige Verkehr, der die Grenze überschreitet, ist das, was wir explizit bridgen; und das ist fast nichts.

Scheduling-Kosten sind höher, als man denkt. Das Erzeugen eines Prozesses ist ein Kernel-Event; ein Fork braucht Pagetabellen-Setup, ein exec und das Aufwärmen des Garbage Collectors. Das Umschalten zwischen Isolates in einem Prozess ist ein Zeigeraustausch. Deshalb kann Cloudflare bei Cold Starts neben sie das Wort „null“ setzen, und deshalb wird das Latenzminimum unserer Edge-Server in Millisekunden gemessen, nicht im Bereich von 200 bis 400 ms, in dem ein frischer Node-Boot üblicherweise landet.

Die Lifecycle-Ebene formuliert die Isolationseigenschaft direkter, als ich es könnte: ein Runner pro Connection-Token, kein Teilen zwischen Tokens. Die Isolation der Credentials ist absolut. Der Runner, der eine Verbindung besitzt, besitzt sein Isolate, seinen Secrets-Snapshot und seine Timer, und nichts im Prozessgraphen reicht darüber hinweg.

Der Preis: Ein Isolate ist eine leere Umgebung. V8 liefert keine Standardbibliothek mit. Es gibt kein fetch, keinen TextEncoder, keine console, kein setTimeout, kein process, keinen Buffer. Die erste echte Arbeit der Runtime ist daher nicht, Tenant-Code auszuführen. Sie ist, die Umgebung zu bauen, in der Tenant-Code läuft.

Die leere Umgebung

Das Hochfahren eines Tenant-Isolates beginnt mit dem, was wir die Life-Support-Schicht nennen: ein zusammengeführtes Bundle aus Polyfills, die die Web- und Node-API-Oberfläche wiederherstellen, die Tenant-Code erwartet. Die Lade-Reihenfolge ist gewollt, denn jede Schicht hängt von der darunter ab. Zuerst die Console- und Process-Shims, weil die darüber liegenden Polyfills loggen und process inspizieren. Dann TextEncoder und TextDecoder mit UTF-8- und Surrogate-Pair-Unterstützung, denn Emojis in Tool-Argumenten sind kein Edge Case. Dann URL und die vollständige URLSearchParams mit 12 Methoden (der Changelog-Eintrag lautet „axios, got, and @octokit now work correctly in edge bundles“, und genau darum ging es). Dann Crypto, Timer und der Netzwerk-Stack: Headers, fetch und ein rein asynchrones XMLHttpRequest-Shim, damit die alten HTTP-Bibliotheken, die man in Connectors einfügt, nicht an xhr is not defined sterben.

Der Life-Support-Stack: Polyfill-Ebenen der Guest-Seite links, dem Host zugeordnete Effekte rechts, mit der Bridge dazwischen

Der Punkt ist, was real ist und was delegiert wird. Nichts, was die Außenwelt berührt, existiert innerhalb des Isolates. fetch in einem Tenant-Bundle ist ein Polyfill auf der Guest-Seite, aber der eigentliche Request findet host-seitig statt, über __host_fetch, als C++-Kopie eines ArrayBuffer über die Grenze: binary-safe, damit ein Bild oder eine Gzip-Antwort nicht in UTF-8-Quatsch verwandelt wird. crypto.getRandomValues ist randomBytes auf der Host-Seite, begrenzt auf 64 KB. crypto.subtle.digest und HMAC, was die JWT-Verifikation braucht, liegen ebenfalls host-seitig, und die Host-Seite prüft Signaturen mit Constant-Time-Vergleich. Timer sind setTimeout-Aufrufe auf der Host-Seite, die über einen registrierten Invoker in den Guest zurückrufen, begrenzt auf 30 s. Die Regel dahinter: Das Polyfill lässt den Aufruf nativ aussehen, und der Host besitzt den Effekt. Ein Tenant kann Absicht ausdrücken. Das Betriebssystem erreicht er nicht.

Argumente und Ergebnisse überschreiten die Grenze per strukturiertem Klonen (copy: true in der isolated-vm-API) statt JSON.stringify. Die Serialisierung zahlt man einmal, in C++, und man behält echte ArrayBuffer-Instanzen und typisierte Objekte auf beiden Seiten. Man merkt das erst, wenn man einen Tool-Call profilt, der eine Last von 1 MB bewegt: Der JSON-Roundtrip war nach dem Netzwerk der zweitkostbarste Anteil.

Auf der Compile-Seite erreicht Tenant-Code die Runtime nie als Source. Die Bundle-Pipeline nimmt die Server-Spec entgegen, erzeugt den Einstiegspunkt und lässt ihn durch esbuild laufen (IIFE, browser-Plattform, ES2022-Ziel, minifiziert); was im Isolate landet, ist eine flache Datei ohne dynamische Imports. Ein Sanitizer der statischen Analyse lehnt die Ausweichwege ab, bevor das Bundle akzeptiert wird: direkter Bridge-Zugriff auf __host_*, Schleichwege über globalThis[...] in Klammer-Notation, Bypass-Muster über Function.constructor. Bundles werden ggezippt und per SHA-256 gehasht. Die API erlaubt bis zu 1,5 MB rohes Bundle, und die Runtime setzt ein Entpacklimit von 2 MB durch, mit einem streamenden Byte-Zähler, der abbricht, bevor eine GZIP-Bomb den Heap füllen kann. Ein Detail, für das ich stehe: Das kompilierte Bundle läuft einmal in einem Wegwerf-Isolate, dessen Bridges alle gestubbt sind, und genau dieser Lauf extrahiert das Tool-Manifest. Das Programm wird zur Compile-Zeit bezahlt, nicht zur Request-Zeit.

Und hier ist der Teil, den Entwickler nie sehen. Das Bundle weiß nicht, dass es in der Cloud ist. Derselbe startServer()-Aufruf, der auf dem Laptop eines Entwicklers einen Server über stdio hochfährt, erkennt das __vinkius_edge_interceptor-Global der Runtime, übergibt seine Tool-Definitionen über diesen Interceptor und überspringt die normale Transport-Initialisierung. Eine Codebase, zwei Runtimes: Man entwickelt lokal mit dem Framework und deployt in die Cloud, ohne eine einzige if (inCloud)-Abzweigung.

Das Protokoll-Versioning reist mit der Runtime, nicht mit dem Bundle. Das Gateway spricht die stateless-Release-Variante von 2026-07-28 als Primärdialekt und verhandelt abwärts über den 2025-Release und den SSE-Transport von 2024-11-05 als Fallbacks; ein Jahr alter Client spricht damit weiterhin mit einem Server, der heute Morgen ausgeliefert wurde. Der Dialekt, auf dem eine Session landet, ist ein Handshake, keine Deploy-Entscheidung.

Warum der Guest nie auf das Netzwerk trifft

Das Gefährlichste, das ein Tenant-Bundle tun kann, ist, einen HTTP-Request zu stellen. fetch ist eine universelle Quelle für SSRF: Einem unachtsamen oder bösartigen Bundle, das ein Tool auf http://169.254.169.254/, den AWS-Metadata-Service, richtet, gibt man damit den Lesepfad zu den Credentials der Instanz in die Hände des AI-Agenten eines Kunden. Deshalb läuft aller ausgehende Verkehr durch den SSRF-Schutz, und der Schutz arbeitet nach einem einzigen Prinzip: Eine Adresse ist nur so lange gültig, wie die Policy, die sie freigegeben hat. Er löst DNS vor dem Request auf und blockt private Bereiche kategorisch (Loopback, 10/8, 172.16/12, 192.168/16, Link-Local 169.254/16, was der Metadata-Service ist, 0/8 und die IPv6-Gegenteile). Er pinnt dann die freigegebene IP in die undici-Verbindung, damit ein Rebinding-Angriff die Adresse nach der Freigabe nicht mehr tauschen kann. Und er hält SNI und TLS zur gepinnten Adresse ausgerichtet, denn Pinning auf Socket-Ebene ohne Pinning des Namens würde bei jedem Request CERT_ALTNAME_INVALID werfen. Der DNS-Cache ist auf 4096 Einträge begrenzt und lebt nicht länger als die gepoolte Verbindung, zu der er gehört.

Die Keep-Alive-Einstellung auf dem gepoolten Agenten ist die langweiligste Fix, die ich je geshippt habe, und ich bin froh, sie aufzuschreiben. Die Standard-Idle-Timeout von undici beträgt 4 s, und sie schloss im Hintergrund gepoolte Sockets zwischen Gesprächsturns; so zahlte nahezu jeder echte Tool-Call einen kalten TLS-Handshake. Wir laufen mit 65 s, knapp über dem Idle-Fenster der ALB von 60 s, und begrenzen den Pool auf 100 Sockets pro Ziel. Das p50 der ausgehenden Aufrufe fiel um die Kosten eines Handshakes. Eine langweilige Fix mit p50 ist der Traum.

Antworten streamen in das Isolate, mit einem laufenden Byte-Zähler und einer harten Grenze bei 10 MB, und einem AbortController, der den gesamten Dispose-Pfad durchzieht: Wird ein Isolate stillgelegt, werden seine in-flight-Requests im selben Aufruf abgebrochen. Zeit ein Dispatch aus (das Budget von 30 s), ist der Fehler nicht ein generisches „Request dauerte zu lange“. Ein Classifier fragt, was das Isolate im Moment der Überschreitung tat: Upstream-I/O in flight, der Guest in Berechnung, oder Memory-Druck? Die tool_error, die zum Agenten zurückgeht, trägt die Antwort plus einen Hinweis auf Wiederherstellung. Die Zuordnung eines Fehlers (die Upstream-API des Tenants, der Code des Tenants oder die Plattform) klärt man nicht, indem man um drei Uhr früh in Logs grept.

Die Ausgabe dieses Classifiers füttert einen Ring der Upstream-Zuordnung mit 32 Slots, neuester zuerst. Das Betriebsbild zeigt dann nicht nur, dass Dispatches fehlschlagen, sondern wessen Upstream. Schlägt die CRM-API eines Tenants um zwei Uhr morgens aus, weiß die Runtime, dass es die CRM des Tenants ist und nicht die Plattform, und der Ring hält den Beleg so lange, dass der Incident-Kanal ihn lesen kann.

Einmal provisionieren: das Snapshot-Prinzip

Ein voller Boot (Bundle kompilieren, Polyfill-Schicht ausführen, IIFE ausführen) kostet beim ersten Kontakt mit einem Deploy etwa 50 bis 100 ms. Für den ersten Request ist das in Ordnung. Nicht für Request 4000, den in Wahrheit jeder weitere ist.

Das Prinzip: Eine Umgebung und ein Programm sind verschiedene Artefakte, und nur eines davon ändert sich häufig. Die Polyfill-Schicht ist für ein gegebenes Deploy statisch: gleiche V8-Version, gleiche Host-Oberfläche. Was pro Request wechselt, ist der Tenant-Code. Also snapshoten wir die Umgebung, nicht das Programm. Die Host-Seite fährt den Snapshot-Builder von isolated-vm über die provisionierte Umgebung und cacht das resultierende Heap-Blob auf Platte, mit der Deploy-ID als Schlüssel und gestempelt mit der V8-Version, unter der es gebaut wurde. Beim Restore wird aus dem Blob ein frisches Isolate erzeugt: ein bis zwei Millisekunden für die Engine, eine bis zwei weitere, um die gestubbt Bridges durch echte zu tauschen, und die IIFE selbst führt sich im wiederhergestellten Kontext aus. Summe: 15 bis 25 ms gegenüber 50 bis 100 ms beim vollen Boot, und die Polyfill-Arbeit passiert exakt einmal pro Deploy. Das Bundle wird neu ausgeführt, nicht gesnapshotet, denn Isolate.createSnapshot() ist eine statische Methode, die keinen asynchronen Code ausführen kann, und unsere IIFEs dürfen top-level await nutzen.

Voller Boot versus Snapshot-Restore: wo die 50 bis 100 ms landen und woher die 15 bis 25 ms kommen

Die harte Seite von Snapshots ist nicht die Geschwindigkeit. Es ist der Fehlermodus. Ein Deserialisierungsfehler in V8 ist ein SIGABRT: Der Prozess stirbt, JavaScript kann ihn nicht fangen, und ein korrupter Blob auf Platte ist ein Crash-Loop, die nur darauf wartet. Restore, Abort, Restart, derselbe Blob wird wiederhergestellt, erneut Abort. Deshalb behandelt das Integritätsmodell den Cache wie nicht vertrauenswürdige Eingabe. Vier Ebenen, in dieser Reihenfolge: Schreibvorgänge sind atomar (der Blob landet in einer .tmp-Datei und wird umbenannt, zuerst die Metadaten, sodass ein Kill mitten im Schreiben ein nachweisbar nicht zusammenpassendes Paar hinterlässt und keinen halben Blob); jeder Blob wird per SHA-256 gehasht, und der Hash wird geprüft, bevor die Bytes V8 erreichen; der V8-Versionstempel macht den Cache ab dem Moment automatisch ungültig, in dem Node auf der Fleet aktualisiert wird; und beim Prozessstart validiert ein Purge-Pass die gepochten Snapshots auf Platte und löscht die fehlerhaften. Die Design-Invariante unter all dem: Ein korrupter Cache-Eintrag degradiert zu einem Cold Boot von 100 ms. Nie zu einem Crash. Das ist der gesamte Vorfall: 80 ms mehr auf einem Request.

Die Snapshot-Ebene fungiert zugleich als Hibernation. Stateful-Bundles exponieren getState- und setState-Hooks mit einem Serialisierungsbudget von 1 s; ein Tenant, der einen Arbeitsbestand in Memory hält, kann damit seinen Zustand extrahieren lassen, sein Isolate stilllegen und seinen Zustand beim nächsten Request injiziert bekommen. Dieselbe Maschine, umgekehrte Richtung.

Invarianten: Was eine Phase der nächsten schuldet

Die Ebene oben ist nur so ehrlich wie ihre Invarianten, und Invarianten verrotten, wenn sie in Köpfen leben. Der Runner trägt deshalb einen schriftlichen Vertrag: 34 Regeln über seine 4 Phasen (Boot, Snapshot, Dispatch, Dispose), neben dem Code, den sie regieren, und gelesen bei jeder Review, die diese Datei berührt. Die Phasen sind der Lifecycle; die Regeln sind, was eine Phase der nächsten schuldet. Die meisten davon sind „Nein“-Regeln. Keine Bridge im Snapshot. Kein Host-Timer, der länger lebt als sein Isolate. Kein Dispatch, das die Timeout-Zuordnung überspringt.

Beim Dispose zahlt sich der Vertrag aus, denn Dispose ist die eine Phase, in der eine falsche Reihenfolge etwas ausleckt. Das Stilllegen eines Isolates ist eine feste Sequenz mit 5 Schritten: Zuerst den AbortController abbrechen, damit ausstehendes HTTP mit dem Abort stirbt; die Host-Timer löschen, damit der Guest sich nicht selbst neu planen kann; die Referenz-Handles von isolated-vm in umgekehrter Allokations-Reihenfolge freigeben; Kontext und Isolate freigeben; und erst dann die letzte JavaScript-Referenz auf null setzen, damit nichts einen toten Heap an den Prozess pinnt. Der Begriff dahinter ist Ownership-Transfer: Keine Bridge darf länger leben als das Isolate, auf das sie zeigt. Man überspringt einen Schritt, und man erhält einen übrigbleibenden Heap, einen Timer, der in eine Leiche feuert, oder einen Request, der seinen Besitzer überlebt. Die Reihenfolge ist tragend, und der Vertrag sagt es so auf dem Papier.

Data-Loss-Prevention bekommt am anderen Ende des Lifecycle dieselbe Behandlung. Bevor eine Last den Tenant-Code erreicht, durchläuft sie einen Redaktions-Durchgang, der beim Boot geladen wird und nicht lazy: Eine Kontrolle, die noch nicht geladen ist, existiert nicht. Credentials in Bewegung und alles, was wie ein Secret aussieht, werden maskiert, bevor sie eine Logzeile oder ein Bridge-Argument berühren. Eine DLP im Tenant-Code ist eine DLP, die der Tenant abschalten kann; eine DLP auf der Host-Seite der Bridge nicht.

Containment per Konstruktion

Die Limits in der Runtime sind keine Wand aus magischen Zahlen. Sie leben in einer einzigen Datei, Limits.ts, die neben jeder Konstante dokumentiert, woher die Zahl kommt. Das Dispatch-Budget von 30 s kommt aus dem Nutzerverhalten: Mainstream-MCP-Clients geben bei einem Tool-Call in einem Fenster von 30 bis 60 s auf, also geben wir jenseits von 30 s Ressourcen für ein Ergebnis aus, das niemand liest. Das Keep-Alive von 65 s spiegelt das Idle-Fenster der ALB. Der Sweep-Horizont gehört zum Idle-Tier von 30 min des Connection-Trackers. Eine Grenze ist der Fußabdruck einer Policy, nicht eine Konstante: Wenn sich die Policy ändert, ändert sich eine Datei, und die Herleitung bleibt im Dokument.

Credentials folgen derselben Disziplin. Die entschlüsselte Secret-Map eines Tenants wird als Deep Copy auf __vinkius_secrets in das Isolate injiziert: Der Guest liest sie, er kann nichts zurück auf den Host schreiben. Der Runner hält einen SHA-256-Fingerprint der Map, nicht der Werte; das macht eine Re-Injektion bei einem späteren Request einen billigen No-Op, wenn sich nichts geändert hat. Jedes Token bekommt seinen eigenen Runner und sein eigenes Isolate; es gibt kein Pooling, das Credential-Zustand zwischen Tenants teilt. Punkt.

Die einzige Token-Form, die ein Isolate erreicht, ist das Live-Connection-Token, alles mit dem Präfix vk_live_. Preview-Tokens, widerrufene Tokens, Tokens für ein anderes Deploy: Sie scheitern an der Runner-Grenze, bevor eine Bridge registriert ist. Die eine Fähigkeit, die der Guest hat, ist VINKIUS_TOKEN, exponiert dem Tool-Code des Catalogs: Es authentifiziert ein Tool zurück zur Plattform als der Nutzer, dem es gehört, begrenzt auf den Catalog dieses Nutzers. Das Modell ist Capabilities, nicht Passwörter: Jedes Überschreiten der Grenze ist ein benanntes, auditierbares, widerrufbares Recht.

Was ein Bundle nicht darf, ist genauso positiv dokumentiert wie das, was es darf. Kein Filesystem, kein process.env, kein direkter Bridge-Zugriff (der Sanitizer lehnt ihn zur Compile-Zeit ab), 128 MB Heap, Timer mit 30 s Obergrenze, 10 Logs pro Sekunde und 1 KB pro Nachricht über die Console-Bridge, ein Fetch-Limit von 10 MB. Kreuzt ein Tenant die finanzielle Linie, antwortet der Circuit Breaker nicht mit einem 429 und Schulterzucken. Die Quota-Ebene gibt einen AI-nativen Fehler aus: Klare Sprache, die dem LLM sagt, aufzuhören mit Retries und dem menschlichen Nutzer die Budget-Obergrenze zu zeigen, mit einem Link in die Vinkius-Cloud-Konsole, wo der Besitzer die Fortsetzung freigibt. Ein Retry-Storm ist der native Fehlermodus von Agenten; die Plattform sollte den Agenten beantworten, nicht nur den Menschen dahinter.

Was die Plattform besitzt

All dies läuft auf einer kleinen, bewusst x86-only gehaltenen Fleet. Das x86-Pin ist eine Einschränkung, für die ich schriftlich stehe: isolated-vm ist ein natives Addon, und seine ARM-Builds waren die unstetigste Abhängigkeit, die wir shippen. Auf x86 ist es langweilig, und langweilig ist das Ziel in der Ebene, in der der Code anderer Leute läuft.

Der Process startet leer. Beim Start wird keine Konfiguration geladen; die erste Verbindung für ein Token zieht die Serverkonfiguration on demand aus der Laravel-API, fährt das Isolate dieses Servers just in time hoch, und ein LRU-Sweep stellt Einträge nach 30 min Idle still, sodass eine Task den Live-Zustand vieler Tenants hält. Redis trägt die Control Plane (Invalidation, Kill-Switches, Tool-Change-Benachrichtigungen, Quota-Updates), sodass eine Konfigurationsänderung in der Konsole alle Tasks ohne Deploy erreicht.

Ein letztes Stück der Security-Story liegt neben den Isolates, und ich schließe damit ab, weil es die Grenze zeigt, die wir bewusst gezogen haben: der kryptografische Audit-Pfad. Jedes Tool-Call-Event fließt in einen single-threaded Streaming-Daemon, der eine SHA-256-Hashkette schmiedet und sie mit einem Ed25519-Session-Schlüssel signiert, der 24 h in RAM gehalten wird und beim Boot durch den Master-Key zertifiziert ist. Nach einem Crash verarbeitet der Daemon nicht-ACKte Events erneut, damit die Kette nie eine Lücke aufweist. Jeder geschmiedete Link wird in eine Redis-Streams-Consumer-Gruppe gecheckpointet, sodass ein Crash beim letzten ACKten Event weiterfährt, statt die Kette neu abzuleiten, und SIEM-Adapter leeren denselben Stream hinter ihren eigenen Circuit-Breakern: Ein fehlerhafter Sink wird abgegrenzt, statt den Audit-Pfad aufstauen zu lassen. Die Regel, die den Daemon nötig machte, passt in eine Zeile Source: V8 berührt nie Crypto. Tenant-Code kann über die Host-Bridges einen Hash berechnen, aber er kann nichts signieren, zertifizieren oder schmieden. Die Audit-Spur ist etwas, das die Plattform der Runtime antut, nicht etwas, auf das die Tenant der Runtime zugreifen können.

Richtung

Die Isolate-Ebene heute ist die stateless, schnelle, hart begrenzte Story: Boot in ein bis zwei Millisekunden, Cap bei 128 MB, Stilllegen in einem Sweep, und der Snapshot trägt den schweren Teil. Die offene Richtung ist die stateful: längerlebender Tenant-Zustand auf den Hibernation-Hooks, dichteres Packen der live Tenants Isolates pro Task, und irgendwann Teams, die ihre eigenen Connector-Catalogs in den Marketplace bringen, ohne auf unsere CI zu warten.

Ich glaube nicht, dass V8-Isolates die finale Antwort auf Multi-Tenant-Edge-Code sind. Sie sind die Antwort für die Form, die diese Plattform hat, und so soll die Form bleiben: billige Tenants, harte Caps, und ein Boot in Millisekunden, denn die Geduld eines Agenten wird in Sekunden gemessen, und sein Vertrauen auch. Cloudflares „Zero Cold Start“ ist ein Satz, den man in Engineering umsetzen kann, kein Slogan, den man nur bewundern kann. Er ist ein Isolate, ein Snapshot, eine Integritätskette und eine Reihe unglamouröser Entscheidungen darüber, wem die Timer gehören. Der Code in diesem Post ist die Runtime hinter den MCP-Servern auf cloud.vinkius.com, und die Tracing-Arbeit, die ihre Tool-Spans in Distributed-Traces einbindet, steht in dem MCP-Fusion-5.1.0-Artikel. Die stateful-Richtung, in die sich diese Plattform bewegt, ist die Session-Ebene selbst: wie der SwarmGateway überlappende Agent-Sessions konsolidiert und kausale Invalidierung auf veralteten State anwendet. Die vollständige Architektur ist im SwarmGateway-Sessions-Artikel.

Die V8-Isolate-Sandbox und ihre 12-stufige Boot-Sequenz werden mit Quellcode in Unternehmens-KI-Fragen behandelt.

Themenv8sandboxingruntimemcpedge