Ansible Playbookによるループ処理からの特定処理の実行

# はじめに

Ansible Playbookを利用して、with_itemsでループ処理を実行したうち、changedになったアイテムだけ特定の処理をさせたいことがあると思います。

Handlerを使うと、1つ以上changedになると指定のHandlerが最後に1回実行されるので、何らかの方法でchangedになったアイテムのリストを渡さなければいけません。

または、`notify: 'xxxxxxx {{ item }}'`とすると、今度は1つでもchangedになると、リストの全アイテムの分だけHandlerが呼ばれてしまいます。仮にchangedのアイテムのみのHandlerを呼び出す方法があったとしても、事前に全アイテム分のHandlerを記述しておかなければいけないため、保守性に欠けます。（アイテムが増えたら、Handlerも追加しなければいけません）

これまで試行錯誤した結果、現時点で満足のいく結果が得られたので、記念に残しておきます。

# 想定ケース

カスタマイズした、あるいは新規のサービスに関するファイルを/etc/systemd/system以下に置く。そのうち追加・更新があったものだけリスタートする。

# 環境

VirtualBox (+ vagrant)
CentOS 7.7
ansible 2.9.6

# Playbook

記事を分かりやすくするため、1本のPlaybook`systemctl.yml`に全て記載します。

まず冒頭で、ファイルを置き換える対象のファイル名をリスト変数で定義します。同名のファイルをPlaybookと同じディレクトリに配置します。

```yaml:systemctl.yml
---
- name: systemctl
  hosts: localhost
  become: true
  vars:
    systemctl_services:
      - tuned.service
      - chronyd.service
```

今回の例では、/usr/lib/systemd/systemにあるファイルのうち、プロセスダウン時に自動的に復旧させたいものに対し、`Restart=on-failure`を追加したファイルを用意するケースを想定します。（ファイルの中身は本題とは関係ないので省略します）

1つ目のタスクで、copyモジュールを用いてファイルをコピーします。その結果をregisterで登録します。

```yaml:systemctl.yml
  tasks:
    - name: /etc/systemd/system files are copied
      copy:
        src: "{{ item }}"
        dest: "/etc/systemd/system/"
        owner: root
        group: root
        mode: '0644'
      with_items:
        - "{{ systemctl_services }}"
      register: systemctl_update
```

2つ目のタスクでは、2つのリスト変数（冒頭定義したサービスの一覧と、1つ目のタスクの結果）を、with_togetherでループ処理します。これにより、サービス1つ1つの名称と、それぞれの1つ目のタスクの結果をセットにして、順にループ処理することが出来ます。

```yaml:systemctl.yml
      with_together:
        - "{{ systemctl_services }}"
        - "{{ systemctl_update.results }}"
```

そして、1つ目のタスクの結果がchangedの時だけ処理させます。`item.1.changed`の`1`は、with_togetherで2つ目に指定した変数と言う意味です。1つ目は`0`です。

```yaml:systemctl.yml
      when:
        - item.1.changed
```

処理させる内容は、serviceモジュールを使用したリスタートです。

```yaml:systemctl.yml
    - name: Updated systemd services are restarted
      service:
        name: "{{ item.0 }}"
        state: restarted
```

あとおまじないを1つ仕掛けます。（説明は後で）

```yaml:systemctl.yml
      loop_control:
        label: "{{ item.0 }} changed={{ item.1.changed }}"
```

Playbook全体はこんな感じになります。

```yaml:systemctl.yml
---
- name: systemctl
  hosts: localhost
  become: true
  vars:
    systemctl_services:
      - tuned.service
      - chronyd.service

  tasks:
    - name: /etc/systemd/system files are copied
      copy:
        src: "{{ item }}"
        dest: "/etc/systemd/system/"
        owner: root
        group: root
        mode: '0644'
      with_items:
        - "{{ systemctl_services }}"
      register: systemctl_update

    - name: Updated systemd services are restarted
      service:
        name: "{{ item.0 }}"
        state: restarted
      when:
        - item.1.changed
      with_together:
        - "{{ systemctl_services }}"
        - "{{ systemctl_update.results }}"
      loop_control:
        label: "{{ item.0 }} changed={{ item.1.changed }}"
```

# 実行結果

実行すると、ファイルに更新があったもののみ、サービスのリスタートが実行されます。以下は、一度実行した後、/etc/systemd/system/chronyd.serviceを削除してから再度実行した時の出力例です。

