02Exploitation5 min

Pourquoi « ça marche en local » ne me dit plus rien

Les différences entre votre machine et la production ne sont pas de la malchance : ce sont quatre catégories précises qui échouent toujours et qu'un build au vert ne peut pas voir.

Par Salva Castells

Image de couverture : Edna Winti, CC BY 2.0.
Dans cet article

L'un des commits dont je suis le moins fier dit littéralement « fixear la mierda de dependencias, que no dan error en build pero sí al desplegar ». Je l'ai écrit fatigué, mais la phrase décrit bien le problème : le build est passé, les tests sont passés, et le déploiement s'est quand même effondré.

Ce n'était pas un cas isolé. Dans un service Python de traitement de documents, j'ai six commits d'affilée le même jour, tous intitulés « try to deploy on railway », numérotés 1 et 2 quand je n'ai plus eu de noms. Chacun était une tentative à l'aveugle contre un environnement que je ne pouvais pas reproduire. Ce n'est pas de la malchance : c'est une catégorie de panne avec sa propre forme.

Les dépendances qui se résolvent différemment selon qui les installe

Le cas des six commits, c'était PyTorch. En local je l'avais déjà en cache et ça démarrait. Dans le conteneur, uv résolvait le torch>=2.0.0 de PyPI, qui embarque toute la pile CUDA : nvidia-cublas-cu12 fait 594 Mo dans un seul wheel, et derrière viennent cuda-cupti, cuda-nvrtc et compagnie. Le build n'échouait même pas sur une erreur de ma part, il mourait par manque d'espace et de temps. Sur une machine qui ne verra jamais de GPU.

Ce qui a réglé le problème, c'est d'arrêter de demander le paquet et de commencer à demander l'index :

dependencies = ["torch==2.9.1+cpu", "torchvision==0.24.1+cpu"]

[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

[tool.uv.sources]
torch = { index = "pytorch-cpu" }

Le détail qui m'a coûté deux tentatives : avec torch>=2.0.0 pointant vers l'index CPU, le lockfile finit avec deux entrées, 2.9.1 pour sys_platform == 'darwin' et 2.9.1+cpu pour le reste, parce que sur macOS cet index ne publie pas de suffixe +cpu. Ça marche, mais cela veut dire que ce que vous installez sur votre Mac et ce que vous installez dans le conteneur ne sont pas le même artefact. Figer la version exacte avec le suffixe ferme cette porte.

L'autre variante, plus bête et plus fréquente : une dépendance que vous utilisez dans le code et qui n'est pas dans le package.json. C'est le cas de puppeteer-core, entré dans un commit intitulé « adds missing dependency », et d'un autre appelé « add missing dependencies » trois semaines plus tôt. En local ça marchait parce que le paquet était dans node_modules en tant que dépendance transitive d'autre chose. Dans un conteneur avec une installation propre, non.

L'environnement que vous ne répliquez pas : les bibliothèques système

La panne la plus coûteuse de cette famille était dans une application de bureau de transcription. Le micro fonctionnait parfaitement avec uv run, et ne fonctionnait pas dans le .deb installé. L'erreur était Invalid sample rate [PaErrorCode -9997].

La cause : PyInstaller empaquetait le PortAudio fourni dans le wheel de sounddevice, compilé sans backend PulseAudio, et un libasound.so.2 sans ses modules alsa-lib. Dans le bundle, les PCM pulse, pipewire et default disparaissaient et il ne restait que les hw: en accès exclusif. PipeWire avait déjà pris le micro, donc l'enregistrement échouait. Avec uv run on ne voyait rien parce que là, l'édition de liens se fait contre le PortAudio du système, qui a bien PulseAudio.

La solution a été d'exclure libportaudio, libasound et libjack du bundle. Et c'est là qu'est arrivée la deuxième partie, celle qui apprend vraiment quelque chose : la version suivante ne démarrait sur aucune distribution moderne.

OSError: cannot load library 'libportaudio.so.2': .../_internal/libstdc++.so.6:
version `GLIBCXX_3.4.32' not found (required by /usr/lib/x86_64-linux-gnu/libjack.so.0)

En chargeant le PortAudio du système, on fait entrer en jeu le libjack du système, qui sur une distribution récente exige GLIBCXX_3.4.32. Mais PyInstaller continuait d'empaqueter libstdc++.so.6, et le bundle passe en premier dans le chemin de recherche de l'éditeur de liens : c'est donc la copie du runner de build qui gagnait, plafonnée à GLIBCXX_3.4.30. La règle que j'en ai tirée : exclure une bibliothèque coupe dans les deux sens. Si une dépendance se résout contre le système, tout ce qu'elle entraîne doit se résoudre contre le système aussi.

Pourquoi un build au vert ne dit presque rien

Un build vérifie que le code compile avec les dépendances que le résolveur a choisies sur cette machine, à ce moment-là. Il ne vérifie pas que ça démarre, que ça trouve ses bibliothèques natives, ni que le processus sait écouter là où le runtime va lui envoyer du trafic.

Ce dernier point aussi, je l'ai payé. Dans un outil interne, j'ai passé un après-midi sur le Procfile et la chaîne de connexion : next start sans -H 0.0.0.0 n'accepte pas de trafic venant de l'extérieur du conteneur, le web: pnpm next start -p $PORT est devenu web: pnpm start -- -p $PORT pour que le flag arrive bien, et au passage il a fallu retirer channel_binding de la DATABASE_URL parce que tous les runtimes ne le supportent pas. Trois correctifs, le même jour, aucun visible en local : en local le serveur écoute sur localhost et vous êtes vous aussi sur localhost.

Le test qui aurait attrapé le bug

L'important dans le cas de libstdc++, ce n'est pas le correctif, c'est la raison pour laquelle le smoke test ne l'a pas vu : il tournait sur le même runner que celui où l'on construisait. Là, le libjack du système est aussi vieux que le libstdc++ empaqueté, donc ils coïncident et l'application démarre. La panne n'apparaissait que sur des distributions plus récentes que la machine de build. Autrement dit, sur celles des utilisateurs.

Ce que je mets aujourd'hui dans la CI, par ordre de ce qui m'a le plus sauvé :

  • Démarrer l'artefact, pas seulement le construire. Un docker run du vrai conteneur qui fait une requête sur /health. Un build au vert et un processus vivant sont deux choses distinctes.
  • Le démarrer dans un environnement différent de celui du build. Ici, un conteneur ubuntu:24.04 installant uniquement les Depends déclarées, ce qui vérifie au passage que cette liste est complète.
  • Une vérification statique de l'ABI. Un script avec objdump qui s'assure que le runtime C++ ne voyage pas dans le bundle et compare ce que demandent les bibliothèques système à ce que le bundle propose. Il ne peut pas être intermittent, et il échoue aussi sur le vieux runner où le bug ne se reproduit pas.
  • Installation propre, sans cache. C'est la seule façon de faire apparaître la dépendance que vous avez par accident.

« Ça marche en local » signifie que ça marche avec vos bibliothèques système, votre cache de paquets, votre version de distribution et votre interface réseau. Quatre choses que la production ne partage pas avec vous.