失敗したとき、何が止まり何が進み続けるのか
一言でいうと
Ansibleのデフォルトは「失敗したホストだけが静かに外れ、残りは続行する」であり、失敗の設計とは、そのデフォルトが自分たちの状況に合っているかを問い、合っていない部分だけをつまみで変えることです。
なぜ必要なのか
サーバー40台にデプロイするプレイブックがありました。ある日、3台でディスクがいっぱいになり、設定ファイルの書き込みが失敗しました。デプロイは赤い文字を数行出して動き続け、最後の行には、37台が成功と出力されました。パイプラインは失敗で終わりましたが、どの台がどの状態なのかを、誰も知りませんでした。3台は、新しいコードが古い設定を見ていて、その状態のまま、ロードバランサーにつながっていました。
ここで間違っていたのは、「失敗した」ことではありません。失敗したときに何が起きるかを、誰も決めていなかったことです。Ansibleは、デフォルト値を1つ選んでおいただけで、そのデフォルト値は、「1つのホストの失敗が、ほかのホストの作業を妨げない」というものです。サーバーを1台ずつ独立して直す作業には良いデフォルト値ですが、40台が1つのサービスを構成しているなら、悪いデフォルト値です。
そのため、失敗の設計は、例外処理の文法を覚えることではなく、3つの質問に対する答えを決めておくことです。この失敗は、本当の失敗なのか。失敗したそのホストは、どうするのか。ほかのホストは、どうするのか。
どう動くのか
デフォルトの動作: 失敗したホストだけが外れる
タスクが1つのホストで失敗すると、Ansibleはそのホストを今回のプレイの対象から除外します。残りのタスクは、そのホストではまったく実行されません。ほかのホストは、何の影響も受けずに最後まで進みます。プレイブック全体の終了コードは0ではありませんが、それは実行が終わったあとの話です。
この動作の結果として、部分的に適用された状態が残ります。5番目のタスクで失敗したホストは、1番目から4番目までが適用されたまま止まっています。冪等なプレイブックなら、原因を直して再実行すればよいですが、「設定だけ変えて、再起動はできなかった」状態のように、途中が危険な場合には、このデフォルトは合いません。
この失敗は本当の失敗なのか: failed_whenとignore_errors
commandとshellは、終了コードが0でなければ失敗と見なします。ところが、終了コードが0でないのが正常なコマンドが、たくさんあります。grepは、見つからなければ1を出します。diffは、異なれば1を出します。systemctl is-activeは、停止していれば3を出します。これらの値は、エラーではなく答えです。
- name: 설정에 그 키가 있는지 센다
ansible.builtin.command: grep -c max_conn /etc/app/app.conf
register: keycount
changed_when: false
failed_when: keycount.rc > 1
failed_whenは、「何を失敗と見なすか」を、タスクごとに再定義します。grepは、見つからなければ1、ファイルがない、または引数が間違っていれば2以上を出すので、上の条件は、「見つからないのは答えで、本当のエラーだけが失敗」という意味になります。changed_when: falseを一緒に置く理由は、取得のタスクが、毎回changedとして報告されるのを防ぐためです。
ignore_errors: trueは、まったく別のものです。判定を変えず、結果だけを無視します。タスクは依然として失敗として記録され、PLAY RECAPのignoredの欄が上がりますが、ホストは外れず、次のタスクへ進みます。
| タスクの判定 | 次のタスク | RECAP | |
|---|---|---|---|
| デフォルト | 失敗 | そのホストは外れる | failed |
failed_when |
自分が決めたとおり | 失敗でなければ続行 | 条件による |
ignore_errors |
失敗のまま | 続行する | ignored |
この2つを混同すると、静かな事故が起きます。ignore_errorsは、「これが失敗であることは確かだが、今は先へ進もう」のときにだけ使います。本当は失敗ではないなら、failed_whenで基準を正すのが正しいです。ignore_errorsを習慣のように付けるプレイブックは、失敗を飲み込むだけなので、半分だけ適用されたサーバーが、緑のランプを付けたまま残ります。そして、ignore_errorsは、到達できないホストには効きません。それはタスクの失敗ではなく、接続の失敗なので、ignore_unreachableが別にあります。
失敗したそのホストをどうするか: block・rescue・always
blockは、複数のタスクを1つにまとめます。そのまとまりにrescueとalwaysを付けると、ほかの言語のtry・except・finallyと同じ形になります。
- name: 새 판으로 바꿔 보기
block:
- name: 새 설정 배치
ansible.builtin.copy: {dest: /etc/app/app.conf, src: new.conf}
- name: 헬스 체크
ansible.builtin.command: /usr/local/bin/healthcheck
rescue:
- name: 옛 설정으로 되돌리기
ansible.builtin.copy: {dest: /etc/app/app.conf, src: old.conf, remote_src: true}
always:
- name: 무슨 일이 있었든 기록을 남긴다
ansible.builtin.lineinfile: {path: /var/log/deploy.log, line: "attempt finished"}
ルールがいくつかあります。blockの中で失敗が起きると、その場でblockを中断してrescueへ移ります。失敗のあとのblockのタスクは、実行されません。rescueが最後まで成功すれば、そのホストは失敗していないものとして扱われ、プレイを続行します。PLAY RECAPには、failedではなくrescuedとして出力されます。alwaysは、成功でも失敗でも救済でも、常に実行されます。
rescueの中では、ansible_failed_taskとansible_failed_resultで、何がなぜ失敗したのかを見られます。ログに残したり、通知を送ったりするときに使います。そして、rescueの中でまた失敗すれば、そのときは本当の失敗です。救済を幾重にも積み重ねるのは、たいてい設計が間違っているというサインです。
注意すべき点が1つあります。rescueが成功すると、パイプラインが緑になります。ロールバックが実行されたという事実が、終了コードに残らないという意味です。そのため、rescueの中には、必ず「ロールバックが起きた」という証拠を残すタスクを置く必要があります。ファイルでも、通知でも、メトリクスでも、人があとで数えられる形である必要があります。
ほかのホストをどうするか: any_errors_fatalとmax_fail_percentage
any_errors_fatal: trueは、1台でも失敗したら、プレイ全体をその場で止めます。失敗していないホストも、残りのタスクを実行しません。クラスター構成のように「すべて成功するか、何も起こらないか」でなければならない作業や、デプロイの前段の事前点検のように、1台でも条件が合わなければ、開始してはいけない場面に使います。
max_fail_percentageは、その中間の値です。失敗したホストの割合がこの値を超えると、残りも止まります。
| ホスト3台のうち | max_fail_percentage 50 |
結果 |
|---|---|---|
| 1台失敗 | 33%は50以下 | 残りの2台が続行する |
| 2台失敗 | 66%は50超過 | プレイがその場で止まる |
重要なのは、この割合がバッチごとに判定されるという点です。serialで分けたローリングデプロイでは、1つのバッチが終わるたびに計算するので、「1つのバッチで半分を超えて失敗したら、残りのバッチには手を付けない」という安全装置になります。max_fail_percentage: 0は、any_errors_fatal: trueとほぼ同じ意味になりますが、割合が0を超えさえすれば止まるからです。
到達できないホスト: unreachableはfailedではない
SSH自体ができないときは、failedではなくunreachableとして数えます。タスクが失敗したのではなく、開始すらできなかったからです。この区別は、PLAY RECAPで欄が異なることに表れ、ignore_errorsでは無視されません。ignore_unreachable: trueをそのタスクに付けると、ホストが外れずに、次のタスクへ進みます。
実務でこの区別が重要な理由は、原因がまったく異なるからです。failedは、たいてい自分たちのコードや対象の状態の問題で、unreachableは、たいてい、ネットワーク・電源・インベントリのタイプミスの問題です。デプロイレポートを見るときに、2つの数字を合算してしまうと、その日何を直すべきかがわからなくなります。
開始する前に止める: assert
最も安価な失敗は、何かを変更する前に起きる失敗です。assertは、条件のリストを受け取り、1つでも偽ならタスクを失敗させます。
- name: 배포 전제 확인
ansible.builtin.assert:
that:
- deploy_env in ["dev", "stage", "prod"]
- app_version is match("^[0-9]+\.[0-9]+\.[0-9]+$")
fail_msg: "배포 전제가 어긋났습니다 (env={{ deploy_env }}, version={{ app_version }})"
success_msg: "전제 조건 통과"
fail_msgを必ず書くよう勧める理由があります。デフォルトのメッセージはAssertion failedの1行なので、条件が複数あるとき、どれが偽だったのかがわかりません。現在の値をメッセージに埋め込んでおけば、ログ1行で終わります。そして、このタスクをプレイの先頭にany_errors_fatalと一緒に置けば、1台でも前提が食い違ったとき、何も触らずに止まります。failモジュールは、whenと一緒に使う従兄弟のようなもので、条件を直接書きたいときに使います。
PLAY RECAPの読み方
web1 : ok=6 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web2 : ok=3 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
db1 : ok=5 changed=2 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0
ghost: ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=1
この4行から読み取るべきことは、次のとおりです。web2は、4番目のタスクあたりで失敗して、その後は何もしていません。db1は、失敗したあと、rescueが救って最後まで進みました。緑のランプですが、ロールバックが実行されました。ghostはSSHができず、それを無視するように書かれていました。ignoredが0でない行は、常に一度のぞいてみる価値があります。無視すると決めておいたものが、今でも無視してよいものかどうかは、時間が経つと変わるからです。
現場での姿
事例1: ignore_errorsが積み重なったプレイブック。あるロールに、ignore_errors: trueが11か所ありました。1つ1つには理由がありました。「このパッケージはすでにインストールされているかもしれないので」「このサービスがない環境もあるので」。ところが、その中に1つ紛れ込んでいました。証明書をデプロイするタスクでした。証明書の発行が失敗してもデプロイは緑で、4か月後に証明書が期限切れになって初めて、そのサーバーが2か月間、古い証明書で動いていたことが明らかになりました。その後、ルールを1つ立てました。ignore_errorsには、なぜ無視してよいのかをコメントで必ず書く。コメントを書いているうちに、ほとんどはfailed_whenやwhenに変えるべきものだったことに気づきます。
事例2: 事前点検を後ろに置いていた日。デプロイのプレイブックが、設定をすべて配布したあと、最後にバージョンの形式を検査していました。タイプミス1つで、バージョンが1.2.3-rcだった日、40台に設定がすべて上がったあとで止まりました。元に戻すのに2時間かかりました。今は、assertのまとまりが、プレイの先頭に、any_errors_fatalと一緒にあります。同じタイプミスが起きても、3秒で、何も触らないまま止まります。
事例3: ロールバックが緑のランプだったパイプライン。block/rescueで自動ロールバックを付けてから、デプロイの失敗件数が0になりました。良いサインと読まれましたが、実際にはrescueが毎週何回も動いていて、誰もそれを数えていませんでした。rescueの中に「ロールバック発生」を記録するタスクを入れ、週次レポートにその数字を載せた翌日、特定のイメージタグでだけヘルスチェックが失敗することが、1時間で明らかになりました。
次のラボですること
ホスト3つのインベントリで1台だけが失敗するようにしておき、デフォルトの動作が本当に残りの2台を先へ進めるのかを、マーカーファイルで確認します。そこにignore_errorsを付けると何が変わるか、failed_whenで基準を正すと何が変わるかを、それぞれ計測します。block/rescue/alwaysでロールバックの構造を作り、rescuedの欄が上がるのを見て、assertで前提を先頭で止めてみます。そのあと、any_errors_fatalとmax_fail_percentageで、「ほかのホストをどうするか」を2通りに設定し、結果がどう分かれるかを確認し、到達できないホストを1つ入れて、unreachableがfailedとどう違うかを見ます。最後に、PLAY RECAPを読んで数字を合算するツールを作成し、6回の実行を1枚のレポートにまとめます。