The Ansible Handler Trap
When converting manual shell scripts into declarative Ansible playbooks, one of the most frustrating early pitfalls is the "Handler Trap".
It manifests as a playbook running successfully and reporting ok for all tasks, but the service you just configured doesn't restart, or the new settings never take effect.
How Handlers Work
In Ansible, a handler is a special task that is only triggered when explicitly notified by another task. They are designed to restart services or reload configurations only when something actually changes.
tasks:
- name: Update configuration file
ansible.builtin.template:
src: config.j2
dest: /etc/myapp/config.conf
notify: Restart MyApp
handlers:
- name: Restart MyApp
ansible.builtin.systemd:
name: myapp
state: restarted
In the example above, Restart MyApp will only run if the template task actually modified the file on the remote server.
The Trap: Re-running Playbooks
If a playbook fails halfway through its execution (for example, due to a missing sudo password or a network timeout), the earlier tasks might have successfully changed state and notified their handlers. However, because the playbook crashed before reaching the end, the handlers never executed.
When you fix the error and run the playbook a second time, Ansible evaluates the first task and sees that /etc/myapp/config.conf is already correct. It reports ok (instead of changed). Because the state didn't change during this specific run, the handler is never notified.
Your configuration is written to disk, but the service is still running with the old configuration in memory!
How to Escape the Trap
There are three ways to solve this when developing playbooks or recovering from a crash:
1. Force Handlers
If a playbook fails mid-execution, you can force Ansible to run any notified handlers immediately, regardless of failures, by running the playbook with the --force-handlers flag.
2. Ad-hoc Recovery
If you've already re-run the playbook and the state is now ok, use an ad-hoc Ansible command to manually fire the handler command across the target nodes:
3. Override Changed Status
If you use a module like ansible.builtin.command or ansible.builtin.shell, Ansible cannot know if the command actually changed anything, so it always registers as changed. Conversely, if you capture the output of a command and intentionally suppress the change status (changed_when: false), you might inadvertently prevent a handler from ever firing.
Always explicitly define when a command constitutes a change:
- name: Configure containerd for NVIDIA runtime
ansible.builtin.command: nvidia-ctk runtime configure --runtime=containerd --set-as-default
register: ctk_config
# This ensures the handler fires if the command succeeded,
# but it's prone to the Trap on subsequent runs!
changed_when: ctk_config.rc == 0
notify: Restart containerd