Uno dei commit di cui vado meno fiero dice letteralmente "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". L'ho scritto stanco, ma la frase descrive bene il problema: la build è passata, i test sono passati e il deploy è caduto lo stesso.
Non è stato un caso raro. In un servizio Python per elaborare documenti ho sei commit di fila lo stesso giorno, tutti intitolati "try to deploy on railway", numerati 1 e 2 quando ho finito i nomi. Ognuno era un tentativo alla cieca contro un ambiente che non potevo riprodurre. Non è sfortuna: è una categoria di guasto con una forma sua.
Le dipendenze che si risolvono in modo diverso a seconda di chi le installa
Il caso dei sei commit era PyTorch. In locale ce l'avevo già in cache e partiva. Nel container, uv risolveva il torch>=2.0.0 di PyPI, che si trascina dietro tutto lo stack CUDA: nvidia-cublas-cu12 sono 594 MB in un solo wheel, e dietro arrivano cuda-cupti, cuda-nvrtc e compagnia. La build non falliva nemmeno con un errore mio, moriva per spazio e tempo. Su una macchina che non vedrà mai una GPU.
Quello che l'ha sistemata è stato smettere di chiedere il pacchetto e iniziare a chiedere l'indice:
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" }
Il dettaglio che mi è costato due tentativi: con torch>=2.0.0 che punta all'indice CPU, il lockfile finisce con due voci, 2.9.1 per sys_platform == 'darwin' e 2.9.1+cpu per il resto, perché su macOS quell'indice non pubblica il suffisso +cpu. Funziona, ma vuol dire che quello che installi sul tuo Mac e quello che installi nel container non sono lo stesso artefatto. Fissare la versione esatta con il suffisso chiude quella porta.
L'altra variante, più stupida e più frequente: una dipendenza che usi nel codice e non sta nel package.json. Ecco puppeteer-core che entra in un commit intitolato "adds missing dependency", e un altro chiamato "add missing dependencies" tre settimane prima. In locale funzionava perché il pacchetto stava in node_modules in quanto dipendenza transitiva di qualcos'altro. In un container con installazione pulita, no.
L'ambiente che non replichi: librerie di sistema
Il guasto più caro che ho avuto di questa famiglia stava in un'app desktop di trascrizione. Il microfono funzionava perfettamente con uv run, e non funzionava nel .deb installato. L'errore era Invalid sample rate [PaErrorCode -9997].
La causa: PyInstaller impacchettava il PortAudio che arriva nel wheel di sounddevice, compilato senza backend PulseAudio, e una libasound.so.2 senza i suoi moduli di alsa-lib. Dentro il bundle sparivano i PCM pulse, pipewire e default, e restavano solo gli hw: in accesso esclusivo. PipeWire aveva già preso il microfono, quindi registrare falliva. Con uv run non si vedeva perché lì si collega al PortAudio di sistema, che PulseAudio ce l'ha.
La soluzione è stata escludere libportaudio, libasound e libjack dal bundle. E lì è arrivata la seconda parte, che è quella che insegna davvero qualcosa: la versione successiva non partiva su nessuna distro 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)
Caricando il PortAudio di sistema entra in gioco la libjack di sistema, che su una distro nuova ha bisogno di GLIBCXX_3.4.32. Ma PyInstaller continuava a impacchettare libstdc++.so.6, e il bundle viene prima nel percorso di ricerca del linker, quindi vinceva la copia del runner di build, tetto GLIBCXX_3.4.30. La regola che ne ho tirato fuori: escludere una libreria taglia in entrambe le direzioni. Se una dipendenza si risolve contro il sistema, tutto quello che si trascina dietro deve risolversi contro il sistema anche lui.
Perché una build verde non dice quasi niente
Una build verifica che il codice compili con le dipendenze che il risolutore ha scelto su quella macchina, in quel momento. Non verifica che parta, che trovi le sue librerie native, né che il processo sappia ascoltare dove il runtime gli manderà il traffico.
Anche quest'ultima l'ho pagata. In uno strumento interno ho passato un pomeriggio con il Procfile e la stringa di connessione: next start senza -H 0.0.0.0 non accetta traffico da fuori del container, il web: pnpm next start -p $PORT è diventato web: pnpm start -- -p $PORT perché il flag arrivasse, e già che c'ero ho dovuto togliere channel_binding dalla DATABASE_URL perché non tutti i runtime lo supportano. Tre correzioni, lo stesso giorno, nessuna visibile in locale: in locale il server ascolta su localhost e anche tu sei su localhost.
Il test che avrebbe beccato il bug
L'importante nel caso di libstdc++ non è la correzione, è perché lo smoke test non l'ha visto: girava sullo stesso runner dove costruivamo. Lì la libjack di sistema è vecchia quanto la libstdc++ impacchettata, quindi coincidono e l'app parte. Il guasto appariva solo su distro più nuove della macchina di build. Cioè su quelle degli utenti.
Quello che metto oggi in CI, in ordine di quanto mi ha salvato:
- Avviare l'artefatto, non solo costruirlo. Un
docker rundel container vero che faccia una richiesta a/health. La build verde e il processo vivo sono due cose diverse. - Avviarlo in un ambiente diverso da quello di build. In questo caso un container
ubuntu:24.04che installa solo leDependsdichiarate, il che verifica anche che quella lista sia completa. - Un controllo statico di ABI. Uno script con
objdumpche assicura che il runtime C++ non viaggi nel bundle e confronta quello che chiedono le lib di sistema con quello che il bundle offre. Non può essere intermittente, e fallisce anche sul runner vecchio dove il bug non si riproduce. - Installazione pulita, senza cache. È l'unico modo perché salti fuori la dipendenza che hai per caso.
"Funziona in locale" vuol dire che funziona con le tue librerie di sistema, la tua cache dei pacchetti, la tua versione di distro e la tua interfaccia di rete. Quattro cose che la produzione non condivide con te.
