@kyanny's blog

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

rbenv のメカニズム

rbenv 環境下で実行された Ruby プログラムの中から他の Ruby プログラムを起動するときに、 rbenv 環境をリセットしたい―要するに別のバージョンの Ruby で外部プログラムを実行したい―という事情があったので rbenv のメカニズムについて調べた。

rbenv 環境下で ruby コマンドを実行するとき、実際にコンパイルされた ruby バイナリが直接実行されているわけではない。 rbenv 環境をお膳立てした上で ruby バイナリを exec するラッパーのシェルスクリプトが実行される。こういうものを binstub と呼ぶ。

binstub である ruby という名前のシェルスクリプトの中身をみてみると、最終的に rbenv exec というサブコマンドを呼び出している。 rbenv のサブコマンドはリポジトリでいうと libexec ディレクトリ以下にある。 rbenv execrbenv-exec スクリプトを実行する。

rbenv-exec は実行時に rbenv-version-name を呼び出して、どの Ruby バージョンを利用するかを決定する。 rbenv-version-name は環境変数 RBENV_VERSION が定義されていればその値を使い、未定義であれば rbenv-version-file を呼び出して .ruby-version または .rbenv-version というファイルを探し、見つかればその値を使う。

rbenv-version-file はあるディレクトリを起点にして、ルートディレクトリ / に向かってパスをさかのぼりながらバージョン指定ファイルを探す。この起点となるディレクトリの初期値は環境変数 RBENV_DIR で、この環境変数は libexec のなかにある rbenv (いわゆる rbenv の本体である) の実行時に、 rbenv コマンドを実行したときのカレントディレクトリが設定される。

このような内部コマンドの助けによって、 rbenv 環境下にインストールされている Ruby のバージョンが決定され、最終的に ruby バイナリへのフルパスが組み立てられてコマンドが実行される。以上の仕組みにより、 rbenv は複数バージョンの Ruby を切り替える振る舞いを実現している。


さて、冒頭の疑問に戻って、 rbenv 環境下で別バージョンの Ruby を使い外部プログラムを実行したい場合はどうすれば良いだろうか。

一番簡単な方法は、環境変数 RBENV_VERSION を再定義してから外部プログラムを呼び出すことだ。外部プログラム実行時に指定したい Ruby のバージョンがわかっている場合は、この方法で決め打ちできる。

バージョンが事前にわからない場合もある。あるディレクトリに移動してプログラムを実行するとき .ruby-version というファイルが置かれていたらそこで指定されたバージョンの Ruby を使いたい。この場合、呼び出し側プログラムが自分で .ruby-version ファイルを読み込んで RBENV_VERSION を再定義することもできるが、 rbenv 本体のバージョン判定の仕組みに任せるほうが手間が少ない。

ここでようやく、呼び出し側スクリプトの rbenv 環境をリセットしたいという話に繋がる。 rbenv は RBENV_VERSION にもとづいて Ruby のバージョンを決定するのだからこの環境変数を空にしてしまえば良いのだが、実は落とし穴がある。 RBENV_DIR が定義済みだと .ruby-version ファイルを探す起点のディレクトリが呼び出し側プログラムの実行時のままであるため、別ディレクトリに chdir してから rbenv exec を実行しても、移動後のディレクトリ内にある .ruby-version ファイルを読んでくれないのだ。よって、 RBENV_DIR 環境変数も空にする必要がある。

以上のことから、 rbenv 環境をリセットするコードは以下のようになる。

command = "ruby -v"

begin
  original_env = ENV.to_hash
  ENV.update('RBENV_VERSION' => nil)
  ENV.update('RBENV_DIR' => nil)
  
  system("rbenv exec #{command}")
ensure
  ENV.replace(original_env)
end

こういう処理はブロックつきメソッドにすると呼び出し側で利用しやすい。

def with_env(env, &block)
  original_env = ENV.to_hash
  ENV.update(env)

  yield if block_given?
ensure
  ENV.replace(original_env.to_hash)
end

def with_clean_rbenv(&block)
  with_env({'RBENV_VERSION' => nil, 'RBENV_DIR' => nil}) do
    yield if block_given?
  end
end

with_clean_rbenv do
  system("rbenv exec ruby -v")
end

余談だが、 Bundler との組み合わせについて。

Bundler も bundle コマンド実行時に環境変数をセットするが、 Bundler.with_clean_env というブロックつきメソッドが用意されており、ブロックの中は Bundler の環境変数がリセットされた状態になる。 Bundler.with_clean_env は内部で Bundler.with_original_env を呼び出しており、これは上記の with_env メソッドとほとんど同じ実装だ (with_env はこれを真似て書いたものなので似ていて当然だ)

ここで使われている定数 ORIGINAL_ENV が曲者で、これは bundle コマンド実行時の環境変数を保持するので、 rbenv exec bundle exec というコマンドを呼び出した場合、 ORIGINAL_ENV に rbenv の環境変数も含まれている。なので、 Bundler.with_clean_env の呼び出しと自前で用意した rbenv 環境のリセットのコードの呼び出し順序に気を使う必要がある。

# 正しい呼び出し方。
# Bundler の環境変数をリセットした後で rbenv の環境変数をリセットする。
Bundler.with_clean_env do
  with_clean_rbenv do
    system("rbenv exec ruby -v")
  end
end

# 間違った呼び出し方。
# `with_clean_rbenv` でリセットした rbenv の環境変数が、
# `Bundler.with_clean_env` の実行時に巻き戻る。
with_clean_rbenv do
  Bundler.with_clean_env do
    system("rbenv exec ruby -v")
  end
end

環境変数は便利だが、目に見えづらいので思わぬところではまることがある。