ストーリー
初めてAnsibleを触った半年前まで、私はchefばかりを使っていた。
そもそも当初Ansibleを使おうとしたのは別の人間が使っていたCapistranoの代わりに、chefのレシピを多ノードで同時に実行させるためである。
Capistranoにはあまり詳しくなかったため(なので実は私はほとんど使っていなかった)、使ってみたかったAnsibleに変えてしまえ、と考えたわけだ。
そんなわけで最初にAnsibleを使って行ったのはshellモジュールでchef-clientを実行してノードにchefのレシピを適用してまわることだった。
それまで私はchef-clientを実行する時にteeを使って標準出力とファイルとの両方に出力していた。
そのため私はそのまま考えなしにplaybookにこう書いたわけだ。
tasks:
- shell: "chef-client | tee /root/chef-client.log"
最初だということもあっていくらか試行錯誤はしたが、最終的にはエラーなく実行できるようになり、私はこれで構築できたと思っていた。
しかし1月ほど経った後、実は1箇所だけ想定通りに構築できていないことに気付くことになる。
その原因
何が起こったのか。
パイプを使ってA | B
とした場合、終了ステータスはAのものではなくBのものが返ってくる。
teeが失敗することはまずないので、Bがteeの場合Ansibleはこのコマンドは上手く行っていると判断してしまう。
つまりchef-clientはエラー終了していたのに、まったくそれを掴むことはできていなかった、というわけだ。
そしてAnsibleがエラーを検知していなければ、ansible-playbook実行時の出力を見てもエラーが出ていたことがわかるようなものはないので気付きようもなかった。せっかくteeでファイルにログを出力していてもエラーがなければ見もしなかったのだ。
解決方法
そもそもAnsibleを使っている時にshellモジュールで実行されるコマンドの実行結果を標準(エラー)出力に出力する必要はあまりないだろうから、ファイルに出力したいならリダイレクトすれば良い。リダイレクトはパイプと違って終了ステータスに影響しない。
私が失敗したchef-clientの例なら、chef-clientには-Lというオプションでログをファイル出力できるので、当然そのようなオプションを使っても良い。
どうしてもteeを使って標準(エラー)出力にもファイルにも出力しないとならない場合(標準出力を拾って内容によって処理を変える必要があるなど)、registerとfailed_whenを使ってエラーの際は失敗させる方法が考えられる。
一例として、操作対象がbashなら以下のようなものでいける。
tasks:
- shell: "chef-client | tee /root/chef-client.log; echo $'\n'${PIPESTATUS[0]}"
register: res
failed_when: res.stdout_lines[-1] != '0'
bashなら、というのはパイプで連結した各コマンドの終了ステータスを保持してくれるPIPESTATUS変数がbashでしか使えないため。
${PIPESTATUS[0]}
はzshだと$pipestatus[1]
となるそうだ(未検証)。
もちろん、標準出力の中にエラーかどうか判別できる文字列があればそれをfailed_whenの中で拾ってやる、という方法でも良い(failed_when: "'文字列' in res.stdout"
)。
別解として、bash 3.0以降やzsh 5.0以降ならpipefailオプションを使えば、パイプで繋がれたうちの1つでも終了ステータスに非0があれば最後の非0を終了ステータスにしてくれるので、もっと短い記述でエラーにすることができる。
bash 3.0はともかくzsh 5.0はかなり最近のバージョンであり、CentOSだと6の標準リポジトリにあるのはzsh 4.3.10。7で5.0.2。
tasks:
- shell: "set -o pipefail; chef-client | tee /root/chef-client.log"
pipefailオプションの効果はshellモジュール1回分で切れるので、後のshellモジュールでset +o pipefail
で元に戻すとかはしなくて良い。
まとめ
ま、shellモジュールでパイプを使う場合は注意する、と10回唱えてからshellモジュールを使え、ってことだ。