AnsibleXXXI · Serial Execution and Failure ToleranceFailure Tolerance
Unreachable is not failed
What you'll learn
- Distinguish unreachable from failed in the recap and in the exit code
- Explain why neither failure keyword responds to unreachable hosts
- Use ignore_unreachable and state what it changes about the recap
- Write a reachability pre-flight that gates a rollout before it starts
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 recap has separate columns for unreachable= and failed=, and the
distinction is not cosmetic. It runs all the way through the failure
machinery, and the previous two lessons kept bumping into it.
A failed host ran a task and the task returned an error. Something happened on the host, and Ansible knows what.
An unreachable host never ran anything. The connection could not be
established, so there is no task result, no changed state, no
information about the host at all beyond the fact that it did not
answer.
The failure keywords were built for the first case. They do not respond to the second, and this lesson is about what that costs.
Neither keyword sees it
Both results are verified, and both are worth seeing side by side because the keyword names promise otherwise.
$ ansible-playbook -i inv-unreach.ini rollout.ymlTASK [Touch the host] **********************************************************
ok: [h02]
ok: [h03]
fatal: [u01]: 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}
TASK [Return to service] *******************************************************
ok: [h02]
ok: [h03]
... every remaining batch ran to completion ...
PLAY RECAP *********************************************************************
h02 : ok=2 changed=0 unreachable=0 failed=0
h03 : ok=2 changed=0 unreachable=0 failed=0
h04 : ok=2 changed=0 unreachable=0 failed=0
h05 : ok=2 changed=0 unreachable=0 failed=0
h06 : ok=2 changed=0 unreachable=0 failed=0
u01 : ok=0 changed=0 unreachable=1 failed=0
exit=4max_fail_percentage: 0 means “abort on any failure”. One host in three
did not answer, and the rollout completed across the entire fleet.
any_errors_fatal: true does slightly more and still does not stop the
run: verified on 2.21.3, it cut the batch containing the unreachable
host — h02 and h03 never reached their final task and were dropped
from the rest of the playbook — but later batches ran to completion and
a subsequent play ran too.
Reading the recap
The recap is the authoritative account, and the two columns answer different questions.
$ ansible-playbook -i production.ini site.ymlPLAY RECAP *********************************************************************
web01 : ok=12 changed=3 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
web02 : ok=8 changed=1 unreachable=0 failed=1 skipped=2 rescued=0 ignored=0
web03 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
web04 : ok=5 changed=2 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0| Host | Reading |
|---|---|
web01 | completed the play, made three changes |
web02 | ran, something returned an error, stopped there |
web03 | never answered at all — ok=0 — so nothing on it was touched |
web04 | ran five tasks, changed two things, then became unreachable |
web04 is the row that matters. unreachable=1 with a non-zero ok=
and changed= count means the host was reachable, was modified, and
then went away — which is exactly what a reboot, a network
reconfiguration or a service restart that took out the SSH path looks
like.
A host that was changed and then stopped answering is the most urgent line in any recap. It is in an unknown state, the play did not finish its work there, and no failure keyword reacted to it.
ignore_unreachable
The keyword exists to say “a host not answering is acceptable here”, and what it does is narrower and more useful than it first appears.
$ ansible-playbook -i inv-unreach.ini ignore.ymlTASK [Touch] *******************************************************************
fatal: [u01]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to
the host via ssh: ...", "unreachable": true, "skip_reason": "..."}
ok: [h02]
ok: [h03]
TASK [After] *******************************************************************
ok: [u01] => { "msg": "AFTER u01" }
ok: [h02] => { "msg": "AFTER h02" }
ok: [h03] => { "msg": "AFTER h03" }
PLAY RECAP *********************************************************************
u01 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1Two things changed, and the second is the one to notice.
The host stays in the play. Without the keyword, an unreachable host
is removed from the active host list and runs nothing further. With it,
the host continues to subsequent tasks — which is why u01 ran the
After task.
The recap reclassifies it. unreachable=0, failed=0, ignored=1.
The host no longer appears as unreachable at all.
That second point deserves suspicion. ignore_unreachable on a
connecting task does not merely tolerate the condition; it erases the
evidence of it from the summary. A reader of that recap sees a clean
run with one ignored task.
Gating reachability before the rollout
Since no failure keyword will do it, the control has to run first, in a play that can see the whole fleet.
The removal behaviour that makes unreachable hosts invisible to the
failure keywords is also what makes them easy to find: an unreachable
host is dropped from ansible_play_hosts but remains in
ansible_play_hosts_all. The difference between the two is exactly the
set of hosts that did not answer.
- name: Confirm the fleet is reachable before rolling anything
hosts: appservers
gather_facts: false
tasks:
- name: Contact every host
ansible.builtin.ping:
- name: Refuse to roll if any host did not answer
ansible.builtin.assert:
that: missing | length == 0
fail_msg: >-
Did not answer: {{ missing | join(", ") }}. Establish why before
rolling a change; a rollout will proceed past unreachable hosts
and leave them on the old version.
success_msg: 'All {{ ansible_play_hosts_all | length }} hosts answered'
vars:
missing: '{{ ansible_play_hosts_all | difference(ansible_play_hosts) }}'
run_once: true
- name: Roll the release
hosts: appservers
become: true
serial:
- 1
- 5
- 25%
max_fail_percentage: 0
tasks:
- name: Deploy and verify
ansible.builtin.include_tasks: deploy-one-host.yml$ ansible-playbook -i inv-unreach.ini preflight.ymlTASK [Contact every host] ******************************************************
ok: [h02]
ok: [h03]
ok: [h04]
ok: [h05]
ok: [h06]
fatal: [u01]: 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}
TASK [Refuse to roll if any host did not answer] *******************************
fatal: [h02]: FAILED! => {
"assertion": "missing | length == 0",
"msg": "Did not answer: u01"
}
--- control run, all six hosts reachable ---
TASK [Refuse to roll if any host did not answer] *******************************
ok: [h01] => { "msg": "All 6 hosts answered" }Three things make this work, and each of them is a decision:
No ignore_unreachable on the probe. The default removal is the
mechanism the check depends on: a host that answers stays in
ansible_play_hosts, a host that does not is dropped, and the set
difference is the answer. Adding ignore_unreachable here would keep
the unreachable host in the list and the assertion would always pass.
ansible_play_hosts_all as the baseline, because it is the only
variable unaffected by hosts dropping out.
A separate, unbatched play. A fleet-wide precondition cannot live inside the rolling play, because there it would only ever see one batch.
Note which host reports the assertion failure in the verified output:
h02, not u01. run_once executes on the first active host, and
u01 was no longer among them.
Why a blip looks like a fleet-wide failure
The diagnostic confusion this lesson exists to prevent: a transient
network problem produces a wall of red UNREACHABLE! lines that looks
identical to a catastrophic failure, and the instinct is to assume the
change caused it.
Three signals separate the two, all available in the run output:
ok=0 across the affected hosts. A host that was never contacted
ran nothing. If the unreachable hosts all have ok=0, the change cannot
have caused their state, because it never reached them. Compare web04
above, where ok=5 changed=2 unreachable=1 says the opposite.
The error text. Connection timed out is a network path problem.
Permission denied (publickey) is an authentication problem and
probably a change you made. Host key verification failed means the
host identity changed — a rebuild, or something worse.
The distribution. Unreachable hosts clustered in one rack, one subnet or one availability zone is infrastructure. Unreachable hosts scattered evenly across the fleet, appearing right after a batch that changed the SSH configuration, is you.
Knowledge check
Knowledge check · 4 questions
Q1. A recap line reads: web04 : ok=5 changed=2 unreachable=1 failed=0. What happened to this host?
Q2. A rolling play with serial and max_fail_percentage: 0 runs during a partial network outage that takes out one rack. What is true? Select all that apply.
Q3. Adding ignore_unreachable: true to a task makes the affected host appear in the recap with unreachable=1 so the condition remains visible.
Q4. Why must a reachability pre-flight live in its own unbatched play rather than at the top of the rolling play?
Passing score: 75%. Answers are checked in this browser.