ビルドログに出続ける警告「Cannot write a changed lockfile while frozen」の正体 — Dependabot と Bundler の見落としがちな落とし穴
2026/6/3
代表
本番デプロイの Docker ビルドログに、こんな一文が紛れ込んでいました。
ビルドは成功(exit 0)していて、デプロイも通っている。いわゆる非致命の警告です。放っておいても今は動く。けれど「凍結(frozen)しているはずの lockfile を書き換えようとした形跡がある」という事実は、Bundler のバージョンや依存解決の状況次第で、いつか本当にビルドを落とすかもしれない潜在リスクをにおわせます。
この記事では、この警告を入り口に、
- そもそも frozen な
bundle installで何が起きていたのか - 一見の容疑者(
timeoutgem)が実は無実だったこと - 真犯人である
BUNDLED WITHとCHECKSUMSの不整合 - そしてそれが Dependabot を使う多くのプロジェクトで一般的に起こりうること
を、実際の調査の流れに沿って解説します。最後に、どんなプロジェクトにも応用できる再発防止策まで紹介します。
「Cannot write a changed lockfile while frozen」とは何か
Rails の標準的な本番 Dockerfile は、gem のインストールを frozen(deployment)モードで行います。環境変数で言うと BUNDLE_DEPLOYMENT=1 です。
frozen モードの狙いはシンプルで、**「Gemfile.lock を一切書き換えさせない」**こと。ローカルで確定・コミットしたロックファイルをそのまま本番に持っていき、本番のビルド中に依存が勝手に解決し直されたり、ロックファイルが書き換わったりする事故を防ぎます。再現性のある決定的なビルドのための、ベストプラクティスです。
裏を返すと、frozen モードで bundle install を実行したとき、Bundler が「ロックファイルを更新したい」と判断した瞬間に、書き込みが禁止されているため次の警告が出ます。
つまりこのメッセージは、「ロックファイルが現状のままでは、Bundler 的には何か直したい差分がある」というシグナルです。逆に言えば、ロックファイルが完全に整合していれば、frozen でも一切何も書こうとせず、警告も出ません。
ちなみに、この状況でビルドが落ちずに警告だけで済んでいるのも、実は Bundler 側で認識されている挙動です。frozen モードでロックファイルの書き換えが必要になったとき、本来なら非ゼロ終了で失敗してほしいところですが、ケースによっては警告を出すだけでインストールを続行し exit 0 になることが、issue として議論されています(ruby/rubygems#8561)。つまり「非致命のまま気付かれにくい」という今回の状況自体が、既知のギャップの上に成り立っていたわけです。
ここで問題は一つに絞られます。Bundler は、いったい何を直したかったのか?
容疑者その1:timeout gem — しかし無実だった
ログを素直に読むと、警告の直前に timeout gem の Fetching / Installing が出ています。状況証拠としては、いかにも「timeout が犯人」に見えます。
実際、timeout は怪しい性質を持っています。timeout は Ruby に同梱される default gem の一つで、ロックファイルにはやや新しいバージョンがピン留めされていました。default gem は「Ruby に最初から入っているが、Gemfile.lock でより新しいバージョンを指定すれば上書きインストールされる」という二面性を持つため、frozen モードと相性問題を起こす既知のエッジケースがいくつかあります。
そこで、本番と同じ条件を手元で再現することにしました。amd64(本番の CPU アーキテクチャ)の、本番と同じ Ruby ベースイメージを使ったクリーンなコンテナで、frozen な bundle install を流します。
警告は見事に再現しました。ここからが切り分けです。「Bundler が本当は何を書き換えたいのか」を知るには、frozen を外して実行すればよい。 frozen でなければ Bundler は遠慮なくロックファイルを書き換えるので、その差分こそが「直したかった内容」です。
返ってきた差分は、たった 1 行でした。
timeout は、差分に一切登場しませんでした。ロックファイル中の timeout の行は frozen でも非 frozen でも全く変化しない。つまり Bundler は timeout のことを何も直したいと思っていません。
timeout の Fetching / Installing は、default gem を指定バージョンへ更新する正常な動作にすぎず、警告とは無関係でした。ログ上でたまたま警告の直前に並んでいたために、容疑者に見えていただけだったのです。
教訓1:ログの「近さ」は因果関係ではない。 警告の直前に出ているメッセージが原因とは限りません。「実際に何を書き換えたいのか」を、frozen を外した差分という形で直接観測するのが、確実な切り分けです。
真犯人:BUNDLED WITH と CHECKSUMS の不整合
差分が指していたのは、Gemfile.lock の末尾近くにある CHECKSUMS セクションの、Bundler 自身のチェックサム行でした。
ここで Gemfile.lock の 2 つのセクションを押さえておきます。
本来、CHECKSUMS 内の bundler (X) の行のバージョンと、BUNDLED WITH のバージョンは一致するはずです。ところが実際の lockfile は、こうなっていました。
lockfile を生成したと記録されている Bundler は 4.0.6 なのに、チェックサムには 4.0.12 のものが残っている。 内部矛盾です。
本番ビルド環境の Bundler は 4.0.6。それが frozen で bundle install を実行すると、「自分(4.0.6)と食い違う 4.0.12 のチェックサム行」を取り除こうとし、しかし frozen なので書き込めず、Cannot write a changed lockfile while frozen. を出していた——これが警告の正体でした。
裏付けとして、この 1 行を取り除いた lockfile で frozen ビルドを流すと、警告は消え、Bundle complete! で正常終了し、ロックファイルは前後で 1 バイトも変化しませんでした。
なぜこうなったのか:Dependabot と Bundler の構造的なすれ違い
では、なぜ CHECKSUMS だけ別バージョンになったのか。git log -S でこの行が混入したコミットを追うと、Dependabot による依存更新 PR に行き着きました。
仕組みはこうです。
- Dependabot は、プロジェクトとは独立した、自前管理の Bundler バージョンで動く。今回はそれが 4.0.12 だった(プロジェクトの 4.0.6 より新しい)。
- gem を 1 つ bump するたびに、Dependabot はその Bundler で
Gemfile.lockを書き直す。このときCHECKSUMSに自分(4.0.12)のチェックサムを書き込む。 - ところが Dependabot は、
BUNDLED WITHを更新しない。これは仕様で、意図的にそうしている(後述)。
結果として、「CHECKSUMS は 4.0.12 が書いたのに、BUNDLED WITH は 4.0.6 のまま」という不整合が、依存更新のたびに生まれていたのです。timeout も、特定の gem も、本質的には関係ありませんでした。
これは私たちだけの問題ではない
ここが、この記事で最も伝えたい点です。これはプロジェクト固有の事故ではなく、条件が揃えばどんなプロジェクトでも起こりうる、一般的なすれ違いです。具体的には、次の 3 つが揃ったときに発生します。
条件 1 と 2 は「modern Rails + セキュリティ機能オン」のプロジェクトではかなりの確率で成立します。そこに Dependabot を有効化していれば、条件 3 もいつ成立してもおかしくありません。
しかも厄介なことに、これは公式も把握している既知の構造です。
- Bundler 公式は checksum 機能を導入した v2.6 のアナウンス(Bundler v2.6: lockfile checksums are finally there)で、機能の説明とともに「Heroku や Dependabot のような一部のプラットフォームは Bundler の自動バージョン切替を尊重しない」と名指しで言及しています。ただし同記事ではそれらも「おそらく問題なく動くだろう(they should deal just fine)」と楽観的に書かれていました。今回踏んだのは、その楽観が外れるケースです。
- Dependabot 側は、「
BUNDLED WITHを自動で bump してほしい」という要望(dependabot-core#4095)に対して、"closed as not planned"(対応しない方針)としています。つまり「自前 Bundler でチェックサムは書くが、そのバージョンはBUNDLED WITHに記録しない」というのは、バグではなく意図された挙動なのです。なお Dependabot は checksum 機能自体には対応済みで(dependabot-core#12010)、CHECKSUMSを自身の Bundler で書き換え・維持します。
そして同 issue でも案内されている現実的な落としどころは、**「自動化に頼らず、各自で CI/CD のチェックを入れること」**でした。
対策:その場しのぎではなく、再発を止める
対応は 2 段構えにしました。
1. 不整合の解消(対症療法)
まず、Gemfile.lock から矛盾した bundler (4.0.12) のチェックサム行を 1 行削除します。これは、正しい Bundler で非 frozen の bundle install を流したときに生成される結果と完全に一致します。これで「今ある不整合」は消えました。
ただし、これだけでは 次の Dependabot PR でまた同じ行が書き込まれ、警告が復活します。根本のすれ違いは残っているからです。
2. CI で「ロックファイルのドリフト」を検知して落とす(再発防止)
そこで、Dependabot 公式が案内する方向に沿って、CI にガードを 1 つ追加しました。考え方はこうです。
非 frozen の
bundle installで lockfile を「正規化」し、差分が出たら、それは不整合がある証拠なので CI を fail させる。
GitHub Actions ではこれだけです。
ruby/setup-ruby は bundler-cache: true のとき内部で bundle install を実行し、必要があれば lockfile を正規化します。その後 git diff --exit-code Gemfile.lock を走らせ、正規化で差分が出た=不整合があるなら CI が赤くなる、という寸法です。
これにより、Dependabot が不整合を持ち込んだ PR は CI が赤くなってマージがブロックされ、本番ビルドに警告が漏れる前に必ず気付けます。実際、矛盾した行を再注入したロックファイルでこのガードを試すと、期待どおり差分を検知して fail し、正規化済みのロックファイルでは何事もなく pass しました。
この方式の良いところは、
- バージョンの追従が不要:将来 Bundler のバージョンがどう変わっても、「ドリフトしていれば検知する」だけなので運用が増えない。
- 他のドリフトもまとめて捕捉:今回の checksum ドリフトに限らず、platform 欠落など他の lockfile 不整合も同じく検知できる。
- セキュリティ機能を残せる:
CHECKSUMS(gem 改ざん検知)を無効化せずに対処できる。
唯一のコストは「Dependabot の PR がときどき赤くなり、ロックファイルの手直しが要る」点ですが、これは本番に不整合が届くより遥かに健全なトレードオフです。
まとめ:3 つの教訓
非致命の警告 1 行から始まった調査でしたが、得られた教訓は普遍的でした。
- ログの「近さ」を因果と取り違えない。 容疑者(
timeout)は無実でした。frozen を外した差分という形で「Bundler が本当は何を書きたいのか」を直接観測することで、初めて真因にたどり着けます。 Gemfile.lockは人間が読める。BUNDLED WITHとCHECKSUMSの意味を知っていれば、内部矛盾はその場で見抜けます。普段あまり開かないセクションこそ、いざというとき効きます。- 既知の構造的すれ違いには、検知の仕組みで備える。 Dependabot と Bundler のすれ違いは公式も認める一般的な事象で、ワンクリックの公式解はありません。だからこそ「PR の時点でドリフトを検知して落とす」CI ガードのような、自プロジェクト側の備えが定石になります。
「動いているから」と見送りがちな非致命の警告も、一つ腰を据えて追いかけると、CI/CD の堅牢性をひと回り底上げするきっかけになります。同じ構成(Rails + Dependabot + frozen デプロイ)でビルドログに同じ警告を見かけたら、ぜひ Gemfile.lock の BUNDLED WITH と CHECKSUMS を覗いてみてください。
参考リンク
- Bundler v2.6: lockfile checksums are finally there — Bundler 公式ブログ。
CHECKSUMS機能の解説と、Heroku / Dependabot がバージョン自動切替を尊重しない旨の言及。 - dependabot-core#4095 — dependabot should bump
BUNDLED WITHin gemfile lock —BUNDLED WITHを更新しない方針(closed as not planned)と、CI チェックによる回避の案内。 - dependabot-core#12010 — Add bundler lockfile checksums — Dependabot が
CHECKSUMSを書き込み・維持するようになった PR。 - dependabot-core#11188 — Does Dependabot support bundler 2.6's checksum feature? — Dependabot の checksum 対応状況に関する issue。
- ruby/rubygems#8561 — Frozen
bundle installdoesn't fail with non-zero exit code whenGemfile.lockis missing checksums — frozen で書き換えが必要でも exit 0 になり得る挙動の議論。