Zum Inhalt springen
tecminds

Serverless-Cold-Start-Warmup-Ping: Wie wir einen 30-Sekunden-Modellstart hinter der Pipeline versteckt haben, die ihn ohnehin gleich aufgerufen hätte

Eine Judging-Pipeline, die sich beim zweiten Durchlauf schnell anfühlte, wirkte beim ersten kaputt: p50 für einen NLI-Batch lag warm bei 0,9 Sekunden und nach Leerlauf bei 28-52 Sekunden, mit einem 238-Sekunden-Worst-Case mitten im Judging-Pfad. Ein Sechs-Zitat-Durchlauf zog sich auf 203 Sekunden Wanduhrzeit; der unmittelbare Re-Run desselben Dokuments war in 24 fertig. Das ist die Aufzeichnung des Serverless-Cold-Start-Warmup-Pings, der den Boot versteckt hat, und der zwei Folge-Commits — eine Concurrency-Schranke und ein RAII-Drop-Guard — die landen mussten, bevor das Fire-and-Forget-Muster unter Last tatsächlich sicher war.

TTobias LüscherCo‑Founder · TecMinds2026-08-24 · 9 Min Lesezeit

Serverless-Cold-Start-Warmup-Ping: Wie wir einen 30-Sekunden-Modellstart hinter der Pipeline versteckt haben, die ihn ohnehin gleich aufgerufen hätte

Die frustrierendste Sorte Produktions-Latenz ist die, die nur beim ersten Klick eines Nachmittags existiert. Durchläufe, die zwanzig Sekunden brauchen, wenn eine Kollegin durch eine Demo klickt, brauchen dreieinhalb Minuten, wenn ein echter Nutzer die gleiche Seite nach der Mittagspause öffnet. Nichts ist kaputt. Keine Queue ist voll. Die Datenbank ist idle, der Worker ist idle, die Logs sind grün. Was passiert, ist, dass der Serverless-Cold-Start-Warmup-Ping, den man nie abgeschickt hat, gerade voll bezahlt wird — und zwar innerhalb der Anfrage, auf die der Nutzer wartet. Das ist die Aufzeichnung des winzigen Fire-and-Forget-Pings, der den Boot im Judging-Pfad von Acurio versteckt hat — unserem Produkt zur Zitatverifikation für akademische Arbeiten (das Repo heisst zoterohero) — und der zwei Folge-Commits, die es brauchte, bevor das Muster tatsächlich sicher in Produktion laufen konnte.

Der Boot, der immer im Judging-Pfad bezahlt wurde

Der Judging-Pfad von Acurio hat einen NLI-Klassifikator — ein Natural-Language-Inference-Modell, das die Behauptung eines Zitats und einen Kandidaten-Ausschnitt aus einer Quelle nimmt und entscheidet, ob die Quelle die Behauptung stützt, ihr widerspricht oder mit ihr nichts zu tun hat. Er ist eines von mehreren Signalen, die wir kombinieren, aber er ist das Signal, das die meisten Grenzfälle entscheidet, und jede beurteilte Zitatstelle ruft ihn auf. In Produktion läuft das Modell auf serverloser Infrastruktur, die im Leerlauf auf null skaliert. Warm dauert ein nli_batch-Aufruf bei p50 rund 900 Millisekunden. Nachdem der Endpoint geschlafen hat — und das ist alles ab ein paar Minuten Leerlauf — dauert derselbe Aufruf bei p50 zwischen 28 und 52 Sekunden, mit einem gemessenen Worst-Case von 238 Sekunden, weil die Plattform einen frischen Container hochfahren, die Modellgewichte herunterladen, sie in den GPU-Speicher laden und dann erst die Anfrage bedienen muss.

