冪等性 — 二度回しても何も起きてはならない
一言でいうと
プレイブックの品質は、最初の実行が成功することではなく、2回目の実行でchanged=0が出ることで判定します。
なぜ必要なのか
構成管理ツールの本当の価値は、「インストールを自動化する」ことではなく、現在の状態が望ましい状態かどうかを、いつでも確認して合わせられることです。そのためには、いつ実行しても安全でなければなりません。そうであって初めて、cronで定期的に実行してドリフトを捉えられ、デプロイパイプラインで前のステップが失敗したあとに再実行しても、怖くありません。
逆に、冪等でないプレイブックは、実行するたびにサービスを再起動したり、ファイルに行を追加したりします。すると、人々はそれを怖がるようになり、怖い自動化は結局使われません。その瞬間、インフラは再び手作業に戻ります。
どう動くのか
冪等性を作るのは、3つの仕組みです。
1つ目は、状態を知っているモジュールです。file、copy、template、lineinfileのようなモジュールは、現在の状態を読み取って、異なる部分だけを変更します。変更するものがなければ、okを出します。
2つ目は、シェルコマンドのガードです。command/shellは、それ自体では冪等にできないので、条件を付ける必要があります。
| 仕組み | 意味 |
|---|---|
creates: /path |
そのパスがすでにあれば、実行しない |
removes: /path |
そのパスがなければ、実行しない |
changed_when: <조건> |
いつ「変更した」と報告するかを、直接定義する(プレースホルダーは条件です) |
failed_when: <조건> |
終了コードの代わりに、失敗の判定を直接定義する(プレースホルダーは条件です) |
changed_when: falseは、特によく使われます。状態を取得するだけのコマンド(例: バージョンの確認)は、決して変更ではないからです。
3つ目は、ハンドラーです。ハンドラーは、「何かが実際に変わったときにだけ」動くタスクです。設定ファイルが変わったときにだけ、サービスに再読み込みさせる用途で使います。
tasks:
- name: 설정 배치
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: reload app
handlers:
- name: reload app
ansible.builtin.command: /usr/bin/app-reload
核心となるルールが3つあります。(1) notifyは、そのタスクがchangedのときにだけ発火します。(2) 複数のタスクが同じハンドラーを呼んでも、ハンドラーは1回しか動きません。(3) ハンドラーは、デフォルトではプレイのすべてのタスクが終わったあとに実行されます。途中でどうしても実行する必要があるなら、meta: flush_handlersを使います。
3つ目のルールで、落とし穴が出てきます。プレイが途中で失敗すると、まだ実行されていないハンドラーは、そのまま消えます。設定は変わったのに、サービスは新しい設定を読み込んでいない状態になるのです。このようなときは、--force-handlersで、失敗してもハンドラーを実行させられます。
現場での姿
1つ目は、「毎回再起動されるデプロイ」です。原因は、ほとんどの場合、テンプレートのレンダリング結果が毎回異なるからです。タイムスタンプやランダムな値、順序が保証されない辞書の走査が、犯人です。レンダリング結果が同じなら、templateモジュールはokを出し、ハンドラーも静かです。
2つ目は、本当の検証方法です。CIでプレイブックを2回連続で実行し、2回目の実行のchangedとfailedが、どちらも0かどうかを確認することが、標準的な冪等性テストです。人が目で見る代わりに、終了コードで判定するようにする必要があります。
3つ目は、冪等性とドリフトです。冪等なプレイブックは、それ自体がドリフト検知器です。手で直しておいたサーバーに実行すると、changedが出て、その数字がそのまま「コードとずれている度合い」です。この性質は、Terraformのplanとまったく同じアイデアです。
changedを正直にする方法
Ansibleでの冪等性は、結果が同じであることではなく、changedが正直であることです。何も変わっていないのにchangedが出るとハンドラーが空回りし、変わったのにokが出ると、サービスが古い設定のまま残ります。
commandとshellは、常にchangedです。Ansibleは、そのコマンドが何をしたのかを知らないからです。そのため、いつ実行するかと、何を変更とみなすかを、直接書いて伝えます。
- name: 스키마 이전
ansible.builtin.command: /srv/app/migrate --apply
args:
creates: /srv/app/.migrated # 이 파일이 있으면 건너뛴다
register: migrate
changed_when: "'applied' in migrate.stdout"
failed_when: migrate.rc != 0 and 'already up to date' not in migrate.stderr
creates/removesで、そもそも実行されないようにするのが1番目で、実行する必要があるなら、changed_whenで判定を修正してあげるのが2番目です。
ハンドラーは、プレイの最後に1回だけ動きます。同じハンドラーを10回通知しても、1回です。これは利点ですが、プレイが途中で失敗するとハンドラーはまったく動かないということでもあります。設定ファイルは変わったのに、デーモンは再起動されないまま残ります。次の実行では、ファイルはすでに合っているのでokが出て、ハンドラーは永遠に動きません。この穴をふさぐのが--force-handlersで、より良い方法は、再起動が必要な状態そのものを確認する作業を別に置くことです。
設定ファイルは、書き込む前に検査します。誤った設定を書き込んでから再起動すると、サービスが落ちます。
- name: nginx 설정
ansible.builtin.template:
src: app.conf.j2
dest: /etc/nginx/conf.d/app.conf
validate: nginx -t -c %s # 통과해야 실제 자리에 놓는다
notify: reload nginx
--checkと--diffで、先に確認します。ただし、前の作業の結果に依存する作業は、チェックモードで失敗したり、見当違いの答えを出したりします。そのような作業には、check_mode: falseを付けて、チェックモードでも実際に読み取りだけを行うようにします。チェックモードが嘘をつき始めると、誰も使わなくなり、そのときからは、本番でいきなり実行するようになります。
次のラボですること
同じプレイブックを2回実行して、最初の実行では変更があり、2回目にはchanged=0が出るようにします。ハンドラーが最初の実行でのみ発火することを確認し、シェルコマンドにガードを付けて、2回実行してもログが1行しか積み上がらないようにします。最後に、冪等性の判定レポートを作成します。