Tausend untätige Apps pausieren: KEDA, CRIU und der cgroup-v2-Freezer
Ein KI-App-Builder hat ein brutales Leerlaufprofil. Nutzer erzeugen Apps so, wie sie Browser-Tabs öffnen: enthusiastisch, im Dutzend, und dann sind sie weg. Zu jedem gegebenen Zeitpunkt tut die überwältigende Mehrheit der Apps auf der Plattform gar nichts – keine Requests, keine Nutzer, kein Grund, als laufende Prozesse zu existieren. Aber jede einzelne ist ein Vite-Dev-Server plus ein Node-Prozessbaum, der ein paar Hundert Megabyte RAM festhält, und der Besitzer erwartet, dass sie genau da ist, wenn er zurückkommt.
Die Anforderung ist also zweiseitig, und die Seiten kämpfen gegeneinander:
- Eine untätige App muss ~null kosten. CPU und, entscheidend, RAM – Speicher ist die knappe Ressource auf einem Node, der mit Hunderten von Namespaces vollgepackt ist.
- Das Aufwecken muss sich sofort anfühlen. Der Nutzer hat auf seinen Preview-Link geklickt. Ein Spinner, der 30 Sekunden läuft, liest sich als „meine App ist kaputt“, nicht als „meine App hat geschlafen“.
Um beidem gerecht zu werden, haben wir eine grundlegende Entscheidung und drei Laufzeit-Architekturen durchlaufen. Die Entscheidung – Container, nicht VMs – steckte den Optionsraum ab; die drei Architekturen waren echte Implementierungen, die auf der Plattform liefen, und jeder Fehlschlag engte den Raum weiter ein, bis nur noch eine Form übrig war. Diese Abfolge ist es wert, erzählt zu werden, weil die Lektion sich verallgemeinern lässt: Der günstigste Weg, etwas keine Ressourcen mehr kosten zu lassen, ist, es gar nicht erst zu stoppen.
VMs oder Container: die erste Weggabelung
Jede Plattform, die fremde Workloads ausführt, steht genau einmal vor dieser Frage, und die Antwort prägt alles Nachgelagerte. Die modische Antwort 2026 ist die microVM – die Firecracker-/Fly-machines-Form: Jede App bekommt eine echte virtuelle Maschine mit eigenem Kernel, on demand gebootet, bei Leerlauf zerstört. Das ist eine respektable Architektur, und es lohnt sich, präzise zu sein, was sie einbringt, denn wir haben uns bewusst von ihr abgewandt.
Was VMs wirklich bieten: eine hardwaredurchgesetzte Isolationsgrenze. Ein Guest-Kernel-Panic, ein Container-Escape-Exploit, ein Angriff zur Erschöpfung von Kernel-Ressourcen – all das endet am Hypervisor. Wenn deine Tenants beliebige feindselige Binaries hochladen, ist diese Grenze nahezu nicht verhandelbar.
Was VMs kosten, speziell für diesen Workload:
- Dichte ist das Geschäftsmodell, und RAM teilt sich nicht über Kernel hinweg. Tausend größtenteils untätige Apps auf einem Node funktionieren nur, wenn ihr Speicher das Problem eines einzigen Kernels ist. Jede microVM trägt ihren eigenen Guest-Kernel, ihren eigenen Page Cache, ihre eigene Kopie jedes Shared-Library-Mappings mit sich – Speicher, den ein einziger gemeinsamer Kernel über Container hinweg kostenlos dedupliziert. Wenn die knappe Ressource RAM ist und die Flotte zu 95 % im Leerlauf, bedeutet eine Kernel-Steuer pro App, genau dort zu zahlen, wo es weh tut.
- Die microVM löst den falschen Cold Start. Firecrackers 125-ms-Boot ist ein Wunderwerk, und für unseren Workload ist er ein Rundungsfehler. Die Aufweckzeit einer generierten App wird vom Aufwärmen des Guests dominiert: Node-Start, Vites Dependency-Scan, der Modulgraph, der JIT. VM-pro-App zahlt für die Hypervisor-Orchestrierung und verschlingt trotzdem die 10–30 Sekunden, auf die es tatsächlich ankommt. Das Cold-Start-Problem, das wir lösen mussten, sitzt über dem Kernel, wo ein Hypervisor nicht hinkommt – aber, wie wir sehen werden, der Kernel kommt hin.
- Der Agent schreibt in ein lebendiges Dateisystem. Das ist einzigartig für einen KI-App-Builder, und es war entscheidend. Generierung und Preview teilen sich ein Read-Write-Volume (
/data/projects): Der Agent schreibt eine Datei, das Vite des laufenden Pods nimmt sie per HMR auf, der Nutzer sieht die Änderung – kein Rebuild, kein Image, kein Deploy. Ein Modell mit ephemeren VMs würde bedeuten, bei jeder Iteration Images zu backen oder Dateisysteme zu synchronisieren, was die schnellste, meistausgeführte Schleife der Plattform – Agent schreibt Code, Nutzer sieht ihn – zur langsamsten macht. - Kubernetes ist eine Lifecycle-Maschine, die wir nicht bauen mussten. Ein Namespace pro App gibt uns Quotas, Limit Ranges, Secret-Injection, Ingress-Routing, RBAC und einen Watch-Stream für Echtzeit-Status – die gesamte Day-2-Operations-Oberfläche – als Konfiguration statt als Code. Die VM-Welt hat auch Orchestratoren, aber keinen mit dieser Tiefe an kostenlosem Ökosystem.
Und das Argument, das erst später sichtbar wurde, das, auf das dieser ganze Beitrag hinausläuft: Ein Container ist eine cgroup, und eine cgroup ist etwas, mit dem der Kernel arbeiten kann. Sie einfrieren, wiegen, auslagern, auftauen – einheitlich, über jede App auf dem Node, mit Datei-Writes. Eine VM ist für den Host-Kernel per Design undurchsichtig; die Isolationsgrenze, die den Workload schützt, schottet ihn auch genau von jener flottenweiten, kernel-vermittelten Ressourcen-Chirurgie ab, die die Ökonomie von Tausenden untätiger Apps überhaupt erst funktionieren lässt. VM-Suspend/Resume existiert, aber tausend suspendierte Memory-Images zu verwalten, ist ein Storage-Subsystem, das du baust und betreibst; tausend eingefrorene cgroups zu verwalten, ist die vorhandene Swap-Maschinerie des Kernels, die ihre Arbeit tut.
Der Kompromiss, den wir eingegangen sind, ist real, und wir tragen ihn bewusst: Ein gemeinsamer Kernel ist eine schwächere Isolationsgrenze als ein Hypervisor. Für diese Plattform ist er begrenzt – jede App läuft in ihrem eigenen Namespace unter ResourceQuota und LimitRange, nichts läuft privilegiert, und die Workloads sind agentengenerierter App-Code, der unter den Verträgen der Plattform selbst operiert, keine beliebig hochgeladenen Binaries. Und er ist revidierbar, ohne neu zu architekturieren: Kubernetes’ RuntimeClass macht gehärtete Sandboxes (im gVisor-Stil) zu einem Austausch pro Workload, nicht zu einem Plattform-Rewrite. Es wurden Container. Die Frage wurde: Was bedeutet „Leerlauf“ für einen Container?
Experiment 1: Scale-to-Zero (KEDA)
Die erste Antwort war die orthodoxe. Leerlauf bedeutet null Replicas. Wir haben KEDA mit seinem HTTP-Add-on ausgerollt: ein HTTPScaledObject pro Projekt, der Interceptor im Request-Pfad, Scale-to-Zero nach einer Cooldown-Phase, zurück auf eins beim nächsten HTTP-Request.
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
spec:
hosts:
- {projectId}.{previewDomain}
scaleTargetRef:
name: app
kind: Deployment
service: web
port: 80
replicas:
min: 0
max: 1
scaledownPeriod: 300
Es funktionierte – in dem Sinne, dass untätige Apps nichts verbrauchten. Und es brachte uns die Beschränkung auf die harte Tour bei: Scale-to-Zero vermischt „aufhören zu kosten“ mit „aufhören zu existieren“. Wenn die Replica-Zahl null erreicht, ist der Pod weg, also ist das Aufwecken ein voller Cold Start – Pod scheduln, Image prüfen, Volumes mounten, Node starten, Vite den Dependency-Graph durchlaufen lassen. Für unseren Workload waren das 10–30 Sekunden pro Aufwecken. Jedes einzelne Mal. Das Datenbank-Äquivalent dieses Problems bekam seinen eigenen Rust-Proxy und einen Aufweck-Tanz mitten in der Verbindung; für den App-Pod gibt es kein Wire-Protocol zum Parken – der Nutzer starrt auf einen Browser-Tab.
Die sekundären Reibungspunkte waren ebenfalls lehrreich:
- Der Autoscaler kämpft gegen manuelles Lifecycle-Management. Wenn ein Nutzer explizit auf Resume klickte, skalierten wir das Deployment 0→1 – und KEDA, das noch keinen Traffic sah (der Nutzer hatte die Preview nicht geladen, weil sie nicht oben war), skalierte es sofort zurück auf 0. Die Lösung war Choreografie: das
HTTPScaledObjectlöschen, bevor man hochskaliert, eine Grace-Periode abwarten, während der Interceptor Traffic registriert, dann neu erstellen. Jedes Design, bei dem man das System vorübergehend abschalten muss, damit das System einen nicht rückgängig macht, ist ein Design, das eine Beschwerde über sich selbst einreicht. - Der Interceptor sitzt im Daten-Pfad. Jeder Request an jede Preview – einschließlich der heißen, wachen – floss nun durch einen zusätzlichen Proxy-Hop, der nur für den seltenen Cold-Fall existierte.
- Der Wake-Zustand sickerte ins Frontend. Keepalive-Pings sind für ein schlafendes Projekt deaktiviert (das ist ja der Sinn), also musste das UI das Polling für den Health-Übergang nach einem Resume gesondert behandeln und veraltete Preview-Tokens auffrischen, die gegen die vorherige Inkarnation des Pods ausgestellt worden waren.
Das Urteil war nicht „KEDA ist schlecht“ – es ist gut in dem, wofür es da ist. Das Urteil war, dass Pod-Löschung das falsche Primitiv für einen Workload ist, dessen warmer Zustand (ein JIT-aufgewärmter Node-Prozess, Vites Modulgraph) teuer wiederaufzubauen und kostenlos zu behalten ist. Wir brauchten einen Prozess, der seinen eigenen Winterschlaf überlebt.
Experiment 2: Checkpoint/Restore (CRIU)
Wenn der Prozess überleben muss, ist die nächste Idee für jeden offensichtlich, der über Live-Migration gelesen hat: CRIU – Checkpoint/Restore In Userspace. Den gesamten Prozessbaum auf die Platte snapshoten (Memory-Pages, File Descriptors, TCP-Verbindungen, alles), den Container killen und ihn später exakt dort wiederherstellen, wo er war. Leerlaufkosten: buchstäblich null, nicht einmal Swap. Aufwecken: Restore aus einem lokalen Image, einstellige Sekunden. Auf dem Papier die perfekte Antwort.
Wir haben es richtig gebaut: ein privilegiertes Rust-DaemonSet (criu-agent) auf jedem Node, das runc gegen k3s’ containerd-State aufruft – per nsenter in die Namespaces des Hosts, weil runc die cgroup-Hierarchie des Hosts und das echte /run braucht:
// criu-agent: checkpoint a project container via the host's runc
let (stdout, stderr, ok) = run_host_cmd(
"runc",
&[
"--root", "/run/k3s/containerd/runc/k8s.io",
"checkpoint",
"--image-path", &image_dir,
"--tcp-established", // snapshot live TCP, kubelet's API can't
"--leave-running",
"--ext-unix-sk",
"--file-locks",
&container_id,
],
timeout,
).await;
(Wir sind bewusst unter die Checkpoint-API des kubelet selbst gegangen – sie unterstützt Checkpoint, aber nicht die Flags, die wir brauchten, und sie hat überhaupt kein Restore. Diese Asymmetrie hätte der erste Hinweis sein sollen.)
Das Checkpointing funktionierte. Images landeten auf der Platte, die Größen waren vernünftig, die Flags kamen mit den offenen Sockets und File Locks des Dev-Servers klar.
Beim Restore brach die Architektur zusammen – nicht an einem Bug, sondern an einem strukturellen Missverhältnis. runc restore erweckt den Prozessbaum wieder, aber es erweckt ihn unterhalb von Kubernetes’ Weltmodell, ohne Kubernetes Bescheid zu sagen. Das kubelet glaubt, der Container, den es verwaltet, sei derjenige, den sein containerd-Shim beaufsichtigt; ein wiederhergestellter Prozess ist ein Wechselbalg, mit dem der Shim keinerlei Beziehung hat. Probes, Lifecycle-Hooks, kubectl exec, Ressourcen-Accounting, Eviction – jedes Control-Plane-Feature setzt voraus, dass das kubelet den Prozess-Lifecycle besitzt, und nach einem Restore tut es das schlicht nicht. Du hast keinen Container wiederhergestellt; du hast einen Prozess wiederhergestellt, der die Kleider des Containers trägt, und die gesamte Management-Schicht der Plattform widerspricht jetzt der Realität. Die Checkpoint-Unterstützung im Upstream-Kubernetes hört aus genau diesem Grund bei „ein forensisches Image schreiben“ auf – Restore-in-Place ist die ungelöste Hälfte.
Wir hätten dagegen ankämpfen können – es gibt Leute, die CRIU-Restore heldenhaft in Container-Runtimes verdrahten –, aber eine Plattform, die Tausende von agentengenerierten, beliebig schrägen Apps hostet, kann nicht auf einem Fundament sitzen, auf dem Pause/Resume, die meistausgeführte Lifecycle-Operation, gegen den Strich des Orchestrators läuft. CRIU verlor nicht, weil es einen Prozess nicht einfrieren konnte, sondern weil Kubernetes nichts davon erfahren konnte.
Ein Artefakt des Experiments überlebte jedoch, und es war wichtig: Das privilegierte Rust-DaemonSet pro Node mit einer winzigen HTTP-API erwies sich als genau die richtige Form für node-lokale Lifecycle-Chirurgie. Wir behielten das Chassis und tauschten den Motor.
Das Design, das überlebte: cgroup-v2-Freezer + Swap-Reclaim
Die Synthesefrage schreibt sich nach zwei Eliminierungen von selbst. KEDA scheiterte, weil der Pod aufhörte zu existieren. CRIU scheiterte, weil Kubernetes nicht wusste, dass der Prozess aufgehört hatte zu existieren. Also: Was, wenn nichts aufhört zu existieren?
Der Kernel hatte die Antwort die ganze Zeit – und hier zahlt die Container-statt-VMs-Entscheidung ihre Dividende aus. Jeder Container ist eine cgroup, und cgroup v2 gibt dir zwei Dateien:
cgroup.freeze– schreibe1, und der Kernel friert jeden Task in der Gruppe ein. Kein SIGSTOP (das Apps beobachten können und das Prozessgruppen merkwürdig umhängt) – ein Deep-Freeze über das Freezer-Subsystem. Null CPU, bedingungslos, für den gesamten Baum.memory.reclaim– bitte den Kernel, die Pages einer cgroup proaktiv in den Swap zu drücken, ohne irgendetwas zu killen oder auch nur aufzuwecken.
Zusammengenommen wird „Pause“ zu vier Datei-Writes – keine Pod-Löschung, kein Image, kein Restore, kein Interceptor:
// freeze-agent: the entire pause operation
fs::write(cgroup.join("memory.swap.max"), b"max")?; // 1. allow swap
let ram_before = read_memory_current(&cgroup)?; // 2. for the books
fs::write(cgroup.join("cgroup.freeze"), b"1")?; // 3. stop the world
fs::write(pod_cgroup.join("memory.reclaim"), // 4. RAM → swap
b"999999999999")?; // (best-effort)
Eingefroren ist der Fußabdruck der App null CPU und ein paar Megabyte nicht auslagerbaren Kernel-Speichers; das Working Set sitzt im Swap, der die Platte ist, die das Günstigste auf dem Node ist. Das Auftauen ist die Umkehrung und es ist nahezu sofort: schreibe 0 nach cgroup.freeze, stelle memory.swap.max wieder her, und der Prozess setzt mitten in der Instruktion fort. Pages werden träge aus dem Swap zurückgeladen, gemäß dem Zugriffsmuster des echten Nutzers – der Vite-Modulgraph, der JIT-Zustand, jeder offene Socket und Timer: alles noch da, weil der Prozess nie gestorben ist. Der 10–30-Sekunden-Rebuild, den Scale-to-Zero bei jedem Aufwecken in Rechnung stellte, wurde zu Page-in-Kosten, die über die ersten paar Interaktionen amortisiert werden.
Und – der Teil, den CRIU uns nicht geben konnte – Kubernetes bemerkt es nie. Der Pod ist die ganze Zeit Running. Der Shim des kubelet besitzt weiterhin den Prozess. Kein Control-Plane-Feature bricht, weil aus Sicht des Orchestrators nichts passiert ist. Wir haben die App unterhalb von Kubernetes’ Modell eingefroren, statt gegen es. (Liveness-Probes würden es natürlich bemerken – generierte App-Pods tragen sie schlicht nicht; der eigene Idle-Sweep und die Health-Schicht der Plattform entscheiden, was „lebendig“ für eine eingefrorene App bedeutet.)
Der Rest des Systems fiel in ein paar Wochen Integration heraus:
- Die Leerlauferkennung blieb absichtlich langweilig: Das Frontend sendet einen Keepalive-Ping, solange irgendjemand ein Projekt betrachtet; der Sweep friert alles ein, was sich nicht innerhalb des Timeouts gemeldet hat. Nirgendwo Traffic-Interception.
- Wake-on-HTTP läuft über den Error-Pfad des nginx-Ingress statt über einen Inline-Proxy: Ein Request an eine eingefrorene App trifft auf
custom-http-errors→ Default Backend → einen Wake-Handler, der den Namespace auftaut und eine gestylte „waking up“-Seite ausliefert, die in die laufende App nachlädt. Der heiße Pfad hat null zusätzliche Hops; nur der kalte Pfad zahlt. - Die Datenbank steigt in denselben Rhythmus ein. Das Postgres-Compute einer eingefrorenen App skaliert unabhängig auf null und wacht mitten in der Verbindung auf. Der Freeze-Zustand wird als Namespace-Label (
adorable/frozen=true) veröffentlicht, damit der DB-Proxy es direkt aus der K8s-API lesen kann – kein gemeinsamer Redis-State zwischen den beiden Systemen, und das Wake-Budget des Proxys berücksichtigt, dass „die ganze App taut auf, nicht nur mein Compute“. - KEDA wurde abgeschaltet. Der Freezer deckte alles ab, was es tat – Leerlauferkennung, null Leerlaufkosten, Aufwecken auf Anfrage – mit einem schnelleren Aufwecken und zwei beweglichen Teilen weniger im Request-Pfad.
Was der Freezer im Gegenzug verlangte
Kein Primitiv ist umsonst, und Ehrlichkeit über das dritte Design ist es, was die ersten beiden Eliminierungen bedeutsam macht. Die Kosten des Freezers sind real – sie haben nur die richtige Form: Korrektheitsverpflichtungen in unserer Control Plane, keine Sekunden auf der Uhr des Nutzers.
Freeze/Thaw ist eine Multi-System-Transaktion, und wir mussten sie auch so behandeln. Ein Freeze berührt die cgroup, die Ingress-Annotations, den Redis-State, das Namespace-Label und die Weltsicht des DB-Proxys. Teilweise angewendet sind die Kombinationen übel – die frühe Implementierung konnte Pods eingefroren lassen, während der Ingress weiterhin Traffic an sie routete, falls ein späterer Schritt fehlschlug, also bekam das Freeze eine Rollback-Kette (den cgroup-Write zurückdrehen, falls der Routing-Flip fehlschlägt). Der Agent selbst lernte dieselbe Lektion im Kleinen: Ein zweiter Freeze-Request, der für einen bereits eingefrorenen Container eintrifft, muss ein No-op sein, der die ursprünglichen Metadaten zurückgibt – die naive Version überschrieb ihr gemerktes ursprüngliches memory.swap.max des Containers mit dem max, das sie selbst geschrieben hatte, sodass das spätere Thaw den Swap auf dauerhaft eingeschaltet „wiederherstellte“. Idempotenz ist in Lifecycle-Code keine nette Geste; sie ist der Unterschied zwischen sauberem State und Datenkorruption.
State, der an zwei Orten lebt, muss gegengeprüft werden. „Ist dieses Projekt eingefroren?“ wird von Redis (schnell) und von der Sicht des freeze-agent auf die tatsächliche cgroup (wahr) beantwortet. Nach einem Node- oder Backend-Neustart können diese auseinanderlaufen, also behandelt die Plattform die cgroup als Grundwahrheit und heilt den Cache an ihr, statt einer der beiden Seiten allein zu trauen.
Und die Operationen brauchten einen einzigen Eigentümer. Freeze, Thaw, Pause (nutzerinitiierter Stopp), Unpause, Start, Rebuild – sechs Subsysteme, die jeweils ihre eigenen Aufrufe in diese Maschinerie hineinwachsen ließen, so kamen die frühen Bugs herein. All das läuft jetzt durch eine einzige ProjectLifecycle-Schicht, die die Reihenfolge der Übergänge besitzt, das Lock pro Projekt hält und der einzige Aufrufer ist, der die Low-Level-Operationen berühren darf. Der Freezer ist vier Datei-Writes; das System um den Freezer herum ist eine Übung darin, vier Datei-Writes über eine verteilte Plattform hinweg transaktional zu machen.
Die Leiter und die Ökonomie
Wo das alles landete, ist eine Lifecycle-Leiter, deren jede Sprosse günstiger und langsamer zu verlassen ist als die vorige:
| Zustand | CPU | RAM | Wake-Kosten | Trigger |
|---|---|---|---|---|
| Running | echt | echt | — | jemand schaut zu |
| Frozen | 0 | ~0 (ausgelagert) | Page-ins, Resume unter einer Sekunde | Leerlauf-Timeout |
| Stopped | 0 | 0 | voller Cold Start | Nutzer hat Stop gedrückt |
| DB asleep | 0 | 0 | einstellige s, mitten in der Verbindung | unabhängiger Idle-Sweep |
Der Standard-Ruhezustand jeder App auf der Plattform ist eingefroren, Datenbank schläft: praktisch kostenlos, um sie für immer zu behalten, schnell genug aufzuwecken, dass „lass es laufen, nur für den Fall“ der Standard ist statt einer monatlichen Rechnung. Das Gegenteil – eine App, deren Nutzer nicht einmal ein Page-in-Warmup tolerieren – ist eine bewusste, bepreiste Wahl (Keep-Warm), kein Zufall der Architektur.
Eine Weggabelung und drei Experimente, eine Schlussfolgerung – und es ist die, die wir auf jeder Schicht dieser Plattform immer wieder neu lernen: Zerstöre warmen State nicht, um ihn dann neu aufzubauen – mach, dass er nichts kostet, und behalte ihn. Die Datenbank tut es mit Copy-on-Write-Branches und einem Proxy, der Compute mitten in der Verbindung aufweckt. Die Apps tun es mit einem Kernel-Freezer und einer Swap-Datei. Das Teure war nie das Pausieren. Teuer war, überhaupt jemals von dort zurückkommen zu müssen.