02Betrieb5 min

Warum "läuft lokal" mir nichts mehr sagt

Die Unterschiede zwischen deiner Maschine und der Produktion sind kein Pech: Es sind vier konkrete Kategorien, die immer scheitern und die ein grüner Build nicht sehen kann.

Von Salva Castells

Titelbild: Edna Winti, CC BY 2.0.
In diesem Beitrag

Einer der Commits, auf die ich am wenigsten stolz bin, lautet wörtlich "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". Ich habe ihn müde geschrieben, aber der Satz beschreibt das Problem gut: Der Build lief durch, die Tests liefen durch, und das Deployment ist trotzdem umgefallen.

Das war kein Einzelfall. In einem Python-Service zur Dokumentenverarbeitung habe ich sechs Commits hintereinander am selben Tag, alle mit dem Titel "try to deploy on railway", durchnummeriert mit 1 und 2, als mir die Namen ausgingen. Jeder war ein Blindversuch gegen eine Umgebung, die ich nicht reproduzieren konnte. Das ist kein Pech: Das ist eine Fehlerkategorie mit eigener Form.

Die Abhängigkeiten, die je nach Installierendem anders aufgelöst werden

Der Fall mit den sechs Commits war PyTorch. Lokal hatte ich es schon im Cache und es startete. Im Container löste uv das torch>=2.0.0 von PyPI auf, das den gesamten CUDA-Stack mitschleppt: nvidia-cublas-cu12 sind 594 MB in einem einzigen Wheel, und dahinter kommen cuda-cupti, cuda-nvrtc und Konsorten. Der Build scheiterte nicht einmal an einem Fehler von mir, er starb an Platz und Zeit. Auf einer Maschine, die nie eine GPU sehen wird.

Was es behoben hat, war, aufzuhören, das Paket anzufordern, und stattdessen den Index anzufordern:

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

Das Detail, das mich zwei Anläufe gekostet hat: Mit torch>=2.0.0 und Verweis auf den CPU-Index landet das Lockfile bei zwei Einträgen, 2.9.1 für sys_platform == 'darwin' und 2.9.1+cpu für den Rest, weil dieser Index auf macOS kein +cpu-Suffix veröffentlicht. Es funktioniert, aber es bedeutet, dass das, was du auf deinem Mac installierst, und das, was du im Container installierst, nicht dasselbe Artefakt sind. Die exakte Version mit Suffix festzunageln schließt diese Tür.

Die andere Variante, dümmer und häufiger: eine Abhängigkeit, die du im Code benutzt und die nicht in der package.json steht. Da ist puppeteer-core, das in einem Commit mit dem Titel "adds missing dependency" auftaucht, und ein weiterer namens "add missing dependencies" drei Wochen davor. Lokal funktionierte es, weil das Paket als transitive Abhängigkeit von etwas anderem in node_modules lag. In einem Container mit sauberer Installation nicht.

Die Umgebung, die du nicht nachbaust: Systembibliotheken

Der teuerste Fehler dieser Familie steckte in einer Desktop-App für Transkription. Das Mikrofon funktionierte mit uv run einwandfrei und funktionierte in der installierten .deb nicht. Der Fehler war Invalid sample rate [PaErrorCode -9997].

Die Ursache: PyInstaller paketierte das PortAudio aus dem Wheel von sounddevice, kompiliert ohne PulseAudio-Backend, und ein libasound.so.2 ohne seine alsa-lib-Module. Innerhalb des Bundles verschwanden die PCMs pulse, pipewire und default, und übrig blieben nur die hw: im exklusiven Zugriff. PipeWire hatte das Mikro schon belegt, also schlug die Aufnahme fehl. Mit uv run sah man das nicht, weil dort gegen das PortAudio des Systems gelinkt wird, das PulseAudio sehr wohl hat.

Die Lösung war, libportaudio, libasound und libjack aus dem Bundle auszuschließen. Und dann kam der zweite Teil, der wirklich etwas beibringt: Die nächste Version startete auf keiner modernen Distribution mehr.

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)

Beim Laden des System-PortAudio kommt das libjack des Systems ins Spiel, das auf einer neuen Distribution GLIBCXX_3.4.32 braucht. Aber PyInstaller paketierte weiterhin libstdc++.so.6, und das Bundle steht im Suchpfad des Linkers vorne, also gewann die Kopie vom Build-Runner, maximal GLIBCXX_3.4.30. Die Regel, die ich daraus mitgenommen habe: Eine Bibliothek auszuschließen schneidet in beide Richtungen. Wenn eine Abhängigkeit gegen das System aufgelöst wird, muss alles, was sie mitzieht, ebenfalls gegen das System aufgelöst werden.

Warum ein grüner Build fast nichts aussagt

Ein Build prüft, dass der Code mit den Abhängigkeiten kompiliert, die der Resolver auf dieser Maschine in diesem Moment ausgewählt hat. Er prüft nicht, dass er startet, dass er seine nativen Bibliotheken findet, und auch nicht, dass der Prozess dort lauschen kann, wohin die Runtime ihm Traffic schicken wird.

Auch das habe ich bezahlt. In einem internen Werkzeug habe ich einen Nachmittag mit dem Procfile und dem Connection String verbracht: next start ohne -H 0.0.0.0 nimmt keinen Traffic von außerhalb des Containers an, aus web: pnpm next start -p $PORT wurde web: pnpm start -- -p $PORT, damit das Flag ankommt, und nebenbei musste channel_binding aus der DATABASE_URL raus, weil nicht alle Runtimes es unterstützen. Drei Korrekturen am selben Tag, keine davon lokal sichtbar: Lokal lauscht der Server auf localhost und du bist auch auf localhost.

Der Test, der den Bug tatsächlich gefangen hätte

Das Wichtige am libstdc++-Fall ist nicht die Korrektur, sondern warum der Smoke Test ihn nicht gesehen hat: Er lief auf demselben Runner, auf dem wir gebaut haben. Dort ist das libjack des Systems genauso alt wie das paketierte libstdc++, also passen sie zusammen und die App startet. Der Fehler trat nur auf Distributionen auf, die neuer waren als die Build-Maschine. Also auf denen der Nutzer.

Was ich heute in die CI stecke, geordnet danach, was mich am meisten gerettet hat:

  • Das Artefakt starten, nicht nur bauen. Ein docker run des echten Containers, das eine Anfrage an /health stellt. Grüner Build und laufender Prozess sind zwei verschiedene Dinge.
  • Es in einer anderen Umgebung als der Build-Umgebung starten. In diesem Fall ein ubuntu:24.04-Container, der nur die deklarierten Depends installiert, was nebenbei prüft, dass diese Liste vollständig ist.
  • Eine statische ABI-Prüfung. Ein Skript mit objdump, das sicherstellt, dass die C++-Runtime nicht im Bundle mitreist, und das vergleicht, was die System-Libs verlangen, mit dem, was das Bundle bietet. Es darf nicht sporadisch sein und schlägt auch auf dem alten Runner fehl, wo sich der Bug nicht reproduzieren lässt.
  • Saubere Installation, ohne Cache. Nur so taucht die Abhängigkeit auf, die du zufällig hast.

"Läuft lokal" heißt, dass es mit deinen Systembibliotheken, deinem Paket-Cache, deiner Distributionsversion und deiner Netzwerkschnittstelle läuft. Vier Dinge, die die Produktion nicht mit dir teilt.