Der Failure-Mode ist nicht der Cold Start selbst. Jedes serverlose Deployment hat einen, und 30-50 Sekunden für ein grosses Modell sind nicht unvernünftig — ein kleines Modell auf CPU ist kleiner, ein grosser Klassifikator auf GPU mit Gewichten auf kaltem Speicher ist, wie er ist. Der Failure-Mode ist, wo der Cold Start landet: mitten im Judging-Pfad, in einer Anfrage, auf die der Nutzer wartet, nachdem die Pipeline vielleicht schon eine Minute mit Übersetzen, Embedden und Matchen verbracht hat. Ein Sechs-Zitat-Durchlauf — völlig vernünftig für einen kurzen Aufsatz oder ein Kapitel — brauchte beim ersten Klick einer Sitzung 203 Sekunden Wanduhrzeit. Der unmittelbare Re-Run desselben Dokuments, wenn alles bereits warm war, brauchte 24. Das ist, was der Nutzer als "das Tool ist beim ersten Klick kaputt und geheimnisvoll grossartig danach" erlebt hat.

Die Pipeline selbst war in Ordnung. Alles vor dem NLI-Aufruf — die Quellen-Fetches, die Übersetzung ins Englische, der Embed-Pass, das Identitäts-Matching, das eine Zitatstelle an ein Quell-PDF bindet — war so optimiert, dass es parallel lief, in einer Form, in der der Klassifikator das letzte grosse I/O war, das der Durchlauf brauchte. Das Problem war, dass "letztes grosses I/O" auch "erster serverless-Aufruf dieses Prozesses seit zehn Minuten" bedeutete — also machte die Pipeline-Struktur, die warme Durchläufe schnell machte, kalte Durchläufe pathologisch.

Wärmen, bevor du es brauchst: der Claim-Zeit-Ping

Der Fix rührt den Judging-Pfad überhaupt nicht an. Er rührt den Moment an, in dem ein Worker Arbeit claimed. In acurio-core — dem Rust-Judging-Service — hat der Worker vier Claim-Arme: run item, prepass, comparison und embed backfill. Alle vier sind die frühesten Signale dafür, dass Judging gleich passieren wird. Die Einsicht ist, dass bis irgendeiner von ihnen seine eigene Arbeit fertig hat und den nli_batch-Aufruf erreicht, der Klassifikator warm sein muss — und wenn wir den Boot parallel zur Claim-Eigenarbeit starten, passiert der Boot neben dem Translate-/Embed-/Match-Pass statt nach ihm.

Also spawnen wir direkt nach einem erfolgreichen Claim einen Fire-and-Forget-Task, der eine einzige minimale Scoring-Anfrage an den NLI-Endpoint schickt. Der Response-Body wird verworfen. Den Endpoint überhaupt zu erreichen ist, was den Container hochfährt. Der Ping steht bewusst ausserhalb des metered Providers — keine Usage-Zeile, kein Per-Job-Budget — weil ein Prozess-Level-Warmup-Ping zu keinem einzelnen Job gehört und keinem einzelnen Budget zugeordnet werden kann. Wenn NLI nicht konfiguriert ist oder der Kill-Switch des Providers an ist, ist der Ping ein No-op.

Über eine Woche Dev-Traffic gemessen bleibt der Warm-p50 bei 0,9 Sekunden. Der Cold-Start-p50 sinkt von ~30 Sekunden auf grob die Differenz zwischen der Boot-Obergrenze und der Zeit, die der Rest der Pipeline braucht, um den NLI-Aufruf zu erreichen — was in der Praxis null ist, weil Translate und Embed langsamer sind als der Boot. Die nutzererlebte Latenz eines kalten Sechs-Zitat-Durchlaufs hat jetzt dieselbe Form wie ein warmer. Das Muster ist eine Variante eines alten Serverless-Latenz-Playbooks: halte etwas warm, aber nur, wenn es echte Anzeichen dafür gibt, dass mehr Arbeit kommt.

Was die naive Version falsch gemacht hat

