serial と delegate_to — バッチに分け、人に任せる
一言でいうと
無停止デプロイは、デプロイツールがやってくれることではなく、プレイブックをどう分けて書くかの問題であり、そのノブが、ホストをバッチに分けるserialと、一部の作業を別のマシンにやらせるdelegate_toです。
なぜ必要なのか
デフォルトの動作では、Ansibleはすべてのホストに対して、タスクを1つずつ順番に実行します。タスク1を全ホストで終えてから、タスク2へ進みます。サービスを再起動するタスクがあれば、その瞬間にすべてのホストが同時に落ちます。20台のWeb層なら、20台が一緒に落ちます。
そのため、「一度に何台までか」という制限が必要になります。ところが、制限だけでは足りません。デプロイするサーバーをロードバランサーからあらかじめ外さなければ、ユーザーのリクエストがそこに入り続け、デプロイの後に状態を確認しなければ、壊れたバージョンが次のバッチへ広がります。この3つ、つまり分割・切り離しと復帰・バッチごとの判定がそろって、はじめて無停止と呼べます。
どう動くのか
serialは、プレイのホスト一覧をバッチに分けます。
| 書き方 | 3台に対する結果 |
|---|---|
serial: 2 |
web1,web2 → web3の2つのバッチ |
serial: "50%" |
2台 → 1台 |
serial: [1, "100%"] |
まずweb1を1台(カナリア)、そのあとに残りをすべて |
リストで書くと、カナリアデプロイになります。1台に先に載せてみて、問題がなければ残りを一度に送ります。最後の要素が残りのホストより小さい場合は、そのサイズで繰り返されます。
ここで必ず知っておくべきことがあります。serialを使うと、プレイがバッチごとに最初からやり直されるかのように動作します。実行ログにPLAY [...]のヘッダーがバッチの数だけ出力されるのが、その証拠です。そして、その結果として、3つのことがバッチ単位になります。
run_once: trueは、プレイ全体ではなくバッチごとに1回動きます。- ハンドラーは、プレイの終わりではなくバッチの終わりに発火します。
max_fail_percentageは、バッチごとに判定されます。
最後のものが最も重要です。serial: 1とmax_fail_percentage: 0を一緒に使うと、「1台でも失敗したら、次の1台へ進まない」になります。2台目で失敗すれば、3台目は開始すらされず、ログにNO MORE HOSTS LEFTが出力されます。事故を3台に広げない仕組みが、これです。
似ているように見えるany_errors_fatal: trueは、別物です。こちらは「どのホストで失敗が出ても、その場でプレイ全体を止める」もので、割合で余裕を与えません。20台のうち1台くらいが失敗しても続けたいならmax_fail_percentageを、1台でも失敗したらすぐに止めなければならないならany_errors_fatalを使います。
delegate_toは、タスク1つを別のホストで実行します。対象の一覧はそのままにして、実行だけを移すものです。
- name: 로드밸런서에서 뺀다
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}"
delegate_to: lb1
ここで混乱しやすい点が1つあります。委譲されたタスクの中でも、inventory_hostnameは元のホストを指します。上の例でinventory_hostnameは、lb1ではなく、今デプロイ中のweb1です。そのため、「ロードバランサーにweb1を外すよう指示する」が、1行で表現できます。実行ログには、changed: [web1 -> lb1]のように、2つの名前が一緒に出力されます。
delegate_facts: trueを一緒に指定すると、そのタスクが作ったファクトが、元のホストではなく委譲先の分として保存されます。ロードバランサーで一度調べた値を、hostvars['lb1']ですべてのホストが一緒に見られるようにする、といった使い方です。このオプションを外すと、ファクトは元のホストに残り、hostvars['lb1']には何もありません。
並行性のノブも、知っておく価値があります。forksは、コントローラーが同時に何ホストを扱うかを決める全体設定で、serialは、プレイのバッチサイズです。両者のうち、小さいほうが実際の同時実行数になります。throttleはタスク1つにだけ掛ける制限なので、「このAPIは毎秒何件まで」のような状況に使います。strategy: freeは、ホスト同士が互いを待たないようにして、速いホストが先に終わるようにしますが、順序を保証しないので、無停止デプロイには向きません。
現場での姿
1つ目は、バッチサイズは容量の計算だということです。20台にserial: "25%"を掛けると、5台が同時に外れます。残りの15台が平常時のトラフィックを支えられなければ、そのデプロイ自体が障害です。バッチサイズは好みではなく、「何台まで外れてもよいか」の答えでなければなりません。
2つ目は、状態確認のないバッチには意味がないことです。デプロイの後にuriやwait_forで確認しなければ、壊れたバージョンがそのまま次のバッチへ進みます。確認タスクが失敗してこそ、max_fail_percentageが働けます。
3つ目は、外したら必ず戻すことです。デプロイ中にプレイが失敗すると、最後に外したサーバーがロードバランサーの外に残ります。そのため、復帰タスクはblock/alwaysの中に置くか、少なくとも「今外れているサーバーはないか」を別に確認する手順が必要です。
4つ目は、委譲先もインベントリにある必要があることです。delegate_to: lb1は、lb1がインベントリにあるときだけ、そのホストの接続設定を使います。なければ、名前だけで接続を試みます。ロードバランサー・踏み台・監視サーバーのように、「対象ではないが作業をやらせる」ホストもインベントリに書いておく理由です。
次のラボですること
3台のWebと1台のロードバランサーをインベントリに書き、serialでバッチを分けて、実際に何回動くかを記録で確認します。カナリアのバッチをリストで作り、run_onceとハンドラーがバッチごとに動くことを出力物で数え、delegate_toで外して戻す記録をロードバランサー側に残し、delegate_factsでファクトが誰の分として書かれるかを確認します。そして、真ん中の1台がヘルスチェックに失敗するようにして、3つ目のバッチが開始すらされないことを確認したあと、最後に、切り離し・デプロイ・確認・復帰の一連の流れを、サマリーファイルとして残します。