Skip to main content
RunBook Academy

AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity

Triaging unreachable hosts

Advanced⏱ ~24 minansible-coreopenssh-client

What you'll learn

  • Map each SSH error string to the layer that produced it and the evidence that confirms it
  • Separate a controller-side configuration failure from a genuine network or host failure
  • Decide whether a partial unreachable set is a network event or a fleet event, from correlation rather than count
  • Choose whether to continue, stop or re-run based on what state the fleet was left in

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 playbooks part of this course established what unreachable means as a result: a distinct outcome from failed, counted in its own recap column, and reported through its own exit code — 4, which takes precedence over the 2 that a task failure produces.

This lesson is about what to do when you have some.

The framing that makes this tractable: unreachable is a statement about the controller’s knowledge, not about the host’s health. It means “I could not establish a working session”. A perfectly healthy machine is unreachable if DNS is wrong. A machine that is on fire is not unreachable until it stops answering.

Read the error text; it is a taxonomy

Ansible passes through the SSH client’s own message, and the messages are precise. This is the highest-value habit in the part, and most people skim straight past it to the recap.

Read-only / Safethe message is in the result
$ ansible-playbook -i inv.ini ping.yml
fatal: [web1.example.com]: UNREACHABLE! => {"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh:
ssh: connect to host 192.0.2.10 port 22: Connection timed out",
"unreachable": true}

That specific string carries information. Compare it against its neighbours:

Error textWhat produced itWhat it meansFirst check
Connection timed outnothing answeredhost down, firewall DROP, or wrong addressis the address right; is the host powered
Connection refusedthe host answered, nothing listeninghost is up, sshd is notsshd status, port, listen address
No route to hosta router sent ICMP unreachablerouting or a REJECT rulerouting table, firewall policy
Name or service not knownresolution failedDNS, or a name that only exists in someone’s hosts fileansible_host, resolver, search domain
Permission denied (publickey)SSH workedthe network is fine; authentication failedkey, ansible_user, authorized_keys
Host key verification failedSSH workedthe key does not match what is recordedis this a rebuild or an impersonation
ControlPath too longthe client, locallycontroller-side configuration errornothing left the controller

The three rows in bold are the ones that change the shape of an incident.

Connection refused versus Connection timed out is the single most useful distinction on the list, and they are constantly treated as the same thing. Refused means a TCP stack answered — the machine is up, on the network, and reachable; only sshd is missing. Timed out means nothing came back at all. One is a service problem on a live host; the other is a host or path problem. Confusing them sends you to the wrong team.

Permission denied and Host key verification failed mean the network is working perfectly. Connectivity was established, the protocol negotiated, and the far end made a decision. Anyone who responds to those by investigating firewalls is investigating something they have already proven is fine.

Separating controller-side failures from real ones

Some UNREACHABLE results never involved the network at all. ControlPath too long is the clearest example — the client refuses before sending a packet — but a broken ProxyJump, an unreadable key file, or an unparseable option all produce the same outcome.

The discriminator is fast:

Read-only / Safedoes it fail for every host, instantly?
# A controller-side error fails all hosts, and fails immediately -
# there is no ConnectTimeout to wait out.
time ansible -i inventory all -m ansible.builtin.ping --limit 'web1*'

A genuine network timeout takes ConnectTimeout seconds — ten, by default — to give up. A controller-side configuration error comes back instantly, and it comes back for every host, including ones you know are healthy. If your entire inventory went unreachable in under a second, stop looking at the fleet.

Then read the command line and run it yourself:

Read-only / Safethe two-step every diagnosis starts with
# 1. What did Ansible actually build?
ansible-playbook -i inventory site.yml --limit web1.example.com -vvvv \
| grep 'SSH: EXEC'

# 2. Run that same command by hand, as the account that runs the automation,
#    with SSH's own verbosity.
ssh -vvv -o ConnectTimeout=10 -o PasswordAuthentication=no web1.example.com 'echo ~'

If step 2 fails the same way, it is not an Ansible problem and reading your playbook is wasted time. If step 2 succeeds, the difference is between your environment and the one Ansible built — a variable resolving to an unexpected ansible_host, a group_vars file supplying an ansible_user you forgot about, an ansible_ssh_common_args from an inventory source you did not know was loaded.

Forty of four hundred: the judgement call

The recap says unreachable=40 out of 400. The run is half done. What now?

The instinct is to look at the number. The number tells you how bad it is; it does not tell you what it is. Correlation tells you what it is, and you can get it in one command.

Read-only / Safeturn 40 names into a pattern
# Which groups do the unreachable hosts belong to?
for h in $(cat unreachable.txt); do
ansible-inventory -i inventory --host "$h" | grep -o '"group_names".*'
done | sort | uniq -c | sort -rn

# What addresses are they, and do they share a subnet?
ansible-inventory -i inventory --list | \
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["_meta"]["hostvars"])'

Four correlations, and each points somewhere different:

They share a routing group. Every unreachable host is behind the same bastion, and hosts reached by other paths are fine. This is a path event. Check the bastion before anything else — and if the number is varying between runs with a different subset each time, revisit the MaxStartups signature from the bastion lesson, because that is not an outage at all.

They share a subnet, rack or site. A network event. Your automation has detected it, which is genuinely useful information for somebody, but it is not your problem to fix from here.

They share a role or a build generation. Now it is interesting. All the database hosts, or everything built before a certain date, becoming unreachable together suggests a change that applied to that set — a configuration push, an image rollout, a firewall rule scoped to a role. Ask what else targets that same set.

No correlation at all. Scattered across groups, sites and roles. This is the shape of a controller-side or credential problem, or of a random drop like MaxStartups. Genuinely uncorrelated host failures are rare; scattered results usually mean the common factor is on your side.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A host reports UNREACHABLE with Connection refused. What has that already told you?

  2. Q2. Forty of four hundred hosts came back unreachable. What should you establish first?

  3. Q3. Which observations indicate a controller-side configuration error rather than a network or host problem? Select all that apply.

  4. Q4. A host that was marked unreachable early in a play rejoins that play automatically once it recovers.

Passing score: 75%. Answers are checked in this browser.