`prune_bundler` 再実行時にユーザー設定の `BUNDLE_*` 環境変数が消失する問題を修正
prune_bundler が有効な環境でPumaを再実行する際、BUNDLE_WITHOUT や BUNDLE_DEPLOYMENT といったユーザー設定の BUNDLE_* 環境変数が失われ、ワーカーの起動クラッシュを引き起こしていた問題が修正されました。
背景
Puma 7以降、prune_bundler と WEB_CONCURRENCY を組み合わせた構成で、ワーカーが Bundler::GemNotFound エラーを出して起動に失敗する不具合が報告されていました(#3744)。Dockerデプロイメントなど、development / test グループのGemをインストールしないケースで特に顕在化し、BUNDLE_WITHOUT の消失によりBundlerがインストールされていないGemグループを含む全グループのアクティベートを試みるため、クラッシュが発生していました。
この問題は、#3297 による修正が引き金となって露わになりました。それ以前のPumaでは、prune_bundler と一緒に WEB_CONCURRENCY を設定すると preload_app が誤って有効化される別のバグが存在していました。アプリがmasterプロセスでプリロードされるため、pruneの実行後もワーカー側で require 'bundler/setup' が呼ばれず、結果として環境変数の欠落が問題として現れなかったのです。#3297 が prune_bundler 使用時に preload_app false を正しく設定するようになったことで、ワーカー起動時に rack_builder 経由で require 'bundler/setup' が呼ばれるパスが確実に実行されるようになり、環境変数の欠落が致命的なエラーとして顕在化しました。
つまり、環境変数の消失という根本的なバグは以前から存在していたが、別のバグが偶然それをマスクしていたという二重のバグが絡み合った構造です。
技術的な変更
lib/puma/launcher/bundle_pruner.rb の prune メソッドで、個別の環境変数を明示的に復元していた処理を、Bundler.original_env から BUNDLE_* プレフィックスを持つ全変数を一括復元する処理に置き換えました。
変更前:
home = ENV['GEM_HOME']
bundle_gemfile = Bundler.original_env['BUNDLE_GEMFILE']
bundle_app_config = Bundler.original_env['BUNDLE_APP_CONFIG']
with_unbundled_env do
ENV['GEM_HOME'] = home
ENV['BUNDLE_GEMFILE'] = bundle_gemfile
ENV['PUMA_BUNDLER_PRUNED'] = '1'
ENV["BUNDLE_APP_CONFIG"] = bundle_app_config
args = [Gem.ruby, puma_wild_path, '-I', dirs.join(':')] + @original_argv
変更後:
home = ENV['GEM_HOME']
original_bundle_env = Bundler.original_env.select { |k, v| k.start_with?('BUNDLE_') && v }
with_unbundled_env do
ENV['GEM_HOME'] = home
ENV['PUMA_BUNDLER_PRUNED'] = '1'
original_bundle_env.each { |k, v| ENV[k] = v }
args = [Gem.ruby, puma_wild_path, '-I', dirs.join(':')] + @original_argv
Bundler.original_env は bundle exec が環境を変更する前の状態を保持しています。この変更では BUNDLE_* プレフィックスを持ち、かつ値が存在する(nil や空文字でない)変数のみを選択し、Bundler.with_unbundled_env がすべての BUNDLE_* 変数を除去した後に一括で復元します。従来は BUNDLE_GEMFILE と BUNDLE_APP_CONFIG の2変数のみがハードコードで復元されていましたが、この変更によりユーザーが設定したあらゆる BUNDLE_* 変数が漏れなく引き継がれます。
テストとして test/rackup/bundle_without.ru が追加され、リクエスト時に BUNDLE_WITHOUT 環境変数の値をレスポンスとして返すRackアプリが用意されました。また test/test_integration_cluster.rb に test_prune_bundler_preserves_bundle_env_vars が追加され、BUNDLE_WITHOUT=development:test を設定した状態でclusterモードのPumaを起動し、ワーカーが正しくその値を受け取れることを検証しています。
設計判断
「既知の変数を列挙する」アプローチではなく「プレフィックスで一括選択する」アプローチが採用されました。
変更前のコードは BUNDLE_GEMFILE と BUNDLE_APP_CONFIG という2つの変数名をハードコードしていました。このアプローチは、Bundlerが新たな BUNDLE_* 変数を導入するたびに対応が必要になるという脆弱性を持ちます。一方、select { |k, v| k.start_with?('BUNDLE_') && v } による一括選択は、変数名を列挙する必要がなく、ユーザーが独自に設定したカスタム変数も含めて網羅的に復元できます。
&& v の条件で値がfalsyな変数を除外しているのも注目すべき点です。Bundler.with_unbundled_env が変数を削除する際、環境変数をnilに設定する実装になっているため、「もともと存在しない変数」と「意図的にunsetされた変数」を区別し、後者を誤って復元しないための配慮です。
まとめ
2変数のハードコードを BUNDLE_* プレフィックスの一括復元に置き換えるという最小限の変更で、prune_bundler の再実行環境における環境変数の完全性が保証されるようになりました。列挙方式から宣言的なパターンマッチ方式への移行により、今後Bundlerが新たな設定変数を追加した場合でも、Puma側の修正なしに正しく動作する堅牢な実装となっています。