Der erste Entwurf dieses Musters hatte die Form, die die meisten Ingenieurinnen auf eine Serviette schreiben würden: eine mutex-geschützte Option<Instant> für den Zeitstempel des letzten Feuerns, ein 60-Sekunden-Cooldown, spawn einen Tokio-Task, wenn der Cooldown verstrichen ist. Simpel. Es ging live. Dann hat ein Code Review — ein mechanisches, auf der Rust-Seite — genau auf das Loch gezeigt, das die Komposition zweier offensichtlicher Ideen erzeugt, wenn keine der beiden vollständig ist.

Der 60-Sekunden-Cooldown drosselt den Start eines Pings. Er begrenzt nicht die Concurrency. Ein Cold Start kann bis zur Per-Request-Obergrenze der serverlosen Plattform dauern — in unserem Fall 330 Sekunden, was den Cooldown fast sechsmal überlebt. In einer belebten Claim-Schleife, die maybe_spawn_warmup nach jedem erfolgreichen Claim feuert, könnten sich bis zu sechs abgetrennte Warmup-Tasks hinter einem einzigen echten Cold Start stapeln, jeder von ihnen würde doppelte serverlose Arbeit genau in dem Moment einreihen, in dem die Plattform ohnehin schon Mühe hat, den ersten Container hochzufahren. Die Kur sieht aus wie die Krankheit. Was einen Cold Start hätte verstecken sollen, würde ihn unter einem schlimm genug schlechten sogar verlängern.

Der Fix besteht darin, ein in_flight-Flag zum gleichen Lock hinzuzufügen, in dem der Cooldown bereits lebt. should_warm armiert jetzt beide Schranken bei true: es verweigert, wenn entweder der Cooldown noch nicht abgelaufen ist oder ein Ping bereits fliegt, und wenn es true zurückgibt, stempelt es last_started und setzt in_flight = true in derselben kritischen Sektion. Der Cooldown zählt weiter ab dem Start-Zeitpunkt, sodass echtes Nachwärmen zwischen Wellen weiter passiert. Die Concurrency, vorher während eines Cold Starts unbeschränkt, ist jetzt auf eins beschränkt. Und weil ein Flip des Provider-Kill-Switches zwischen Spawn und Send zählt — ein Worker, dem gesagt wurde, den Endpoint nicht mehr zu treffen, soll den Endpoint nicht mehr treffen — prüft warmup_ping den Kill-Switch innerhalb des Tasks erneut und passt so zum Per-Attempt-Vertrag, den der metered Adapter schon immer hatte.

Der zweite Review-Durchgang fing eine kleinere, aber gemeinere Version derselben Form. Der explizite finish_warmup-Aufruf am Ende des gespawnten Tasks löschte in_flight auf dem Happy Path. Er war unerreichbar, wenn der Task panikte, gecancelt wurde oder die Runtime ihn während eines Shutdowns fallen liess. in_flight würde true bleiben, bis der Prozess neu startet, und aus diesem Worker würde nie wieder ein Warmup feuern. Der Fix ist ein RAII-Drop-Guard — ein Struct ohne Grösse, dessen einzige Aufgabe es ist, in_flight in seiner Drop-Implementierung zu löschen:

struct InFlightReset;

impl Drop for InFlightReset {
    fn drop(&mut self) {
        finish_warmup(&mut lock_warmup_state());
    }
}

Binde eine Instanz oben im gespawnten Task mit let _reset = InFlightReset; und die Invariante überlebt jetzt jeden Ausstieg, den Rust hat: normale Vollendung, Panic-Unwind, Task-Cancellation, Runtime-Shutdown mitten in einem await. Das Muster ist der Systems-Programming-Zwilling des Debounced-Autosave-Finalize-Gates, das wir im Juli geschrieben haben: beide handeln davon, ein promise-förmiges Stück Zustand (ein In-Flight-Ding) korrekt zu terminieren, egal wie der zugrunde liegende Task endet. Im Browser greift man zu einem Promise, das man in einem Ref trackt. In Rust greift man zu Drop.

Drei Regeln, die verallgemeinern

