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.
| # | Incident | Comment il s'est manifesté | Correctif |
|---|---|---|---|
| 1 | Tag d'image mobile + limite mémoire serrée | L'onboarding des agents échouait avec une erreur client vague | Épingler par digest, relever la limite, échouer bruyamment |
| 2 | Les migrations de la passerelle ont effacé un schéma partagé | relation "users" does not exist après un redémarrage | Base de données séparée pour la passerelle |
| 3 | Un $ dans un secret, interpolé par Compose | Le 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: 1500MUn 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
200avec 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 inspectaffichait unRestartCountet unExitCodequi 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)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: 3072MEt 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-stableLeç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 databaseLiteLLM 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.
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/litellmEnsuite, 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$Xk9defest 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 backendNous 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 256Leç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 :
- É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.
- 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.
- Surveiller
docker eventspouroometdie. Ne pas se fier à un seul instantané dedocker inspect. - Une base de données par service qui exécute ses propres migrations.
- Vérifier après le délai, pas seulement juste après un redémarrage.
- Échouer bruyamment au moment de la requête quand une dépendance obligatoire est en panne. Pas de
200avec des champs vides. - Lancer
docker compose configaprès avoir modifié des fichiers d'environnement, et traiter « variable is not set » comme bloquant. - 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