ビルドログに出続ける警告「Cannot write a changed lockfile while frozen」の正体 — Dependabot と Bundler の見落としがちな落とし穴

2026/6/3

代表

本番デプロイの Docker ビルドログに、こんな一文が紛れ込んでいました。

#19 44.01 Fetching timeout 0.6.1
#19 44.03 Installing timeout 0.6.1
#19 63.96 Cannot write a changed lockfile while frozen.

ビルドは成功(exit 0)していて、デプロイも通っている。いわゆる非致命の警告です。放っておいても今は動く。けれど「凍結(frozen)しているはずの lockfile を書き換えようとした形跡がある」という事実は、Bundler のバージョンや依存解決の状況次第で、いつか本当にビルドを落とすかもしれない潜在リスクをにおわせます。

この記事では、この警告を入り口に、

  • そもそも frozen な bundle install で何が起きていたのか
  • 一見の容疑者(timeout gem)が実は無実だったこと
  • 真犯人である BUNDLED WITHCHECKSUMS の不整合
  • そしてそれが Dependabot を使う多くのプロジェクトで一般的に起こりうること

を、実際の調査の流れに沿って解説します。最後に、どんなプロジェクトにも応用できる再発防止策まで紹介します。

「Cannot write a changed lockfile while frozen」とは何か

Rails の標準的な本番 Dockerfile は、gem のインストールを frozen(deployment)モードで行います。環境変数で言うと BUNDLE_DEPLOYMENT=1 です。

ENV BUNDLE_DEPLOYMENT="1"
...
RUN bundle install

frozen モードの狙いはシンプルで、**「Gemfile.lock を一切書き換えさせない」**こと。ローカルで確定・コミットしたロックファイルをそのまま本番に持っていき、本番のビルド中に依存が勝手に解決し直されたり、ロックファイルが書き換わったりする事故を防ぎます。再現性のある決定的なビルドのための、ベストプラクティスです。

裏を返すと、frozen モードで bundle install を実行したとき、Bundler が「ロックファイルを更新したい」と判断した瞬間に、書き込みが禁止されているため次の警告が出ます。

Cannot write a changed lockfile while frozen.

つまりこのメッセージは、「ロックファイルが現状のままでは、Bundler 的には何か直したい差分がある」というシグナルです。逆に言えば、ロックファイルが完全に整合していれば、frozen でも一切何も書こうとせず、警告も出ません。

