Capistrano、sudoとrunと:env

Capistranoにはリモートホストでコマンド実行させるためのメソッドがいくつかある。その代表がrunメソッドとsudoメソッド。runメソッドはリモートホストにログインしたユーザ(特に指定しなければ実行ユーザ)でコマンドを実行する。sudoメソッドはsudoコマンドを使って指定したユーザ権限を得てからコマンド実行する。

この二つのメソッドは使い方がほぼ同じ。違いといえばsudoメソッドでユーザ名を指定できるということ。ただ、これは目的からして当然だといえる。ではそれ以外に違いがないのかというとそうでもない。実際のコマンド実行まで追いかけると二点ばかり、そしてけっこう大きな違いがあることに気付く。(以下、Capistrano 2.5.5で確認。)

一つめは複数コマンドの実行。runメソッドを使って複数のコマンドを一度に実行したいとき、たとえば次のように記述する。

run "mkdir /tmp/foo; touch /tmp/foo/bar; ..."

これを特定のユーザ権限で実行したくなったとする。メソッドをrunからsudoに変えればどうかと考えるわけだが、そうしても意図通りには動作しない。これはリモートホストに送信されるコマンドラインを見れば明らかである。

# runメソッドの場合
sh -c "mkdir /tmp/foo; touch /tmp/foo/bar; ..."
# sudoメソッドの場合
sh -c "sudo -p 'sudo password: ' mkdir /tmp/foo; touch /tmp/foo/bar; ..."

このように、sudoコマンドは最初のコマンドであるmkdirにしか効果を与えることができない。もちろんsudoメソッドを複数回使うことで連続したコマンド実行をすることは可能である。ただ、Capistranoではコマンド実行エラーが起きるとそこでタスクの処理を止めてしまうため、意図を表現しにくいということもある。そんなときには、sudoメソッドを使うとよい。

上の例でもsudoメソッドを使っているが、もう一つ別の使い方がある。sudoメソッドを次のように使うと、Capistranoが必要とするsudoコマンドのコマンドラインを生成できる。

run "#{sudo} mkdir /tmp/foo; #{sudo} touch /tmp/foo/bar; ... "

このとき送信されるコマンドラインは次のようになる。

sh -c "sudo -p 'sudo password: ' mkdir /tmp/foo; sudo -p 'sudo password: ' touch /tmp/foo/bar; ... "
"#{sudo :as => "www-data"} ..."のようにすればユーザを指定できるし、必要に応じてパスワードの処理も行われる。 二つめは環境変数の問題。両メソッドともオプションでコマンド実行時に設定する環境変数を指定できるようになっている。たとえばLANGとGEM_HOMEを指定するなら次のように記述する。
run  "gem install rails", :env => { "LANG" => "C", "GEM_HOME" => "/tmp/GEM" }
sudo "gem install rails", :env => { "LANG" => "C", "GEM_HOME" => "/tmp/GEM" }

それぞれのメソッドにより送信されるコマンドラインは次の通り。

env LANG=C GEM_HOME=/tmp/GEM sh -c "gem install rails"
env LANG=C GEM_HOME=/tmp/GEM sh -c "sudo gem install rails"

ここで問題なのは、sudoコマンドが特定の環境変数以外を削除してしまう点である。一つめの場合と同じように、あるときrunメソッドをsudoメソッドに変えたとしても、環境変数の指定がうまくいとは限らない。さらに、この問題は:default_environmentオプションを設定している場合でも同様で、環境変数によってはrunメソッドには効くのにsudoメソッドには効かないということが起きる。

この問題を回避するには…… と考えてみたのだが、あまりよい手がなさそうだ。よい手はないのだが、とってもイヤなやり方ならないでもない。以下では、Rails向けの標準レシピを利用するにあたって細工を加えてみている。

set :local_username, `whoami`.chomp
set :default_environment, "GEM_HOME" => "/tmp/GEM"
set :runner, "#{fetch(:runner, local_username)} env " + 
      fetch(:default_environment, {}).inject("") {|memo,(k,v)| "#{k}=#{v}"}
set :admin_runner, "#{fetch(:admin_runner, local_username)} env " + 
      fetch(:default_environment, {}).inject("") {|memo,(k,v)| "#{k}=#{v}"}

標準レシピでは:runnerや:admin_runnerの値がsudoメソッドの:asに渡される。:asの値はsudoコマンドの-uの引数として埋め込まれるが、その際、特別な処理はされないため、上のようなおかしな値を与えてやることでenvコマンドのインジェクションが可能となる…… のではあるが、言うまでもなくどうしようもなくひどいやり方だ。

とはいえ、コマンドラインに直接envコマンドを書けばよい自前のタスクとは違って、標準レシピのようにあり物を使おうとする場合にはどうにもしようもないということも事実である。

少し追いかけてみたのだが、今のCapistranoの実装においては、:envオプションに対応するenvコマンドの展開とsudoメソッドに対応するsudoコマンドの展開が別々の場所で行われていることもあり、シンプルに解決するのはやはり難しそうだった。考えてみたのはパッチのような変更で、$CAPISTRANO:ENV$を新設してsudoメソッドで埋め込み、最終的なコマンドラインへと展開されるタイミングでenvコマンドに変換されるようにした。これにより文字列中のsudoメソッドにも対応できるはずだし、任意の場所でenvコマンドを使用できるようにもなる。

ただ、これをやってしまうとenvコマンドの実行を許さなくてはならなくなり、コマンドの実行制限をしている場合にはうまくない。そうするくらいなら必要な環境変数を渡せるようsudoersを書き換えるほうがよいとも考えられ、状況としてはどっちもどっちといったところ。ならばそのあたりを設定で切り替えられるようにしておけば、とも考えるが、はたしてそこまでやる価値があるかどうか。