Un dels commits dels quals estic menys orgullós diu literalment "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". El vaig escriure cansat, però la frase descriu bé el problema: el build va passar, els tests van passar, i el desplegament va caure igualment.
No va ser un cas rar. En un servei de Python per processar documents tinc sis commits seguits el mateix dia, tots titulats "try to deploy on railway", numerats 1 i 2 quan se'm van acabar els noms. Cadascun era un intent a cegues contra un entorn que no podia reproduir. Això no és mala sort: és una categoria de fallada amb forma pròpia.
Les dependències que resolen diferent segons qui les instal·li
El cas dels sis commits era PyTorch. En local ja el tenia en memòria cau i arrencava. Al contenidor, uv resolia el torch>=2.0.0 de PyPI, que arrossega l'stack de CUDA sencer: nvidia-cublas-cu12 són 594 MB en un sol wheel, i al darrere vénen cuda-cupti, cuda-nvrtc i companyia. El build ni tan sols fallava amb un error meu, es moria per espai i temps. En una màquina que mai no veurà una GPU.
El que ho va arreglar va ser deixar de demanar el paquet i començar a demanar l'índex:
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" }
El detall que em va costar dos intents: amb torch>=2.0.0 apuntant a l'índex CPU, el lockfile acaba amb dues entrades, 2.9.1 per a sys_platform == 'darwin' i 2.9.1+cpu per a la resta, perquè a macOS aquell índex no publica sufix +cpu. Funciona, però vol dir que allò que instal·les al teu Mac i allò que instal·les al contenidor no són el mateix artefacte. Fixar la versió exacta amb sufix tanca aquesta porta.
L'altra variant, més ximple i més freqüent: una dependència que fas servir al codi i no és al package.json. Aquí hi ha puppeteer-core entrant en un commit titulat "adds missing dependency", i un altre anomenat "add missing dependencies" tres setmanes abans. En local funcionava perquè el paquet era a node_modules per ser dependència transitiva d'una altra cosa. En un contenidor amb instal·lació neta, no.
L'entorn que no repliques: llibreries del sistema
La fallada més cara que he tingut d'aquesta família era en una app d'escriptori de transcripció. El micròfon funcionava perfectament amb uv run, i no funcionava al .deb instal·lat. L'error era Invalid sample rate [PaErrorCode -9997].
La causa: PyInstaller empaquetava el PortAudio que ve al wheel de sounddevice, compilat sense backend de PulseAudio, i un libasound.so.2 sense els seus mòduls d'alsa-lib. Dins del bundle desapareixien els PCM pulse, pipewire i default, i només quedaven els hw: en accés exclusiu. PipeWire ja tenia el micro agafat, així que gravar fallava. Amb uv run no es veia perquè allà s'enllaça contra el PortAudio del sistema, que sí que té PulseAudio.
La solució va ser excloure libportaudio, libasound i libjack del bundle. I aleshores va arribar la segona part, que és la que de debò ensenya alguna cosa: la versió següent no arrencava en cap distribució moderna.
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 carregar el PortAudio del sistema entra en joc el libjack del sistema, que en una distribució nova necessita GLIBCXX_3.4.32. Però PyInstaller continuava empaquetant libstdc++.so.6, i el bundle va primer a la ruta de cerca del linker, així que guanyava la còpia del runner de build, sostre GLIBCXX_3.4.30. La regla que en vaig treure: excloure una llibreria talla en les dues direccions. Si una dependència resol contra el sistema, tot el que arrossegui ha de resoldre contra el sistema també.
Per què el build en verd no diu gairebé res
Un build comprova que el codi compila amb les dependències que el resolutor va triar en aquella màquina, en aquell moment. No comprova que arrenqui, que trobi les seves llibreries natives, ni que el procés sàpiga escoltar allà on el runtime li enviarà trànsit.
Això últim també ho he pagat. En una eina interna em vaig passar una tarda amb el Procfile i la cadena de connexió: next start sense -H 0.0.0.0 no accepta trànsit des de fora del contenidor, el web: pnpm next start -p $PORT va passar a web: pnpm start -- -p $PORT perquè el flag arribés, i de passada va caldre treure channel_binding de la DATABASE_URL perquè no tots els runtimes el suporten. Tres arreglaments, el mateix dia, cap de visible en local: en local el servidor escolta a localhost i tu també ets a localhost.
El test que sí que hauria caçat el bug
L'important del cas del libstdc++ no és l'arreglament, és per què el smoke test no ho va veure: corria al mateix runner on construíem. Allà el libjack del sistema és tan vell com el libstdc++ empaquetat, així que coincideixen i l'app arrenca. La fallada només apareixia en distribucions més noves que la màquina de build. És a dir, en les dels usuaris.
El que fico avui a CI, per ordre del que més m'ha salvat:
- Arrencar l'artefacte, no només construir-lo. Un
docker rundel contenidor real que faci una petició a/health. El build verd i el procés viu són dues coses diferents. - Arrencar-lo en un entorn diferent del de build. En aquest cas, un contenidor
ubuntu:24.04instal·lant només lesDependsdeclarades, que de passada comprova que aquella llista sigui completa. - Una comprovació estàtica d'ABI. Un script amb
objdumpque assegura que el runtime de C++ no viatja al bundle i compara el que demanen les llibreries del sistema amb el que el bundle ofereix. No pot ser intermitent, i falla també al runner vell on el bug no es reprodueix. - Instal·lació neta, sense memòria cau. És l'única manera que aparegui la dependència que tens per accident.
"Funciona en local" vol dir que funciona amb les teves llibreries del sistema, la teva memòria cau de paquets, la teva versió de distribució i la teva interfície de xarxa. Quatre coses que producció no comparteix amb tu.
