こういう状況になったことはありませんか。shellモジュールは確かに何でもできますが、べき等性を確保するには「現在の状態を毎回調べて変化があったかどうかを判定するロジック」を Playbook 側に書き続ける必要があります。そのロジックが複雑になるほど、Playbook は読みにくくなり、テストも難しくなります。
Ansible には、その問題を解決する仕組みがあります。Python でカスタムモジュールを作ることで、べき等性の判定ロジックをモジュール側に隠蔽し、Playbook をシンプルに保つことができます。
この記事では、Ansibleカスタムモジュールの開発方法を基礎から解説します。AnsibleModuleクラスの仕組み・changed/failed判定の設計・roleへの組み込みまで、RHEL 9.4 / Ubuntu 24.04 LTSで実際に動かしたコードを使って説明します。
この記事のポイント
・AnsibleModuleクラスを使えばJSON入出力を抽象化してべき等性のある独自操作を設計できる
・changed=Trueを返す条件を明示することでdry-run(--check)が正しく動くモジュールになる
・library/ディレクトリへの配置だけでroleから透過的に呼び出せる
・shellモジュールのchanged_when地獄を卒業する設計方針が得られる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜshellモジュールでなくカスタムモジュールが必要なのか
Ansible の shellモジュールは「何でもできる」という意味では最強ですが、べき等性を担保するのが難しいという欠点があります。たとえば「/opt/myapp/conf.d/ 以下にあるすべての .conf ファイルに特定の設定行を追記する」という操作を考えてみてください。shell モジュールで書こうとすると、次のようになります。
- name: confファイルに設定行を追記(shell版) shell: | for f in /opt/myapp/conf.d/*.conf; do grep -q "timeout=30" "$f" || echo "timeout=30" >> "$f" done changed_when: ??? register: result
changed_when に何を書けばいいかが不明確です。シェルの戻り値だけでは「変更が実際に起きたかどうか」が分かりません。「何件追記したか」を数えて出力する工夫をしても、Playbook 側のロジックが膨らみ続けます。カスタムモジュールを使うと、この問題が解決します。モジュール側で「変更前後の状態比較 → changed フラグの設定」を完結させるので、Playbook はシンプルな呼び出しだけになります。
- name: confファイルに設定行を追記(カスタムモジュール版) my_conf_line: path: /opt/myapp/conf.d/ line: "timeout=30" state: present
Ansibleモジュールのアーキテクチャ — JSON入出力の仕組み
カスタムモジュールを作る前に、Ansible がモジュールをどのように実行しているかを理解しておきましょう。Ansible がモジュールを呼び出す流れは以下のとおりです。
・Ansible コントロールノードが Python スクリプト(モジュール)をターゲットホストへ転送する
・ターゲットホスト上で
python3 モジュール名.py が実行される・モジュールは標準出力に JSON 文字列を出力して終了する
・Ansible がその JSON を解析して changed/failed/msg 等のフィールドを取り出す
つまり、Ansible モジュールの正体は「JSON を stdout に出力する Python スクリプト」です。最低限の実装をすると次のようになります。
#!/usr/bin/python3 import json, sys result = {"changed": False, "msg": "nothing to do"} print(json.dumps(result)) sys.exit(0)
AnsibleModuleクラスで最小モジュールを作る
AnsibleModule クラスは Ansible 付属のユーティリティで、引数の定義・検証・JSON 出力・check_mode 対応などを自動で処理してくれます。実務では、ほぼすべてのカスタムモジュールがこのクラスを使います。1. モジュールの配置場所
最も簡単な配置方法は、Playbook または role のlibrary/ ディレクトリに Python ファイルを置くことです。# Playbook と同じディレクトリに置く場合 . ├── site.yml └── library/ └── my_conf_line.py # role に同梱する場合 roles/ └── myapp/ ├── library/ │ └── my_conf_line.py └── tasks/ └── main.yml
library/ ディレクトリはデフォルトのモジュール検索パスに含まれるため、ansible.cfg に追加設定なしで Playbook から呼び出せます。2. AnsibleModuleクラスを使った基本構造
ファイル名と同じ名前がモジュール名になります。ここではmy_conf_line.py というファイルを作ります。#!/usr/bin/python3 # -*- coding: utf-8 -*- from ansible.module_utils.basic import AnsibleModule import os def main(): # ① 引数の定義 module_args = dict( path=dict(type='str', required=True), line=dict(type='str', required=True), state=dict(type='str', default='present', choices=['present', 'absent']), ) # ② AnsibleModuleの初期化 module = AnsibleModule( argument_spec=module_args, supports_check_mode=True, # --checkフラグを有効にする ) path = module.params['path'] line = module.params['line'] state = module.params['state'] # ③ ファイルが存在しなければエラー if not os.path.exists(path): module.fail_json(msg=f"ファイルが見つかりません: {path}") # ④ 現在の状態を取得 with open(path, 'r') as f: lines = f.readlines() line_exists = any(line.rstrip('\n') == l.rstrip('\n') for l in lines) # ⑤ 変更が不要なら即 exit if state == 'present' and line_exists: module.exit_json(changed=False, msg="行はすでに存在します") if state == 'absent' and not line_exists: module.exit_json(changed=False, msg="行は存在しません(対応不要)") # ⑥ check_mode では実際の変更をスキップ if module.check_mode: module.exit_json(changed=True, msg="[check] 変更が予定されています") # ⑦ 実際の変更処理 if state == 'present': with open(path, 'a') as f: f.write(line + '\n') module.exit_json(changed=True, msg=f"行を追記しました: {line}") else: new_lines = [l for l in lines if l.rstrip('\n') != line] with open(path, 'w') as f: f.writelines(new_lines) module.exit_json(changed=True, msg=f"行を削除しました: {line}") if __name__ == '__main__': main()
・
argument_spec で引数の型・デフォルト値・選択肢を定義する。AnsibleModule が自動でバリデーションを行う・
supports_check_mode=True を指定すると module.check_mode が True になる(--check 実行時)・
module.fail_json() はエラー終了。msg が Ansible に伝わり、タスクが FAILED になる・
module.exit_json() は正常終了。changed= の値が Ansible の changed カウントに反映される3. Playbookから呼び出して実行してみる
# site.yml --- - hosts: webservers tasks: - name: タイムアウト設定を追記 my_conf_line: path: /etc/myapp/app.conf line: "timeout=30" state: present
$ ansible-playbook -i hosts site.yml PLAY [webservers] ********************** TASK [タイムアウト設定を追記] ***** changed: [web01.example.internal] PLAY RECAP *************************** web01.example.internal : ok=1 changed=1 unreachable=0 failed=0
$ ansible-playbook -i hosts site.yml TASK [タイムアウト設定を追記] ***** ok: [web01.example.internal] PLAY RECAP *************************** web01.example.internal : ok=1 changed=0 unreachable=0 failed=0
changed=0 になっています。べき等性が正しく機能しています。べき等性の実装パターン — changed判定の設計
カスタムモジュールで最も重要なのは、「いつ changed=True を返すか」の設計です。ここを誤ると、毎回 changed になる(実際は変化していないのに)か、変更しても changed=False になる(Playbook の通知が機能しない)かのどちらかになります。1. 「現在の状態 → 目標状態」の比較モデルを作る
べき等性の基本は「現在の状態と目標状態を比較し、差分があれば変更する」という思考モデルです。・現在の状態: ファイルの内容・プロセスの起動状態・設定値など、実際にシステムから取得するもの
・目標の状態: Playbook でパラメーターとして渡す「こうなってほしい状態」
・差分チェック: 現在 ≠ 目標 のとき changed=True で変更する
この 3ステップを常に意識することで、べき等なモジュールが自然と書けます。
2. check_modeへの対応が運用品質を決める
supports_check_mode=True を指定し、module.check_mode が True のときは実際の変更処理(ファイル書き込み・コマンド実行)を スキップして changed=True だけを返す実装が必要です。check_mode に対応していないモジュールは
ansible-playbook --check を実行した際に WARNING: This module does not support check mode が表示され、実際の変更が走ってしまいます。本番前のドライラン確認が機能しなくなるため、必ず対応してください。より実践的なAnsible設計パターンを体系的に学びたい方は、Ansible構成管理入門コースも参考にしてください。ハンズオン形式でrole設計からカスタムモジュールまで扱っています。
カスタムモジュールをroleに組み込む
カスタムモジュールは role のlibrary/ ディレクトリに置くことで、その role を利用するすべての Playbook から参照できるようになります。1. roleのディレクトリ構成
roles/ └── myapp_config/ ├── library/ │ └── my_conf_line.py # カスタムモジュール ├── tasks/ │ └── main.yml ├── defaults/ │ └── main.yml └── meta/ └── main.yml
2. roleのtasks/main.ymlから呼び出す
# roles/myapp_config/tasks/main.yml --- - name: タイムアウト設定を反映 my_conf_line: path: "{{ myapp_conf_path }}" line: "timeout={{ myapp_timeout }}" state: present notify: restart myapp - name: 古い設定行を削除 my_conf_line: path: "{{ myapp_conf_path }}" line: "old_setting=deprecated" state: absent
# roles/myapp_config/defaults/main.yml --- myapp_conf_path: /etc/myapp/app.conf myapp_timeout: 30
changed=True のときだけ restart myapp が発動し、不必要な再起動が起きません。3. 複数のroleで共用する場合
複数の role で同一のカスタムモジュールを使いたい場合は、Playbook ルートのlibrary/ に置くか、ansible.cfg の library パラメーターで共通ディレクトリを指定します。# ansible.cfg [defaults] library = ./library:~/.ansible/plugins/modules
デバッグとテストの方法
カスタムモジュールの動作確認には 2つのアプローチがあります。1. Playbookから-vvvオプションで実行ログを確認する
ansible-playbook -vvv を使うと、モジュールへの引数・返却された JSON・転送されたスクリプト内容が表示されます。$ ansible-playbook -i hosts site.yml -vvv ... TASK [タイムアウト設定を追記] ***** task path: /home/ansible/site.yml:4 ...
{"path": "/etc/myapp/app.conf", "line": "timeout=30", "state": "present"} ... {"changed": true, "msg": "行を追記しました: timeout=30", ...}
2. ローカルで直接引数ファイルを渡してテストする
開発中は Playbook を使わず、引数を JSON ファイルに書いて直接 Python を実行できます。# args.json(テスト用引数ファイル) {"ANSIBLE_MODULE_ARGS": { "path": "/tmp/test.conf", "line": "timeout=30", "state": "present" }} # 実行 $ python3 library/my_conf_line.py args.json {"changed": true, "msg": "行を追記しました: timeout=30", "invocation": {"module_args": {...}}}
ansible-core がインストールされていれば動作します。3. Moleculeでrole単体テストに組み込む
カスタムモジュールを含む role は、Molecule を使って role 全体としての結合テストが可能です。molecule test を実行することで、Docker コンテナ上で Playbook を実行し、べき等性(2回目の実行で changed=0 になるか)を自動検証できます。# molecule/default/verify.yml(べき等性検証の例) --- - name: Verify idempotency hosts: all tasks: - name: 2回目の実行(changed=0 になるはず) my_conf_line: path: /etc/myapp/app.conf line: "timeout=30" state: present register: result - name: changedがFalseであることを確認 assert: that: - result.changed == false fail_msg: "べき等性が壊れています"
よくあるエラーと対処法
カスタムモジュール開発でつまずきやすいポイントと対処法をまとめます。「ModuleNotFoundError: No module named 'ansible'」が出る
ローカルテスト(python3 モジュール.py args.json)時に、ターゲット環境に ansible-core がインストールされていない場合に発生します。・対処:
pip3 install ansible-core を実行してから再試行してください・注意: ターゲットホスト上での実行はAnsibleが管理するため問題ありません。このエラーはローカルテスト時のみ発生します
毎回 changed=True になってしまう
変更前後の状態比較が正しくできていないケースです。特に文字列の末尾改行(\n)の扱いを見落としがちです。・よくある原因: ファイルから読んだ行と比較文字列の末尾
\n が一致していない・対処: 比較前に
.rstrip('\n') や .strip() で正規化してから比較する# NG: 末尾\nが食い違って毎回 changed=True になる line_exists = line in lines # OK: 正規化してから比較する line_exists = any(line.rstrip('\n') == l.rstrip('\n') for l in lines)
--checkで「This module does not support check mode」が出る
supports_check_mode=True を AnsibleModule の初期化に指定していない場合に発生します。・対処:
AnsibleModule(argument_spec=..., supports_check_mode=True) に修正する・警告: check_mode に対応していないモジュールは本番前のドライランが機能せず、意図しない変更が本番に入るリスクがあります。必ず対応してください
モジュールがターゲットホストで見つからない
role に組み込んだモジュールが「No module named ...」ではなく「ERROR! couldn't resolve module/action」と出る場合は配置場所を確認します。・role の
library/ 以外の場所に置いた可能性がある・
ansible.cfg の library パスが通っていない・Ansible Galaxy からインストールした role は
~/.ansible/roles/ 配下に展開されるため、library/ の参照パスが変わる場合がある本記事のまとめ
Ansible カスタムモジュールの開発ポイントをまとめます。| やりたいこと | 実装方法 |
|---|---|
| 引数を定義して自動バリデーション | AnsibleModule(argument_spec=...) |
| 変更ありとして終了 | module.exit_json(changed=True, msg=...) |
| 変更なしとして終了 | module.exit_json(changed=False, msg=...) |
| エラーとして終了 | module.fail_json(msg=...) |
| --checkフラグへの対応 | supports_check_mode=True + if module.check_mode: exit |
| roleに同梱する | roles/role名/library/モジュール名.py |
| 複数roleで共用する | Playbookルートの library/ または ansible.cfg の library パラメーター |
| ローカル単体テスト | JSON 引数ファイルを渡して python3 モジュール.py args.json |
changed_when を書き続けるのではなく、操作の意味を抽象化したモジュールとして切り出すことで、role の再利用性も格段に上がります。標準モジュールでは対応できない処理が出てきたとき、まずカスタムモジュール化を検討してみてください。
Ansibleを使ったサーバー自動化の設計パターンを体系的に学べる講座を用意しています。role設計・カスタムモジュール・inventory設計まで、実機を使ったハンズオンで習得できます。
>> Ansible構成管理入門を見てみる
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:Ansibleのインベントリ2軸設計入門|役割グループと環境グループで再利用性の高い構成管理を実現する方法
- この記事の属するカテゴリ:Ansibleへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます