てくなべ (tekunabe)

ansible / network automation / 学習メモ

[Ansible] ハンドラー名はユニークにしておいた方が良い理由と検証

はじめに

Ansible には、タスクのステータスが changed のときだけ実行したい処理を定義するために、ハンドラーという機能があります。

docs.ansible.com

ハンドラーには、通常のタスクと同様に name を指定できます。ハンドラーで指定した name をこの記事ではハンドラー名と呼びます。

タスクとハンドラーを関連付ける方法は複数あります。その一つは、通常のタスクの notify ディレクティブで、呼びたいハンドラー名を指定する方法です。 (実際には一通りタスクの処理が終わってからハンドラーが呼ばれます。ロールを含めた処理順はこちら

そのため、ハンドラー名は重要な識別子の役割を持ちます。もし、同じハンドラー名のハンドラーが複数あると思わぬ結果を招く場合あります。ハンドラー名はユニークにしておくべきと、ドキュメントに記載があります。

Each handler should have a globally unique name.

同じくドキュメントに記載されている、グロバルスコープで扱われるという点も重要です。

Handlers from roles are not just contained in their roles but rather inserted into the global scope with all other handlers from a play.

この記事では、同じ名前のハンドラーがある場合の挙動を、通常の場合と比較して検証します。

  • 検証環境
    • ansible-core 2.15.2

1. 通常の場合(ユニークなハンドラー名の場合)

まずは、比較のためにユニークなハンドラー名で構成された通常の場合の挙動です。

フォルダ・ファイル構成

それぞれハンドラーを持つ 2つのロール role1role2 からなる構成とします。

.
├── playbook.yml
└── roles
    ├── role1
    │   ├── handlers
    │   │   └── main.yml
    │   └── tasks
    │       └── main.yml
    └── role2
        ├── handlers
        │   └── main.yml
        └── tasks
            └── main.yml

role1 ロール

role1/tasks/main.yml:

---
- name: Task with changed
  ansible.builtin.command:    # changed
    cmd: "echo Hello!"
  notify:
    - Test Handler in role1

role1/handlers/main.yml:

---
- name: Test Handler in role1   # ロール固有の名前
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

role2 ロール

role1 とすべて同じです)

role2/tasks/main.yml:

---
- name: Task with changed
  ansible.builtin.command:    # changed
    cmd: "echo Hello!"
  notify:
    - Test Handler in role2

role2/handlers/main.yml:

---
- name: Test Handler in role2   # ロール固有の名前
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

Playbook

2つのロールを呼び出すPlaybookです。

playbook.yml:

---
- name: Test Play
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Import role1
      ansible.builtin.import_role:
        name: role1

    - name: Import role2
      ansible.builtin.import_role:
        name: role2

実行結果

以下のような実行順序になります。

  1. role1 のタスク
  2. role2 のタスク
  3. role1 のハンドラー
  4. role2 のハンドラー

コマンド実行ログは以下のとおりです。

$ ansible-playbook -i localhost, playbook.yml

PLAY [Test Play] ****************************************************************************************************

TASK [role1 : Task with changed] ************************************************************************************
changed: [localhost]

TASK [role2 : Test Handler in role1] ********************************************************************************
changed: [localhost]

RUNNING HANDLER [role1 : Test Handler in role1] *********************************************************************
ok: [localhost] => {
    "msg": "handler role1"
}

RUNNING HANDLER [role2 : Test Handler in role2] *********************************************************************
ok: [localhost] => {
    "msg": "handler role2"
}

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

2. 同じ名前のハンドラーがある場合

次に、同じ名前のハンドラーが複数に含まれている場合です。

role1 ロール

role1/tasks/main.yml:

---
- name: Task with changed
  ansible.builtin.command:    # changed
    cmd: "echo Hello!"
  notify:
    - Test Handler

role1/handlers/main.yml:

---
- name: Test Handler    # 他のロールにあるハンドラーと同じ名前
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

role2 ロール

role1 とすべて同じです)

