超高速が引き起こす「椅子取りゲーム」:uv のマルチスレッド配置が誘発した __init__.py 競合バグ
1. 概要
Rust製パッケージマネージャ uv の最大の特徴は、従来の pip を圧倒する「超高速なインストール処理」である。この高速化は、複数のパッケージを並行(マルチスレッド)でダウンロード・解凍・配置することによって実現されている。 しかし、この極限の並行ファイルI/O処理は、Pythonの歴史的パッケージの一部が抱える「同一ファイルパスの共有・上書き(Namespaceの衝突)」と出会ったとき、非決定論的(確率的)にインポートエラーを引き起こす恐れのあるファイルシステムの競合状態(Race Condition)を誘発する。 本稿では、uv の並行処理によって発現した __init__.py の上書き衝突バグ(astral-sh/uv#6568)を事例に、並行インストーラが孕む可用性の罠について分析する。
2. インシデントの分析:Dockerビルドを破壊する「確率的 Import Error」
2.1. 現象:2回に1回失敗する謎のインポートエラー
開発者が Dockerfile 内で uv pip sync を使用して依存パッケージを同期し、ビルド完了直後に Python を実行してライブラリ(pyjwt)をインポートしようとした際、非決定論的(ある時は成功し、ある時は失敗する)に以下のエラーが発生し、ビルドが停止する現象が発生した。
ImportError: cannot import name 'PyJWKClient' from 'jwt' (/opt/venv/lib/python3.12/site-packages/jwt/__init__.py)この現象は docker system prune などを実行してキャッシュをクリアすると再現しやすく、同じ設定ファイル(requirements.txt)であるにもかかわらず、ビルドの成功確率が五分五分になるという、極めて追跡困難な挙動を示した。
2.2. 原因:マルチスレッド配置によるファイル書き込みの「椅子取りゲーム」
このバグの原因は、jwt パッケージと pyjwt パッケージという、Pythonの歴史的経緯から「同じインストール先ディレクトリ(site-packages/jwt/)」および「同じファイル名(__init__.py)」を共有し、お互いがお互いを上書き・拡張しようとする歪んだパッケージ設計にあった。
- 従来の
pip: シングルスレッドで極めて低速に、パッケージを一つずつ順番にインストールしていくため、上書きの順序が常に一定(決定論的)になり、結果としてpyjwtが最後に__init__.pyを書き換えて上書きを完了させることができていたため、表面上は動いていた。 - 超高速な
uv: Rustの並行処理により、jwtの展開処理とpyjwtの展開処理を異なるスレッドで同時に実行した。 これにより、OSのファイルシステム上において、**「どちらのスレッドが最後にjwt/__init__.pyを書き込んで完了させるか」の物理的なレースコンディション(椅子取りゲーム)**が発生した。 スレッドスケジューリングの妙によってjwtのスレッドがわずかに遅れて上書きを完了させてしまった場合、pyjwtが書き込んだはずのPyJWKClientなどの必要なコードが__init__.pyから完全に抹消され、インポートエラーを引き起こす。
uv のシステム側から見れば、各スレッドは割り当てられたファイルを正常にディスクに展開しただけであり、何のエラーも警告も出さない。しかし、ディスク上には「敗北したスレッドの壊れたファイル」が静かに残ることになる。
3. 実体験:マルチスレッド並行処理が呼び覚ます「眠れる race condition」
ソフトウェアをシングルスレッド(同期処理)から、GoのゴルーチンやRustのスレッド、あるいはNode.jsの非同期並行処理で「高速化」した際、それまで静かに眠っていた「タイミング依存のバグ(レースコンディション)」が一斉に牙を剥く。
- 実体験エピソード: あるWebアプリで、ユーザーのプロフィール画像をアップロードし、サムネイルを複数サイズ(100x100, 200x200等)にリサイズしてオブジェクトストレージに保存する処理を、直列(同期)から「Promise.allによる並行処理(非同期)」に書き換えて3倍の高速化を達成した。 リリース後、特定のユーザーから「プロフィール画像が壊れている」「サイズがバラバラ」というクレームが届いた。 調査した結果、サムネイル画像を一時的にローカルに書き出す一時ファイル名が、すべてのサイズで
tmp_thumb.jpgという固定値で共有されていたことが判明した。 直列処理の時は「100x100を書き出して転送し、次に200x200を書き出す」ため問題なかったが、並行処理にしたことで、複数のサイズリサイズスレッドが同じ一時ファイルを同時に上書きし合い、最終的にストレージに転送された画像が「中身が混ざって壊れたファイル」になっていた。 「遅いから動いていたロジック」を、本質的な隔離(一時ファイルのユニーク化やスレッドセーフ設計)を行わずに高速化すると、システムは必ず壊れる。
4. 解決策と「超高速並行処理」における設計原則
uv の開発メンバー(Charliermarsh氏やKonstin氏ら)は、このIssueに対し、パッケージ側(jwt と pyjwt の混在)の設計の悪さを認めつつも、競合を防ぐために以下の対処や設計原則を提示している。
4.1. インストール順序の段階的隔離(ワークアラウンド)
同一ファイルを上書きし合うパッケージがどうしても混在する場合、uv sync --no-install-package <pkg> を使って衝突するパッケージの配置を一時的に除外し、段階的に直列でインストールすることで、競合を強制的に回避する。
4.2. ファイル競合検知(Conflict Warning)の検討
インストール中に同じファイルを上書きしようとする競合が発生した際、そのファイルのハッシュ値を比較し、不一致であれば「パッケージ間の衝突によるレースコンディションの危険がある」として、ユーザーに警告(Warning)を出して注意喚起する仕組み。
5. 結論
「高速化」は善であるが、並行ファイルシステムへのアクセスにおいては、それまで「遅さ」によって隠蔽されていたパッケージ同士の結合・衝突(Namespaceの汚染)を顕在化させる。 ツールを設計する際は、並行スレッドが共有リソース(同一パス)に同時に書き込む可能性を常に考慮し、「上書き(Clobber)が発生した際は警告を出す」「一時ファイルや出力先はスレッドごとにユニークにする」 という、並行処理を前提としたセーフティネットをインフラ層で張っておくことこそが、予期せぬ可用性障害(Import Error)からシステムを守るための絶対条件である。
6. 参考文献 / 一次情報
- Race condition when two libraries update the same init.py: astral-sh/uv#6568
- pyjwt issue import error Stackoverflow: Stackoverflow post