```
$ ansible-playbook -i inventories/test systemctl.yml 

PLAY [systemctl] *******************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************
ok: [localhost]

TASK [/etc/systemd/system files are copied] ****************************************************************************
ok: [localhost] => (item=tuned.service)
changed: [localhost] => (item=chronyd.service)

TASK [Updated systemd services are restarted] **************************************************************************
skipping: [localhost] => (item=tuned.service changed=False) 
changed: [localhost] => (item=chronyd.service changed=True)

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

# labelによるループ出力の制限

説明を後回しにした loop_control ですが、AnsibleのDocumentを眺めていてたまたま見つけた以下の記述をもとにしたものです。

[Limiting loop output with label](https://docs.ansible.com/ansible/latest/user_guide/playbooks_loops.html#limiting-loop-output-with-label)

これをつけないと、2つ目のタスク実行結果の出力が大変なことになります。（ブラウザによっては右にずっとスクロールして見てください・・・ターミナルだと、これが改行されて全部見せられます）

```
$ ansible-playbook -i inventories/test systemctl.yml 

PLAY [systemctl] *******************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************
ok: [localhost]

TASK [/etc/systemd/system files are copied] ****************************************************************************
ok: [localhost] => (item=tuned.service)
ok: [localhost] => (item=chronyd.service)

TASK [Updated systemd services are restarted] **************************************************************************
skipping: [localhost] => (item=['tuned.service', {'diff': {'before': {'path': '/etc/systemd/system/tuned.service'}, 'after': {'path': '/etc/systemd/system/tuned.service'}}, 'path': '/etc/systemd/system/tuned.service', 'changed': False, 'uid': 0, 'gid': 0, 'owner': 'root', 'group': 'root', 'mode': '0644', 'state': 'file', 'size': 376, 'invocation': {'module_args': {'owner': 'root', 'group': 'root', 'mode': '0644', 'dest': '/etc/systemd/system/', '_original_basename': 'tuned.service', 'recurse': False, 'state': 'file', 'path': '/etc/systemd/system/tuned.service', 'force': False, 'follow': True, 'modification_time_format': '%Y%m%d%H%M.%S', 'access_time_format': '%Y%m%d%H%M.%S', '_diff_peek': None, 'src': None, 'modification_time': None, 'access_time': None, 'seuser': None, 'serole': None, 'selevel': None, 'setype': None, 'attributes': None, 'content': None, 'backup': None, 'remote_src': None, 'regexp': None, 'delimiter': None, 'directory_mode': None, 'unsafe_writes': None}}, 'checksum': '4f2a27700ca6cc6da49f8678dec1c4cad782befa', 'dest': '/etc/systemd/system/tuned.service', 'failed': False, 'item': 'tuned.service', 'ansible_loop_var': 'item'}]) 
skipping: [localhost] => (item=['chronyd.service', {'diff': {'before': {'path': '/etc/systemd/system/chronyd.service'}, 'after': {'path': '/etc/systemd/system/chronyd.service'}}, 'path': '/etc/systemd/system/chronyd.service', 'changed': False, 'uid': 0, 'gid': 0, 'owner': 'root', 'group': 'root', 'mode': '0644', 'state': 'file', 'size': 495, 'invocation': {'module_args': {'owner': 'root', 'group': 'root', 'mode': '0644', 'dest': '/etc/systemd/system/', '_original_basename': 'chronyd.service', 'recurse': False, 'state': 'file', 'path': '/etc/systemd/system/chronyd.service', 'force': False, 'follow': True, 'modification_time_format': '%Y%m%d%H%M.%S', 'access_time_format': '%Y%m%d%H%M.%S', '_diff_peek': None, 'src': None, 'modification_time': None, 'access_time': None, 'seuser': None, 'serole': None, 'selevel': None, 'setype': None, 'attributes': None, 'content': None, 'backup': None, 'remote_src': None, 'regexp': None, 'delimiter': None, 'directory_mode': None, 'unsafe_writes': None}}, 'checksum': 'cc8a888eeccff040ae6e5d37b6c35804817086a5', 'dest': '/etc/systemd/system/chronyd.service', 'failed': False, 'item': 'chronyd.service', 'ansible_loop_var': 'item'}]) 

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

ループ処理の結果をregisterで登録したリスト変数を、以降のループ処理で単純に使うと、登録された情報が全て画面に表示されてしまいます。ずっとこの膨大な出力の抑制方法が無いものかと悩んでいたのですが、先のマニュアル記載を発見し、実行結果のようなシンプルな結果となったのでした。

# 応用、改善

これで、サービスを追加・変更したい場合は、ファイルを所定の場所に置き、あるいは編集して、追加の場合は変数を追加するだけで、追加・変更したサービスだけを再起動させることが出来るようになりました。

この手法は、他の用途にも応用可能と思います。例えば、shellモジュールで何かしらの状態（作成済みかどうか、など）をコマンドやシェルスクリプトでチェックし、その出力結果やリターンコードに応じて処理を変える、といった用途に使うと、shellモジュールを使わざるを得ない場合に、冪等性を作り込むのにも役立ちそうな気がします。（1つ目のタスクは`changed_when: true`として、必要ものだけ2つ目のタスク処理を実行するイメージ）

また、例のようにファイルのcopyをトリガーにする場合は、with_fileglobを使うと、変数の編集も不要になり、もっと幸せになるかもしれませんが、それはまた別の機会にでも。