Skip to main content
RunBook Academy

AnsibleXVI · HandlersHandlers

The handler that never ran

Advanced⏱ ~21 minansible-playbook

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

Not yet marked complete on this device.

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

Read-only / Safea failure between the notify and the flush
- 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
Read-only / Safeexecuted on ansible-core 2.21.3
$ ansible-playbook -i inv.ini fail.yml
TASK [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=0

changed=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:

FormScope
--force-handlersone run
force_handlers: true (play keyword)one play
force_handlers = True in ansible.cfg [defaults], or ANSIBLE_FORCE_HANDLERS=1every run, via DEFAULT_FORCE_HANDLERS
Read-only / Safethe same play with --force-handlers
$ ansible-playbook -i inv.ini fail.yml --force-handlers
TASK [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=0

The 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.

Service impact possibleapply what was written, then handle the 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: reloaded

Finding 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.

Read-only / Safeask the process, not the filesystem
- 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.

Read-only / Safethe service-agnostic check
- 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

  1. 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?

  2. Q2. Which situation does force_handlers NOT improve?

  3. Q3. A rescue block whose only task is meta: flush_handlers has which consequences? Select all that apply.

  4. 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.