「Ansibleは本当に冪等なのか」〜冪等性が崩れる構造と設計〜 第10回 「冪等に設計する」とは何を設計することなのか
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
📋 目次
- はじめに
- 「冪等に設計する」とはどういう意味か
- desired stateをPlaybook内で閉じるとはどういうことか
- shellモジュールを使う場合の設計上の責任
- タスク依存を減らす設計
- 「Ansibleが冪等性を保証する」のか「設計者が冪等性を設計するのか」
- まとめ
- おわりに
- 次回予告
- 連載一覧:「Ansibleは本当に冪等なのか」〜冪等性が崩れる構造と設計〜
1. はじめに
第9回 では、冪等性の確認手段とその限界を整理しました。--check モードはshellモジュールの非冪等性を検出できず、register / when の構成では実際の実行経路と食い違う場合がある。「2回実行して changed=0」という確認は実態に近いが、外部状態が変化した後や別の実行経路では保証が成立しない。MoleculeとCIはその確認を自動化・継続化する仕組みであり、確認内容の限界は引き継ぐ。
今回はその先に進みます。「壊れていないことをどう確認するか」から「そもそも壊れない設計をするとはどういうことか」という問いです。
第1回〜第9回で見てきた問題を振り返ると、共通する構造があります。shellモジュールは状態を観測しない。state: latest はdesired stateをリポジトリに委譲する。lineinfile はファイル全体ではなく一行だけを管理する。register / when の組み合わせはタスクを実行履歴に依存させる。これらは別々の問題のように見えますが、逆から読むと同じ方向を指しています。「あるべき状態をPlaybook内で定義し、タスクがその状態に向かって独立して動く」という構造が崩れているとき、冪等性が壊れやすくなります。
この回では、その共通する方向性を「壊れない設計」の原則として整理します。各回で見た具体的な問題と対応づけながら、「どういう設計の選択が冪等性を維持するか」を順に見ていきます。
2. 「冪等に設計する」とはどういう意味か
「冪等に設計する」とは何を意味するのかを整理します。
Ansibleは宣言型ツールとして説明されることが多いです。しかし 第8回 で確認したように、register / when / handler の組み合わせが増えるとPlaybookは手続き型のワークフローへ近づいていきます。「宣言型ツールを使っている」ことと「宣言型として設計している」ことは別です。
以下の2つのタスクを比較します。
# 操作の指示(imperative)
- name: nginx を再起動する
ansible.builtin.shell: systemctl restart nginx
# 状態の宣言(declarative)
- name: nginx が起動している状態にする
ansible.builtin.service:
name: nginx
state: started
前者は「再起動する」という操作の指示です。実行のたびにnginxが停止・再起動されます。nginxがすでに起動しているかどうかは関係ありません。第1回 で確認したshellモジュールの動作がこれにあたります。
後者は「起動している状態にする」という状態の宣言です。nginxがすでに起動していれば何もしません。service モジュールは現在の状態を観測し、desired stateと比較してから動作を決めます。第2回 で確認したモジュールの差分検出の仕組みがこれを支えています。
この構造の差は、第1回〜第5回で見てきた問題の根底にあります。shellモジュールが毎回 changed=1 になるのは、操作の指示として書かれているからです。notify が意図しない再起動を引き起こすのは、操作の連鎖として設計されているからです。
「冪等に設計する」とは、Playbookを「操作の羅列」ではなく「あるべき状態の宣言の集合」として書くことです。そしてその選択は一度だけではなく、タスクを追加するたびに、モジュールを選ぶたびに、条件分岐を書くたびに、意識的に行い続けることです。
Ansibleが提供する宣言型モジュール(service / file / package など)はその選択を支える道具です。しかし道具を使うだけでは宣言型の設計にはなりません。「このタスクは操作を指示しているのか、それとも状態を宣言しているのか」という問いを持ちながら書くことが、冪等な設計の出発点になります。
3. desired stateをPlaybook内で閉じるとはどういうことか
desired stateがPlaybook内で閉じているとはどういうことかを整理します。
第4回 では state: latest を取り上げました。このとき確認したのは、「最新バージョン」の定義がリポジトリの状態によって変わるという構造です。Playbookの記述は変わっていないのに、リポジトリが更新されると実行結果が変わります。desired stateの定義がPlaybook外のリポジトリに出ていっています。
第6回 では lineinfile を取り上げました。lineinfile は特定の行への局所的な編集操作であり、ファイル全体の状態はPlaybookから読み取れません。どの行が書き換えられるかは、ファイルの現在の内容に依存します。ここでもdesired stateの一部がPlaybook外のファイルの現在の状態に出ていっています。
第3回 では、Jinja2テンプレートのwhitespace差分を取り上げました。trim_blocks / lstrip_blocks を明示しない場合、レンダリング結果に意図しない空白や改行が混入し、実行のたびに差分が検出されます。テンプレートの記述は変わっていないのに、whitespaceの扱いが未定義のままdesired stateが不安定になっています。
これらに共通しているのは、「Playbookを読んでもシステムのあるべき状態が確定しない」という構造です。desired stateの一部が外部の状態や未定義の挙動に委ねられています。
逆にdesired stateをPlaybook内で閉じるための設計の選択を具体的に示します。
バージョンを固定する
# desired stateがリポジトリに依存する
- name: nginx をインストールする
ansible.builtin.dnf:
name: nginx
state: latest
# desired stateがPlaybook内で閉じている
- name: nginx をインストールする
ansible.builtin.dnf:
name: nginx-1.20.1-28.el9_8.2.rocky.0.1.x86_64
state: present
ファイルを全体管理で扱う
# desired stateがファイルの現在の内容に依存する
- name: タイムアウト値を設定する
ansible.builtin.lineinfile:
path: /etc/app.conf
regexp: '^timeout='
line: 'timeout=30'
# desired stateがPlaybook内で閉じている
- name: 設定ファイルを配置する
ansible.builtin.template:
src: templates/app.conf.j2
dest: /etc/app.conf
whitespace制御を明示する
{# trim_blocks / lstrip_blocks を明示しない場合、レンダリング結果が不安定になる #}
{% if enable_gzip %}
gzip on;
{% endif %}
# templateモジュールでwhitespace制御を明示する
- name: 設定ファイルを配置する
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
vars:
ansible_template_jinja2_trim_blocks: true
ansible_template_jinja2_lstrip_blocks: true
これらはいずれも同じ方向を向いています。「Playbookを読めばシステムのあるべき状態が分かる」という設計の目標です。バージョンが固定されていれば、どのバージョンがインストールされるべきかがPlaybookから読み取れます。テンプレートでファイル全体を管理していれば、そのファイルがどういう内容であるべきかがPlaybookから読み取れます。whitespace制御を明示していれば、レンダリング結果がPlaybookの記述から予測できます。
desired stateがPlaybook外に出ていくほど、「Playbookを実行したらシステムがどういう状態になるか」を予測することが難しくなります。desired stateをPlaybook内に閉じる設計の選択は、その予測可能性を維持するための選択です。
4. shellモジュールを使う場合の設計上の責任
shellモジュールを使う場合に設計者が持つべき責任を整理します。
第1回 で確認したとおり、shellモジュールは構造上、状態を観測しません。渡されたコマンドをそのまま実行し、stdout / stderr / rc を返すだけです。「現在の状態」も「desired state」も持たないため、差分検出は行われません。これはshellモジュールの欠陥ではなく、「コマンドをそのまま実行するモジュール」という設計そのものです。
この構造上の限界は変わりません。しかし「shellを使う=冪等性を捨てる」ということではありません。shellモジュール自体が冪等判定できない分、その周囲に冪等性の構造を設計者が持ち込む責任が生じます。
その具体的なパターンを示します。
creates / removes パラメータを使う
shell モジュールには creates と removes というパラメータがあります。creates に指定したパスが存在する場合、そのタスクはスキップされます。removes に指定したパスが存在しない場合も同様です。
- name: 初期化スクリプトを実行する
ansible.builtin.shell: /opt/app/bin/init.sh
args:
creates: /opt/app/initialized
このタスクは /opt/app/initialized が存在しない場合のみ実行されます。スクリプトの実行後にそのファイルが作成される構成にしておけば、2回目以降はスキップされます。shellモジュール自体が状態を観測するのではなく、ファイルの存在という外部の状態をスキップ条件として設計者が定義しています。
stat モジュールで事前確認をしてから実行する
stat モジュールで現在の状態を確認し、when 条件でshellの実行有無を制御する構成です。
- name: 対象ファイルの存在を確認する
ansible.builtin.stat:
path: /opt/app/initialized
register: init_flag
- name: 初期化スクリプトを実行する
ansible.builtin.shell: /opt/app/bin/init.sh
when: not init_flag.stat.exists
creates と目的は同じですが、stat で得られる情報(ファイルのサイズ・パーミッション・更新日時など)を条件に使いたい場合や、複数の条件を組み合わせたい場合に有効です。when の参照先が register 変数ではなく stat モジュールで取得したシステムの現在状態であることも重要です。第8回 で整理したとおり、when の参照先が「現在の状態」であれば実行履歴への依存は生じません。
register と changed_when を組み合わせる
shellモジュールの実行結果を register で受け取り、その内容を判断材料として changed_when で changed の判定を制御する構成です。
- name: サービスの状態を確認する
ansible.builtin.shell: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: false
- name: nginx を起動する
ansible.builtin.shell: systemctl start nginx
when: nginx_status.stdout != "active"
changed_when: nginx_status.stdout != "active"
1つ目のタスクはshellで現在の状態を読み取るだけで、changed_when: false を付けて常に changed=0 とします。2つ目のタスクはその結果を受けて実行するかどうかを判断し、changed_when で実際に状態が変わった場合のみ changed=1 を返すよう制御します。
ただしこの構成には注意が必要です。第1回 で確認したとおり、changed_when はAnsibleの表示と handler の発火を制御するものです。changed_when を正しく設定しても、shellモジュールが実行するコマンドそのものの副作用は変わりません。「changed の判断をロジックで補う」という構成が意図どおりに機能するかどうかは、shellが実行するコマンドと changed_when の条件が正しく対応しているかどうかにかかっています。
これら3つのパターンに共通しているのは、「shellモジュール自体を冪等にしているのではない」という点です。shellモジュールはコマンドを実行するだけです。その周囲に「実行すべき状態かどうか」を判断する構造を設計者が持ち込むことで、冪等に近い動作を実現しています。shellを使う場面では、その構造を設計する責任は設計者にあります。
5. タスク依存を減らす設計
タスク間の依存を減らす設計の方向性を整理します。
第8回 で確認したとおり、register / when の組み合わせが増えると、Playbookの動作経路が実行履歴に依存し始めます。「前のタスクが changed=1 だったから次のタスクを実行する」という連鎖が増えるほど、「どのタスクが今回の実行でchangedになったか」という組み合わせによって状態遷移の経路が増えます。経路が増えるほど、すべての経路で同じ最終状態へ収束することの保証は難しくなります。
when の参照先を実行履歴ではなく現在の状態にする
第8回 で整理したとおり、when 条件の参照先には2種類あります。
# 実行履歴を参照する条件
when: install_result.changed
# システムの現在状態を参照する条件
when: ansible_os_family == "RedHat"
前者は「今回の実行で前のタスクが changed=1 を返したかどうか」を参照しています。この値は実行回数によって変わります。後者はリモートノードのOS種別を参照しています。環境が変わらない限り値は変化しません。
when: some_task.changed を使う場面では、「この条件はシステムの現在状態を表しているか、それとも今回の実行履歴を表しているか」を確認する習慣を持つことが、タスク依存を増やさないための第一歩です。
「changed だったときだけ実行する」連鎖を見直す
以下のような構成を見たとき、後続タスクが本当に install_result.changed に依存する必要があるかを検討します。
- name: パッケージをインストールする
ansible.builtin.dnf:
name: nginx
state: present
register: install_result
- name: 設定ファイルを配置する
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
when: install_result.changed
このPlaybookでは、設定ファイルの配置がインストールの changed に依存しています。インストール済みの環境に再実行すると、設定ファイルの配置はスキップされます。設定ファイルが別の手段で書き換えられていた場合でも、スキップされます。
設定ファイルの配置は「nginxがインストールされているかどうか」ではなく「設定ファイルが正しい内容であるかどうか」を判断基準にすべきです。template モジュールはその判断を自分で行えます。
- name: パッケージをインストールする
ansible.builtin.dnf:
name: nginx
state: present
- name: 設定ファイルを配置する
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
各タスクが独立してdesired stateを宣言できる構造にすると、タスク間の依存がなくなり、それぞれのタスクがシステムの現在状態だけを判断材料にして動くようになります。
handlerを正当な用途に限定する
handler は「設定ファイルが変更されたときだけサービスをrestartする」という用途に適しています。この使い方は、意図しない再起動を防ぐという意味で冪等性に貢献しています。
# handlerの正当な用途
- name: 設定ファイルを配置する
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: nginx を再起動する
一方で、notify を「あるタスクの結果を別のタスクに伝える手段」として使い始めると、実行履歴への依存を手続き的に増やす方向に進みます。handler はあくまで「変更検知に連動した後処理」として使い、タスク間の情報伝達の手段として使わないことが、依存を増やさないための設計の判断です。
register / when / handler を完全に排除することがここでの目標ではありません。これらはAnsibleの正当な機能であり、適切な場面では有効に機能します。「タスク間の依存が増えるほど状態遷移の経路が増える」という構造を意識した上で、その依存が本当に必要かどうかをタスクを書くたびに問い直すことが、タスク依存を必要最小限に保つ設計の方向性です。
6. 「Ansibleが冪等性を保証する」のか「設計者が冪等性を設計するのか」
シリーズ全体の結論として、「Ansibleは冪等なツールである」という理解の正確な意味を整理します。
Ansibleが提供しているのは、冪等な設計を実現しやすくする仕組みです。宣言型モジュールが現在の状態を観測して差分検出を行う、notify が差分のないときにhandlerの発火を防ぐ、state: present のような desired state を記述する文法がある——これらはすべて「冪等な設計をしやすくする構造」です。設計者が何も考えなくても冪等になる保証ではありません。
第1回〜第9回で見てきた「壊れる構造」を振り返ります。
shellモジュールは状態を観測しないため、コマンドを実行するたびに changed=1 を返します(第1回)。copy や template を正しく使っていても、改行コードやJinja2のwhitespace差分によって意図しない changed=1 が発生します(第3回)。state: latest はdesired stateをリポジトリに委譲するため、リポジトリが更新されると同じPlaybookで結果が変わります(第4回)。lineinfile はファイル全体ではなく一行だけを管理するため、ファイルの現在の内容によって動作が変わります(第6回)。register / when の組み合わせが増えると実行履歴に依存した経路が生まれます(第8回)。
これらはいずれも、Ansibleの仕組みを正しく使っていても設計の選択によって生じうる問題です。shellモジュールを使うかどうか、desired stateをどこで定義するか、タスク間の依存をどう設計するか——これらはすべて設計者の判断の範囲にあります。Ansibleはその判断を強制しません。
「Ansibleを使っているから冪等性は保証されている」という理解は正確ではありません。正確には「Ansibleを使い、かつ冪等な設計を選択し続けることで冪等性が維持される」です。
この違いは実運用上の責任の所在に関わります。「Ansibleが冪等性を保証する」という理解のもとでは、意図しない再起動や設定の上書きが発生したとき、「Ansibleがそうしたのだから仕方がない」という解釈になります。「設計者が冪等性を設計する」という理解のもとでは、「この設計の選択が問題を引き起こした」という分析と改善につながります。
Ansibleが提供する仕組みは、冪等な設計を選択しやすくするための道具です。その道具を使いながら「このタスクは状態を宣言しているか」「desired stateはPlaybook内で閉じているか」「タスク間の依存は必要最小限か」という問いを持ち続けることが、冪等性を維持するための設計者の役割です。
7. まとめ
この回で整理した内容を確認します。
-
「冪等に設計する」とは、Playbookを「操作の羅列」ではなく「あるべき状態の宣言の集合」として書くことである。「nginxをrestartする」という操作の指示と「nginxがstartedの状態にある」という状態の宣言は構造が異なる。宣言型モジュールを使うことと宣言型として設計することは別であり、その選択をタスクを書くたびに意識的に行い続けることが冪等な設計の出発点になる。
-
desired stateをPlaybook内で閉じるとは、「Playbookを読めばシステムのあるべき状態が分かる」という設計の目標に向かうことである。バージョンを固定する、
lineinfileではなくtemplate/copyでファイル全体を管理する、Jinja2のwhitespace制御を明示する——これらはいずれもdesired stateがPlaybook外の外部状態に出ていくことを防ぐ設計の選択である。 -
shellモジュールを使う場合、冪等性の構造を設計者が周囲に持ち込む責任が生じる。
creates/removesパラメータ、statモジュールによる事前確認、registerとchanged_whenの組み合わせは、shellモジュール自体を冪等にするのではなく、shellの周囲に冪等性の判断構造を持ち込むパターンである。 -
タスク間の依存を必要最小限に設計することで、実行履歴依存の経路を減らせる。
when: some_task.changedのような実行履歴を参照する条件より、when: ansible_os_family == "RedHat"のような現在のシステム状態を参照する条件を優先する。各タスクが独立してdesired stateを宣言できる構造にすることで、タスク間の依存から生じる状態遷移の経路の増加を抑えられる。 -
Ansibleが提供しているのは「冪等性を設計しやすい構造」であり、「冪等性の自動保証」ではない。宣言型モジュール・差分検出・
notifyの仕組みはすべて冪等な設計を実現しやすくする道具であり、設計者が何も考えなくても冪等になる保証ではない。第1回〜第9回で見てきた「壊れる構造」はいずれも、Ansibleの仕組みを正しく使っていても設計の選択によって生じうるものだった。 -
「Ansibleを使っているから冪等性は保証されている」ではなく、「Ansibleを使い、かつ冪等な設計を選択し続けることで冪等性が維持される」というのがこのシリーズ全体を通じた結論である。冪等性を維持するための設計者の役割は、Ansibleが提供する仕組みを道具として使いながら、状態宣言・desired stateの定義・タスク依存の管理について問い続けることにある。
8. おわりに
「Ansibleは本当に冪等なのか」という問いから始まったこのシリーズも、今回で最終回になります。
第0回 で「冪等性とは状態遷移制御である」と整理してから、第1回〜第8回で冪等性が崩れる構造を見てきました。shellモジュールは状態を観測しない。差分検出はバイト列とメタデータを見ているだけで意味を理解しない。desired stateが外部に委譲されると同じPlaybookで結果が変わる。タスク間の依存が増えると実行履歴に依存した経路が生まれる。第9回 ではその確認手段の限界を整理し、第10回では壊れない設計の方向性をまとめました。
この問いに対するこのシリーズの答えは、「条件付きでYes」です。Ansibleは冪等な設計を実現しやすくする仕組みを持っています。しかし「Ansibleを使えば冪等になる」のではなく、「Ansibleを使いながら冪等な設計を選択し続けることで冪等性が維持される」のが正確なところです。状態を宣言しているか、desired stateがPlaybook内で閉じているか、タスク間の依存は必要最小限か——これらの問いを持ち続けることが、冪等性を維持するための設計者の役割として残ります。
このシリーズが、Ansibleを使った構成管理の設計を考えるときの手がかりになれば幸いです。
9. 次回予告
本シリーズでは「Playbookの設計問題」として冪等性が崩れる構造を扱ってきました。次のシリーズでは、その先にある別の問いを扱います。
冪等性シリーズ で整理した「壊れない設計」を前提に、次シリーズはAnsibleが実行されていない時間に起きる構成ドリフトを扱います。Playbookを正しく設計して冪等性を保っていても、Ansibleが実行されていない時間に起きる変化には対応できません。
| 回 | タイトル | 内容(概要) |
|---|---|---|
| 第0回 | なぜAnsibleで管理しているのにサーバはずれていくのか | 構成ドリフトを「Playbookで定義したdesired stateと実際のサーバの状態がずれていく現象」として定義し、冪等性シリーズとの違いを整理する。ドリフトが「Ansibleが実行されていない時間」に起きる問題であることを理解する。 |
| 第1回 | なぜ手動変更はAnsibleの管理を壊すのか | 障害対応や一時的な直接編集がドリフトを生む構造を、templateとlineinfileの挙動の違いを通して理解する。手動変更が「次にAnsibleが実行されるまで気づかれない」空白を作ることを整理する。 |
| 第2回 | なぜdriftは--checkで検知できないのか |
--check/--diffモードをドリフト検知の手段として使う場合の限界を整理する。検知できるケースと検知できないケースを実機で切り分け、「--checkで問題が出ない=ドリフトがない」ではないことを理解する。 |
| 第3回 | なぜドリフトは気づかれないまま進むのか | changedが出ない「静かなドリフト」の構造を扱う。cronジョブやアプリケーションの自己書き換え、パッケージの自動更新など、Ansible管理外で起きる変化がPlaybook実行結果に現れない理由を理解する。 |
| 第4回 | ドリフトを検知して修正する設計 | シリーズの結論として、ドリフトを「防ぐ」「検知する」「修正する」の3つの設計パターンを整理する。定期実行・アラート化・自動修正フローなど、運用に組み込むための具体的な設計を扱う。 |
次回:【構成ドリフト編】第0回:なぜAnsibleで管理しているのにサーバはずれていくのか
📑 連載の移動 前の記事:【冪等性編】 第9回 | 次の記事:【構成ドリフト編】 第0回
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
10. 連載一覧:「Ansibleは本当に冪等なのか」〜冪等性が崩れる構造と設計〜
| 回 | タイトル | 内容(概要) |
|---|---|---|
| 第0回 | なぜAnsibleは「何度実行しても安全」だと思われているのか | 冪等性(idempotency)の本来の意味を整理し、「同じコマンドを繰り返せる」ことと「状態が収束する」ことの違いを理解する。 |
| 第1回 | shellモジュールはなぜ“状態”を扱えないのか | shell/command が desired state を持てない理由を構造から整理する。changed_when は表示制御であり、冪等性保証ではないことを理解する。 |
| 第2回 | なぜAnsible moduleは「変更不要」を判断できるのか | file/copy/template などの module が、現在状態と desired state の差分比較によって動作していることを理解する。Ansibleが宣言的管理に見える理由を整理する。 |
| 第3回 | なぜファイル操作は簡単に非冪等になるのか | 改行コード、owner/group、Jinja2レンダリング差分など、「見えない差分」が changed を発生させる構造を理解する。 |
| 第4回 | なぜ“最新化”は冪等性を壊すのか | yum/apt の state: latest やバージョン未固定が、再実行ごとに状態を変化させる理由を理解する。desired state と「現在の最新版」は別物であることを学ぶ。 |
| 第5回 | なぜサービス制御は再起動ループを生むのか | service/systemd/handler の notify 連鎖を通して、「変更検知」がどのように状態遷移を引き起こすのかを理解する。reload/restart の違いも整理する。 |
| 第6回 | なぜlineinfileは“安全そうに見えて危険”なのか | lineinfile は「状態管理」ではなく「部分パッチ」である。正規表現・重複挿入・文脈依存編集によって冪等性が崩れる構造を理解する。 |
| 第7回 | なぜ同じPlaybookなのに環境ごとに結果が変わるのか | OS・ディストリビューション・systemd・Python・locale 差分によって、module の内部動作が変化することを理解する。 |
| 第8回 | なぜタスクの依存関係は冪等性を壊すのか | handler、register、条件分岐、タスク順序によって「前回実行結果」に依存したPlaybookが生まれる。状態遷移が閉じなくなる理由を整理する。 |
| 第9回 | 冪等性はどうやって検証すべきなのか | check mode の限界、diff mode、molecule、CI、自動テストを通して、「2回目 changed=0」をどう保証するのかを理解する。 |
| 第10回 | 「冪等に設計する」とは何を設計することなのか | imperative(手続き)ではなく declarative(状態宣言)としてPlaybookを設計する考え方を整理し、「壊れない構成管理」の原則を完成させる。 |