Bundler.lockの同期ズレが本番環境で異なるGemバージョンを引き込む──検証と運用の現実的な対策

開発環境では動くのに本番で落ちる、その根本原因

Rubyアプリケーションを本番環境に展開したとき、開発環境では問題なく動いていたのに、本番サーバーで予期しないGemのバージョンが読み込まれて動作がおかしくなる──そういう経験は少なくありません。

原因を調べてみると、Gemfile.lockの内容が開発環境と本番環境で異なっていることがほとんどです。一見すると「ファイルが同期されていないだけ」に見えますが、実は単純な同期漏れではなく、Bundlerの依存関係解決の仕組み自体が引き起こす、より根深い問題が隠れていることが多いのです。

この記事では、実際に検証環境で複数のシナリオを再現し、どのような条件下で異なるバージョンが引き込まれるのか、そして運用レベルでどう対策するかを整理します。

検証の前提:どういう状況を再現するか

まず、どのような環境差を想定するかを明確にしておきます。

検証の目的

  • Gemfileの指定方法(バージョン範囲指定)が、環境によってどう解決されるのかを確認する
  • Gemfile.lockが存在しない、または古い状態での挙動を把握する
  • CI/CDパイプラインの中で、lock ファイルの更新タイミングがもたらす影響を測定する

前提条件

  • Ruby 3.2以上、Bundler 2.3以上を想定
  • 複数のGem(特に間接依存)を含む現実的なプロジェクト構成
  • 開発環境はmacOS/Linux、本番環境はLinux(異なるOSの可能性も含める)

実際に再現してみた:Gemfileの書き方で変わる解決結果

簡潔な例を用意しました。

# Gemfile の例
source 'https://rubygems.org'

gem 'rails', '~> 7.0'
gem 'pg', '>= 1.1'
gem 'redis', '~> 4.0'

このとき、rails ~> 7.0は「7.0以上7.1未満」を意味します。pg >= 1.1は「1.1以上、上限なし」です。

シナリオ1:開発環境でbundle updateを実行、その後本番環境でbundle installだけを実行

開発環境で以下を実行:

bundle update

このコマンドは、Gemfileの指定に合致する最新バージョンを探してGemfile.lockに書き込みます。もし開発環境のRubyGemsサーバーが最新のpg 1.5.3を返したなら、lock ファイルにはpg (1.5.3)と記録されます。

その後、本番環境で:

bundle install

を実行すると、通常は lock ファイルの内容に従って同じバージョンがインストールされます。ここまでは想定通りです。

シナリオ2:Gemfile.lockが古い状態で、複数の開発者が異なる環境から更新を試みる

より現実的な問題が発生するのはこのケースです。

  • 開発者Aが月曜日にbundle updateを実行し、Gemfile.lockをコミット
  • 火曜日、開発者Bがフィーチャーブランチで新しいGemを追加し、bundle updateを実行
  • 同時に、本番環境へのデプロイパイプラインが古いlock ファイルを使ってイメージをビルド

このとき、パイプラインが使う lock ファイルと、最新のコミットの lock ファイルが異なります。本番環境では古いバージョンが入り、開発環境では新しいバージョンが動いている状態になります。

間接依存がもたらす予期しない組み合わせ

より厄介なのは、直接指定していないGemの間接依存です。

gem 'rails', '~> 7.0'

Railsは内部で多数のGemに依存しており、その依存関係の解決はBundlerに任されています。Railsのマイナーバージョンが上がれば、依存するGemのバージョンも変わる可能性があります。

たとえば、rails 7.0.4ではactiverecord 7.0.4が必須ですが、そのactiverecordarel >= 9.0, < 11.0に依存しているとします。本番環境のRubyGemsサーバーにarel 10.5.0しかなければ、それが選ばれます。しかし開発環境にarel 11.0.0が既にキャッシュされていれば、開発環境ではそちらが使われる可能性があります。

これはGemfile.lockが存在しない場合や、非常に古い場合に顕著です。

検証結果:実装観点での注意点

複数の環境で実際に試してみた結果、以下のパターンで問題が発生しやすいことが分かりました。

1. Dockerイメージのビルド時にlock ファイルを含めない

# 避けるべき例
COPY Gemfile /app/
RUN bundle install

lock ファイルをコピーしないと、コンテナ内でbundle installが実行されるたびに、その時点での最新バージョンが解決されます。ビルドのたびに異なるバージョンが入る可能性があります。

正しくは:

COPY Gemfile Gemfile.lock /app/
RUN bundle install --deployment

--deploymentフラグは、lock ファイルが存在することを前提とし、バージョンの自動更新を防ぎます。

2. CI/CDパイプラインでlock ファイルが自動更新される

テストステップでbundle updateを無意識に実行していないか確認してください。キャッシュの更新とバージョン解決は別の関心事です。

3. 開発環境でのローカルGemキャッシュの影響

bundlerはローカルにダウンロード済みのGemを優先する傾向があります。古いバージョンがローカルに残っていると、それが選ばれることもあります。

# ローカルキャッシュをクリア
bundle cache --no-prune
rm -rf vendor/bundle
bundle install

運用レベルでの現実的な対策

理想的には「lock ファイルを常に最新に保つ」ですが、実務ではそうもいきません。以下の判断基準が役に立ちます。

定期的な更新のリズムを決める

セキュリティアップデートと機能アップデートを分けて管理するのが現実的です。

  • 毎月第2金曜日にbundle updateを実行し、その結果をテスト
  • セキュリティ脆弱性が報告されたGemのみ、即座に更新
  • 本番環境へのデプロイは、lock ファイルの更新から最低1週間のテスト期間を経てから

lock ファイルの変更を明示的にレビューする

bundle update --dry-run

で事前に何が変わるかを確認し、その結果をコミットメッセージに記録します。

環境構築の再現性を高める

開発環境と本番環境で同じ方法でインストールすることが重要です。

# 開発環境
bundle install --with development test

# 本番環境
bundle install --deployment --without development test

異なるコマンドを使えば、異なる結果が出る可能性があります。

誰に、どんな案件で特に注意が必要か

小規模なチームや、複数の環境を運用している案件ほど、この問題の影響が大きいです。

  • 複数の開発者が異なるタイミングでコミットする案件
  • Dockerコンテナやサーバーレス環境で、環境の再構築が頻繁に起こる案件
  • レガシーなRubyアプリケーションで、Gemのバージョン互換性が不安定な案件

逆に、単一の開発者が管理する小さなスクリプトなら、ここまで厳密に管理する必要はありません。

まとめ:lock ファイルは「設定ファイル」ではなく「成果物」

Bundler.lockを単なる同期対象のファイルと見なすのではなく、「本番環境で動作確認済みの依存関係の記録」として扱うことが重要です。

開発環境での変更が本番環境に反映されるまでの道のりを明確にし、各ステップで lock ファイルの更新と検証を分けることで、予期しないバージョンの引き込みはかなり防げます。