Was beim Betrieb unseres eigenen LLM-Gateways in Produktion kaputtging (und was wir geändert haben)

Was beim Betrieb unseres eigenen LLM-Gateways in Produktion kaputtging (und was wir geändert haben) — Ein Build-Log eines kleinen Teams: drei Produktionsvorfälle mit unserem selbst gehosteten LiteLLM-Gateway (OOM durch ein bewegliches Image-Tag, gelöschtes Postgres-Schema, geleertes Secret) und die Fixes und Checks, die wir behalten haben.
17. Okt. 20268 Min. Lesezeit
Share with

Wir betreiben ein kleines Backend für Office Claws: eine Go-API, Postgres, einen Caddy-Reverse-Proxy und einen selbst gehosteten LiteLLM-Proxy, alles in einem Docker-Compose-Stack auf einem einzigen VPS. LiteLLM ist das LLM-Gateway. Beim Provisioning bittet das Backend es, für einen neuen Agenten einen virtuellen Schlüssel mit Budgetlimit auszustellen.

Das Setup ist gewöhnlich, und trotzdem ging es im Juni innerhalb einer Woche dreimal kaputt. Keiner der Fehler war exotisch. Jeder blieb eine Weile unbemerkt, und genau deshalb tat jeder weh. Das hier ist das Build-Log: was wir gesehen haben, was tatsächlich falsch war, was wir geändert haben und welche Lektion wir allen mitgeben würden, die einen ähnlichen selbst gehosteten Stack betreiben.

#VorfallWie er sich zeigteFix
1Bewegliches Image-Tag + knappes SpeicherlimitAgenten-Onboarding scheiterte mit einer vagen Client-FehlermeldungPer Digest pinnen, Limit erhöhen, laut fehlschlagen
2Gateway-Migrationen löschten ein geteiltes Schemarelation "users" does not exist nach einem NeustartEigene Datenbank für das Gateway
3$ in einem Secret, von Compose interpoliertDienst startete normal, Secret war stillschweigend falschAls $$ escapen, compose config prüfen

Vorfall 1: Ein bewegliches Tag zog einen schwereren Build und landete in einer OOM-Schleife

Symptom. Neue Agenten schlossen ihr Setup nicht mehr ab. Die Desktop-App scheiterte beim Onboarding mit „LiteLLM virtual key and base URL are required“. Diese Meldung kam Minuten, nachdem die Provisioning-Anfrage bereits Erfolg gemeldet hatte, und lenkte unseren Verdacht deshalb zuerst auf den Client.

Ursache. Unsere Compose-Datei referenzierte LiteLLM über ein bewegliches Tag:

litellm:
  image: ghcr.io/berriai/litellm:main-stable
  deploy:
    resources:
      limits:
        memory: 1500M

Ein routinemäßiges Deployment zog, worauf main-stable an diesem Tag zeigte. Der neue Build brauchte mit zwei Workern etwa 2,1 GiB zum Starten. Unter einem 1500M-Limit beendete der Kernel ihn, bevor er fertig hochgefahren war, Docker startete ihn neu, und der Zyklus wiederholte sich ungefähr alle 62 Sekunden. Als wir es bemerkten, war der Container über etwa drei Tage 4.088-mal neu gestartet worden.

Zwei Dinge hielten das Problem verborgen:

  • Unser Backend verschluckte den Fehler. Wenn das Ausstellen des Schlüssels scheiterte, schrieb der Provisioning-Handler eine Warnung ins Log und gab 200 mit leeren LLM-Feldern zurück. Der eigentliche Fehler tauchte erst später auf dem Client als generische Meldung auf.
  • Die naheliegende Diagnose log. docker inspect zeigte mitten im Zyklus einen RestartCount und ExitCode, die gesund aussahen. Was die Schleife tatsächlich sichtbar machte, war das Live-Beobachten der Events:
docker events --filter container=<litellm-container> --filter event=oom --filter event=die
# ... oom
# ... die (exitCode=137)

Eine Container-OOM-Schleife: der Speicher beim Start überschreitet das Limit, der Kernel beendet den Prozess, Docker startet ihn neu

Fix. Drei Änderungen in einem PR:

litellm:
  # Pinned by digest. Bump deliberately: verify boot memory fits the limit first.
  image: ghcr.io/berriai/litellm@sha256:<digest-you-tested>
  deploy:
    resources:
      limits:
        # Boot memory of the pinned image is ~2.1G with two workers.
        memory: 3072M

Und im Provisioning-Handler führt ein fehlgeschlagenes Ausstellen des Schlüssels jetzt zu einem harten 502 (oder 503, wenn das Gateway nicht konfiguriert ist) statt zu einem 200 mit leeren Feldern. Ein Fehler, den der Nutzer sofort erneut versuchen kann, ist besser als einer, der später als verwirrende Meldung auftaucht.

So findest du den Digest des Images, das du tatsächlich getestet hast:

