Ansibleのtemplateとhandlerでnginxを安全にreloadする方法
Jinja2 templateでnginx設定を生成し、変更時だけhandlerでreloadします。playbookを2回実行して、2回目に変更なしとなる冪等性を確認する手順です。
最初の playbook にテンプレートとハンドラーを追加する意味
Ansible のテンプレートとハンドラーは、静的な playbook を実用的なものに変える2つの要素です。テンプレートは変数から設定ファイルを生成するため、1つのファイルで全ホストに対応できます。ハンドラーは、task で実際に変更が発生した場合だけ実行されます。そのため、サービスは設定が実際に変更された場合だけ reload され、それ以外ではそのまま維持されます。
このガイドは、VPS で最初の Ansible playbook を作成するの続きです。すでに、パッケージをインストールしてサービスを起動する play があります。以下は、play がローカル接続で localhost を対象とするため、1台のマシン上で実行します。手順の確認に2台目のサーバーは必要ありません。同じ play は task を変更せずに実際の inventory ホストへ適用できます。最後のセクションでは、変更が必要な箇所を説明します。
作業ディレクトリを準備する
sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templatesこの例で nginx を使うのは、設定ファイルと reload コマンドを持つ実際のサービスであり、必要な要素がそろっているためです。ansible --version は ansible-core のバージョンと、使用する Python インタープリターを表示します。両方を確認してください。以下の playbook では、ansible.builtin.template などの完全修飾モジュール名を使用します。これには Ansible 2.10 以降が必要ですが、現在のディストリビューションパッケージは通常この要件を大きく上回っています。
inventory.ini を作成します。
[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"ansible_connection=local により、Ansible は各タスクを、自分自身への SSH セッションを開かずにローカルプロセスとして実行します。2 つ目の設定は単なる装飾ではありません。インベントリファイルに localhost と記述すると、それは通常のホストになり、暗黙の localhost に Ansible が自動的に渡すインタープリターを失います。その結果、インタープリター検出に戻り、play を実行している Python とは別の Python が選ばれる可能性があります。ansible_playbook_python は、現在 ansible-playbook を実行しているインタープリターです。これにより、両者を同じものにできます。
ansible.cfg を作成します。
[defaults]
inventory = inventory.iniこのファイルがない場合は、すべてのコマンドで -i inventory.ini を指定します。インベントリをまったく指定しないと、Ansible は [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' を表示し、hosts: all を指定した play は何にも一致しません。ansible.cfg について、もう1点注意が必要です。world-writable なディレクトリに置かれている場合、Ansible はこのファイルを無視します。そのため、プロジェクトはホームディレクトリ配下に置いてください。インベントリファイルにはホスト一覧以上の情報が含まれる。これは目的を達成するための最小構成です。
template と copy の使い分け
ansible.builtin.copy はファイルをそのまま転送します。ansible.builtin.template はファイルを最初に Jinja2 で処理し、その結果を転送します。モジュールのソースでは template を「完全に action plugin として実装され、controller 上で実行される仮想モジュール」と説明しています。ここから、覚えておくべき点が生じます。レンダリングは ansible-playbook を入力したマシン上で行われます。対象ホストが変数を参照することはなく、Jinja2 をインストールする必要もありません。
すべてのホストでファイルの内容が同一の場合は copy を使用します。ホストごとに値が異なる場合、または {% for %} ループや {% if %} ブロックが必要な場合は、template を使用します。copy にも content: パラメーターはあります。そこに含めた変数は、他のタスク引数と同じように置換されます。ただし、そこではループも条件分岐も使用できません。そのため、構造を持つ内容はテンプレートに記述します。両モジュールは同じドキュメント断片を読み込むため、同じファイルオプションを受け取ります。したがって、owner、group、mode、backup、validate は、どちらでも同じように動作します。
テンプレートを作成する: 1 つの変数と 1 つのループ
これを templates/app.conf.j2 として保存します。
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}
server {
listen {{ app_listen_port }};
server_name {{ app_server_name }};
location / {
proxy_pass http://{{ app_name }}_backend;
proxy_set_header Host $host;
}
}ここでは、2 種類の Jinja2 タグを使います。{{ ... }} は式で、その値を出力します。{% ... %} は文で、それ自体は何も出力しません。app_backends は辞書のリストなので、backend.host は各エントリから 1 つのキーを読み取り、ループは定義したエントリ数に応じて 1 行の server を出力します。
空白について、Jinja2 を別の環境で使ったことがある場合に意外に感じる点を説明します。Ansible はデフォルトで trim_blocks を yes に設定しますが、Jinja2 自体にはこの設定がありません。そのため、{% ... %} タグの直後にある改行が削除され、ループの後に空行が残りません。Ansible は lstrip_blocks を no のままにするため、{% タグの前に入れたスペースは保持され、レンダリング後のファイルに現れます。出力に不要なインデントが入る場合は、テンプレートタスクで lstrip_blocks: true を設定します。
{{ ansible_managed }} はデフォルトでリテラルテキスト Ansible managed としてレンダリングされます。この設定は変更しないでください。ansible_managed に日付を含めるよう ansible.cfg で再定義することがあります。しかし、そうするとレンダリング後のファイルが実行ごとに変わり、タスクは実行ごとに変更を報告し、サービスも実行ごとに再読み込みされます。この 1 つの設定によって、このガイド全体で重視する特性が失われます。.j2 拡張子は慣例にすぎず、Ansible は検証しません。
プレイブック
これを site.yml として保存します。
- name: Render an nginx site from a template
hosts: local
become: true
vars:
app_name: learn
app_listen_port: 8080
app_server_name: learn.example.com
app_backends:
- host: 127.0.0.1
port: 9001
weight: 3
- host: 127.0.0.1
port: 9002
weight: 1
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render the site configuration
ansible.builtin.template:
src: templates/app.conf.j2
dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
notify: nginx config changed
- name: Make sure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Test the nginx configuration
ansible.builtin.command:
cmd: /usr/sbin/nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changedmode: '0644' は意図的に引用符で囲んでいます。file options のドキュメントでは、8 進数を「Ansible が文字列として受け取り、文字列から数値への変換を独自に行えるようにするため」に引用するよう説明されています。引用符を付けない場合、YAML パーサーは 0644 を単純な数値として解釈するため、意図しない権限になることがあります。
notify: nginx config changed は handler ではなくトピックを指定します。2 つの handler がどちらも listen: nginx config changed を持つため、1 回の notify で両方が実行されます。後から同じ listen の行を持つ 3 つ目の handler を追加しても、template task の編集は不要です。cache_valid_time: 3600 により、同じ時間内の 2 回目の実行で再び package mirror へアクセスすることを防ぎます。
1 回実行して、出力を確認します
ansible-playbook site.ymlsudo でパスワードを求められる場合は、-K を追加すると Ansible が入力を求めます。
まずタスクごとの行を確認し、最後に表示される PLAY RECAP を確認します。各タスクでは、Ansible が変更を加えた場合に changed:、ホストがすでに目的の状態だった場合に ok: が表示されます。recap には、ホストごとのこれらのカウンターの合計が表示されます。play 内のすべてのタスクが完了した後、つまりそれより前ではなく、RUNNING HANDLER [Test the nginx configuration] に続いて RUNNING HANDLER [Reload nginx] が表示されます。
次に、出力をそのまま信頼せず、マシン自体を確認します。
sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/nginx -t は、組み立てた設定ファイルの構文解析に成功すると nginx: configuration file /etc/nginx/nginx.conf test is successful を表示します。curl は nginx のステータス行を返します。ここでの正しい結果は 502 Bad Gateway です。server block が有効で、ポート 9001 または 9002 で待ち受けているプロセスがないためです。sudo tail /var/log/nginx/error.log は、その理由を平易な言葉で connect() failed (111: Connection refused) while connecting to upstream と示します。
2 回目も実行して冪等性を確認する
ansible-playbook site.yml重要なのはこの実行です。1 回目の出力と 1 行ずつ比較してください。テンプレートタスクは、1 回目に changed: と表示された箇所で、今度は ok: と表示されるはずです。また、どちらの handler も出力中に表示されません。
この仕組みは単純ですが、デバッグの基準になるため理解しておく価値があります。template は controller 上でファイルを生成し、その結果の checksum を dest にすでに存在するファイルの checksum と比較します。内容、所有者、mode が一致していれば実行する処理はないため、タスクは ok を報告します。そのため notify は発生せず、handler も実行されません。handler が実行されるのは changed の場合だけです。
逆方向も確認してください。vars の weight: 3 を weight: 1 に変更して playbook を再度実行します。テンプレートタスクは changed を報告し、両方の handler が実行され、sudo cat /etc/nginx/conf.d/learn.conf に新しい値が表示されます。
同じ内容で 2 回目も変更が報告される場合、生成結果が安定していません。まず、出力に時刻などに依存する値が含まれていないか確認してください。これはよくある原因で、カスタマイズした ansible_managed が通常の原因です。次に、タスクの mode と owner が実際にディスク上にある状態と一致しているか確認します。ここが一致していなければ、バイト列が同一でも変更として扱われます。
変更前に差分を確認する
ansible-playbook site.yml --check --diff--check はホストを変更せずに play を実行します。--diff は各タスクで変更される内容を出力します。template の場合、render 結果とディスク上のファイルとの差分を行単位で表示します。これらを組み合わせると、実際に変更を適用せずに「この実行で何が起きるか」を確認できます。Check mode 自体にも注意点があります。特に、結果が前のタスクに依存し、そのタスクが check mode では実際に実行されない場合に注意が必要です。
ハンドラーが play の最後まで待機する理由
handlers のドキュメントには、明確に記載されています。「デフォルトでは、ハンドラーは特定の play のすべてのタスクが完了した後に実行されます。通知されたハンドラーは、次の各セクションの後に、次の順序で自動的に実行されます。pre_tasks、roles/tasks、post_tasks。」
理由は、まとめて処理するためです。1 つのサービス用に 4 つの設定ファイルを生成する play では、4 つすべてのファイルを配置した後、最後にそのサービスを 1 回だけ再起動するのが適切です。各ファイルの後に再起動すると、合計 4 回の再起動が発生し、そのうち 3 回では未完成の設定が読み込まれます。同じページには、この保証も明記されています。「同じハンドラーを複数回通知しても、通知したタスクの数に関係なく、ハンドラーは 1 回だけ実行されます。」
実行順序も固定されています。「ハンドラーは notify ステートメントに記載された順序ではなく、handlers セクションで定義された順序で実行されます。」そのため、この playbook では Test the nginx configuration が Reload nginx より上に記述されています。テストの定義が先に記述されているため、テストが先に実行されます。notify 行の内容によって、この順序が変わることはありません。
ハンドラーを早期に実行する方法と、失敗後に実行する方法
同じ play の後続タスクで、新しい設定を読み込んだサービスがすでに起動している必要が生じる場合があります。その時点で、meta module を使用して通知済みのハンドラーを実行します。ドキュメントでは、meta module について「それまでに通知されたハンドラータスクを Ansible が実行する」と説明しています。
- name: Run the notified handlers now instead of at the end of the play
ansible.builtin.meta: flush_handlers
- name: Wait for the new listener to accept connections
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 10この meta 行を削除すると、wait_for task は nginx が古い設定のままサービスを提供している状態で実行されます。初回実行時は、port 8080 でまだ待ち受けているプロセスがないため、task は 10 秒間待機した後に失敗します。
2 つ目は失敗が発生した場合です。「task がハンドラーを通知した後、play の後続タスクが失敗すると、デフォルトではその host 上でハンドラーは実行されません。その結果、host が予期しない状態になることがあります。」設定を生成した後に無関係な task で失敗する play では、ディスク上の新しいファイルが残る一方、実行中のサービスには古い設定が読み込まれたままになります。コマンドラインでは --force-handlers、play では force_handlers: true を指定して、この動作を変更します。同じ切り替えは ansible.cfg の [defaults] では force_handlers = True として、環境変数では ANSIBLE_FORCE_HANDLERS として指定できます。デフォルト値は False です。
Handler 名が衝突しても、負けた側は何も通知しない
ドキュメントには次の規則が記載されています。「各 handler には、全体で一意の名前を付ける必要があります。同じ名前の handler が複数定義されている場合、play に最後に読み込まれた handler だけが通知され、実行されます。」role 内で定義した handler も、その role に限定されるわけではありません。play 全体で共有する 1 つのグローバルな handler リストに追加されます。そのため、2 つの role がそれぞれ Restart nginx を定義すると、名前から解決できるのはどちらか 1 つだけになり、通知元の role ではなく、読み込み順によって実行対象が決まります。
この規則に依存する前に、実際に確認してください。次の内容を handlers-dup.yml として保存します。
- name: Two handlers, one name
hosts: local
gather_facts: false
tasks:
- name: Notify the duplicated name
ansible.builtin.command:
cmd: /bin/true
changed_when: true
notify: Duplicated handler
handlers:
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-first
state: touch
mode: '0644'
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-second
state: touch
mode: '0644'rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-secondplay は成功し、RUNNING HANDLER [Duplicated handler] は 1 回だけ表示されます。また、ls は一方の /tmp/dup-first と、もう一方の ls: cannot access '/tmp/dup-second': No such file or directory の行を出力します。実行された handler は最後に読み込まれたものではなく、先に記述したものです。これは、先ほどの文が予測する動作とは逆です。
この違いは理解しておく必要があります。ドキュメントの規則が対象としているのは、ファイル内の行ではなく handler ブロックだからです。1 つの role、続いて別の role から読み込まれる handler は別々のブロックです。そのため、後のブロックが前のブロックを隠します。一方、play 内の通常の handlers: リストは 1 つのブロックです。ブロック内の検索は上から下へ進み、最初に名前が一致した時点で停止します。そのため、1 つのファイル内では最初の定義が使用され、2 番目の定義には到達できません。role 間では、ドキュメントの説明どおりに後の定義が前の定義を隠します。どちらの場合も両方の handler を実行することはできず、どちらの動作も前提にすべきではありません。
解決方法は 2 つあります。各 handler 名に、その role 固有のプレフィックスを付けます。または、修飾形式の role_name : handler_name を通知します。ドキュメントでは、これを「role 外に同じ名前の handler がある場合でも、role 内の handler に通知されることを保証する」方法として説明しています。コロンの前後にあるスペースも、この構文の一部です。自分で作成していない role を取り込み始めると、すぐにこの問題が発生します。
同じページには、もう 1 つ規則があります。「handler 名に変数を設定しないでください。handler 名は早い段階でテンプレート処理されるため、このような handler 名に使用する値を Ansible が取得できない場合があります。」Restart {{ service_name }} という handler は、名前のテンプレート処理時にその変数が未定義だと、play 全体を失敗させます。handler 名を固定文字列にし、listen でグループ化すれば、この問題を避けられます。
validate: 壊れた設定の配置を拒否する
validate は、Ansible がレンダリングしたファイルを配置先へ移動する前に、そのファイルに対してコマンドを実行します。ドキュメントには次のように記載されています。「更新後のファイルを最終的な配置先へコピーする前に実行する検証コマンドです。検証には一時ファイルのパスが使用されます。このパスは %s を通じて渡されるため、以下の例のように指定する必要があります。また、コマンドは安全に渡されるため、展開やパイプなどの shell 機能は使用できません。」
この説明から、2 つのルールがそのまま導かれます。%s は必須です。これを含まない validate 文字列を指定すると、タスクは validate must contain %s で失敗します。また、shell は使用されないため、パイプ、リダイレクト、glob、&& は機能しません。指定できるのは、1 つのコマンドと 1 つのファイル引数です。
公式の module 例には、この方法が完全に機能する 2 つのケースが示されています。
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: /mine/sudoers
dest: /etc/sudoers
validate: /usr/sbin/visudo -cf %s
- name: Update sshd configuration safely, avoid locking yourself out
ansible.builtin.template:
src: etc/ssh/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
backup: yesどちらも、各チェッカーが 1 つのファイルを受け取り、その内容を個別に検証するため機能します。visudo -cf は sudoers ファイルを読み取ります。sshd -t -f は完全な sshd_config を読み取ります。
このガイドでvalidateがnginxファイルを検証できない理由
テンプレートタスクにvalidate: /usr/sbin/nginx -t -c %sを追加すると、タスクは失敗します。メッセージには原因が示されます。
nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2nginx -t -cは、最上位にeventsブロックとhttpブロックがある完全な設定を想定しています。このplayが生成するファイルはフラグメントで、/etc/nginx/nginx.conf内のinclude /etc/nginx/conf.d/*.conf;によってhttpブロックに取り込まれます。そのコンテキストから切り離して単独で扱うと、upstreamは実際に不正な場所にあるディレクティブです。そのため、実際の配置では完全に正しいファイルでも、nginxは拒否します。検証ツールにフラグメントを渡し、完全な設定として扱わせていたことが原因です。
実用的な解決策は、playbookですでに採用されています。フラグメントを配置し、reload handlerより前に定義したhandlerで、組み立て済みの設定を検証します。handlerは定義順に実行されるため、nginx -tはフラグメントを含む実際の/etc/nginx/nginx.confを検証します。そこで失敗すれば、systemctl reloadが呼び出される前にplay全体が失敗します。ただし、代償を明確に理解しておく必要があります。この検証が失敗した時点では壊れたファイルがディスク上に存在し、nginxは誰かが再起動するまで、最後に読み込んだ設定でサービスを提供し続けます。
このため、backup: trueが必要になります。これは上書き前に以前のファイルのコピーを元のファイルと同じディレクトリへ作成し、basename.PID.YYYY-MM-DD@HH:MM:SS~という名前を付けます。その結果、ディレクトリにはlearn.conf.4127.2026-08-20@11:42:09~のようなエントリが残ります。変更後にsudo ls -l /etc/nginx/conf.d/を実行すると、その1つを確認できます。
この命名の詳細は、見た目以上に重要です。メイン設定がconf.d/*.confだけをincludeし、バックアップ名がチルダで終わるため、/etc/nginx/conf.d/ではバックアップは問題になりません。一方、裸の*でincludeするディレクトリでは問題になります。DebianとUbuntuでは、/etc/nginx/nginx.confがまさにその方法で/etc/nginx/sites-enabled/*をincludeします。backup: trueを使用してsites-enabledへテンプレートを配置すると、nginxはバックアップを2つ目の稼働中のserver blockとして読み込みます。そのため、このplayは代わりにconf.dへ書き込みます。
実際のインベントリホストに同じ play を実行する
hosts: localを使用するグループ名に変更するだけで、play 内の他の部分は変わりません。テンプレートはホストごとに1回レンダリングされるため、app_listen_portとapp_backendsにはgroup_varsとhost_varsの値を使用できます。テンプレートファイル自体は1つのままです。値をファイル内に直接記述せず、変数に置く利点はここにあります。
変更が必要なのは2点です。become: trueでは、対象ホストで passwordless sudo を使用できない限り、各ホストの sudo パスワードが必要になります。そのため、-Kを追加します。また、そのテンプレート内のデータベースパスワードや API トークンなどの Secret を、コミットするファイルの vars: に平文で保存してはいけません。これらの値を Ansible Vault で暗号化するうえで、現在と同じように名前で参照してください。テンプレートは変数の取得元を認識する必要がないためです。
play が複数のサービスを扱うようになれば、vars:、templates/、handlers:には標準的な配置先がすでに用意されています。これらをそこへ移すことが、playbook と role を分ける目的です。
FAQ
Ansible の handler が実行されないのはなぜですか?
ほとんどの場合、handler を通知する task が ok ではなく changed を報告したことが原因です。handler は変更があった場合にだけ実行されます。そのため、render 結果がディスク上の既存ファイルと一致する template task は、何も通知しません。次に、4 点を確認します。notify の文字列は、handler の name または listen topic と、大文字・小文字や空白を含めて完全に一致している必要があります。その host で後続の task が失敗すると、--force-handlers を渡さない限り、通知済みの handler は抑止されます。別の play で定義した handler は、この play から参照できません。また、when 条件によって通知元の task が skip された場合、handler は通知されません。
playbook が毎回 changed を報告するのはなぜですか?
render されたテキストが実行ごとに一定ではありません。最も一般的な原因は出力内の timestamp です。日付を含むカスタマイズした ansible_managed 文字列も、同じ問題を起こします。次に、task の mode と owner を確認します。これらがディスク上の既存ファイルと一致しない場合、内容が同じでも Ansible はそれらを修正し、変更を報告します。ansible-playbook site.yml --check --diff を実行して、どちらが原因かを確認してください。--diff には、task が加えようとしている差分が表示されます。
Ansible の template と copy の違いは何ですか?
ansible.builtin.copy はファイルを変更せずに送信します。ansible.builtin.template は controller 上で最初に Jinja2 を通して render してから結果を送信するため、ファイルが target host に到達する前に変数と loop が解決されます。どの host でも byte 単位で同一のファイルには copy を使用します。host ごとに内容が変わるファイルには template を使用します。両者は同じ file option を使用するため、mode、owner、backup、validate はどちらでも同じように機能します。
play の途中で handler を実行するにはどうすればよいですか?
実行したい位置に ansible.builtin.meta: flush_handlers を task として追加します。これまでに通知されたすべての handler を実行してから、play は通常どおり続行されます。後続の task が、更新された設定でサービスがすでに実行されていることを必要とする場合に使用します。たとえば、reload 後にだけ存在する port に対する wait_for などです。play の終了前に handler を実行する場合にサポートされている方法です。
nginx の config fragment で validate を使用できますか?
nginx -t -c %s では使用できません。この command は、最上位の events および http block で始まる完全な configuration を想定しています。そのため、conf.d fragment を指定すると、"upstream" directive is not allowed here のような message で拒否されます。その fragment は http block 内では有効ですが、単独では無効です。ファイルを install し、reload handler より前に定義した handler で、組み立て済みの configuration に対して nginx -t を実行します。handler は定義された順序で実行されるため、configuration に問題があると reload の前に play が失敗します。template task に backup: true を設定し、復元できるように以前のファイルを残します。