@kyanny's blog

My thoughts, my life. Views/opinions are my own.

bundle のなかで bundle する

Bundler.with_clean_env と bundle install --gemfile について追記しました

bundle exec した環境下でさらに bundle exec したいことがある。 bundle exec rake resque:work で起動した Resque ワーカーのなかで system("bundle exec rake spec") のような外部コマンドを呼び出すとか。ありますよね。ぼくは最近ありました。そしてハマった (そしてググりづらかった) のでこれ以上犠牲者を増やさないためにブログに書く。

bundler は実行時にいくつかの環境変数を定義するが、この場合問題になるのは BUNDLE_GEMFILE と GEM_HOME だ。 BUNDLE_GEMFILE は bundler が参照する Gemfile のパスで、 GEM_HOME は gem のインストール先となるパスだ。

BUNDLE_GEMFILE は Bundler::Runtime#setup_environment のなかで設定される。ソースを追うと Bundler::SharedHelpers#default_gemfile を経由して Bundler::SharedHelpers#find_gemfile のなかで Gemfile のパスを探している。 find_gemfile はカレントディレクトリを起点として、 Gemfile というファイルが見つかるまで上位ディレクトリにさかのぼっていく。通常 bundle コマンドを実行するときは Gemfile が置いてあるディレクトリに移動しておくだろうから、 BUNDLE_GEMFILE にはカレントディレクトリの Gemfile のパスが設定されることになる。

しかし外部コマンドを呼び出したときは挙動が変わる。まず bundle install した gem に付属する実行ファイル (rake とか) は bundle exec 経由で呼び出せるように bundler 自身がラッパースクリプトで置き換える。このあたりの処理は Bundler::Installer のなかに書いてあり、実行ファイルのラッパースクリプトのひな形が lib/bundler/templates 以下にある。このひな形ファイルのなかで ENV['BUNDLE_GEMFILE'] が未定義だったら設定するようになっており、つまり BUNDLE_GEMFILE が定義済みであればその値をそのまま使う。

Ruby において Kernel.#system や Kernel.#exec などで外部コマンドを呼び出すと子プロセスで指定されたコマンドが実行されるが、親プロセスの環境変数を引き継ぐ。だから、 bundle exec で起動した親プロセスの中から system("bundle exec") という風に起動された子プロセスにおいては BUNDLE_GEMFILE 環境変数が定義済みで、この値は親プロセスの bundle exec を実行したときに参照した Gemfile のパスであるため、子プロセスで実行する bundle コマンドを別ディレクトリで実行しているつもりでもうまく動かない。

一方、 GEM_HOME のほうは gem のインストール先を指定する環境変数で、 bundler においては Bundler#configure_gem_home_and_path を経て Bundler#configure_gem_home というプライベートメソッドのなかで設定される。 configure_gem_home_and_path メソッドは ENV['GEM_HOME'] の有無をチェックして、未定義か disable_shared_gems オプションが指定された場合のみ configure_gem_home が呼び出される。

これも BUNDLE_GEMFILE と同様に、親プロセスで設定された値が子プロセスに引き継がれるため、例えば bundle exec したプロセスの中で、親プロセスの bundle exec を実行したのと別ディレクトリに移動し system("bundle install --deployment") を実行すると、期待に反して依存 gem が vendor/bundle 以下にインストールされない。

そういうディレクトリレイアウトの具体的な例は sorcery で、面白いことに spec/ ディレクトリ以下にバージョン別の Rails アプリケーションを丸ごと持っている。 rake spec すると各 Rails アプリケーションのディレクトリ内で rake spec を実行するのだが、これを Travis CI 上で行うと「bundle のなかで bundle 問題」が起こる。

Travis CI: Building a Ruby Project の Default Test Script や Dependency Management に記載があるように、 Travis CI は依存 gem のインストールとテスト実行に bundler を使う。おそらく bundle install --deployment && bundle exec rake spec のようなコマンドを実行しているのだろう。すると bundle exec rake spec のなかで bundle exec rake spec が実行されることになり、 sorcery の spec は各 Rails アプリケーションの Rails.root にあたるディレクトリ内にある Gemfile に基づいてテストが実行されることを期待しているのに、環境変数 GEM_HOME と BUNDLE_GEMFILE が悪さをしてテストがこける

sorcery にまつわる話は @banyan が教えてくれた。彼が送った Pull Request がマージされ、無事に Travis CI 上での sorcery のテストがパスするようになったようだ。

この問題は親プロセスの環境変数が子プロセスに引き継がれて、子プロセス側の bundler の動作に影響を及ぼすことが原因なので、環境変数をリセットすれば解決する。 Ruby 1.9 では外部コマンドの実行時に環境変数を上書きできるので、

env = {
  'BUNDLE_GEMFILE' => nil,
  'GEM_HOME' => nil,
}
system(env, "bundle install --deployment")

のようにして BUNDLE_GEMFILE と GEM_HOME の値に nil を渡して未定義にしてやれば、子プロセス側の bundler がよしなに環境変数を設定しなおしてくれる。

追記 Wed Apr 25 2012 22:46:41 GMT+0900 (JST)

ブクマコメントで教えてもらったが、 Bundler.with_clean_env というそのものズバリのメソッドが用意されていた。 bundler 1.1 から入った機能のようだ。このブログを書いてる最中に参照した bundle-exec(1) のマニュアルにも ENVIRONMENT MODIFICATIONS の一番最後の Shelling out の項に記載されていた。ソースを読むと BUNDLE_ ではじまる環境変数を削除してブロックを実行してくれるようだ。 clean_system, clean_exec なんてメソッドもある。 GEM_HOME をいじっている箇所は特定できなかった。 bundler/setup も require するので Bundler.setup -> Bundler::Runtime#setup も追ったがわからず。

それとは別に、 bundle install --gemfile オプションの存在もブログを書いてから知った。こちらも bundle-install(1) マニュアルに記載されている。こちらもソースを読むと、 --gemfile で指定したパスにファイルが存在すれば BUNDLE_GEMFILE を上書きしてくれる。こちらも GEM_HOME をどのように扱うのかはわからなかった。

Bundler.with_clean_env は当然 bundler を require していないと使えない。もしくは親プロセス自身が「自分は bundler の中で動いている」と知っている必要があるので、 require 'bundler' した上で常に bundle exec で実行するコードの中であれば、

Bundler.with_clean_env do
  system("bundle install --gemfile /path/to/rails/Gemfile")
  system("bundle exec rake spec")
end

# または
Bundler.clean_system("bundle install --gemfile /path/to/rails/Gemfile")
Bundler.clean_system("bundle exec rake spec")

のように呼び出すのがスマートなのかもしれない (繰り返すが GEM_HOME の扱いがどうなるかは追えていないし試してもいないのでこのコードが動く保証はない)

@banyan 調べでは、 sorcery の Rakefile には require 'bundler/setup' しているコードが "Too slow" とかいうコメントとともにコメントアウトされて残っているそうだ。おそらく bundle のなかで bundle とは別の理由で bundler を require していたがテストの実行に時間がかかり、外しても影響がないのでやめたままになっていてみんな忘れていたが、最近 Travis CI にのせてみたらテストがこけはじめた、けど開発者たちは $ rake spec のように bundle exec を介さず手元でテストを実行していたので何が問題かわからなかった・・・といったところだろうか。

(追記終わり)

思ったよりも長くなった上に途中から bundler のソースコードリーディングになってしまったけど、まとめると、外部コマンドの実行時におかしなことが起きたら環境変数を疑いましょう。それではみなさんすてきな bundle ライフを!