docker buildx imagetools inspect ghcr.io/berriai/litellm:main-stable
# or, for an image already pulled on the host:
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/berriai/litellm:main-stable

Lektion. Ein bewegliches Tag bedeutet bei jedem Deployment ein ungeprüftes Upgrade, und ein Speicherlimit, das für das Image vom letzten Monat dimensioniert wurde, wird irgendwann zu klein. Pinne die Images, von denen du abhängst, per Digest und behandle ein Upgrade als Änderung, die du auf Speicherbedarf beim Start testest, nicht als Nebeneffekt eines Deployments. Und wenn eine Abhängigkeit erforderlich ist, schlag direkt bei der Anfrage fehl. Fallbacks, die optional aussehen, sind der Ort, an dem stille Vorfälle beginnen.

Vorfall 2: Die Migrationen des Gateways löschten unsere Backend-Tabellen

Symptom. Nach einem Neustart schlugen Registrierung und Login fehl mit:

ERROR: relation "users" does not exist (SQLSTATE 42P01)

Das Seltsame: Die Backend-Logs zeigten, dass unsere eigenen Migrationen beim Start sauber liefen. Eine schnelle Prüfung direkt nach dem Neustart sah gut aus. Eine Minute später waren die Tabellen weg.

Ursache. Wir hatten beide Dienste auf dieselbe Postgres-Datenbank zeigen lassen, um das Setup klein zu halten:

# backend.env
DB_DSN=postgresql://app:<password>@postgres:5432/app
 
# litellm.env
DATABASE_URL=postgresql://app:<password>@postgres:5432/app   # same database

LiteLLM verwaltet sein Schema mit Prisma. Bei jedem Start vergleicht es die laufende Datenbank mit seiner eigenen schema.prisma. Die Tabellen unseres Backends (Nutzer, Agenten, VPS-Einträge und so weiter) existieren in LiteLLMs Schema nicht, also sahen sie für Prisma wie Drift aus. Prismas Reaktion war, das Schema zurückzusetzen und die eigenen Migrationen erneut anzuwenden, wodurch jede Tabelle gelöscht wurde, die nicht zu LiteLLM gehörte.

Der Reset lief etwa 40 bis 70 Sekunden nach dem Start. Dieses Timing erklärte die verwirrenden Logs. Unser Backend legte seine Tabellen an, das Gateway beendete seinen Abgleich und löschte sie, und jede Prüfung in der ersten Minute sah gut aus.

Geteilte Datenbank vs. getrennte Datenbanken: Die Migrationen des Gateways behandeln die Tabellen des Backends als Drift

Fix. Gib dem Gateway eine eigene Datenbank. Das Backend spricht mit LiteLLM nur über HTTP und liest nie dessen Tabellen, es gab also keinen Grund, eine gemeinsame Datenbank zu nutzen.

CREATE DATABASE litellm OWNER app;
# litellm.env
DATABASE_URL=postgresql://app:<password>@postgres:5432/litellm

Danach das Gateway neu erstellen, damit es seine Tabellen in litellm anlegt, das Backend neu starten, damit es seine eigenen Tabellen in app wieder anlegt, und die verwaisten Gateway-Tabellen aus der alten Datenbank löschen. Zur Kontrolle haben wir das Gateway erneut neu gestartet, das Zeitfenster des Abgleichs abgewartet und bestätigt, dass unsere Tabellen überlebt haben und die Registrierung 200 zurückgab.

Lektion. Drittanbieter-Dienste, die ihr eigenes Schema verwalten, gehen davon aus, dass ihnen die ganze Datenbank gehört. Lass zwei Migrationswerkzeuge kein Schema teilen, außer beide unterstützen das ausdrücklich. Getrennte Datenbanken auf derselben Postgres-Instanz kosten fast nichts. Außerdem: Wenn ein Fehler verzögert auftritt, prüf nach der Verzögerung noch einmal. Eine Prüfung eine Minute nach dem Start hätte diesen Fehler nicht gefunden. Und schließlich: Aktualisiere das Runbook. Nach dem Fix beschrieben unsere eigenen Setup-Notizen immer noch die geteilte Datenbank, ein Neuaufbau nach diesen Notizen hätte den Fehler also direkt zurückgebracht.

Vorfall 3: Ein $ in einem Secret wurde stillschweigend geleert

Symptom. Fast nichts, und genau das ist das Beunruhigende. Das Backend startete und meldete im Log, dass sein Cloud-Provider-Client initialisiert war. Diese Startprüfung bestätigte nur, dass die Zugangsdaten nicht leer waren. Die Zugangsdaten selbst waren falsch, also wäre jeder echte API-Aufruf an der Authentifizierung gescheitert.

Der einzige sichtbare Hinweis war eine Warnung bei docker compose up, die man leicht überscrollt:

WARN[0000] The "Xk9def" variable is not set. Defaulting to a blank string.

