02Operatie5 min

Waarom "het werkt lokaal" me niets meer zegt

De verschillen tussen jouw machine en productie zijn geen pech: het zijn vier concrete categorieën die altijd misgaan en die een groene build niet kan zien.

Door Salva Castells

Omslagafbeelding: Edna Winti, CC BY 2.0.
In dit bericht

Een van de commits waar ik het minst trots op ben zegt letterlijk "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". Ik schreef hem moe, maar de zin beschrijft het probleem goed: de build slaagde, de tests slaagden, en de deploy ging alsnog onderuit.

Het was geen uitzondering. In een Python-service voor het verwerken van documenten heb ik zes commits achter elkaar op dezelfde dag, allemaal getiteld "try to deploy on railway", genummerd 1 en 2 toen de namen op waren. Elk ervan was een blinde poging tegen een omgeving die ik niet kon reproduceren. Dat is geen pech: het is een foutcategorie met een eigen vorm.

De dependencies die anders resolven afhankelijk van wie ze installeert

Het geval van die zes commits was PyTorch. Lokaal had ik het al in cache en startte het. In de container resolvede uv de torch>=2.0.0 van PyPI, die de hele CUDA-stack meesleept: nvidia-cublas-cu12 is 594 MB in één wheel, en daarachter komen cuda-cupti, cuda-nvrtc en consorten. De build faalde niet eens met een fout van mij, hij stierf aan ruimte en tijd. Op een machine die nooit een GPU gaat zien.

Wat het oploste was stoppen met het pakket vragen en beginnen met de index vragen:

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" }

Het detail dat me twee pogingen kostte: met torch>=2.0.0 wijzend naar de CPU-index eindigt het lockfile met twee entries, 2.9.1 voor sys_platform == 'darwin' en 2.9.1+cpu voor de rest, omdat die index op macOS geen +cpu-suffix publiceert. Het werkt, maar het betekent dat wat je op je Mac installeert en wat je in de container installeert niet hetzelfde artefact zijn. De exacte versie met suffix vastzetten sluit die deur.

De andere variant, dommer en vaker: een dependency die je in de code gebruikt en die niet in de package.json staat. Daar heb je puppeteer-core dat binnenkomt in een commit met de titel "adds missing dependency", en nog een genaamd "add missing dependencies" drie weken eerder. Lokaal werkte het omdat het pakket in node_modules stond als transitieve dependency van iets anders. In een container met een schone installatie niet.

De omgeving die je niet repliceert: systeembibliotheken

De duurste fout die ik in deze familie heb gehad zat in een desktopapp voor transcriptie. De microfoon werkte prima met uv run, en werkte niet in de geïnstalleerde .deb. De fout was Invalid sample rate [PaErrorCode -9997].

De oorzaak: PyInstaller verpakte de PortAudio die in de wheel van sounddevice zit, gecompileerd zonder PulseAudio-backend, plus een libasound.so.2 zonder zijn alsa-lib-modules. Binnen de bundle verdwenen de PCM's pulse, pipewire en default, en bleven alleen de hw: in exclusieve toegang over. PipeWire had de microfoon al te pakken, dus opnemen mislukte. Met uv run zag je het niet omdat daar tegen de PortAudio van het systeem wordt gelinkt, die wél PulseAudio heeft.

De oplossing was libportaudio, libasound en libjack uit de bundle uitsluiten. En toen kwam het tweede deel, dat pas echt iets leert: de volgende versie startte op geen enkele moderne distro.

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)

Bij het laden van de PortAudio van het systeem komt de libjack van het systeem in het spel, die op een nieuwe distro GLIBCXX_3.4.32 nodig heeft. Maar PyInstaller verpakte nog steeds libstdc++.so.6, en de bundle staat vooraan in het zoekpad van de linker, dus won de kopie van de build-runner, met als plafond GLIBCXX_3.4.30. De regel die ik daaruit meenam: een bibliotheek uitsluiten snijdt in twee richtingen. Als een dependency tegen het systeem resolvet, moet alles wat hij meesleept ook tegen het systeem resolven.

Waarom een groene build bijna niets zegt

Een build controleert dat de code compileert met de dependencies die de resolver op die machine, op dat moment, koos. Hij controleert niet dat het start, dat het zijn native bibliotheken vindt, of dat het proces weet te luisteren waar de runtime verkeer naartoe stuurt.

Dat laatste heb ik ook betaald. In een interne tool was ik een middag kwijt aan het Procfile en de connectiestring: next start zonder -H 0.0.0.0 accepteert geen verkeer van buiten de container, de web: pnpm next start -p $PORT werd web: pnpm start -- -p $PORT zodat de flag doorkwam, en meteen moest channel_binding uit de DATABASE_URL omdat niet alle runtimes het ondersteunen. Drie fixes, dezelfde dag, geen ervan zichtbaar lokaal: lokaal luistert de server op localhost en zit jij ook op localhost.

De test die de bug wél had gevangen

Het belangrijke aan het libstdc++-geval is niet de fix, maar waarom de smoke test het niet zag: hij draaide op dezelfde runner waar we bouwden. Daar is de libjack van het systeem net zo oud als de verpakte libstdc++, dus komen ze overeen en start de app. De fout verscheen alleen op distro's nieuwer dan de buildmachine. Dat wil zeggen: die van de gebruikers.

Wat ik vandaag in CI stop, op volgorde van wat me het meest heeft gered:

  • Het artefact starten, niet alleen bouwen. Een docker run van de echte container die een request doet naar /health. Een groene build en een levend proces zijn twee verschillende dingen.
  • Het starten in een andere omgeving dan die van de build. In dit geval een ubuntu:24.04-container die alleen de gedeclareerde Depends installeert, wat meteen controleert of die lijst compleet is.
  • Een statische ABI-controle. Een script met objdump dat garandeert dat de C++-runtime niet meereist in de bundle en dat vergelijkt wat de systeembibliotheken vragen met wat de bundle biedt. Het mag niet intermitterend zijn, en het faalt ook op de oude runner waar de bug zich niet voordoet.
  • Schone installatie, zonder cache. Dat is de enige manier om de dependency te laten opduiken die je per ongeluk hebt.

"Het werkt lokaal" betekent dat het werkt met jouw systeembibliotheken, jouw pakketcache, jouw distroversie en jouw netwerkinterface. Vier dingen die productie niet met je deelt.