0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ansibleが理解できない理由はLinuxにあった【Ansible編】第5回:なぜ変数が意図通りに扱えないのか

0
Last updated at Posted at 2026-07-06

📚 Ansibleが理解できない理由はLinuxにあった【Ansible編】第5回:なぜ変数が意図通りに扱えないのか

⓪ なぜShellの知識がないとAnsibleは壊れるのか
① なぜshellモジュールは危険なのか
② なぜmoduleを使うと安全になるのか
③ なぜ毎回changedになるのか
④ なぜ条件分岐が壊れるのか(Ansible編)
⑤ なぜ変数が意図通りに扱えないのか
⑥ なぜタスクの順序で壊れるのか
⑦ なぜエラー制御が破綻するのか
⑧ なぜ非同期・待機で失敗するのか
⑨ なぜPlaybookが読めないのか
⑩ なぜAnsibleを正しく設計できるようになるのか


🗺️ 初めての方・シリーズの全体像を知りたい方はこちら

本記事は、OSの仕組みからAnsible設計までを繋ぐ連載シリーズの一部です。
「どこから読み始めればいいか」あるいは、「OS/Shell/Ansible編の関係性」 について把握されたい場合は、以下の統合ガイドで整理しています。

→ Ansibleが理解できない理由はLinuxにあった|統合ガイド


📑 【Ansible編】全体のまとめはこちら
「Ansibleが理解できない理由はLinuxにあった【Ansible編】まとめ」


📑 連載の移動
前の記事:【Ansible編】第4回 | 次の記事:【Ansible編】第6回


📋 目次

  1. 前回の振り返り(第4回)
  2. 逆引き辞典との連動
  3. はじめに
  4. 問題定義:変数が意図通りに反映されない原因
  5. 構造解剖:Ansibleにおける変数の優先順位(Precedence)
  6. 原因分析:評価タイミングとホスト間の変数隔離による実行時の不整合
  7. 原因分析:遅延評価(Lazy Evaluation)による実行時の不整合
  8. 設計:register変数の生存期間と上書きリスクの管理
  9. 本質:メンテナンス性を担保する変数配置の原則
  10. まとめ
  11. 次回予告
  12. 連載一覧:Ansibleが理解できない理由はLinuxにあった【Ansible編】

※本記事の位置とシリーズ全体の関係を先に確認します。


:map: シリーズ全体構造(学習 × 問題解決)


本シリーズは
「理解(各記事)」と「問題解決(逆引き辞典)」を組み合わせて
スキルを身につける構成になっています。

この図は、どこから学び、どこに進めばよいかを示した“ロードマップ”です。


📍 現在の位置


現在はこの図の「Ansible編の第5回」になります。


1. 前回の振り返り(第4回)

前回 は、条件分岐(when 句)の評価プロセスを確認しました。終了ステータス(rc)が直接評価されるのではなく、一度JSONデータとして register 変数に格納され、それをJinja2が判定する仕組みです。


↑ 目次に戻る


2. 逆引き辞典との連動

「設計上の原因」を特定したい場合は、以下の逆引き辞典を活用してください。

Ansibleが理解できない理由はLinuxにあった【Ansible編】:トラブル逆引き辞典(設計パターン版)

  • まず「逆引き辞典」で該当ステップを特定する
  • 次に「本編(各連載記事)」で仕組みを理解する

↑ 目次に戻る


3. はじめに

Ansibleの変数は、インベントリ・Play・Role・タスク・コマンドライン引数など、さまざまな場所で定義できます。同じ名前の変数が複数の場所で定義された場合、どの値が使われるかは優先順位のルールによって決まります。
本記事では、この「変数の優先順位(Precedence)」と「遅延評価(Lazy Evaluation)」の仕組みを整理します。変数が意図通りに動かない原因を理解し、安定したPlaybookを設計するための指針を提示します。


↑ 目次に戻る


4. 問題定義:変数が意図通りに反映されない原因

実務では、以下のような変数に関連する不具合が発生します。

  • 優先順位の誤認: group_vars で定義した値が、インベントリファイルの記述によって意図せず上書きされ、設定が反映されない。
  • 評価タイミングのズレ: 変数の中に別の変数({{ other_var }})を含めた場合、実行時のコンテキストによって予期しない値になる。
  • register 変数の上書き: ループ処理や複数タスクで同じ変数名に register を使った結果、意図したデータが失われる。

これらはいずれも、Ansibleの変数優先順位とスコープを把握していないことが原因です。


↑ 目次に戻る


5. 構造解剖:Ansibleにおける変数の優先順位(Precedence)

Ansibleでは、同じ名前の変数が複数箇所で定義された場合、優先順位(precedence)に従って最終的な値が決定されます。

公式ドキュメントでは20段階以上の優先順位が定義されていますが、(実際には約22段階)ここでは実務で重要となる主要な階層を抽出した簡略モデルを示します。


変数採用の優先ロジック(簡略版)


構造のポイント

  1. インベントリ vs Playbook・Role: 一般的に、PlaybookやRole内で定義された変数は、インベントリ(group_vars や host_vars)よりも優先されます。

  2. Play vars vs Role vars: Playbookの vars 句(Play vars)よりも、Role内の vars/main.yml(Role vars)で定義された値のほうが高い優先順位を持ち、上書きされます。

  3. 動的変数の扱い(set_fact): set_fact によって実行時に生成された変数は、ホストに紐づく事実(Fact)として扱われるため、静的に定義された Role vars よりも高い優先順位を持ちます。

  4. 決定的な上書き: -e(extra-vars)で指定された変数は、他のすべての変数定義よりも常に優先されます。

補足:register変数の優先順位について

タスクの実行結果を格納する register 変数は、公式の優先順位の階層には独立して登場しません。内部的には set_fact とほぼ同等の動的変数(ホスト変数)として扱われますが、これらは定義する場所ではなく「実行時のタイミング」で値が書き換わる特性を持ちます。本質的な優先順位の混乱を防ぐため、まずは上記の静的な階層構造をベースに設計を行うのが安全です。


🛠️ 挙動検証:Playbook変数とコマンドライン引数による優先順位の上書き

検証の前提条件(ターゲットノード側の状態)

本検証では、インベントリのグループ変数(group_vars/all.yml)にベースとなる設定値が定義されている環境を前提とします。

# group_vars/all.yml の中身
---
env_stage: "development_from_group_vars"

この状態で、Playbook内の変数(vars)や、実行コマンドの引数(-e)を組み合わせた際、簡略モデルの通りに優先順位が適用され、最終的な値が上書きされるかを確認します。

【検証5-1】Playbook内変数による上書き(group_vars < Play vars)

インベントリ変数よりPlaybook内の変数が優先される挙動を確認します。

ファイル名: check_precedence_play.yml

---
- name: 5-1. インベントリ変数とPlaybook変数の優先順位を確認する
  hosts: test_servers
  gather_facts: false
  vars:
    env_stage: "staging_from_play_vars"

  tasks:
    - name: 現在適用されている変数の値を表示
      ansible.builtin.debug:
        msg: "現在の環境は【 {{ env_stage }} 】として評価されています。"

【コントローラーノード側】

実行コマンド

ansible-playbook -i inventory.ini check_precedence_play.yml

▼ 実行結果


PLAY [5-1. インベントリ変数とPlaybook変数の優先順位を確認する] ******************************************************************************************************************************

