Jeden z commitów, z których jestem najmniej dumny, mówi dosłownie "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". Pisałem go zmęczony, ale zdanie dobrze opisuje problem: build przeszedł, testy przeszły, a wdrożenie i tak padło.
To nie był odosobniony przypadek. W pythonowym serwisie do przetwarzania dokumentów mam sześć commitów pod rząd tego samego dnia, wszystkie zatytułowane "try to deploy on railway", ponumerowane 1 i 2, kiedy skończyły mi się nazwy. Każdy z nich był ślepą próbą przeciwko środowisku, którego nie umiałem odtworzyć. To nie pech: to kategoria błędu o własnym kształcie.
Zależności, które rozwiązują się inaczej zależnie od tego, kto je instaluje
Przypadkiem tych sześciu commitów był PyTorch. Lokalnie miałem go już w cache i startowało. W kontenerze uv rozwiązywał torch>=2.0.0 z PyPI, który ciągnie za sobą cały stack CUDA: nvidia-cublas-cu12 to 594 MB w jednym wheelu, a za nim idą cuda-cupti, cuda-nvrtc i spółka. Build nawet nie wywalał się na moim błędzie, umierał na braku miejsca i czasu. Na maszynie, która nigdy nie zobaczy GPU.
Naprawiło to przestanie proszenia o paczkę i zaczęcie proszenia o indeks:
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" }
Szczegół, który kosztował mnie dwa podejścia: przy torch>=2.0.0 wskazującym na indeks CPU lockfile kończy z dwoma wpisami, 2.9.1 dla sys_platform == 'darwin' i 2.9.1+cpu dla reszty, bo na macOS ten indeks nie publikuje sufiksu +cpu. Działa, ale oznacza to, że to, co instalujesz na swoim Macu, i to, co instalujesz w kontenerze, to nie ten sam artefakt. Przypięcie dokładnej wersji z sufiksem zamyka te drzwi.
Drugi wariant, głupszy i częstszy: zależność, której używasz w kodzie, a której nie ma w package.json. Tu wchodzi puppeteer-core w commicie zatytułowanym "adds missing dependency", i inny o nazwie "add missing dependencies" trzy tygodnie wcześniej. Lokalnie działało, bo paczka siedziała w node_modules jako tranzytywna zależność czegoś innego. W kontenerze z czystą instalacją — nie.
Środowisko, którego nie odtwarzasz: biblioteki systemowe
Najdroższy błąd z tej rodziny miałem w desktopowej aplikacji do transkrypcji. Mikrofon działał bez zarzutu przy uv run, a nie działał w zainstalowanym .deb. Błąd brzmiał Invalid sample rate [PaErrorCode -9997].
Przyczyna: PyInstaller pakował PortAudio z wheela sounddevice, skompilowane bez backendu PulseAudio, oraz libasound.so.2 bez jego modułów z alsa-lib. Wewnątrz bundle'a znikały PCM-y pulse, pipewire i default, zostawały tylko te hw: w dostępie wyłącznym. PipeWire trzymał już mikrofon, więc nagrywanie padało. Przy uv run tego nie było widać, bo tam linkuje się systemowe PortAudio, które PulseAudio ma.
Rozwiązaniem było wykluczenie libportaudio, libasound i libjack z bundle'a. I tu zaczyna się druga część, ta, która naprawdę czegoś uczy: kolejna wersja nie startowała na żadnej nowoczesnej dystrybucji.
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)
Przy ładowaniu systemowego PortAudio do gry wchodzi systemowy libjack, który na nowej dystrybucji potrzebuje GLIBCXX_3.4.32. Ale PyInstaller dalej pakował libstdc++.so.6, a bundle idzie pierwszy w ścieżce wyszukiwania linkera, więc wygrywała kopia z runnera buildowego, z pułapem GLIBCXX_3.4.30. Reguła, którą stąd wyniosłem: wykluczenie biblioteki tnie w obie strony. Jeśli zależność rozwiązuje się przeciwko systemowi, wszystko, co ona ciągnie, też musi rozwiązywać się przeciwko systemowi.
Dlaczego zielony build prawie nic nie mówi
Build sprawdza, że kod kompiluje się z zależnościami, które resolver wybrał na tej maszynie, w tamtym momencie. Nie sprawdza, czy wystartuje, czy znajdzie swoje natywne biblioteki, ani czy proces umie słuchać tam, gdzie runtime będzie mu wysyłał ruch.
To ostatnie też opłaciłem. W wewnętrznym narzędziu spędziłem popołudnie z Procfile i stringiem połączenia: next start bez -H 0.0.0.0 nie przyjmuje ruchu spoza kontenera, web: pnpm next start -p $PORT zmieniło się na web: pnpm start -- -p $PORT, żeby flaga doszła, i przy okazji trzeba było wyrzucić channel_binding z DATABASE_URL, bo nie wszystkie runtime'y to wspierają. Trzy poprawki, tego samego dnia, żadna widoczna lokalnie: lokalnie serwer słucha na localhoście i ty też jesteś na localhoście.
Test, który naprawdę złapałby ten bug
Najważniejsze w przypadku libstdc++ nie jest to, jak go naprawiłem, tylko dlaczego smoke test tego nie zobaczył: chodził na tym samym runnerze, na którym budowaliśmy. Tam systemowy libjack jest tak samo stary jak zapakowany libstdc++, więc się zgadzają i aplikacja startuje. Błąd pojawiał się tylko na dystrybucjach nowszych niż maszyna buildowa. Czyli na tych u użytkowników.
Co dziś wrzucam do CI, według tego, co najbardziej mnie uratowało:
- Uruchomienie artefaktu, nie tylko zbudowanie go.
docker runprawdziwego kontenera z zapytaniem do/health. Zielony build i żywy proces to dwie różne rzeczy. - Uruchomienie go w środowisku innym niż buildowe. W tym wypadku kontener
ubuntu:24.04instalujący wyłącznie zadeklarowaneDepends, co przy okazji sprawdza, czy ta lista jest kompletna. - Statyczne sprawdzenie ABI. Skrypt z
objdump, który pilnuje, żeby runtime C++ nie podróżował w bundlu, i porównuje to, czego żądają biblioteki systemowe, z tym, co bundle oferuje. Nie może być niestabilny i wywala się także na starym runnerze, gdzie bug się nie odtwarza. - Czysta instalacja, bez cache. To jedyny sposób, żeby wyszła zależność, którą masz przez przypadek.
"U mnie działa" znaczy, że działa z twoimi bibliotekami systemowymi, twoim cache paczek, twoją wersją dystrybucji i twoim interfejsem sieciowym. Cztery rzeczy, których produkcja z tobą nie dzieli.
