Ce qui a cassé en exploitant notre propre passerelle LLM en production (et ce que nous avons changé)

Ce qui a cassé en exploitant notre propre passerelle LLM en production (et ce que nous avons changé) — Le journal de build d'une petite équipe : trois incidents de production avec notre passerelle LiteLLM auto-hébergée (OOM via un tag mobile, schéma Postgres partagé effacé, secret vidé) et les correctifs et contrôles que nous avons gardés.
17 oct. 202610 min de lecture
Share with

Nous faisons tourner un petit backend pour Office Claws : une API en Go, Postgres, un reverse proxy Caddy et un proxy LiteLLM auto-hébergé, le tout dans une seule stack Docker Compose sur un seul VPS. LiteLLM est la passerelle LLM. Pendant le provisioning, le backend lui demande d'émettre une clé virtuelle plafonnée en budget pour chaque nouvel agent.

Cette configuration est banale, et pourtant elle a cassé trois fois en une semaine au mois de juin. Aucune de ces pannes n'était exotique. Chacune est restée silencieuse un moment, et c'est pour cela que chacune a fait mal. Voici le journal de build : ce que nous avons vu, ce qui n'allait vraiment pas, ce que nous avons changé, et la leçon que nous transmettrions à quiconque exploite une stack auto-hébergée similaire.

#IncidentComment il s'est manifestéCorrectif
1Tag d'image mobile + limite mémoire serréeL'onboarding des agents échouait avec une erreur client vagueÉpingler par digest, relever la limite, échouer bruyamment
2Les migrations de la passerelle ont effacé un schéma partagérelation "users" does not exist après un redémarrageBase de données séparée pour la passerelle
3Un $ dans un secret, interpolé par ComposeLe service démarrait normalement, le secret était faux sans bruitÉchapper en $$, vérifier compose config

Incident 1 : un tag mobile a tiré un build plus lourd et a bouclé en OOM

Symptôme. Les nouveaux agents ne terminaient plus leur configuration. L'application desktop échouait à l'étape d'onboarding avec « LiteLLM virtual key and base URL are required ». Ce message arrivait plusieurs minutes après que la requête de provisioning avait déjà renvoyé un succès, si bien qu'il nous a d'abord orientés vers le client.

Cause racine. Notre fichier compose référençait LiteLLM par un tag mobile :

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

Un déploiement de routine a tiré ce vers quoi pointait main-stable ce jour-là. Le nouveau build avait besoin d'environ 2,1 GiB pour démarrer avec deux workers. Sous une limite de 1500M, le noyau le tuait avant la fin de son démarrage, Docker le relançait, et le cycle se répétait à peu près toutes les 62 secondes. Quand nous l'avons repéré, le conteneur avait redémarré 4 088 fois en environ trois jours.

Deux choses l'ont gardé silencieux :

  • Notre backend avalait l'échec. Quand l'émission de la clé échouait, le handler de provisioning écrivait un avertissement dans les logs et renvoyait 200 avec des champs LLM vides. La vraie erreur n'apparaissait que plus tard, côté client, sous forme de message générique.
  • Le diagnostic évident mentait. docker inspect affichait un RestartCount et un ExitCode qui semblaient sains lorsque nous vérifiions en plein cycle. Ce qui a vraiment montré la boucle, c'est d'observer les événements en direct :
docker events --filter container=<litellm-container> --filter event=oom --filter event=die
# ... oom
# ... die (exitCode=137)

Une boucle OOM de conteneur : la mémoire au démarrage dépasse la limite, le noyau le tue, Docker le relance

Correctif. Trois changements dans une seule 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

