AnsibleXVI · HandlersHandlers
The handler that never ran
What you'll learn
- Explain why a failed host does not run its notified handlers
- Use force_handlers and --force-handlers, and state what they do not fix
- Flush handlers inside a rescue block so a guarded failure still applies configuration
- Find hosts left with new configuration on disk and old configuration in memory
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A notification is a promise to do something at the end of the section. A host that fails before reaching the end of the section never keeps it.
The resulting state is specific and it is worth being able to describe
precisely: the new configuration is on disk, the running process is
still holding the old configuration, and the recap reports that host as
failed for an entirely different reason. Nobody reading the recap will
connect failed=1 on a package task to a reverse proxy that is now
running configuration from three weeks ago.
Watching it happen
- name: A failure between the notify and the flush
hosts: local
gather_facts: false
tasks:
- name: Deploy the configuration
ansible.builtin.debug:
msg: config written
changed_when: true
notify: Reload the service
- name: A later task fails
ansible.builtin.fail:
msg: something went wrong
handlers:
- name: Reload the service
ansible.builtin.debug:
msg: RELOADED$ ansible-playbook -i inv.ini fail.ymlTASK [Deploy the configuration] ************************************************
changed: [localhost]
TASK [A later task fails] ******************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "something went wrong"}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0changed=1 and failed=1. The change happened; the consequence of the
change did not. There is no counter anywhere for “handlers that were
queued and discarded”, and no line of output that would let you tell
this run apart from one where the failing task came first.
force_handlers
Ansible provides a switch for exactly this: run the notified handlers on a host even after that host has failed.
It exists in three forms, all equivalent:
| Form | Scope |
|---|---|
--force-handlers | one run |
force_handlers: true (play keyword) | one play |
force_handlers = True in ansible.cfg [defaults], or ANSIBLE_FORCE_HANDLERS=1 | every run, via DEFAULT_FORCE_HANDLERS |
$ ansible-playbook -i inv.ini fail.yml --force-handlersTASK [Deploy the configuration] ************************************************
changed: [localhost]
TASK [A later task fails] ******************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "something went wrong"}
RUNNING HANDLER [Reload the service] *******************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0The host is still reported failed, which is correct — something did go wrong. But the configuration it wrote is now live.
Is forcing the right thing?
Not always, and this is a genuine judgement call rather than a setting with a correct value.
Forcing is usually right when the handler applies configuration that
is already on disk. The file was written; leaving the service out of
step with it is worse than the failure that stopped the play. This
covers the common case — a template task succeeded, a later package
task failed on a repository timeout, and the reload should still happen.
Forcing is wrong when the handler is the dangerous half of the operation and the failure is evidence that the host is not in a fit state for it. A play that writes a new cluster configuration, fails validating quorum, and then restarts the cluster daemon anyway has taken a bad situation and made it an outage. Here the queued restart is the thing you want to not happen.
The distinguishing question: if this host is broken, does running the handler make it better or worse? A reload of already-written config is usually better. A restart of a service whose preconditions just failed to verify is usually worse.
The precise tool: flushing inside a rescue
force_handlers is a blunt instrument: every notified handler, on every
failed host, regardless of what failed. A block/rescue gives you the
same outcome scoped to a specific failure.
- name: Deploy the reverse proxy configuration
hosts: webservers
become: true
tasks:
- name: Configure and verify
block:
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
mode: '0644'
validate: nginx -t -c %s
notify: Reload nginx
- name: Confirm the upstream is reachable from this host
ansible.builtin.wait_for:
host: '192.0.2.40'
port: 8080
timeout: 10
rescue:
- name: Apply the configuration that was already written
ansible.builtin.meta: flush_handlers
- name: Record that this host needs attention
ansible.builtin.debug:
msg: 'Upstream check failed on {{ inventory_hostname }}; configuration applied, host needs review'
handlers:
- name: Reload nginx
ansible.builtin.systemd_service:
name: nginx
state: reloadedFinding the hosts afterwards
You now have a fleet where some unknown subset of hosts has new configuration on disk and old configuration in memory. The recap does not tell you which. Two approaches, and the second is the one that scales.
Compare loaded configuration against on-disk configuration. Most services can be asked what they actually loaded, and the answer is authoritative in a way the file is not.
- name: Find hosts whose running config differs from the file on disk
hosts: webservers
gather_facts: false
tasks:
- name: Dump the configuration nginx actually loaded
ansible.builtin.command: nginx -T
changed_when: false
register: loaded
- name: Report hosts missing the expected directive
ansible.builtin.debug:
msg: '{{ inventory_hostname }} has not loaded the new listener'
when: "'listen 8443' not in loaded.stdout"Compare service start time against file modification time. More general, because it needs no per-service knowledge: if the configuration file is newer than the process that reads it, the process has not reloaded since the file changed.
- name: Find services older than their configuration
hosts: webservers
gather_facts: false
tasks:
- name: Stat the configuration file
ansible.builtin.stat:
path: /etc/nginx/conf.d/site.conf
register: cfg
- name: Read when the unit last entered the active state
ansible.builtin.shell: >-
date -d "$(systemctl show nginx --property=ActiveEnterTimestamp --value)" +%s
changed_when: false
register: started
- name: Flag hosts whose configuration is newer than the running service
ansible.builtin.debug:
msg: '{{ inventory_hostname }}: config written after the last service start'
when:
- cfg.stat.exists
- (cfg.stat.mtime | int) > (started.stdout | int)Two caveats on that check. A reload does not always update
ActiveEnterTimestamp — for a unit that reloads in place, the active
state never changed — so this finds missed restarts reliably and
missed reloads only for services that restart to pick up
configuration. And a host whose file was written by something other than
Ansible will also be flagged, which is arguably a feature.
Treat it as a triage sweep that narrows a fleet to a candidate list, then confirm each candidate with the service’s own answer.
Knowledge check
Knowledge check · 4 questions
Q1. A template task deploys a new nginx config and notifies a reload. Three tasks later, a package task fails on that host. What state is the host in?
Q2. Which situation does force_handlers NOT improve?
Q3. A rescue block whose only task is meta: flush_handlers has which consequences? Select all that apply.
Q4. Under serial, a batch that fails discards only its own queued handlers; batches that already completed have flushed theirs.
Passing score: 75%. Answers are checked in this browser.