Running handlers on each iteration in the included role

George Shuklin
OpsOps
Published in
2 min readAug 9, 2024

Warning: using includes is a bad idea. If you can unroll loop statically, do it statically. Include is a last ‘hope’ for situations when you no longer have other options.

Running per-service handlers, where handlers are per list, is one of them.

Reader discretion is advised.

The problem: You have a list of templatized systemd units. Our systemd template is llm@.service, and we want to run it for aribtrary list of parameters to that template: llm@gpt.service, llm@gemini.service, llm@llama.service… you got the gist.

Let’s say each of them is dependent on a config: /etc/llm/%i.conf

You want to restart service if config changed.

Tasks:

- name: Configure llm {{ llm }}
template:
src: llm.conf.j2
dest: /etc/llm/{{ llm }}.conf
notify: Restart llm

And a handler:

- name: Restart llm
systemd:
name: llm@{{ llm }}.service
state: restarted

If you run it as a play:

- name: LLM
hosts: llm
tasks:
- include_role: llm
vars:
llm: "{{ item }"
loop: "{{ llms }}"

It will configure all LLMs, but will trigger handler only for the last LLM, because handlers are run once, even if they are inside ‘include_role’. I don’t remember when this had changed, but I remember older version, actually, had run handlers on each iteration. Newer versions (e.g. 2.17) are running it once.

Solution

Well, the main advantage of include, is that it allows to use dynamic variables, which are interpolated at the moment of inclusion.

Which gives us the perfect opportunity to use it to ‘multiple’ handlers:

Tasks:

- name: Configure llm {{ llm }}
template:
src: llm.conf.j2
dest: /etc/llm/{{ llm }}.conf
notify: Restart llm {{ llm }}

Handler:

- name: Restart llm {{ llm }}
systemd:
name: llm@{{ llm }}.service
state: restarted

When Ansible includes role, it will parametrize both task and handler with current value for llm, so, following handlers will run at the end (for my initial example of llms):

RUNNING HANDLER [llm : llm gpt]
changed: [localhost] => {
"service": "llm@gpt.service"
}
RUNNING HANDLER [llm : llm gemini]
changed: [localhost] => {
"service": "llm@gemini.service"
}
RUNNING HANDLER [llm : llm llama]
changed: [localhost] => {
"service": "llm@llama.service"
}

Afterword

I want to emphasize, that using includes without justification is a bad idea for Ansible. Ansible already had pretty weak type system, and it gives very low static assurances. Introduction an additional dynamic part reduces this even more. Any subtle bug with naming, variable values, especially, with non-trivial Jinja, will cause very late runtime bugs, and with dynamically generated code (which includes are under the hood) they become much harder to analyze. In static code it’s possible to reduce problematic playbook to MVP, minimal code to show behavior. For dynamic code this is much harder, because you need not only replicate problematic code, but all pre-conditions, leading to a specific parametrization.

--

--

George Shuklin
OpsOps

I work at Servers.com, most of my stories are about Ansible, Ceph, Python, Openstack and Linux. My hobby is Rust.