2026年9月1日

2026年9月1日

PythonでAnsibleのカスタムモジュールを開発する方法|AnsibleModuleの書き方・冪等性・チェックモード・テスト

Ansible の既存モジュールで足りない処理は、Python で自作のモジュールにできます。ansible.module_utils.basic の AnsibleModule で引数を受け取り、変更の有無を判定して exit_json() で結果を返す、という形が基本です。プレイブックの隣に library/ ディレクトリを作って置けば、すぐに使えます。この記事では、JSON 形式の設定ファイルのキーを指定した値にそろえるモジュールを例に、引数の定義、冪等性(何度実行しても同じ結果になること)、チェックモードと差分表示への対応、ドキュメントの書き方、Ansible を通さずに単体で動かすテスト方法までを説明します。

作る前に、既存のモジュールで済まないか確認する

自作モジュールは保守の手間がかかります。書き始める前に、ansible.builtin や community.general などのコレクションに同じ役割のモジュールが無いかを確認してください。ansible-doc -l で、インストール済みのモジュールを一覧できます。ファイルの1行を書き換えるなら lineinfile、テンプレートからファイルを作るなら template が使えます。

shell や command モジュールでも多くの処理は書けますが、これらは「変更があったかどうか」を Ansible が判断できず、changed_when などで毎回自分で判定を書く必要があります。同じ処理を何度も使う、判定が複雑になる、チェックモードで安全に事前確認したい、といった場合がモジュール化を考えるタイミングです。

モジュールの置き場所