role2/tasks/main.yml:

---
- name: Task with changed
  ansible.builtin.command:    # changed
    cmd: "echo Hello!"
  notify:
    - Test Handler

role2/handlers/main.yml:

---
- name: Test Handler    # 他のロールにあるハンドラーと同じ名前
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

Playbook

2つのロールを呼び出すPlaybookです。

playbook.yml:

---
- name: Test Play
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Import role1
      ansible.builtin.import_role:
        name: role1

    - name: Import role2
      ansible.builtin.import_role:
        name: role2

実行結果

以下のような実行順序になります。

  1. role1 のタスク
  2. role2 のタスク
  3. role2 のハンドラー

role1 のハンドラーが呼ばれていないところがポイントです。

コマンド実行ログは以下のとおりです。

$ ansible-playbook -i localhost, playbook.yml

PLAY [Test Play] ****************************************************************************************************

TASK [role1 : Task with changed] ************************************************************************************
changed: [localhost]

TASK [role2 : Task with changed] ************************************************************************************
changed: [localhost]

RUNNING HANDLER [role2 : Test Handler] ******************************************************************************
ok: [localhost] => {
    "msg": "handler role2"
}

PLAY RECAP **********************************************************************************************************
localhost                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0  

この、role1 のハンドラーが呼ばれなかったところが、ドキュメントの言葉でいう

shadowing all of the previous handlers with the same name.

かなと思います。

なお、ansible.builtin.import_role でインポートするロールを逆( role2role1 の順)にしたら、role1 のハンドラーのみが呼ばれました。

おまけ

ハンドラーはグローバルで有効という性質上、以下のようなこともできます。

特定のロールのハンドラーを指定

notify で ロール名 : ハンドラー名 を指定すると、ロールを明示的に指定できます。ドキュメントにも方法が載っています(最近知りました)。

たとえば、role1/tasks/main.yml で、以下のように role2 内の ハンドラーを Test Handler 呼ぶとします。

---
- name: Task with changed
  ansible.builtin.command:
    cmd: "echo Hello!"
  notify:
    - "role2 : Test Handler"

これで、role2/handlers/main.yml の以下のハンドラーが呼ばれます。

---
- name: Test Handler
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

ただし、role2 も Playbook 側で読み込んでおく必要があります。

ハンドラーのみのロールでハンドラーを共通化

ハンドラーのみのロールを用意して、他のロールからハンドラーを呼ぶようにして、ハンドラーを共通化するような方法です。

.
├── playbook.yml
└── roles
    ├── role1
    │   └── tasks
    │       └── main.yml
    └── role_handler_only
        └── handlers
            └── main.yml

role_handler_only/handlers/main.yml:

---
- name: Test Handler      # role_handler_only ロールはこれのみ
  ansible.builtin.debug:
    msg: "handler {{ ansible_role_name }}"

role1/tasks/main.yml:

---
- name: Task with changed # role1 ロールはこれのみ
  ansible.builtin.command:
    cmd: "echo Hello!"
  notify:
    - Test Handler

playbook.yml:

---
- name: Test Play
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Import role1
      ansible.builtin.import_role:
        name: role1

    - name: Import role_handler_only
      ansible.builtin.import_role:
        name: role_handler_only  # ハンドラーのみのロール

実行ログ:

ansible-playbook -i localhost, playbook.yml

PLAY [Test Play] *************************************************************************************************************

TASK [role1 : Task with changed] *********************************************************************************************
changed: [localhost]

RUNNING HANDLER [role_handler_only : Test Handler] ***************************************************************************
ok: [localhost] => {
    "msg": "handler role_handler_only"
}

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

ハンドラーのみのロールの実装のヒントは、以下の記事から得ました。ありがとうございます。

qiita.com

おわりに

同じ名前のハンドラーがあることで、起こる現象を検証しました。

ざっくり、ハンドラー名は Play 内でグローバルなスコープで処理される、というイメージでとらえておけばよいかなと思いました。