Skip to main content
RunBook Academy

AnsibleXXXI · Serial Execution and Failure ToleranceFailure Tolerance

Unreachable is not failed

Advanced⏱ ~24 minansible-playbook

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

Not yet marked complete on this device.

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.

Read-only / Safemax_fail_percentage: 0 with one unreachable host in a three-host batch
$ ansible-playbook -i inv-unreach.ini rollout.yml
TASK [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=4

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

Read-only / Safefour outcomes in one recap
$ ansible-playbook -i production.ini site.yml
PLAY 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
HostReading
web01completed the play, made three changes
web02ran, something returned an error, stopped there
web03never answered at all — ok=0 — so nothing on it was touched
web04ran 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.

Read-only / Safeignore_unreachable: true on the connecting task — executed on 2.21.3
$ ansible-playbook -i inv-unreach.ini ignore.yml
TASK [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=1

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

Read-only / Safea reachability pre-flight
- 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
Read-only / Safethe pre-flight, executed both ways on 2.21.3
$ ansible-playbook -i inv-unreach.ini preflight.yml
TASK [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

  1. Q1. A recap line reads: web04 : ok=5 changed=2 unreachable=1 failed=0. What happened to this host?

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

  3. Q3. Adding ignore_unreachable: true to a task makes the affected host appear in the recap with unreachable=1 so the condition remains visible.

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