Xk9def war keine Variable, die wir je definiert hatten (der Wert hier ist ausgedacht). Es waren die Zeichen direkt nach einem $ in einem unserer Secret-Werte.

Ursache. Docker Compose v2 interpoliert Werte in env_file-Dateien. Ein Secret wie dieses:

PROVIDER_API_PASSWORD=abc$Xk9def

wird als abc + der Wert von $Xk9def gelesen (nicht gesetzt, also leer). Compose übergibt den gekürzten, falschen Wert ohne Fehler an den Container.

Fix. Jedes literale $ beim Schreiben von Env-Dateien als $$ escapen und dann den Container neu erstellen:

sed -i '/^PROVIDER_API_PASSWORD=/ s/\$/$$/g' secrets/backend.env
docker compose up -d --force-recreate backend

Außerdem haben wir eine Prüfung nach dem Schreiben jeder Secrets-Datei eingeführt. Sie muss null Treffer liefern:

docker compose config 2>&1 | grep "is not set"

Noch eine Falle: Unser Backend-Image ist minimal, daher schlägt docker exec <ctr> printenv fehl, und der Fehlertext kann wie ein Wert aussehen. Um zu prüfen, was der Container tatsächlich erhalten hat, ohne das Secret auszugeben, vergleiche Hashes:

docker inspect <ctr> --format '{{range .Config.Env}}{{println .}}{{end}}' \
  | grep '^PROVIDER_API_PASSWORD=' | cut -d= -f2- | shasum -a 256

Lektion. Dein Config-Loader ist ein Parser und hat eigene Regeln für Sonderzeichen. Finde heraus, wie diese Regeln aussehen, bevor du ein zufällig generiertes Passwort hineinkopierst. Eine Startprüfung, die nur auf „nicht leer“ testet, lässt ein beschädigtes Secret durch. Prüfe Zugangsdaten nach Möglichkeit beim Start mit einer echten Anfrage und behandle Interpolationswarnungen als Fehler.

Was wir insgesamt geändert haben

Die Fixes waren klein. Das nützlichere Ergebnis ist eine kurze Checkliste, die wir jetzt auf jeden Dienst im Stack anwenden:

  1. Images per Digest pinnen für alles, was zustandsbehaftet ist oder auf dem kritischen Pfad liegt. Bewusst upgraden und dabei den Speicherbedarf beim Start gegen das Limit prüfen.
  2. Speicherlimits nach gemessenen Startspitzen dimensionieren, nicht nach geschätzten Dauerwerten, und Reserve lassen.
  3. docker events auf oom und die beobachten. Sich nicht nur auf einen einzelnen docker inspect-Schnappschuss verlassen.
  4. Eine Datenbank pro Dienst, der eigene Migrationen ausführt.
  5. Nach der Verzögerung prüfen, nicht nur direkt nach einem Neustart.
  6. Bei der Anfrage laut fehlschlagen, wenn eine erforderliche Abhängigkeit ausfällt. Kein 200 mit leeren Feldern.
  7. docker compose config ausführen, nachdem Env-Dateien bearbeitet wurden, und „variable is not set“ als Blocker behandeln.
  8. Das Runbook mit dem Fix synchron halten, sonst bringt der nächste Neuaufbau den Fehler zurück.

Nichts davon ist spezifisch für LLM-Gateways. Es gilt für jeden kleinen selbst gehosteten Stack, bei dem eine Person deployt und niemand um 3 Uhr nachts alarmiert wird. Mehr zum Betrieb von Diensten auf einem VPS findest du in unserem OpenClaw-Self-Hosting-Guide, in OpenClaw-Monitoring und in OpenClaw-Secrets-Management. Ein früheres Build-Log beschreibt, wie wir das Provisioning auf unter 3 Minuten gebracht haben.

Agenten betreiben, ohne das Gateway selbst zu betreiben

Wenn du Codex-Agenten auf einem VPS willst, aber keinen solchen Stack betreiben möchtest, genau dafür gibt es Office Claws. Deine Agenten laufen auf deinem eigenen Contabo-VPS, Codex nutzt dein bestehendes ChatGPT-Abo, und Office Claws kostet $4.99/Monat. Installiere die CLI mit:

curl -fsSL https://officeclaws.com/downloads/install.sh | sh
Autor

Office Claws Team

Wir gestalten die Zukunft des KI-Agenten-Managements bei Office Claws. Einblicke in Infrastruktur, Sicherheit und Entwicklererfahrung.

Starte deinen ersten Agenten in etwa 3 Minuten

Installiere die Office Claws CLI, verbinde dein Contabo-Konto und dein Codex-Abo – dein Agent bekommt einen eigenen VPS und einen Schreibtisch im Büro.

curl -fsSL https://officeclaws.com/downloads/install.sh | sh

$4.99/Monat + dein eigener Contabo-VPS · Schlüssel bleiben in deinem Schlüsselbund