Et dans le handler de provisioning, une émission de clé ratée renvoie désormais un 502 franc (ou 503 si la passerelle n'est pas configurée) au lieu d'un 200 avec des champs vides. Un échec que l'utilisateur peut retenter tout de suite vaut mieux qu'un échec qui apparaît plus tard sous la forme d'une erreur déroutante.

Pour trouver le digest de l'image que vous avez réellement testée :

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

Leçon. Un tag mobile, c'est une mise à jour non revue à chaque déploiement, et une limite mémoire dimensionnée pour l'image du mois dernier finira par être trop petite. Épinglez par digest les images dont vous dépendez, et traitez une mise à jour comme un changement dont vous testez la mémoire au démarrage, pas comme un effet de bord d'un déploiement. Et quand une dépendance est obligatoire, échouez au moment de la requête. Les replis qui ont l'air optionnels sont l'endroit où commencent les incidents silencieux.

Incident 2 : les migrations de la passerelle ont supprimé nos tables backend

Symptôme. Après un redémarrage, l'inscription et la connexion se sont mises à échouer avec :

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

Le plus étrange : les logs du backend montraient nos propres migrations s'appliquer proprement au démarrage. Une vérification rapide juste après le redémarrage semblait correcte. Une minute plus tard, les tables avaient disparu.

Cause racine. Nous avions fait pointer les deux services vers la même base Postgres pour garder une configuration légère :

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

LiteLLM gère son schéma avec Prisma. À chaque démarrage, il compare la base en service avec son propre schema.prisma. Les tables de notre backend (utilisateurs, agents, enregistrements de VPS, etc.) n'existent pas dans le schéma de LiteLLM, donc pour Prisma elles ressemblaient à une dérive. Sa réponse a été de réinitialiser le schéma et de réappliquer ses propres migrations, ce qui a supprimé toutes les tables qui n'étaient pas les siennes.

La réinitialisation intervenait environ 40 à 70 secondes après le démarrage. Ce délai expliquait les logs déroutants. Notre backend créait ses tables, la passerelle terminait sa comparaison et les effaçait, et toute vérification faite dans la première minute semblait correcte.

Base partagée vs bases séparées : les migrations de la passerelle traitent les tables du backend comme une dérive

Correctif. Donner à la passerelle sa propre base de données. Le backend ne parle à LiteLLM qu'en HTTP et ne lit jamais ses tables, il n'y avait donc aucune raison de partager une base.

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

Ensuite, recréer la passerelle pour qu'elle construise ses tables dans litellm, redémarrer le backend pour qu'il recrée ses propres tables dans app, et supprimer les tables orphelines de la passerelle dans l'ancienne base. Pour vérifier, nous avons redémarré la passerelle une nouvelle fois, attendu la fin de la fenêtre de comparaison, et confirmé que nos tables avaient survécu et que l'inscription renvoyait 200.

Leçon. Les services tiers qui gèrent leur propre schéma supposent qu'ils possèdent toute la base. Ne laissez pas deux outils de migration partager un schéma, sauf si les deux le prennent explicitement en charge. Des bases séparées sur la même instance Postgres ne coûtent presque rien. Par ailleurs, si une panne est différée, vérifiez à nouveau après le délai : une vérification faite une minute après le démarrage n'aurait pas détecté celle-ci. Enfin, mettez à jour le runbook. Après le correctif, nos propres notes d'installation décrivaient encore la base partagée, ce qui veut dire qu'une reconstruction à partir de ces notes aurait ramené le bug directement.

Incident 3 : un $ dans un secret a été vidé sans bruit

Symptôme. Presque rien, et c'est bien ce qui fait peur. Le backend démarrait et indiquait dans les logs que son client de fournisseur cloud était initialisé. Cette vérification au démarrage confirmait seulement que l'identifiant n'était pas vide. L'identifiant lui-même était faux, donc tout vrai appel API aurait échoué à l'authentification.

Le seul indice visible était un avertissement au docker compose up, facile à faire défiler sans le lire :

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

Xk9def n'était pas une variable que nous avions jamais définie (la valeur ici est inventée). C'étaient les caractères situés juste après un $ dans l'une de nos valeurs secrètes.

Cause racine. Docker Compose v2 interpole les valeurs des fichiers env_file. Un secret comme celui-ci :

PROVIDER_API_PASSWORD=abc$Xk9def

est lu comme abc + la valeur de $Xk9def (non définie, donc vide). Compose transmet la valeur tronquée et fausse au conteneur sans aucune erreur.

Correctif. Échapper chaque $ littéral en $$ lors de l'écriture des fichiers d'environnement, puis recréer le conteneur :

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

Nous avons aussi ajouté un contrôle après l'écriture de tout fichier de secrets. Il doit renvoyer zéro résultat :

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

Encore un piège : notre image backend est minimale, donc docker exec <ctr> printenv échoue, et le texte d'erreur peut ressembler à une valeur. Pour vérifier ce que le conteneur a réellement reçu sans afficher le secret, comparez des hachages :

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

Leçon. Votre chargeur de configuration est un parseur, et il a ses propres règles pour les caractères spéciaux. Renseignez-vous sur ces règles avant d'y coller un mot de passe généré aléatoirement. Une vérification au démarrage qui teste seulement « non vide » laissera passer un secret corrompu. Si vous le pouvez, vérifiez les identifiants avec une vraie requête au démarrage, et traitez les avertissements d'interpolation comme des erreurs.

Ce que nous avons changé au global

Les correctifs étaient petits. Le résultat le plus utile est une courte checklist que nous appliquons désormais à chaque service de la stack :

  1. Épingler les images par digest pour tout ce qui a un état ou se trouve sur le chemin critique. Mettre à jour volontairement, en vérifiant la mémoire au démarrage par rapport à la limite.
  2. Dimensionner les limites mémoire à partir des pics de démarrage mesurés, pas d'estimations en régime stable, et garder de la marge.
  3. Surveiller docker events pour oom et die. Ne pas se fier à un seul instantané de docker inspect.
  4. Une base de données par service qui exécute ses propres migrations.
  5. Vérifier après le délai, pas seulement juste après un redémarrage.
  6. Échouer bruyamment au moment de la requête quand une dépendance obligatoire est en panne. Pas de 200 avec des champs vides.
  7. Lancer docker compose config après avoir modifié des fichiers d'environnement, et traiter « variable is not set » comme bloquant.
  8. Garder le runbook synchronisé avec le correctif, sinon la prochaine reconstruction ramène le bug.

Rien de tout cela n'est propre aux passerelles LLM. Cela s'applique à toute petite stack auto-hébergée où une seule personne déploie et où personne n'est réveillé à 3 h du matin. Pour en savoir plus sur l'exploitation de services sur un VPS, consultez notre guide OpenClaw auto-hébergé, la supervision d'OpenClaw et la gestion des secrets OpenClaw. Pour un journal de build précédent, voyez comment nous avons ramené le provisioning sous les 3 minutes.

Faire tourner des agents sans exploiter la passerelle vous-même

Si vous voulez des agents Codex sur un VPS sans exploiter ce genre de stack, c'est précisément le rôle d'Office Claws. Vos agents tournent sur votre propre VPS Contabo, Codex utilise votre abonnement ChatGPT existant, et Office Claws coûte $4.99/mois. Installez la CLI avec :

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

Office Claws Team

Nous construisons le futur de la gestion des agents IA chez Office Claws. Partage d'analyses sur l'infrastructure, la sécurité et l'expérience développeur.

Lancez votre premier agent en 3 minutes environ

Installez la CLI Office Claws, connectez votre compte Contabo et votre abonnement Codex : votre agent obtient son propre VPS et un bureau dans l'office.

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

4,99 $/mois + votre propre VPS Contabo · les clés restent dans votre trousseau