Ansible は、次の場所にある Python ファイルをモジュールとして読み込みます。

  • **プレイブックと同じ階層の library/**:手軽に試すならここです。
  • **ロールの中の library/**:そのロールを使うプレイから利用できます。
  • **コレクションの plugins/modules/**:複数のプロジェクトで共有したり配布したりするならこの形にします。

この記事では次の構成で進めます。

project/
├── site.yml
└── library/
    └── json_setting.py

モジュールは、コントロールノード(Ansible を実行するマシン)ではなく、管理対象のサーバー側の Python で実行されます。管理対象に入っていないライブラリを import するとそこで失敗するので、標準ライブラリだけで書けるならそのほうが扱いやすくなります。

モジュールの全体像

次のモジュールは、JSON の設定ファイルのトップレベルのキーを、指定した値に設定(state: present)または削除(state: absent)します。すでに目的の状態なら何もせず changed: false を返します。

#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import annotations

DOCUMENTATION = r"""
---
module: json_setting
short_description: JSON 設定ファイルのキーを指定した値にそろえる
description:
  - JSON 形式の設定ファイルで、トップレベルのキーの値を設定または削除する。
options:
  path:
    description: 対象の JSON ファイル。存在しない場合は state=present のときに作成する。
    type: path
    required: true
  name:
    description: トップレベルのキー名。
    type: str
    required: true
  value:
    description: 設定する値。state=present のときに指定する。
    type: raw
  state:
    description: present で設定、absent で削除する。
    type: str
    choices: [present, absent]
    default: present
  backup:
    description: 変更前のファイルをタイムスタンプ付きで残す。
    type: bool
    default: false
author:
  - Your Name (@your-github-id)
"""

EXAMPLES = r"""
- name: メンテナンスモードをオフにする
  json_setting:
    path: /etc/myapp/config.json
    name: maintenance
    value: false
    backup: true
"""

RETURN = r"""
previous:
  description: 変更前の値。キーが無かった場合は null。
  returned: always
  type: raw
backup_file:
  description: バックアップしたファイルのパス。
  returned: changed and backup is true
  type: str
"""

import json
import os
import tempfile

from ansible.module_utils.basic import AnsibleModule


def main():
    module = AnsibleModule(
        argument_spec=dict(
            path=dict(type="path", required=True),
            name=dict(type="str", required=True),
            value=dict(type="raw"),
            state=dict(type="str", default="present", choices=["present", "absent"]),
            backup=dict(type="bool", default=False),
        ),
        required_if=[("state", "present", ["value"])],
        supports_check_mode=True,
    )
    path = module.params["path"]
    name = module.params["name"]
    value = module.params["value"]
    state = module.params["state"]

    data = {}
    if os.path.exists(path):
        try:
            with open(path, encoding="utf-8") as f:
                data = json.load(f)
        except (OSError, ValueError) as e:
            module.fail_json(msg=f"{path} を JSON として読み込めません: {e}")
        if not isinstance(data, dict):
            module.fail_json(msg=f"{path} のトップレベルがオブジェクトではありません")
    elif state == "absent":
        module.exit_json(changed=False, previous=None, path=path)

    before = dict(data)
    previous = data.get(name)
    if state == "present":
        changed = name not in data or data[name] != value
        data[name] = value
    else:
        changed = name in data
        data.pop(name, None)

    result = dict(changed=changed, previous=previous, path=path)
    if module._diff:
        result["diff"] = dict(
            before=json.dumps(before, ensure_ascii=False, indent=2) + "\n",
            after=json.dumps(data, ensure_ascii=False, indent=2) + "\n",
        )

    if changed and not module.check_mode:
        if module.params["backup"] and os.path.exists(path):
            result["backup_file"] = module.backup_local(path)
        fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".json_setting.")
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
            f.write("\n")
        module.atomic_move(tmp, path)

    module.exit_json(**result)


if __name__ == "__main__":
    main()

各部分の役割

argument_spec:引数の型と制約

argument_spec に引数の名前・型・既定値・選択肢を書くと、AnsibleModule が値の検証と型変換をしてくれます。間違った値が渡されたときのエラーメッセージも自動で作られるので、自分で if を書く必要はありません。

  • type="path" は、~ やシェル変数の展開をしたうえで文字列として渡されます。
  • type="raw" は型変換をしません。プレイブックの YAML で value: false と書けば真偽値、value: 10 と書けば数値のまま届きます。アドホックコマンドの -a "value=false" 形式で渡すと文字列 "false" になる点には注意が必要です。
  • required_if は「state が present のときは value が必要」のような条件付きの必須を表します。

パスワードやトークンを受け取る引数には no_log=True を付けます。付けないと、値が実行ログや -v の出力にそのまま表示されることがあります。

冪等性:変更が必要なときだけ書き込む

モジュールの中心は、「今の状態」と「あるべき状態」を比べて、違うときだけ変更し、その結果を changed で正しく返すことです。このモジュールでは、キーが無いか値が違う場合だけ changed=True になり、ファイルを書き込みます。2回目の実行では changed=False になるので、プレイブックを何度流しても安全で、notify でハンドラを呼ぶ処理も、本当に変わったときだけ動きます。

「いつも changed=True を返す」モジュールは動いているように見えても、実行結果から「何が変わったか」が読み取れなくなります。

チェックモードと差分表示

supports_check_mode=True を指定すると、ansible-playbook --check で実行されたときもモジュールが呼ばれ、module.check_mode が True になります。このモジュールでは、変更の判定まではいつもどおり行い、書き込みだけを飛ばしています。こうすると、--check の結果で「本番で実行したら何が変わるか」を事前に確認できます。

--diff を付けて実行したときは module._diff が True になり、返した diff の before と after の違いが表示されます。

安全な書き込み

書き込みは、同じディレクトリに一時ファイルを作ってから module.atomic_move() で置き換えています。途中でディスクが満杯になったり接続が切れたりしても、壊れた設定ファイルが残らないようにするためです。atomic_move() は、既存ファイルを置き換える場合にその権限を引き継ぎます。所有者やパーミッションを明示したいときは、後続のタスクで ansible.builtin.file を使うのが簡単です。

backup: true のときは module.backup_local() で、元のファイルをタイムスタンプ付きの名前で残しています。

結果の返し方

成功は module.exit_json()、失敗は module.fail_json(msg=...) で返します。どちらも JSON を出力してプロセスを終了するため、その後の行は実行されません。モジュールの中で print() を使うと、出力された JSON が壊れて Ansible が結果を読めなくなるので、デバッグ情報を出したいときは module.warn() や module.log()、または返り値の項目に入れてください。

外部コマンドを実行したい場合は、subprocess を直接使うより module.run_command() を使うと、環境変数やエラー処理が Ansible の流儀にそろいます。引数はリストで渡すと、シェルを経由しないので安全です。

rc, out, err = module.run_command(["systemctl", "is-active", "nginx"])

ドキュメントを書く理由

DOCUMENTATION・EXAMPLES・RETURN は YAML で書く文字列で、ansible-doc から読めるモジュールの説明書になります。

ansible-doc -M ./library json_setting

社内向けの小さなモジュールでも、半年後の自分やチームの誰かが使い方を確認できるので書いておく価値があります。コレクションとして配布する場合は、ansible-test sanity がこれらの内容と argument_spec の一致を検査するため、事実上の必須項目になります。

Ansible を通さずにモジュールを単体で動かす

開発中は、プレイブックを毎回流すより、モジュールを直接実行するほうが速く試せます。引数を ANSIBLE_MODULE_ARGS というキーに入れた JSON ファイルを作ります。

{"ANSIBLE_MODULE_ARGS": {"path": "/tmp/test-config.json", "name": "maintenance", "value": false}}
python3 library/json_setting.py args.json

返ってくる JSON の changed が、1回目は true、同じ引数での2回目は false になることを確認します。これが冪等性のテストになります。このとき、Ansible(ansible-core)をインストールした Python で実行してください。ansible.module_utils を import できないと動きません。

次に、アドホックコマンドで Ansible 経由の動作を確かめます。

ansible localhost -M ./library -m json_setting -a "path=/tmp/test-config.json name=site_name value=example" --check --diff

--check を付けているので、ファイルは変わらずに差分だけが表示されます。この形式では value が文字列として渡るので、真偽値や数値を試したいときはプレイブックから実行します。

最後にプレイブックに組み込みます。本番サーバーに使う前に、検証用のサーバーで --check --diff を流して変更内容を確認し、対象ファイルのバックアップがあることを確かめてから実行してください。

- hosts: app
  become: true
  tasks:
    - name: メンテナンスモードをオフにする
      json_setting:
        path: /etc/myapp/config.json
        name: maintenance
        value: false
        backup: true

外部ライブラリを使うモジュールの書き方

どうしても標準ライブラリ以外が必要な場合は、import の失敗を捕まえて、モジュールの実行時にわかりやすいエラーを返します。

from ansible.module_utils.basic import AnsibleModule, missing_required_lib

try:
    import requests
    HAS_REQUESTS = True
except ImportError:
    HAS_REQUESTS = False

# main() の中で AnsibleModule を作ったあとに
if not HAS_REQUESTS:
    module.fail_json(msg=missing_required_lib("requests"))

こうしておくと、管理対象にライブラリが入っていないときに、何をインストールすればよいかがエラーメッセージに表示されます。

管理対象で使える Python のバージョンは、ansible-core のバージョンごとにサポート範囲が決まっています。古い OS を管理対象に含む場合は、使っている ansible-core のドキュメントでサポートされる Python のバージョンを確認し、新しい構文を使いすぎないようにしてください(2026年10月現在)。

まとめ

  • 書く前に既存のモジュールで代用できないか確認し、shell では判定が難しい処理や繰り返し使う処理をモジュールにする
  • AnsibleModule の argument_spec で型・必須・選択肢を宣言し、秘密情報の引数には no_log=True を付ける
  • 今の状態と目的の状態を比べ、変更が必要なときだけ書き込んで changed を正しく返す
  • supports_check_mode=True と module.check_mode、diff の返却で --check --diff に対応し、書き込みは atomic_move() で行う
  • ANSIBLE_MODULE_ARGS の JSON ファイルで単体実行し、2回目に changed: false になることを確認してから、検証環境で --check --diff を流す

お気軽にご相談ください

お見積りへ お問い合わせへ
▲