AnsibleXLIV · Failure Modes and Partial Fleet FailureFailure Modes and Partial Fleet Failure
Why unreachable hosts are the dangerous ones
What you'll learn
- Distinguish the three sub-populations inside a set of unreachable hosts
- Demonstrate why a controller-side pre-flight cannot prove reachability
- Explain what ignore_unreachable changes in the recap and the exit code
- Produce a quarantine list as the required artefact of an unreachable population
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
The thirty failures got the attention. They printed error messages, they have stack traces, they are interesting.
The fifty unreachable hosts printed one line each and then stopped existing as far as the run was concerned. They are the ones that will still be a problem in a month.
An unreachable host is a host you have not changed
That sentence is the whole lesson, and everything else is consequences of it.
The fifty are not a reporting problem. They are fifty production machines running the configuration you decided last night was no longer acceptable. Nothing about the run altered them. Nothing about the run put them on a list. When the run finished and the exit code came back, they went straight back to being ordinary production hosts that nobody is looking at — running the old config, with the old package versions, without the security fix.
The next time anyone notices them is when something depends on the change having landed everywhere. That is not a hypothetical: it is the standard shape of the incident. A certificate rotation misses forty hosts; six weeks later the old certificate expires; forty hosts fail simultaneously and the change record says the rotation completed.
The fifty are three different populations
Before deciding anything, split them.
Off, or genuinely down. The host is powered off, decommissioned, or crashed. Consistent, stale, harmless right now. The action is to find out whether it should exist at all — a stable population of unreachable hosts is usually an inventory that has drifted from reality.
Reachable, but not by you, right now. DNS is broken, a firewall rule changed, the bastion is saturated, your key was rotated out, the host key changed. The host is up and serving traffic. It is running the old config while looking perfectly healthy to everything except Ansible. This is the largest bucket in most real incidents and the most dangerous, because monitoring says the host is fine.
Unreachable because of the change. This is the one that turns a
partial failure into an outage. The play reconfigured the firewall,
restarted the network, rotated an SSH key, changed sshd_config, or
rebooted — and the host is now unreachable as a direct result of a
task that succeeded.
The pre-flight check that proves nothing
The natural response to fifty unreachable hosts is to add a reachability check before the next run. The natural way to write it is wrong, and this is worth executing because the failure is completely silent.
$ ansible-playbook -i inv.ini controller-only.yml ; echo exit=$?PLAY [A pre-flight built from controller-side modules] *************************
TASK [Report in] ***************************************************************
ok: [lh] => {
"msg": "reporting from lh"
}
ok: [web01] => {
"msg": "reporting from web01"
}
ok: [web02] => {
"msg": "reporting from web02"
}
PLAY RECAP *********************************************************************
lh : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
exit=0Both remote hosts are unreachable. Both report ok=1, both report
unreachable=0, and the run exits 0. The play cheerfully printed
“reporting from web01” for a host that does not answer.
The reason is that ansible.builtin.debug is an action plugin that
executes on the controller. It never opens a connection, so it can
never discover that a connection is impossible. The same is true of
ansible.builtin.fail, ansible.builtin.set_fact, and assert where
the condition needs no facts.
$ ansible-playbook -i inv.ini unreach-only.yml ; echo exit=$?PLAY [Unreachable only] ********************************************************
TASK [Something that needs a connection] ***************************************
fatal: [web01]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to
connect to the host via ssh: ssh: connect to host 192.0.2.11 port 22: Connection
timed out", "unreachable": true}
fatal: [web02]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to
connect to the host via ssh: ssh: connect to host 192.0.2.12 port 22: Connection
timed out", "unreachable": true}
PLAY RECAP *********************************************************************
web01 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
exit=4- name: Establish which hosts are actually reachable
hosts: appservers
gather_facts: false
any_errors_fatal: false
tasks:
- name: Prove transport, auth and remote Python
ansible.builtin.ping:
ignore_unreachable: true
register: reachability
- name: Record the hosts that did not answer
ansible.builtin.copy:
content: "{{ ansible_play_hosts_all
| difference(ansible_play_hosts)
| join('\n') }}\n"
dest: ./artifacts/unreachable-{{ ansible_date_time.iso8601_basic_short
| default('run') }}.txt
mode: '0644'
delegate_to: localhost
run_once: trueNote what this play does with ignore_unreachable: it uses it once,
in a play whose only purpose is to measure, and then writes the result
down. That is the only defensible use of the keyword, and the next
section is about what happens when it escapes into a change play.
ignore_unreachable converts an unknown into a silent one
The keyword exists, it is tempting, and at fleet scale it is close to indefensible. Verified on 2.21.3:
$ ansible-playbook -i inv.ini ignunreach.yml ; echo exit=$?TASK [Connect and report] ******************************************************
fatal: [web01]: UNREACHABLE! => {"changed": false, "msg": "...Connection timed
out", "unreachable": true}
...ignoring
TASK [Say we are done] *********************************************************
ok: [web01] => {
"msg": "change applied to web01"
}
ok: [web02] => {
"msg": "change applied to web02"
}
PLAY RECAP *********************************************************************
lh : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web01 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1
web02 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1
exit=0Read that recap carefully. For a host that was never contacted:
unreachable=0. The counter that exists to report this condition reports zero.ok=2. Higher than the number of tasks that could possibly have run on it.ignored=1— the only trace, and it is in the column nobody watches.- The play printed “change applied to web01” for a host it never reached.
- The run exited
0.
There is a narrower, legitimate use: a task that is expected to make a
host unreachable, such as a reboot, followed by wait_for_connection.
There the unreachability is the intended outcome of a specific task and
the following task proves recovery. That is a scoped ignore_unreachable
on one task, not a play-level policy, and it is the shape to insist on
in review.
The quarantine list is the required output
An unreachable population is not resolved by understanding it. It is resolved by a list that outlives the incident.
The list needs four fields per host, and the fourth is the one that makes it work:
| Field | Why |
|---|---|
| Hostname | Obvious, and must come from the run artefact, not from memory |
| Error text | The taxonomy: timed out, refused, permission denied, host key mismatch |
| The change it did not receive | So the reconciliation is specific, not “run the playbook again some day” |
| Owner and a date | An entry with no owner is a note; an entry with an owner is work |
log=/var/log/ansible/patch-2026-08-11.log
printf 'host,error,change,owner,due\n' > quarantine-2026-08-11.csv
grep 'UNREACHABLE!' "$log" \
| sed -E 's/^fatal: \[([^]]+)\].*"msg": "([^"]+)".*/\1,"\2"/' \
| sed 's/$/,patch-2026-08-11,,/' \
>> quarantine-2026-08-11.csv
wc -l quarantine-2026-08-11.csvKnowledge check
Knowledge check · 4 questions
Q1. A pre-flight play whose only task is a debug message is a valid way to confirm that every host in a group can be reached before a change run.
Q2. Fifty of 300 hosts came back unreachable from a play that reconfigured the host firewall. Which observation would most change your immediate action?
Q3. On ansible-core 2.21.3, a play with ignore_unreachable: true runs against a host that cannot be contacted. Which of these appear in the output? Select all that apply.
Q4. What is the required output of triaging a population of unreachable hosts?
Passing score: 75%. Answers are checked in this browser.