Python パッケージング #5 依存関係のロック — ロックファイルと再現性

読了 5分

#2 で requirements.txt 1 つが「意図」と「スナップショット」を兼ねて崩れるのを見て、#3 で意図は pyproject.toml へ引っ越しました。残る半分、スナップショットの現代的な形がロックファイルです。#4uv add の裏で静かに更新されていた uv.lock を、今回は実際に開いてみます。中身が読めるようになると、再現性、セキュリティ、アップグレードがすべてこのファイル 1 つで説明できます。

宣言とロック — 同じ情報の違う時制 #

2 つのファイルの関係を一文でまとめるとこうです。pyproject.toml は「私たちのコードが許容する範囲」(現在と未来)、ロックファイルは「前回の解決で実際に確定した世界」(過去の記録)です。

pyproject.toml と uv.lock
# pyproject.toml — 宣言
dependencies = ["django>=5.2,<6"]

# uv.lock — ロック (抜粋)
[[package]]
name = "django"
version = "5.2.4"
dependencies = [
    { name = "asgiref" },
    { name = "sqlparse" },
]
wheels = [
    { url = "...", hash = "sha256:8c8b3..." },
]

ロックファイルには、宣言になかったものが 3 つあります。正確なバージョン(5.2.4)、宣言にはまったく登場しなかった推移的依存のすべて(asgiref、sqlparse までそれぞれ項目として)、そして配布物のハッシュです。django>=5.2,<6 という範囲を満たす無数の組み合わせのうち 1 つが解決(resolve)で確定した結果で、このファイルがある限り、どのマシンでも同じ組み合わせがインストールされます。#2 で見た「インストール日のくじ引き」が構造的に消える場所です。

uv.lock は、特定の OS で freeze した一覧と違ってプラットフォーム独立です。macOS 専用、Linux 専用の配布物が条件とともにすべて記録され、1 つのファイルで全メンバーと CI をカバーします。だからロックファイルは .venv と違って必ずコミットします。 再現性の原本がまさにこのファイルだからです。

ハッシュ — 再現性の先にあるサプライチェーン防御 #

各配布物に付く sha256 ハッシュは、単なる整合性チェック以上のものです。インストール時にダウンロードしたファイルがロック時のファイルとバイト単位で同じか検証されるため、パッケージレジストリが汚染されたり、同じバージョン番号で中身がすり替えられたりする類のサプライチェーン攻撃が、インストールの段階で捕まります。ロックしたバージョンに新しく公表された脆弱性があるかは別の問題で、uvx pip-audit でロック済み環境をスキャンする習慣をテスト #7 の CI に 1 行足せば補完できます。

CI とデプロイでは — –frozen が基本です #

ロックファイルの価値は CI で完成します。鍵になるフラグが frozen です。

ロックのとおりにインストール
uv sync --frozen     # ロックファイルのとおりにインストール、ロックの更新は試みない

--frozen は「ロックと pyproject.toml がずれていたら、直さずに失敗せよ」という意味です。CI にこのフラグがないと、誰かが pyproject.toml だけ直してロックの更新を忘れたとき、CI が黙って解決をやり直してローカルと違う環境でテストを通してしまう事故が起きます。CI は検証する場所であって、解決する場所ではありません。ローカルでロックし、CI は frozen で再現だけします。

アップグレード — イベントではなくルーチンに #

ロックの副作用は放置です。一度ロックして忘れると、セキュリティパッチもバグ修正も受け取れないままバージョンだけが古びていきます。バランスを取るには、アップグレードを意図的なルーチンにすることです。

アップグレード
uv lock --upgrade-package django   # 1 つだけ上げる
uv lock --upgrade                  # 宣言の範囲内ですべて上げる
uv sync

アップグレードはロックファイルの diff として正確に見えるので、実務のルールが 3 つ付いてきます。

  1. 機能変更と混ぜません: 依存関係のアップグレードは独立したコミット・独立した PR にします。問題が起きたとき、戻す単位が明確になります。
  2. 周期を決めます: 「毎月第 1 週」のような周期で --upgrade を回してテストを通します。Renovate や Dependabot のようなボットに PR 作成を任せるのが、このルーチンの自動化版です。
  3. 範囲外のメジャーアップグレードは宣言の修正が先です: Django 6 に行くなら、pyproject.toml の <6 を直す決定が先にあります。ロックは宣言を超えられません。

pylock.toml — 道具の間をつなぐ標準が来ました #

ロックファイルの長年の弱点は、道具ごとに形式が違って互換性がないことでした(uv.lock、poetry.lock、…)。2025 年に承認された PEP 751 が標準ロック形式 pylock.toml を定義したことで、この壁が低くなりつつあります。2026 年現在、uv は pylock.toml を書き出してインストールにも使え、pip も 26.1 から実験的に pylock.toml からのインストールをサポートしています。

標準形式で書き出し
uv export -o pylock.toml     # 標準形式で書き出し

実務での意味は移植性です。開発は uv でも、デプロイのパイプラインには pip しかない環境なら、標準ロックを渡して再現性を保ったまま越えられます。道具の選択がロック形式に縛られる時代が終わりつつあるのです。当面はプロジェクトの原本は uv.lock、pylock.toml は交換用と整理しておけば十分です。

まとめ #

今回扱った内容です。

  • pyproject.toml は許容範囲の宣言で、ロックファイルは解決で確定した依存関係グラフ全体の記録です。正確なバージョン、推移的依存のすべて、ハッシュが収められます
  • uv.lock はプラットフォーム独立なので 1 ファイルで全メンバーと CI をカバーし、.venv と違って必ずコミットします
  • ハッシュ検証はすり替え型のサプライチェーン攻撃をインストール段階で防ぎます。既知の脆弱性のスキャンは pip-audit を CI に足して補完します
  • CI は uv sync --frozen が基本です。ロックと宣言がずれたら、黙って解決し直すのではなく失敗すべきです
  • アップグレードは独立 PR + 定期ルーチンにします。メジャーアップグレードは宣言の修正が先です
  • PEP 751 の pylock.toml が道具間の標準交換形式になり、uv でロックして pip でインストールする移動が開けました

次回(#6 公開)では方向を反転させます。ここまでは他人のパッケージを受け取る側でしたが、今度は自分のコードを wheel にビルドして PyPI に公開する、作る側のワークフローを扱います。

X