自分のコミットでいちばん誇れないもののひとつに、文字どおり「fixear la mierda de dependencias, que no dan error en build pero sí al desplegar」と書かれたものがあります。疲れて書いたものですが、問題はよく表しています。ビルドは通り、テストも通り、それでもデプロイは落ちました。
珍しい話ではありません。ドキュメント処理用の Python サービスでは、同じ日に六つ連続でコミットがあり、全部 "try to deploy on railway" という題で、名前が尽きたところで 1 と 2 の番号が振られています。どれも、再現できない環境に向けた手探りの試みでした。これは不運ではありません。固有の形を持った故障の分類です。
インストールする人によって解決が変わる依存
六つのコミットの正体は PyTorch でした。ローカルではすでにキャッシュにあり、起動していました。コンテナでは uv が PyPI の torch>=2.0.0 を解決し、CUDA のスタックを丸ごと引き連れてきます。nvidia-cublas-cu12 は単一の wheel で 594 MB あり、その後ろに cuda-cupti や cuda-nvrtc などが続きます。ビルドは自分のエラーで落ちることすらなく、容量と時間で死にました。GPU を一生見ることのないマシンで、です。
直したのは、パッケージを要求するのをやめてインデックスを要求するようにしたことです。
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" }
二回試すまで気づかなかった細部があります。torch>=2.0.0 を CPU のインデックスに向けると、ロックファイルには二つのエントリが残ります。sys_platform == 'darwin' には 2.9.1、それ以外には 2.9.1+cpu。macOS 向けにはそのインデックスが +cpu の接尾辞を公開しないからです。動きはしますが、Mac にインストールするものとコンテナにインストールするものが同じ成果物ではない、という意味になります。接尾辞つきの正確なバージョンで固定すれば、その扉は閉じます。
もうひとつの、もっと間抜けで、もっと頻繁な変種。コードで使っているのに package.json にない依存です。"adds missing dependency" という題のコミットで puppeteer-core が入り、その三週間前には "add missing dependencies" という別のコミットがあります。ローカルで動いていたのは、そのパッケージが別のものの推移的依存として node_modules にあったからです。クリーンインストールのコンテナでは、そうはいきません。
再現できない環境: システムのライブラリ
この系統でいちばん高くついた失敗は、文字起こしのデスクトップアプリでした。マイクは uv run では完璧に動き、インストールした .deb では動きません。エラーは Invalid sample rate [PaErrorCode -9997] でした。
原因はこうです。PyInstaller が sounddevice の wheel に入っている PortAudio を同梱していて、それは PulseAudio のバックエンドなしでコンパイルされたものでした。さらに alsa-lib のモジュールを持たない libasound.so.2 も。バンドルの中では PCM の pulse、pipewire、default が消え、排他アクセスの hw: だけが残ります。PipeWire がすでにマイクを掴んでいたので、録音は失敗しました。uv run では見えなかったのは、そちらではシステムの PortAudio にリンクされ、そちらには PulseAudio があるからです。
解決策は libportaudio、libasound、libjack をバンドルから除外することでした。そしてここから第二幕が始まります。本当に学びがあるのはこちらです。次のバージョンは、最近のディストリでは一切起動しなくなりました。
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)
システムの PortAudio を読み込むとシステムの libjack が絡んできて、新しいディストリではそれが GLIBCXX_3.4.32 を要求します。ところが PyInstaller は libstdc++.so.6 を同梱し続けていて、リンカの探索パスではバンドルが先に来るので、ビルド用ランナーのコピーが勝ちます。上限は GLIBCXX_3.4.30 です。ここから得た規則。ライブラリの除外は両方向で効く。 ある依存がシステム側に解決されるなら、それが引き連れてくるものも全部システム側に解決されなければなりません。
ビルドが緑でもほとんど何も言えない理由
ビルドが確かめるのは、そのマシンのその瞬間に解決器が選んだ依存でコードがコンパイルできることだけです。起動することも、ネイティブのライブラリを見つけられることも、ランタイムがトラフィックを送ってくる先でプロセスが待ち受けられることも、確かめていません。
最後の点も痛い目を見ました。社内ツールで、Procfile と接続文字列に午後を丸ごと費やしました。-H 0.0.0.0 のない next start はコンテナ外からのトラフィックを受け付けません。フラグが届くように web: pnpm next start -p $PORT を web: pnpm start -- -p $PORT に変えました。ついでに、すべてのランタイムが対応しているわけではないので DATABASE_URL から channel_binding を外す必要もありました。同じ日に三つの修正、どれもローカルでは見えません。ローカルではサーバーが localhost で待ち受け、自分も localhost にいるからです。
そのバグを実際に捕まえられたはずのテスト
libstdc++ の件で重要なのは修正ではなく、なぜスモークテストが見逃したかです。ビルドと同じランナーで走っていたからです。そこではシステムの libjack が同梱の libstdc++ と同じくらい古いので噛み合い、アプリは起動します。故障はビルドマシンより新しいディストリでしか現れませんでした。つまり、ユーザーのマシンです。
いま CI に入れているものを、助かった順に並べます。
- 成果物をビルドするだけでなく起動する。 本物のコンテナを
docker runして/healthにリクエストを投げます。ビルドが緑なのとプロセスが生きているのは別の話です。 - ビルドとは別の環境で起動する。 この場合は
ubuntu:24.04のコンテナで、宣言したDependsだけをインストールします。ついでにその一覧が完全かどうかも確かめられます。 - ABI の静的チェック。
objdumpを使うスクリプトで、C++ のランタイムがバンドルに紛れ込んでいないことを保証し、システムのライブラリが要求するものとバンドルが提供するものを突き合わせます。これは不安定であってはならず、バグが再現しない古いランナーでもきちんと落ちます。 - キャッシュなしのクリーンインストール。 偶然そこにあるだけの依存をあぶり出す唯一の方法です。
「ローカルでは動く」とは、自分のシステムライブラリ、自分のパッケージキャッシュ、自分のディストリのバージョン、自分のネットワークインターフェースで動く、という意味です。本番がひとつも共有していない四つです。