ちなみに、この状況でビルドが落ちずに警告だけで済んでいるのも、実は Bundler 側で認識されている挙動です。frozen モードでロックファイルの書き換えが必要になったとき、本来なら非ゼロ終了で失敗してほしいところですが、ケースによっては警告を出すだけでインストールを続行し exit 0 になることが、issue として議論されています(ruby/rubygems#8561)。つまり「非致命のまま気付かれにくい」という今回の状況自体が、既知のギャップの上に成り立っていたわけです。

ここで問題は一つに絞られます。Bundler は、いったい何を直したかったのか?

容疑者その1:timeout gem — しかし無実だった

ログを素直に読むと、警告の直前に timeout gem の Fetching / Installing が出ています。状況証拠としては、いかにも「timeout が犯人」に見えます。

実際、timeout は怪しい性質を持っています。timeoutRuby に同梱される default gem の一つで、ロックファイルにはやや新しいバージョンがピン留めされていました。default gem は「Ruby に最初から入っているが、Gemfile.lock でより新しいバージョンを指定すれば上書きインストールされる」という二面性を持つため、frozen モードと相性問題を起こす既知のエッジケースがいくつかあります。

そこで、本番と同じ条件を手元で再現することにしました。amd64(本番の CPU アーキテクチャ)の、本番と同じ Ruby ベースイメージを使ったクリーンなコンテナで、frozen な bundle install を流します。

docker run --rm --platform linux/amd64 \
  -v "$PWD":/app -w /app ruby:<本番と同じ>-slim bash -c '
    apt-get update -qq && apt-get install -y build-essential libpq-dev libyaml-dev pkg-config
    export BUNDLE_DEPLOYMENT=1 BUNDLE_PATH=/usr/local/bundle
    bundle install
  '

警告は見事に再現しました。ここからが切り分けです。「Bundler が本当は何を書き換えたいのか」を知るには、frozen を外して実行すればよい。 frozen でなければ Bundler は遠慮なくロックファイルを書き換えるので、その差分こそが「直したかった内容」です。

# 同じコンテナで、今度は frozen を外して実行し、差分を見る
cp Gemfile.lock Gemfile.lock.orig
bundle config set --local frozen false
bundle install
diff Gemfile.lock.orig Gemfile.lock

返ってきた差分は、たった 1 行でした。

- bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac

timeout は、差分に一切登場しませんでした。ロックファイル中の timeout の行は frozen でも非 frozen でも全く変化しない。つまり Bundler は timeout のことを何も直したいと思っていません。

timeoutFetching / Installing は、default gem を指定バージョンへ更新する正常な動作にすぎず、警告とは無関係でした。ログ上でたまたま警告の直前に並んでいたために、容疑者に見えていただけだったのです。

教訓1:ログの「近さ」は因果関係ではない。 警告の直前に出ているメッセージが原因とは限りません。「実際に何を書き換えたいのか」を、frozen を外した差分という形で直接観測するのが、確実な切り分けです。

真犯人:BUNDLED WITHCHECKSUMS の不整合

差分が指していたのは、Gemfile.lock の末尾近くにある CHECKSUMS セクションの、Bundler 自身のチェックサム行でした。

ここで Gemfile.lock の 2 つのセクションを押さえておきます。

セクション役割
BUNDLED WITHこの lockfile を最後に生成・更新した Bundler 自身のバージョンを記録する
CHECKSUMS各 gem(Bundler 自身を含む)の sha256 を記録し、インストール前に改ざんを検知するセキュリティ機能

本来、CHECKSUMS 内の bundler (X) の行のバージョンと、BUNDLED WITH のバージョンは一致するはずです。ところが実際の lockfile は、こうなっていました。

項目
BUNDLED WITH4.0.6
CHECKSUMS 内の bundler (...)4.0.12

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 に行き着きました。

仕組みはこうです。

  1. Dependabot は、プロジェクトとは独立した、自前管理の Bundler バージョンで動く。今回はそれが 4.0.12 だった(プロジェクトの 4.0.6 より新しい)。
  2. gem を 1 つ bump するたびに、Dependabot はその Bundler で Gemfile.lock を書き直す。このとき CHECKSUMS自分(4.0.12)のチェックサムを書き込む。
  3. ところが Dependabot は、BUNDLED WITH を更新しない。これは仕様で、意図的にそうしている(後述)。

結果として、「CHECKSUMS は 4.0.12 が書いたのに、BUNDLED WITH は 4.0.6 のまま」という不整合が、依存更新のたびに生まれていたのです。timeout も、特定の gem も、本質的には関係ありませんでした。

これは私たちだけの問題ではない

ここが、この記事で最も伝えたい点です。これはプロジェクト固有の事故ではなく、条件が揃えばどんなプロジェクトでも起こりうる、一般的なすれ違いです。具体的には、次の 3 つが揃ったときに発生します。

#条件補足
1Gemfile.lockCHECKSUMS セクションがあるBundler の checksum 機能(近年のバージョンで付与されることが増えた)
2本番・ビルドが frozen(deployment)モードRails の標準 Dockerfile が BUNDLE_DEPLOYMENT=1 を設定するため成立しがち
3Dependabot の Bundler ≠ プロジェクトの Bundlerバージョンが一致していればドリフトしない

条件 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 ではこれだけです。

jobs:
  bundle_lockfile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true   # ここで非 frozen の bundle install が走り lockfile が正規化される

      - name: Verify Gemfile.lock is in sync
        run: git diff --exit-code Gemfile.lock

ruby/setup-rubybundler-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 行から始まった調査でしたが、得られた教訓は普遍的でした。

  1. ログの「近さ」を因果と取り違えない。 容疑者(timeout)は無実でした。frozen を外した差分という形で「Bundler が本当は何を書きたいのか」を直接観測することで、初めて真因にたどり着けます。
  2. Gemfile.lock は人間が読める。 BUNDLED WITHCHECKSUMS の意味を知っていれば、内部矛盾はその場で見抜けます。普段あまり開かないセクションこそ、いざというとき効きます。
  3. 既知の構造的すれ違いには、検知の仕組みで備える。 Dependabot と Bundler のすれ違いは公式も認める一般的な事象で、ワンクリックの公式解はありません。だからこそ「PR の時点でドリフトを検知して落とす」CI ガードのような、自プロジェクト側の備えが定石になります。

「動いているから」と見送りがちな非致命の警告も、一つ腰を据えて追いかけると、CI/CD の堅牢性をひと回り底上げするきっかけになります。同じ構成(Rails + Dependabot + frozen デプロイ)でビルドログに同じ警告を見かけたら、ぜひ Gemfile.lockBUNDLED WITHCHECKSUMS を覗いてみてください。

参考リンク

© 2026 つくばAIラボ株式会社