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.
| # | Vorfall | Wie er sich zeigte | Fix |
|---|---|---|---|
| 1 | Bewegliches Image-Tag + knappes Speicherlimit | Agenten-Onboarding scheiterte mit einer vagen Client-Fehlermeldung | Per Digest pinnen, Limit erhöhen, laut fehlschlagen |
| 2 | Gateway-Migrationen löschten ein geteiltes Schema | relation "users" does not exist nach einem Neustart | Eigene Datenbank für das Gateway |
| 3 | $ in einem Secret, von Compose interpoliert | Dienst startete normal, Secret war stillschweigend falsch | Als $$ 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: 1500MEin 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
200mit leeren LLM-Feldern zurück. Der eigentliche Fehler tauchte erst später auf dem Client als generische Meldung auf. - Die naheliegende Diagnose log.
docker inspectzeigte mitten im Zyklus einenRestartCountundExitCode, 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)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: 3072MUnd 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-stableLektion. 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 databaseLiteLLM 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.
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/litellmDanach 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$Xk9defwird 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 backendAuß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 256Lektion. 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:
- 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.
- Speicherlimits nach gemessenen Startspitzen dimensionieren, nicht nach geschätzten Dauerwerten, und Reserve lassen.
docker eventsaufoomunddiebeobachten. Sich nicht nur auf einen einzelnendocker inspect-Schnappschuss verlassen.- Eine Datenbank pro Dienst, der eigene Migrationen ausführt.
- Nach der Verzögerung prüfen, nicht nur direkt nach einem Neustart.
- Bei der Anfrage laut fehlschlagen, wenn eine erforderliche Abhängigkeit ausfällt. Kein
200mit leeren Feldern. docker compose configausführen, nachdem Env-Dateien bearbeitet wurden, und „variable is not set“ als Blocker behandeln.- 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