TASK [現在適用されている変数の値を表示] *****************************************************************************************************************************************************
ok: [192.168.1.21] => {
    "msg": "現在の環境は【 staging_from_play_vars 】として評価されています。"
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

結論(検証5-1)

実行結果の通り、group_vars/all.yml で定義された development_from_group_vars ではなく、Playbookの vars 句で定義した staging_from_play_vars が採用されました。
これは、Ansibleの変数優先順位において、インベントリで静的に定義された変数よりも、Playbook(Playスコープ)で定義された変数のほうが高い優先度を持つというルールが確認できました。

【検証5-2】コマンドライン引数による上書き(最高優先度: -e オプション)

すべての変数を上書きする extra vars(-e)の挙動を検証します。Playbookは【検証5-1】と全く同じものを使い、実行コマンド側で値を指定します。

【コントローラーノード側】

実行コマンド

ansible-playbook -i inventory.ini check_precedence_play.yml -e "env_stage=production_from_extra_vars"

▼ 実行結果

PLAY [5-1. インベントリ変数とPlaybook変数の優先順位を確認する] ******************************************************************************************************************************

TASK [現在適用されている変数の値を表示] *****************************************************************************************************************************************************
ok: [192.168.1.21] => {
    "msg": "現在の環境は【 production_from_extra_vars 】として評価されています。"
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

結論(検証5-2)

Playbook内で staging_from_play_vars が明示されているにもかかわらず、コマンドライン引数で渡した production_from_extra_vars が最優先で採用されました。
この結果から、-e(extra-vars)は公式ドキュメントの定義通り、インベントリ・Playbook・Role・タスクといった他のすべての変数定義を上書きする最高優先度を持つことが確認できました。
一時的なデバッグや実行時のパラメータ指定など、値を確実に上書きしたい場面で有効な手段です。


🔍 裏付けとなる公式ドキュメント


↑ 目次に戻る


6. 原因分析:評価タイミングとホスト間の変数隔離による実行時の不整合

Ansibleのシステム構造と変数の独立性

複数台のサーバーを一斉にアップデートする際、「1台でもエラーが出たら、全体の処理(全サーバー)をその場で停止させたい」という要件は実務でよくあります。
しかし、Ansibleの変数仕様を誤解したまま停止処理を独自に変数のフラグ管理で実装しようとすると、一部のサーバーで処理が停止せず、後続タスクが継続実行されるという不整合が発生します。

まず前提として、Ansibleの処理と変数の管理は以下のようなシステム構造になっています。

  • リモートノードへの処理は「一元管理」されている
    Ansibleのプロセスが動いているのは、手元のコントローラーノード側だけです。コントローラーノードがすべてのリモートノードの実行結果(成功・失敗)をリアルタイムに一元管理しています。

  • 変数は「ホストごとに完全隔離」されている
    コントローラーノードのメモリ内において、変数はホストごとに完全に独立したデータとして管理されています。ホストAの変数が変わっても、ホストBの変数には一切影響しません(ホスト間でのデータの同期通信は発生しません)。

「全体で状態を共有する共通の変数」を独自に作ろうとすると、このシステム構造の違いによって、意図しない処理の継続を引き起こす原因になります。


6-1.【失敗例】変数による停止処理を個別に記述したPlaybook

変数の値をホスト間で連携させて全体を停止させようと試みて、意図通りに動かないパターンを、実際のミドルウェア(nginx)のインストールタスクを用いて検証します。

  • 1台目のリモートノード(192.168.1.21)では存在しないパッケージ名を指定してわざとインストールを失敗させます。

  • 2台目のリモートノード(192.168.1.22)では正しいパッケージ名を指定してインストールを成功させます。双方のホストともに、実行前は nginx は未インストールの状態です。

検証コード:bad_timing_flg.yml

1 ---
2 - name: 6-1. 【失敗例】変数による個別制御の試行と処理の不整合(パッケージインストール編)
3   hosts: test_servers
4   become: yes
5   gather_facts: false
6   vars:
7     any_failed: false
8     # 評価タイミングの仕様:使う瞬間に都度、各ホストの手元の値で再計算される
9     run_next_task: "{{ not any_failed }}"
10
11   tasks:
12     - name: 1. Webサーバー(nginx)のインストール
13       ansible.builtin.dnf:
14         name: "{{ 'nginx-wrong-name' if inventory_hostname == groups['test_servers'][0] else 'nginx' }}"
15         state: present
16         use_backend: dnf4
17       ignore_errors: true
18       register: result
19
20     - name: 2. 失敗を検知したら、自ホストのフラグを上書き
21       ansible.builtin.set_fact:
22         any_failed: true
23       # タスク1でインストールが失敗(failed)したホストだけ実行
24       when: result.failed is defined and result.failed
25
26     - name: 3. 【判定】変数を評価し、後続の重大な処理(設定ファイルの配置など)を実行する
27       ansible.builtin.debug:
28         msg: "【注意】{{ inventory_hostname }} で設定ファイルの配置(後続処理)が実行されました。"
29       when: run_next_task | bool

コントローラーノード側

実行コマンド

ansible-playbook -i inventory.ini bad_timing_flg.yml

▼ 実行結果


PLAY [6-1. 【失敗例】変数による個別制御の試行と処理の不整合(パッケージインストール編)] ****************************************************************************************************

TASK [1. Webサーバー(nginx)のインストール] ************************************************************************************************************************************************
fatal: [192.168.1.21]: FAILED! => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3"}, "changed": false, "failures": ["No package nginx-wrong-name available."], "msg": "Failed to install some of the specified packages", "rc": 1, "results": []}
...ignoring
changed: [192.168.1.22]

TASK [2. 失敗を検知したら、自ホストのフラグを上書き] ****************************************************************************************************************************************
ok: [192.168.1.21]
skipping: [192.168.1.22]

TASK [3. 【判定】変数を評価し、後続の重大な処理(設定ファイルの配置など)を実行する] ********************************************************************************************************
skipping: [192.168.1.21]
ok: [192.168.1.22] => {
    "msg": "【注意】192.168.1.22 で設定ファイルの配置(後続処理)が実行されました。"
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=1
192.168.1.22               : ok=2    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

結果

設計意図としては「1台目(.21)のインストールが失敗したため、全体の処理を止めたい(2台目もタスク3をスキップさせたい)」という制御を期待していました。しかし、実際にはAnsibleの変数隔離仕様により、各ホストが独立したメモリ空間上で個別にタスクを実行するため、以下のような処理の不整合(すれ違い)が発生します。

「1台目」と「2台目」それぞれのホストにおける、実行時の内部挙動と変数の遷移は以下の通りです。

【共通のスタートライン】
 Playbook開始(初期値ロード: any_failed = false)
   │
   ├─► [ 192.168.1.21 ]
   │     ├─► タスク1:インストール失敗 [NG] (nginx-wrong-name)
   │     ├─► タスク2:失敗を検知してフラグを上書き (any_failed = true)
   │     └─► タスク3:後続の判定 (not true ➔ false)
   │           └─► [SKIP] タスク3をスキップ(制御成立)
   │
   └─► [ 192.168.1.22 ]
         ├─► タスク1:正常にインストール成功 [OK] (nginx)
         ├─► タスク2:自ホストは成功なのでフラグ維持 (any_failed = false のまま)
         └─► タスク3:後続の判定 (not false ➔ true)
               └─► [警告] 他ホストのエラーを検知できずに実行(不整合発生)

【共通のスタートライン】初期値の定義
Playbookの実行が始まると、対象となるすべてのホスト(192.168.1.21 / 192.168.1.22)は、タスクを実行する前の段階で、7行目に定義されている any_failed: false(エラーなし)という状態をそれぞれのホスト変数(初期値)としてメモリ空間に一斉に格納 します。この状態から各ホストの処理がスタートします。

リモートノード1台目(192.168.1.21)の処理推移

  • タスク1(12〜18行目):
    14行目の三項演算子により nginx-wrong-name のインストールを試み、パッケージ名不正によりタスク自体は失敗(FAILED) します。しかし、17行目の ignore_errors: true が指定されているためPlaybookの実行は停止せず、18行目の register: result により、1台目のホスト変数として「タスクが失敗した」という事実を示すデータ(result.failed = true)が格納されます。

  • タスク2(20〜24行目):
    タスクの実行に先立ち、24行目の when 句(条件判定)が評価されます。1台目の result は「失敗」を記録しているため条件に合致し、21〜22行目の set_fact モジュールが実行されます。これにより、ホスト変数 any_failed の値が true へ上書き されます。

  • タスク3(26〜29行目):
    29行目の when 句が評価される際、定義に基づき9行目の変数式(not any_failed)が展開されます。
    1台目のメモリ空間における any_failed の最新値は true であるため、Jinja2式による反転処理が行われます。

【1台目の真偽値評価】 not true ➔ false

評価結果が false となるため、29行目の when 条件を満たさず、タスク3は実行されずに skipping(スキップ)となります。

リモートノード2台目(192.168.1.22)の処理推移

  • タスク1(12〜18行目):
    14行目の三項演算子により正規の nginx パッケージのインストールを試み、正常に成功(changed) します。18行目の register: result により、2台目のホスト変数として「タスクは失敗していない」という事実を示すデータ(result.failed = false)が格納されます。

  • タスク2(20〜24行目):
    24行目の when 句が評価されます。2台目の result は「成功」を記録しており条件に合致しないため、このタスクはスキップ(処理キャンセル)されます。タスクが実行されないため、ホスト変数 any_failed は上書きされず、7行目で定義された初期値(false)のまま維持 されます。

  • タスク3(26〜29行目):
    29行目の when 句において、同様に9行目の変数式(not any_failed)が展開されます。
    2台目のメモリ空間における any_failed の最新値は、タスク2をスキップしたため初期値の false です。この値を基に反転処理が計算されます。

【2台目の真偽値評価】 not false ➔ true

他ホスト(1台目)のエラー情報が自ホストのメモリ空間に共有されていないため、2台目のホスト単体としては条件を満たしていると判定され、タスク3が ok(継続実行) されてしまいます。

実行後のリモートノードの状態

  • 192.168.1.21: nginx は未インストール、設定ファイルも配置されず(タスク3のスキップによる制御成立)。
[ansibleuser@localhost ~]$ dnf list --installed | grep nginx
[ansibleuser@localhost ~]$
  • 192.168.1.22: nginx がインストール済みとなり、他ホストのエラーを検知できずに設定ファイルの配置(タスク3)まで実行された(不整合の発生)。
[root@localhost ~]# dnf list installed nginx
インストール済みパッケージ
nginx.x86_64

[root@localhost ~]# rpm -qa | grep nginx
nginx-filesystem-1.20.1-24.el9_7.3.rocky.0.1.noarch
nginx-core-1.20.1-24.el9_7.3.rocky.0.1.x86_64
nginx-1.20.1-24.el9_7.3.rocky.0.1.x86_64

検証6-1のまとめ

このコードの致命的な問題点は、「Ansibleの構文エラー(Syntax Error)にはならず、Playbook自体は正常に終了してしまう」 という点にあります。

「エラーが発生していないホストのみ処理を継続させる」という意図で実装された not any_failed(9行目の真偽値の反転ロジック)ですが、「変数はホストごとに完全隔離されている」というAnsibleの根本的な仕様と組み合わさることで、マルチホスト環境において他マシンのステータスを隠蔽してしまうバグへと変貌します。

このように、個別のホスト変数(set_fact)のフラグ管理と真偽値反転によって複数ホストの同期的な停止制御を試みるアプローチは、Ansibleにおける典型的なアンチパターンです。


6-2. 【成功例】any_errors_fatal と serial を組み合わせた完全同期停止制御

独自にフラグ変数(set_fact)を管理するアプローチでは、Ansibleの「変数隔離」という仕様により、ホスト間で処理の不整合が発生してしまいました。

では、「1台でもエラーが出たら、全体の処理をその場で止めたい」という要件を満たすにはどうすればよいでしょうか。

その答えは、独自の制御ロジックを自作するのではなく、Ansibleの標準機能である any_errors_fatal: true と serial: 1 を組み合わせることです。

Ansibleはデフォルトで、1つのタスクを全ホストに対して並列(同時)に実行します。そのため、any_errors_fatal を指定しただけでは、1台目が失敗を検知した時点ですでに2台目の処理が完了してしまうケースを防ぐことができません。

そこで serial: 1 を追加して処理を1台ずつの直列実行に変えることで、1台目が失敗した時点で2台目以降への処理を開始せずにPlaybookを停止させることができます。

【検証】any_errors_fatal と serial を用いた Playbook

6-1と同じ環境(1台目はわざと失敗、2台目は正しいパッケージ名)で、any_errors_fatal と serial: 1 を組み合わせた際の挙動を確認します。

検証コード:good_sync_stop.yml

1 ---
2 - name: 6-2. 【成功例】any_errors_fatalとserialを用いた同期的な全体停止制御(パッケージインストール編)
3   hosts: test_servers
4   become: yes
5   gather_facts: false
6   # 1台でもタスクが失敗した瞬間、プレイ全体を強制終了させる
7   any_errors_fatal: true
8   # 並列実行(同時実行)を禁止し、1台ずつ順番に処理を確定させていく
9   serial: 1
10
11   tasks:
12     - name: 1. Webサーバー(nginx)のインストール
13       ansible.builtin.dnf:
14         name: "{{ 'nginx-wrong-name' if inventory_hostname == groups['test_servers'][0] else 'nginx' }}"
15         state: present
16         use_backend: dnf4
17
18     - name: 2. 【判定】後続の重大な処理(設定ファイルの配置など)
19       ansible.builtin.debug:
20         msg: "【確認】{{ inventory_hostname }} で設定ファイルの配置(後続処理)が実行されました。"
21

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini good_sync_stop.yml

▼ 実行結果

PLAY [6-2. 【成功例】any_errors_fatalとserialを用いた同期的な全体停止制御(パッケージインストール編)] **************************************************************************************

TASK [1. Webサーバー(nginx)のインストール] ************************************************************************************************************************************************
fatal: [192.168.1.21]: FAILED! => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3"}, "changed": false, "failures": ["No package nginx-wrong-name available."], "msg": "Failed to install some of the specified packages", "rc": 1, "results": []}

NO MORE HOSTS LEFT **************************************************************************************************************************************************************************

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

実行結果の読み方

6-1では出力されていた changed: [192.168.1.22](2台目のタスク1完了)が、今回の結果には存在しません。
1台目(.21)が失敗した直後に NO MORE HOSTS LEFT が出力され、そこでPlaybookが停止しています。

【Playbook開始】(any_errors_fatal: true / serial: 1)
   │
   ├─► [ 192.168.1.21 ](1巡目のバッチ)
   │     ├─► タスク1:インストール失敗 [NG] (nginx-wrong-name)
   │     └─► 判定:any_errors_fatal が作動
   │           └─► [強制終了] プレイ全体をその場で停止
   │
   │   ── NO MORE HOSTS LEFT(以降のバッチ処理はすべてキャンセル)
   │   
   │
   └─► [ 192.168.1.22 ](2巡目のバッチ)
         ├─► タスク1:開始されずに [未実行]
         └─► タスク2:開始されずに [未実行]
               └─► [OK] nginx は未インストールのまま安全に終了

この停止が実現できた仕組みは以下の通りです。

  • serial: 1 による直列実行への切り替え
    serial: 1 を指定すると、Ansibleは1台ずつ順番に処理を進めます。1台目の処理が完了(または失敗)するまで、2台目以降には何も実行しません。

  • any_errors_fatal による全体停止
    1台目(.21)がタスク1でエラーになった時点で、any_errors_fatal: true が作動します。コントローラーノードはまだ処理を開始していない残りのホストへの実行を取りやめ、Playbookを停止します。その結果、2台目(.22)のタスクは開始されませんでした。

実行後のリモートノードの状態

  • 192.168.1.21: タスク1でエラーが発生し、nginx は未インストールのまま。
[ansibleuser@localhost ~]$ dnf list --installed | grep nginx
[ansibleuser@localhost ~]$
  • 192.168.1.22: 1台目のエラーを受けて処理が止まったため、nginx は未インストールのまま。
[ansibleuser2@localhost ~]$ dnf list --installed | grep nginx
[ansibleuser2@localhost ~]$

serial: 1 + any_errors_fatal をいつ使うか

この組み合わせは、1台でも失敗したら後続ホストには何もさせたくない場面に適しています。DBクラスターのアップグレードやレプリケーション構成のメンテナンスなど、途中で失敗した場合に後続ノードへ処理を進めたくない場面が代表例です。
一方、WebサーバーやAPサーバーへのローリングアップデートでは、serial と max_fail_percentage(失敗許容率)を組み合わせる構成が一般的です。一時的なディスクフルやネットワーク瞬断など、個別ホストで起きた軽微なエラーで全体が止まる事態を避けられるため、運用の柔軟性を保てます。

なお本検証で serial: 1 を使っているのは、2台という最小構成で挙動を確認しやすくするためです。実務では対象サーバーの役割や構成に応じて判断してください。

まとめ

6-1で見たように、set_fact によるフラグ管理でホスト間の同期を取ろうとするアプローチは、Ansibleの変数隔離の仕様により意図した通りに動きません。
ホスト間の停止制御が必要な場面では、serial や any_errors_fatal、max_fail_percentage といった標準機能を要件に応じて組み合わせるのが正しいやり方です。



↑ 目次に戻る


7. 原因分析:遅延評価(Lazy Evaluation)による実行時の不整合

Ansibleの変数には、もう一つ注意すべき仕様があります。変数の「評価タイミング」です。Ansible(Jinja2テンプレート)では、変数は定義した時点ではなく、タスク内で実際に使われる瞬間に評価されます。 これを「遅延評価(Lazy Evaluation)」と呼びます。
この仕様を把握していないと、実行途中で変数の値が意図せず変わり、タスク間で想定外の動作を引き起こす原因になります。

7-1. 【検証】遅延評価による変数の動的変化

他の変数を参照して定義した変数(derived)が、実行途中で依存元(base)の値が変わった場合にどう動くかを確認します。

検証コード:lazy_evaluation_test.yml

1 ---
2 - name: 7. 遅延評価(Lazy Evaluation)の挙動検証プレイ
3   hosts: test_servers
4   gather_facts: false
5   vars:
6     base: 1
7     derived: "{{ base + 1 }}"
8 
9   tasks:
10     - name: 1. 初期状態での変数評価(想定:1 + 1 = 2)
11       ansible.builtin.debug:
12         msg: "derived の値(初期): {{ derived }}"
13 
14     - name: 2. 依存元となる base 変数の値を 10 に更新
15       ansible.builtin.set_fact:
16         base: 10
17 
18     - name: 3. base 更新後の変数評価(遅延評価なら 10 + 1 = 11 になる)
19       ansible.builtin.debug:
20         msg: "derived の値(更新後): {{ derived }}"

コントローラーノード側:実行コマンド

ansible-playbook lazy_evaluation_test.yml

▼ 実行結果

PLAY [7. 遅延評価(Lazy Evaluation)の挙動検証プレイ] ***************************************************************************************************************************************

TASK [1. 初期状態での変数評価(想定:1 + 1 = 2)] *******************************************************************************************************************************************
ok: [192.168.1.21] => {
    "msg": "derived の値(初期): 2"
}
ok: [192.168.1.22] => {
    "msg": "derived の値(初期): 2"
}

TASK [2. 依存元となる base 変数の値を 10 に更新] ********************************************************************************************************************************************
ok: [192.168.1.21]
ok: [192.168.1.22]

TASK [3. base 更新後の変数評価(遅延評価なら 10 + 1 = 11 になる)] **************************************************************************************************************************
ok: [192.168.1.21] => {
    "msg": "derived の値(更新後): 11 "
}
ok: [192.168.1.22] => {
    "msg": "derived の値(更新後): 11 "
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

実行結果の読み方

タスク3で derived の値が 2 から 11 に変わっています。
即時評価であればタスク3でも 2 が出力されるはずです。しかしAnsibleは derived が参照されるたびに {{ base + 1 }} を再計算するため、タスク2で更新した base: 10 がそのままタスク3の結果に反映されます。


7-2. 実務での失敗例

遅延評価の仕様を意識しないまま共通のベース変数を使い回すと、意図しない動作を引き起こすことがあります。
以下は、ベースディレクトリ(base_dir)を定義して変数を組み立てたものの、途中のタスクで set_fact を使って上書きしてしまったパターンです。


7-2-1.【事前準備】環境構築Playbook

検証に必要なディレクトリと、タスク1で存在確認する main.conf をリモートノードに用意します。

コントローラーノード側

ファイル名:setup_env.yml

---
- name: 7-3. 【事前準備】検証環境のフォルダ・ファイル作成
  hosts: test_servers
  gather_facts: false
  become: true  # /etc や /opt への書き込みのため特権が必要

  tasks:
    - name: 1. メインアプリ用の設定ディレクトリを作成 (/etc/myapp)
      ansible.builtin.file:
        path: "/etc/myapp"
        state: directory
        mode: '0755'

    - name: 2. 外部同期用の設定ディレクトリを作成 (/opt/external_sync)
      ansible.builtin.file:
        path: "/opt/external_sync"
        state: directory
        mode: '0755'

    - name: 3. 検証の前提条件となる「既存ファイル」を配置
      ansible.builtin.file:
        path: "/etc/myapp/main.conf"
        state: touch
        mode: '0644'

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini setup_env.yml

▼ 実行結果

PLAY [7-3. 【事前準備】検証環境のフォルダ・ファイル作成] ************************************************************************************************************************************

TASK [1. メインアプリ用の設定ディレクトリを作成 (/etc/myapp)] *******************************************************************************************************************************
changed: [192.168.1.21]
changed: [192.168.1.22]

TASK [2. 外部同期用の設定ディレクトリを作成 (/opt/external_sync)] ***************************************************************************************************************************
changed: [192.168.1.21]
changed: [192.168.1.22]

TASK [3. 検証の前提条件となる「既存ファイル」を配置] ****************************************************************************************************************************************
changed: [192.168.1.21]
changed: [192.168.1.22]

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

playbook実行後のリモートノード側の確認

リモートノード1

[ansibleuser@localhost ~]$ ls -l /etc/myapp
合計 0
-rw-r--r--. 1 root root 0  6月  1 07:00 main.conf
[ansibleuser@localhost ~]$ ls -l /opt/external_sync
合計 0

リモートノード2

[ansibleuser2@localhost ~]$ ls -l /etc/myapp
合計 0
-rw-r--r--. 1 root root 0  6月  1 07:00 main.conf
[ansibleuser2@localhost ~]$ ls -l /opt/external_sync
合計 0

※上記の通り、検証用の「/etc/myapp/main.conf」と、「/opt/external_sync」を作成済み。


7-2-2.【検証用】Playbook

コントローラーノード側

ファイル名:trouble_scenario.yml

1 ---
2 - name: 7. 【実例】遅延評価による条件分岐(when)の崩壊シナリオ
3   hosts: test_servers
4   gather_facts: false
5   become: true
6   vars:
7     # 共通のベースディレクトリ
8     base_dir: "/etc/myapp"
9     # ベースディレクトリを前提とした各設定ファイルのパス
10     main_config: "{{ base_dir }}/main.conf"
11     sync_config: "{{ base_dir }}/sync.conf"
12
13   tasks:
14     # -----------------------------------------------------------------
15     # シナリオ前段:本来のメイン処理
16     # -----------------------------------------------------------------
17    <span class="skiptoken"></span>- name: 1. メイン設定ファイルが存在するかステータスを取得
18      ansible.builtin.stat:
19         path: "{{ main_config }}"  # 評価結果: /etc/myapp/main.conf
20         register: main_stat
21
22     # -----------------------------------------------------------------
23     # シナリオ中段:別アプリとの同期処理(ここでベース変数を不用意に上書き)
24     # -----------------------------------------------------------------
25     - name: 2. 外部ツールとの同期用として一時的にベースディレクトリを書き換え
26       ansible.builtin.set_fact:
27         base_dir: "/opt/external_sync"  #   軽い気持ちで共通変数を再利用してしまった
28
29     - name: 3. 同期用設定ファイルの配置タスク
30       ansible.builtin.file:
31         path: "{{ sync_config }}"  # 評価結果: /opt/external_sync/sync.conf
32         state: touch
33
34     # -----------------------------------------------------------------
35     # シナリオ後段:メイン処理の続き(関係ないはずのwhen句が崩壊する)
36     # -----------------------------------------------------------------
37     - name: 4. 【危険】メイン設定ファイルが存在「しない」場合のみ初期化を実行
38      ansible.builtin.file:
39        path: "{{ main_config }}"
40        state: touch
41       # あえてタスク1の main_stat を使わず is file で直接評価することで、
42       # 遅延評価による「判定パスのすり替わり(大事故)」を意図的に再現しています
43      when: not (main_config is file)

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini trouble_scenario.yml

▼ 実行結果

PLAY [7-3. 【実例】遅延評価による条件分岐(when)の崩壊シナリオ] ****************************************************************************************************************************

TASK [1. メイン設定ファイルが存在するかステータスを取得] ************************************************************************************************************************************
ok: [192.168.1.21]
ok: [192.168.1.22]

TASK [2. 外部ツールとの同期用として一時的にベースディレクトリを書き換え] ********************************************************************************************************************
ok: [192.168.1.21]
ok: [192.168.1.22]

TASK [3. 同期用設定ファイルの配置タスク] ****************************************************************************************************************************************************
changed: [192.168.1.21]
changed: [192.168.1.22]

TASK [4. 【危険】メイン設定ファイルが存在「しない」場合のみ初期化を実行] ********************************************************************************************************************
changed: [192.168.1.21]
changed: [192.168.1.22]

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

playbook実行後のリモートノード側の確認

リモートノード1

[root@localhost ~]# ls -l /etc/myapp
合計 0
-rw-r--r--. 1 root root 0  6月  1 07:00 main.conf
[root@localhost ~]# ls -l /opt/external_sync
合計 0
-rw-r--r--. 1 root root 0  6月  1 09:33 main.conf
-rw-r--r--. 1 root root 0  6月  1 09:33 sync.conf

リモートノード2

[root@localhost ~]# ls -l /etc/myapp
合計 0
-rw-r--r--. 1 root root 0  6月  1 07:00 main.conf
[root@localhost ~]# ls -l /opt/external_sync
合計 0
-rw-r--r--. 1 root root 0  6月  1 09:33 main.conf
-rw-r--r--. 1 root root 0  6月  1 09:33 sync.conf
  • タスク3の処理にて「sync.conf」が「/etc/myapp」配下では無く、「/opt/external_sync」に作成された。
  • タスク4にて「main.conf」が「/opt/external_sync」に作成されてしまった。

全体の処理

タスク4の処理


なぜ条件分岐(when)が意図通りに動かないのか

タスク4の時点で base_dir が /opt/external_sync のまま戻っていないため、Ansibleがチェックするパスがすり替わり、ファイルが存在しないと判断して処理を実行してしまったことが原因です。

【読者向けの補足:なぜ main_stat.stat.exists を使わないのか?】
「タスク1で取得した main_stat.stat.exists を使えばよかったのでは?」と思う読者もいるかもしれません。
確かにその書き方であれば、base_dir が上書きされる前のタスク1時点のスナップショット(true)を使って判定するため、タスク4はスキップされ、別フォルダへのファイル誤作成は回避できます。
しかしこれは、タスク1がタスク2より前に実行されているために結果的にセーフになっているだけです。処理の順序が変われば、stat を使っていても同じ問題が発生します。
また、タスク4がスキップされたとしても、base_dir が /opt/... のまま残っているという問題自体は解消されていません。後続にタスクが追加された場合、同じ原因で意図しない動作が再発します。
そのため今回の検証では、base_dir のすり替わりによる影響をログ(changed)として目に見える形で再現するために、あえてその場で評価する is file を使用しています。

この問題が発生した原因は、以下の2つのAnsibleの仕様が重なったことにあります。

  1. set_fact で上書きした変数は自動で元に戻らない
    タスク2の set_fact を「タスク3のためだけに一時的に書き換えた」という感覚で使っていたとします。しかし set_fact で上書きした変数は、明示的に再設定しない限りそれ以降のタスクでも有効なままです。vars 句の初期値(/etc/myapp)に自動で戻ることはありません。

  2. 遅延評価により、参照先のパスが変わる
    タスク4の時点で base_dir は /opt/external_sync のままです。この状態で when: not (main_config is file) が評価されると、main_config({{ base_dir }}/main.conf)がその時点の base_dir の値で再計算され、チェック対象のパスが /opt/external_sync/main.conf になります。

  3. 判定対象のパスが変わった結果、条件が成立して実行される
    事前準備で /etc/myapp/main.conf は作成済みのため、本来タスク4はスキップされるはずでした。しかし判定対象が /opt/external_sync/main.conf に変わっているため、そのファイルは存在せず false と判定されます。結果として when: not false(条件成立)となり、タスク4が実行されます。
    さらに path も同じく遅延評価で /opt/external_sync/main.conf になっているため、本来作成されるべきでない場所に空のファイルが作られます(ログの changed がその証拠です)。


対策:用途ごとに変数名を分ける

役割の異なる変数に同じ名前を使い回さないことが基本的な対策です。set_fact で上書きして後から戻すような設計は避け、最初から変数名を切り分けて定義します。

vars:
  # メイン処理用
  myapp_base_dir: "/etc/myapp"
  myapp_main_config: "{{ myapp_base_dir }}/main.conf"

  # 外部同期用(変数名を分けて独立させる)
  sync_base_dir: "/opt/external_sync"
  myapp_sync_config: "{{ sync_base_dir }}/sync.conf"

変数名を最初から分けておけば、途中で set_fact による上書きや元に戻す処理は不要になります。
Ansibleの変数は参照された瞬間に評価されます。「用が済んだら元の値に戻るだろう」という感覚で設計すると、今回のような意図しない動作につながります。変数の上書きが必要になる設計自体を避けることが、安全なPlaybookを書くうえでの基本的な考え方です。


参考文献


↑ 目次に戻る


8. 設計:register変数の生存期間と上書きリスクの管理

register 変数はホストごとに独立して管理されます。各ホストの実行結果はコントローラーノードのメモリ上に個別に保持され、他のホストとは共有されません。
ただし register をループ処理(loop)と組み合わせると、データの格納構造が変わります。これを知らないまま後続タスクで参照しようとすると、属性未定義エラー(has no attribute)が発生します。

8-1. ループ処理で register を使う際の誤った参照方法

複数のファイルの存在確認をループで行い、その結果を次のタスクで条件分岐に使うという構成です。一見問題なさそうですが、参照方法を間違えるとエラーになります。

ファイル名: check_files.yml

---
- name: register変数の挙動確認Playbook
  hosts: test_servers
  gather_facts: false

  tasks:
    - name: 1. 複数ファイルの存在確認
      # ansible.builtin.stat:指定したファイルやディレクトリの状態を調べるコマンド
      ansible.builtin.stat:
        # loopで指定したファイルパス(item)が、上から順番にここに入ります
        path: "{{ item }}"
      # 調査したいファイルを箇条書きで指定し、順番にループ処理させます
      loop:
        - "/etc/myapp/main.conf"         # 存在するファイル
        - "/etc/myapp/non_existent.conf" # 存在しないファイル
      # 2つのファイルを調べた結果(状態)を、まとめて「file_stat」という変数に保存します
      register: file_stat

    - name: 2. 判定(NG:existsを直接参照してしまう)
      ansible.builtin.debug:
        msg: "ファイルが存在します"
      when: file_stat.stat.exists # ここでエラー(FAILED)になります

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini check_files.yml

▼ 実行結果

PLAY [register変数の挙動確認Playbook] *******************************************************************************************************************************************************

TASK [1. 複数ファイルの存在確認] ************************************************************************************************************************************************************
ok: [192.168.1.22] => (item=/etc/myapp/main.conf)
ok: [192.168.1.21] => (item=/etc/myapp/main.conf)
ok: [192.168.1.22] => (item=/etc/myapp/non_existent.conf)
ok: [192.168.1.21] => (item=/etc/myapp/non_existent.conf)

TASK [2. 判定(NG:existsを直接参照してしまう)] ********************************************************************************************************************************************
fatal: [192.168.1.21]: FAILED! => {"msg": "The conditional check 'file_stat.stat.exists' failed. The error was: error while evaluating conditional (file_stat.stat.exists): 'dict object' has no attribute 'stat'. 'dict object' has no attribute 'stat'\n\nThe error appears to be in '/opt/ansible/workspace/check_files.yml': line 15, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n\n    - name: 2. 判定(NG:existsを直接参照してしまう)\n      ^ here\n"}
fatal: [192.168.1.22]: FAILED! => {"msg": "The conditional check 'file_stat.stat.exists' failed. The error was: error while evaluating conditional (file_stat.stat.exists): 'dict object' has no attribute 'stat'. 'dict object' has no attribute 'stat'\n\nThe error appears to be in '/opt/ansible/workspace/check_files.yml': line 15, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n\n    - name: 2. 判定(NG:existsを直接参照してしまう)\n      ^ here\n"}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

なぜエラーになるのか?
ループ処理(loop)を使うと、register で保存されるデータ構造が自動的に results リスト形式に変わります。しかしタスク2の when: では、ループなしの単一タスクと同じ感覚で file_stat.stat.exists と指定しているため、構造が合わずエラーになります。
file_stat の直下に stat は存在せず、results リストの中に格納されているため、'dict object' has no attribute 'stat' というエラーが発生します。


8-2. register 変数の中身を確認する

エラーの原因を確認するため、8-1のNGタスクをコメントアウトし、代わりに register 変数(file_stat)の中身を ansible.builtin.debug で出力します。Ansibleが内部でどのようなデータ構造を作っているかを確認します。

ファイル名: debug_register.yml

---
- name: register変数の挙動確認Playbook
  hosts: test_servers
  gather_facts: false

  tasks:
    - name: 1. 複数ファイルの存在確認
      # ansible.builtin.stat:指定したファイルやディレクトリの状態を調べるコマンド
      ansible.builtin.stat:
        # loopで指定したファイルパス(item)が、上から順番にここに入ります
        path: "{{ item }}"
      # 調査したいファイルを箇条書きで指定し、順番にループ処理させます
      loop:
        - "/etc/myapp/main.conf"         # 存在するファイル
        - "/etc/myapp/non_existent.conf" # 存在しないファイル
      # 2つのファイルを調べた結果(状態)を、まとめて「file_stat」という変数に保存します
      register: file_stat

    # エラー原因調査のため、8-1のNGタスクをそのままコメントアウトします
    # - name: 2. 判定(NG:existsを直接参照してしまう)
    #   ansible.builtin.debug:
    #     msg: "ファイルが存在します"
    #   when: file_stat.stat.exists # ここでエラー(FAILED)になります

    - name: 2. register変数の中身をダンプして構造を確認する
      ansible.builtin.debug:
        var: file_stat # 変数の中身をそのまま画面に出力して構造を調べます

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini debug_register.yml

▼ 実行結果

PLAY [register変数の挙動確認Playbook] *******************************************************************************************************************************************************

TASK [1. 複数ファイルの存在確認] ************************************************************************************************************************************************************
ok: [192.168.1.21] => (item=/etc/myapp/main.conf)
ok: [192.168.1.22] => (item=/etc/myapp/main.conf)
ok: [192.168.1.21] => (item=/etc/myapp/non_existent.conf)
ok: [192.168.1.22] => (item=/etc/myapp/non_existent.conf)

TASK [2. register変数の中身をダンプして構造を確認する] **************************************************************************************************************************************
ok: [192.168.1.21] => {
    "file_stat": {
        "changed": false,
        "msg": "All items completed",
        "results": [
            {
                "ansible_facts": {
                    "discovered_interpreter_python": "/usr/bin/python3"
                },
                "ansible_loop_var": "item",
                "changed": false,
                "failed": false,
                "invocation": {
                    "module_args": {
                        "checksum_algorithm": "sha1",
                        "follow": false,
                        "get_attributes": true,
                        "get_checksum": true,
                        "get_md5": false,
                        "get_mime": true,
                        "path": "/etc/myapp/main.conf"
                    }
                },
                "item": "/etc/myapp/main.conf",
                "stat": {
                    "atime": 1780382631.3375158,
                    "attr_flags": "",
                    "attributes": [],
                    "block_size": 4096,
                    "blocks": 0,
                    "charset": "binary",
                    "checksum": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
                    "ctime": 1780264817.411059,
                    "dev": 64768,
                    "device_type": 0,
                    "executable": false,
                    "exists": true,
                    "gid": 0,
                    "gr_name": "root",
                    "inode": 51133421,
                    "isblk": false,
                    "ischr": false,
                    "isdir": false,
                    "isfifo": false,
                    "isgid": false,
                    "islnk": false,
                    "isreg": true,
                    "issock": false,
                    "isuid": false,
                    "mimetype": "inode/x-empty",
                    "mode": "0644",
                    "mtime": 1780264817.411059,
                    "nlink": 1,
                    "path": "/etc/myapp/main.conf",
                    "pw_name": "root",
                    "readable": true,
                    "rgrp": true,
                    "roth": true,
                    "rusr": true,
                    "size": 0,
                    "uid": 0,
                    "version": "3210405001",
                    "wgrp": false,
                    "woth": false,
                    "writeable": false,
                    "wusr": true,
                    "xgrp": false,
                    "xoth": false,
                    "xusr": false
                }
            },
            {
                "ansible_loop_var": "item",
                "changed": false,
                "failed": false,
                "invocation": {
                    "module_args": {
                        "checksum_algorithm": "sha1",
                        "follow": false,
                        "get_attributes": true,
                        "get_checksum": true,
                        "get_md5": false,
                        "get_mime": true,
                        "path": "/etc/myapp/non_existent.conf"
                    }
                },
                "item": "/etc/myapp/non_existent.conf",
                "stat": {
                    "exists": false
                }
            }
        ],
        "skipped": false
    }
}
ok: [192.168.1.22] => {
    "file_stat": {
        "changed": false,
        "msg": "All items completed",
        "results": [
            {
                "ansible_facts": {
                    "discovered_interpreter_python": "/usr/bin/python3"
                },
                "ansible_loop_var": "item",
                "changed": false,
                "failed": false,
                "invocation": {
                    "module_args": {
                        "checksum_algorithm": "sha1",
                        "follow": false,
                        "get_attributes": true,
                        "get_checksum": true,
                        "get_md5": false,
                        "get_mime": true,
                        "path": "/etc/myapp/main.conf"
                    }
                },
                "item": "/etc/myapp/main.conf",
                "stat": {
                    "atime": 1780382631.5373602,
                    "attr_flags": "",
                    "attributes": [],
                    "block_size": 4096,
                    "blocks": 0,
                    "charset": "binary",
                    "checksum": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
                    "ctime": 1780264817.8705661,
                    "dev": 64768,
                    "device_type": 0,
                    "executable": false,
                    "exists": true,
                    "gid": 0,
                    "gr_name": "root",
                    "inode": 50590150,
                    "isblk": false,
                    "ischr": false,
                    "isdir": false,
                    "isfifo": false,
                    "isgid": false,
                    "islnk": false,
                    "isreg": true,
                    "issock": false,
                    "isuid": false,
                    "mimetype": "inode/x-empty",
                    "mode": "0644",
                    "mtime": 1780264817.8705661,
                    "nlink": 1,
                    "path": "/etc/myapp/main.conf",
                    "pw_name": "root",
                    "readable": true,
                    "rgrp": true,
                    "roth": true,
                    "rusr": true,
                    "size": 0,
                    "uid": 0,
                    "version": "3597000240",
                    "wgrp": false,
                    "woth": false,
                    "writeable": false,
                    "wusr": true,
                    "xgrp": false,
                    "xoth": false,
                    "xusr": false
                }
            },
            {
                "ansible_loop_var": "item",
                "changed": false,
                "failed": false,
                "invocation": {
                    "module_args": {
                        "checksum_algorithm": "sha1",
                        "follow": false,
                        "get_attributes": true,
                        "get_checksum": true,
                        "get_md5": false,
                        "get_mime": true,
                        "path": "/etc/myapp/non_existent.conf"
                    }
                },
                "item": "/etc/myapp/non_existent.conf",
                "stat": {
                    "exists": false
                }
            }
        ],
        "skipped": false
    }
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ログの読み解き方

出力は .21 と .22 の2つに分かれています。各ホストが結果を独立して保持しているためです。
それぞれの file_stat の中には、ループの実行回数分だけ結果が格納されています。

  • 1つ目(ループ1周目):/etc/myapp/main.conf の結果。ファイルが存在するため "exists": true。
  • 2つ目(ループ2周目):/etc/myapp/non_existent.conf の結果。ファイルが存在しないため "exists": false。

なぜこの構造になるのか?

file_stat の直下に stat は存在しません。ループ付きの register では、各周の結果が results リストに順番に追加されていく仕様になっているためです。

"file_stat": {
    "results": [
        { "item": "/etc/myapp/main.conf", "stat": { "exists": true } },
        { "item": "/etc/myapp/non_existent.conf", "stat": { "exists": false } }
    ]
}

そのため file_stat.stat.exists と指定しても stat が見つからず、属性未定義エラーが発生します。


8-3. results リストから正しく値を取り出す方法

results リストから各ファイルの存在確認を行う方法は大きく2つあります。用途に応じて使い分けてください。

  • ① 後続タスクでもループを使って1つずつ判定する
  • ② Jinja2フィルタでリスト全体を一括判定する

用途に合わせて使い分けられるようにしましょう。

パターン1:後続タスクもループで1つずつ判定する

タスク2でも loop を使い、results リストの中身を1つずつ展開して判定する方法です。

ファイル名: check_files_fixed_loop.yml

---
- name: register変数の挙動確認Playbook(ループ展開版)
  hosts: test_servers
  gather_facts: false

  tasks:
    - name: 1. 複数ファイルの存在確認
      # ansible.builtin.stat:指定したファイルやディレクトリの状態を調べるコマンド
      ansible.builtin.stat:
        # loopで指定したファイルパス(item)が、上から順番にここに入ります
        path: "{{ item }}"
      # 調査したいファイルを箇条書きで指定し、順番にループ処理させます
      loop:
        - "/etc/myapp/main.conf"         # 存在するファイル
        - "/etc/myapp/non_existent.conf" # 存在しないファイル
      # 2つのファイルを調べた結果(状態)を、まとめて「file_stat」という変数に保存します
      register: file_stat

    - name: 2. 判定(OK:resultsをループで1つずつ取り出す)
      ansible.builtin.debug:
        msg: "ファイル {{ item.item }} が存在します"
      # 格納されたresultsリストをそのまま次のループに投入します
      loop: "{{ file_stat.results }}"
      # ループ要素(item)の中にあるstat.existsを評価します
      when: item.stat.exists

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini check_files_fixed_loop.yml

▼ 実行結果

PLAY [register変数の挙動確認Playbook(ループ展開版)] ***************************************************************************************************************************************

TASK [1. 複数ファイルの存在確認] ************************************************************************************************************************************************************
ok: [192.168.1.21] => (item=/etc/myapp/main.conf)
ok: [192.168.1.22] => (item=/etc/myapp/main.conf)
ok: [192.168.1.21] => (item=/etc/myapp/non_existent.conf)
ok: [192.168.1.22] => (item=/etc/myapp/non_existent.conf)

TASK [2. 判定(OK:resultsをループで1つずつ取り出す)] **************************************************************************************************************************************
ok: [192.168.1.21] => (item={'changed': False, 'stat': {'exists': True, 'path': '/etc/myapp/main.conf', 'mode': '0644', 'isdir': False, 'ischr': False, 'isblk': False, 'isreg': True, 'isfifo': False, 'islnk': False, 'issock': False, 'uid': 0, 'gid': 0, 'size': 0, 'inode': 51133421, 'dev': 64768, 'nlink': 1, 'atime': 1780382631.3375158, 'mtime': 1780264817.411059, 'ctime': 1780264817.411059, 'wusr': True, 'rusr': True, 'xusr': False, 'wgrp': False, 'rgrp': True, 'xgrp': False, 'woth': False, 'roth': True, 'xoth': False, 'isuid': False, 'isgid': False, 'blocks': 0, 'block_size': 4096, 'device_type': 0, 'readable': True, 'writeable': False, 'executable': False, 'pw_name': 'root', 'gr_name': 'root', 'checksum': 'da39a3ee5e6b4b0d3255bfef95601890afd80709', 'mimetype': 'inode/x-empty', 'charset': 'binary', 'version': '3210405001', 'attributes': [], 'attr_flags': ''}, 'invocation': {'module_args': {'path': '/etc/myapp/main.conf', 'follow': False, 'get_md5': False, 'get_checksum': True, 'get_mime': True, 'get_attributes': True, 'checksum_algorithm': 'sha1'}}, 'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'failed': False, 'item': '/etc/myapp/main.conf', 'ansible_loop_var': 'item'}) => {
    "msg": "ファイル /etc/myapp/main.conf が存在します"
}
skipping: [192.168.1.21] => (item={'changed': False, 'stat': {'exists': False}, 'invocation': {'module_args': {'path': '/etc/myapp/non_existent.conf', 'follow': False, 'get_md5': False, 'get_checksum': True, 'get_mime': True, 'get_attributes': True, 'checksum_algorithm': 'sha1'}}, 'failed': False, 'item': '/etc/myapp/non_existent.conf', 'ansible_loop_var': 'item'})
ok: [192.168.1.22] => (item={'changed': False, 'stat': {'exists': True, 'path': '/etc/myapp/main.conf', 'mode': '0644', 'isdir': False, 'ischr': False, 'isblk': False, 'isreg': True, 'isfifo': False, 'islnk': False, 'issock': False, 'uid': 0, 'gid': 0, 'size': 0, 'inode': 50590150, 'dev': 64768, 'nlink': 1, 'atime': 1780382631.5373602, 'mtime': 1780264817.8705661, 'ctime': 1780264817.8705661, 'wusr': True, 'rusr': True, 'xusr': False, 'wgrp': False, 'rgrp': True, 'xgrp': False, 'woth': False, 'roth': True, 'xoth': False, 'isuid': False, 'isgid': False, 'blocks': 0, 'block_size': 4096, 'device_type': 0, 'readable': True, 'writeable': False, 'executable': False, 'pw_name': 'root', 'gr_name': 'root', 'checksum': 'da39a3ee5e6b4b0d3255bfef95601890afd80709', 'mimetype': 'inode/x-empty', 'charset': 'binary', 'version': '3597000240', 'attributes': [], 'attr_flags': ''}, 'invocation': {'module_args': {'path': '/etc/myapp/main.conf', 'follow': False, 'get_md5': False, 'get_checksum': True, 'get_mime': True, 'get_attributes': True, 'checksum_algorithm': 'sha1'}}, 'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'failed': False, 'item': '/etc/myapp/main.conf', 'ansible_loop_var': 'item'}) => {
    "msg": "ファイル /etc/myapp/main.conf が存在します"
}
skipping: [192.168.1.22] => (item={'changed': False, 'stat': {'exists': False}, 'invocation': {'module_args': {'path': '/etc/myapp/non_existent.conf', 'follow': False, 'get_md5': False, 'get_checksum': True, 'get_mime': True, 'get_attributes': True, 'checksum_algorithm': 'sha1'}}, 'failed': False, 'item': '/etc/myapp/non_existent.conf', 'ansible_loop_var': 'item'})

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

補足:ループ内のスキップと PLAY RECAP の関係
タスク2で non_existent.conf の評価時に skipping: と表示されていますが、PLAY RECAP の skipped カウントは 0 のままです。
PLAY RECAP はタスク単位で集計されます。ループ内で一部の要素がスキップされても、タスク全体として1つでも実行されていれば ok としてカウントされます。skipped が増えるのは、タスク全体がスキップされた場合のみです。

ログの読み解き方
タスク2が各ホストでループ展開されています。

  • 1周目(main.conf): exists: true のため条件を満たし、ok でメッセージが出力されています。
  • 2周目(non_existent.conf): exists: false のため条件を満たさず、skipping になっています。

なぜエラーにならず処理できるのか?
loop: "{{ file_stat.results }}" を指定することで、results リストの各要素が順番に item に格納されます。各 item の直下には stat が存在するため、item.stat.exists という参照が正しく機能します。

パターン2:Jinja2フィルタでリスト全体を一括判定する

「1つでも存在すれば処理を実行する」「1つでも欠けていれば止める」といった場合は、map フィルタで一括判定する方法が適しています。後続タスクで再度ループする必要がなく、記述をシンプルに保てます。

ファイル名: check_files_fixed_filter.yml

---
- name: register変数の挙動確認Playbook(フィルタ一括判定版)
  hosts: test_servers
  gather_facts: false

  tasks:
    - name: 1. 複数ファイルの存在確認
      # ansible.builtin.stat:指定したファイルやディレクトリの状態を調べるコマンド
      ansible.builtin.stat:
        # loopで指定したファイルパス(item)が、上から順番にここに入ります
        path: "{{ item }}"
      # 調査したいファイルを箇条書きで指定し、順番にループ処理させます
      loop:
        - "/etc/myapp/main.conf"         # 存在するファイル
        - "/etc/myapp/non_existent.conf" # 存在しないファイル
      # 2つのファイルを調べた結果(状態)を、まとめて「file_stat」という変数に保存します
      register: file_stat

    - name: 2. 判定(OK:Jinja2フィルタでexistsの真偽値リストを作る)
      ansible.builtin.debug:
        msg: "指定されたファイルが少なくとも1つ存在します"
      # results内のすべての「stat.exists」の値だけを抽出し、1つでもtrueがあるかを判定します
      when: file_stat.results | map(attribute='stat.exists') is any

コントローラーノード側:実行コマンド

ansible-playbook -i inventory.ini check_files_fixed_filter.yml

▼ 実行結果

PLAY [register変数の挙動確認Playbook(フィルタ一括判定版)] *********************************************************************************************************************************

TASK [1. 複数ファイルの存在確認] ************************************************************************************************************************************************************
ok: [192.168.1.22] => (item=/etc/myapp/main.conf)
ok: [192.168.1.21] => (item=/etc/myapp/main.conf)
ok: [192.168.1.21] => (item=/etc/myapp/non_existent.conf)
ok: [192.168.1.22] => (item=/etc/myapp/non_existent.conf)

TASK [2. 判定(OK:Jinja2フィルタでexistsの真偽値リストを作る)] ****************************************************************************************************************************
ok: [192.168.1.21] => {
    "msg": "指定されたファイルが少なくとも1つ存在します"
}
ok: [192.168.1.22] => {
    "msg": "指定されたファイルが少なくとも1つ存在します"
}

PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.22               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ログの読み解き方

when: file_stat.results | map(attribute='stat.exists') is any

この1行は以下の順で処理されます。

  1. file_stat.results:ループ結果のリストを対象にします。
  2. | map(attribute='stat.exists'):各要素から stat.exists の値だけを取り出し、[true, false] という真偽値のリストに変換します。
  3. is any:リストの中に1つでも true があるかを判定します。今回は true が含まれるため、条件全体が true になります。

🔍 裏付けとなる公式ドキュメント

Registering variables with a loop (Ansible公式ドキュメント)

Variables(registerの基本仕様)(Ansible公式ドキュメント)

Jinja2 Tests(is any / is all の公式説明)


↑ 目次に戻る


9. 本質:メンテナンス性を担保する変数配置の原則

第5〜8セクションでは、変数のスコープや優先順位、register 変数のデータ構造、遅延評価による意図しない上書きといった、実行時の動的な挙動を検証してきました。
これらの検証を通じて見えてきたのは、「実行時に何が起きるか」を理解するだけでなく、「どこに変数を定義するか」を最初から明確にしておくことがトラブル防止の基本だということです。
以下の3つの原則を基準に変数の配置を設計してください。

  1. デフォルト値は roles/x/defaults/main.yml に置く
    Ansibleの変数優先順位の中で最も低い位置にあるため、group_vars や vars 句など他の場所から容易に上書きできます。Roleを柔軟に再利用したい場合に適しています。
  2. 環境固有の値は group_vars に置く
    第5〜6セクションで確認した通り、変数はホスト(インベントリ)の括りに応じて管理されます。環境ごとに異なる設定値は group_vars にまとめることで、インベントリ単位での管理がしやすくなります。
  3. 上書きされたくない値は roles/x/vars/main.yml に置く
    Play vars よりも優先順位が高く、インベントリ変数などによる意図しない上書きを防げます。第6〜7セクションで見た set_fact による実行時の上書きや遅延評価の影響を避けるためにも、変更されたくない値は静的な定義ファイルに明示しておくことが重要です。

🔍 裏付けとなる公式ドキュメント


↑ 目次に戻る


10. まとめ

  • 変数の優先順位は公式ドキュメントで定義された22段階のルールに従って決まります。どこに定義するかによって最終的な値が変わるため、配置の設計が重要です。

  • -e(extra-vars)はすべての変数定義を上書きする最高優先度を持ちます。デバッグや実行時のパラメータ指定に有効ですが、意図しない上書きにも注意が必要です。

  • 変数は定義した時点ではなく参照された瞬間に評価されます(遅延評価)。set_fact で変数を上書きした場合、それ以降のタスクで参照するすべての依存変数に影響が及びます。

  • register 変数はホストごとに独立して管理されます。ループと組み合わせた場合はデータ構造が results リスト形式に変わるため、参照方法に注意が必要です。


↑ 目次に戻る


11. 次回予告

第5回では、変数の優先順位と評価タイミングによる不整合の原因を整理しました。
次に直面するのが「実行順序」の問題です。Ansibleのタスクは1つずつ独立して実行されるため、Shellの逐次実行とは異なる制約があります。

  • 「前のタスクでディレクトリを移動したのに、次のタスクで反映されていない」
  • 「環境変数を設定したのに、後続のコマンドで参照できない」

次回はこれらの原因となる、Ansibleのタスク実行単位とプロセスの分離について解説します。

次回:【Ansible編】第6回:なぜタスクの順序で壊れるのか


↑ 目次に戻る


📑 連載の移動
前の記事:【Ansible編】第4回 | 次の記事:【Ansible編】第6回


📑 【Ansible編】全体のまとめはこちら
「Ansibleが理解できない理由はLinuxにあった【Ansible編】まとめ」


🗺️ 初めての方・シリーズの全体像を知りたい方はこちら

本記事は、OSの仕組みからAnsible設計までを繋ぐ連載シリーズの一部です。
「どこから読み始めればいいか」あるいは、「OS/Shell/Ansible編の関係性」 について把握されたい場合は、以下の統合ガイドで整理しています。

→ Ansibleが理解できない理由はLinuxにあった|統合ガイド


📚12. 連載一覧:Ansibleが理解できない理由はLinuxにあった【Ansible編】

回数とタイトル 内容(概要)
【Ansible編】第0回:なぜShellの知識がないとAnsibleは壊れるのか AnsibleはShellのラッパーとして動作している。Shell編で学んだ構造(Ansible → SSH → Shell → Linux)を再確認し、「なぜmoduleを使うべきなのか」の前提を整理する。
【Ansible編】第1回:なぜshellモジュールは危険なのか shellモジュールはShellの仕様(分割・展開・環境)に依存するため壊れやすい。commandとの違いを理解し、「どこまでShellを許容すべきか」を判断できるようになる。
【Ansible編】第2回:なぜmoduleを使うと安全になるのか file / copy / lineinfile などのmoduleは状態管理を前提としている。「結果」ではなく「状態」を扱うことで、Shell依存を排除し安全な構成を実現する。
【Ansible編】第3回:なぜ毎回changedになるのか shellモジュールは状態を判定できないため、常にchangedになる。changed_whenを使った制御と冪等性(idempotency)の設計を理解する。
【Ansible編】第4回:なぜ条件分岐が壊れるのか(Ansible編) when条件が意図通りに動かない原因は、rc / stdout / stderrの扱いにある。Shell編で学んだ終了ステータスと組み合わせ、正しい条件分岐を設計する。
【Ansible編】第5回:なぜ変数が意図通りに扱えないのか vars / host_vars / group_vars のスコープや、registerの扱いを誤ると値が壊れる。Ansible内での変数管理と評価タイミングを整理する。
【Ansible編】第6回:なぜタスクの順序で壊れるのか Ansibleのタスクはそれぞれ独立したプロセスとして実行される。chdir / environment の制御と、「前の結果に依存する設計」の危険性を理解する。
【Ansible編】第7回:なぜエラー制御が破綻するのか ignore_errors や failed_when の使い方を誤るとエラーが隠蔽または暴発する。Shellのrcと連動させた正しいエラー制御を設計する。
【Ansible編】第8回:なぜ非同期・待機で失敗するのか サービス起動や外部依存処理はタイミング問題を引き起こす。async / poll / wait_for / retries を使い、安定した実行制御を実現する。
【Ansible編】第9回:なぜPlaybookが読めないのか role構成やタスク分割が不適切だと可読性が崩壊する。実務で保守できるPlaybook設計と構造化の原則を理解する。
【Ansible編】第10回:なぜAnsibleを正しく設計できるようになるのか これまでの知識を統合し、Shell編と接続することで「壊れない設計」ができるようになる。Ansibleを実務で使いこなすための設計思想を完成させる。

↑ 目次に戻る


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?