Drei Regeln haben diese Umschreibung überlebt und verallgemeinern sich auf jedes Warmup-, Keep-Alive- oder Pre-Fetch-Muster, das eine Anfrage feuert, um die der Nutzer nicht explizit gebeten hat:

Bezahl den Boot, bevor die Anfrage des Nutzers die Sache braucht. Der Sinn eines Claim-Zeit-Pings ist nicht, den Cold Start zu eliminieren — das kannst du nicht, das Modell muss immer noch laden — sondern ihn mit Arbeit zu überlappen, die du sowieso machen wolltest. Jede Pipeline-Stage, die einem teuren Aufruf zuverlässig um mehr Zeit vorausgeht, als der Cold Start des Aufrufs dauert, ist ein Kandidat zum Warmup-Feuern. Überspring das Warmup, wenn der teure Aufruf das erste ist, was die Pipeline macht; dann gibt es nichts, hinter dem man sich verstecken könnte.

Ein Cooldown ist keine Concurrency-Schranke. Ein "eins pro 60 Sekunden"-Throttle tut genau das, was er sagt, und mehr nicht. Wenn die gethrottelte Sache länger dauern kann als das Throttle-Fenster, brauchst du zusätzlich einen separaten In-Flight-Guard obenauf, in derselben kritischen Sektion wie den Zeitstempel. Jedes serverless-nahe Muster, das ich in Produktion habe fallen sehen, ist an genau dem gescheitert: die naive Version nahm an, die Arbeit passe ins Fenster, und der Retry-Sturm bewies das Gegenteil. Das ist ein enger Cousin der SKIP-LOCKED-Disziplin im Postgres-Backstop-Sweep-Writeup — anderer Mechanismus, dieselbe zugrunde liegende Disziplin darüber, wer die Arbeit gerade tun darf.

Fire-and-forget muss deterministisch terminieren. Ein Tokio-Task, eine Node-.then()-Kette, ein Browser-Promise — alles, was du spawnst und dann nicht mehr trackst, ist eine Panic, eine Cancellation oder ein Shutdown davon entfernt, den Zustand zu leaken, den es freigeben sollte. Wenn der Zustand "eine weitere Instanz dieses Tasks darf laufen, sobald ich fertig bin" ist, ist der Leak still und permanent bis zum Prozess-Restart, was die schlimmste Sorte ist. RAII in Rust, try/finally in JavaScript, defer in Go: alle drei Sprachen geben dir einen Weg, Aufräumen unabhängig vom Happy Path zu machen, und keine davon ist teurer als der Bug, den sie verhindert.

Die Komposition dieser drei Regeln ist, was den Zweiten-Tag-Patch kleiner gemacht hat als das Erst-Tag-Feature. Die Ein-Zeile "spawn ein Warmup" ist die Form, die die meisten Leute schon kennen. Die drei Zeilen drumherum — Cooldown, In-Flight-Flag, Drop-Guard — sind, was die Form Kontakt mit echter serverloser Latenz überleben lässt. Eine der drei wegzulassen funktioniert auf einem synthetischen Benchmark einwandfrei und pathologisch auf einem echten Cold Start, was eine Heuristik ist, die es sich zu verinnerlichen lohnt: Muster, die Latenz verstecken, sind die einfachsten, die man auf Arten kaputt macht, die die Latenz schlimmer machen.

Wenn du eine Pipeline verdrahtest, die ein scale-to-zero-Modell, einen Inference-Endpoint oder irgendeine serverlose Abhängigkeit aufruft, bei der die erste Anfrage einer Sitzung einen Boot-Preis bezahlt, den dein Nutzer bemerkt — buche einen kostenlosen AI-Potenzial-Check, oder lies das Next.js-Worker-Split-mit-pg_notify-Writeup für die Prozessgrenzen-Hälfte des gleichen "Wer besitzt die langsame Arbeit"-Themas.

acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.

Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.

NÄCHSTER SCHRITTHat dich das interessiert?