AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management
Proving a host came back, not just answered
What you'll learn
- Distinguish reachability from readiness and from health, and validate each separately
- Read systemctl is-system-running states and know which of them are failures
- Verify mounts, services and cluster membership from facts rather than shell pipelines
- Place validation so that its failure stops the batch instead of being recorded and ignored
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
A host that answers SSH after a reboot has proven exactly one thing:
sshd started.
That is a low bar and it is the bar most patch plays stop at. The host
whose data volume failed to mount answers SSH. The host whose
application did not start answers SSH. The host that came up in
degraded state with a failed unit answers SSH. The host that rebooted
into the old kernel because the bootloader default was never updated
answers SSH, cheerfully, for months.
Validation is the step that turns “answered” into “working”, and it is the only thing standing between a rolling patch run and a rolling outage.
Three questions, in order
| Question | Answered by | What it proves |
|---|---|---|
| Is it reachable? | wait_for_connection, or the reboot module’s test_command | the transport works |
| Is it ready? | systemctl is-system-running, mounts, service_facts | the operating system finished booting correctly |
| Is it healthy? | an application probe, cluster membership | the host is doing its job |
They are strictly ordered, and each one failing means something different. Skipping to the third produces confusing failures: an application probe against a host that is still booting fails for reasons that have nothing to do with the application.
Readiness: systemctl is-system-running
The command reports one of eight states and exits zero for exactly one of them.
| State | Meaning | Exit code |
|---|---|---|
initializing | early bootup, before basic.target | non-zero |
starting | late bootup, job queue not yet idle | non-zero |
running | fully operational | 0 |
degraded | operational, but one or more units failed | non-zero |
maintenance | rescue or emergency target is active | non-zero |
stopping | the manager is shutting down | non-zero |
offline | the manager is not running | non-zero |
unknown | state could not be determined | non-zero |
degraded is the state this whole lesson exists for. The system is
operational. SSH works. Everything looks fine. One or more units failed
to start — and on a host you just patched, the unit that failed is
disproportionately likely to be the one whose package you just replaced.
The --wait option is important for post-reboot use: it blocks until
the boot process completes rather than reporting initializing or
starting. Without it, running the check too early returns a non-zero
state that means “not finished yet”, which is indistinguishable in the
exit code from “broken”.
- name: Wait for systemd to finish booting and report its state
ansible.builtin.command:
argv: [systemctl, is-system-running, '--wait']
register: system_state
changed_when: false
failed_when: false
- name: Refuse to continue with a system that is not fully running
ansible.builtin.assert:
that: system_state.stdout | trim == 'running'
fail_msg: >-
{{ inventory_hostname }} came back in state
'{{ system_state.stdout | trim }}' rather than 'running'.
Investigate with: systemctl --failed
success_msg: '{{ inventory_hostname }} reports running'Asserting on the stdout string rather than the exit code is
deliberate. Both give you pass or fail; only the string tells you which
of the seven failure states you are in, and degraded and maintenance
want very different responses.
Readiness: services, from facts rather than shell
ansible.builtin.service_facts populates ansible_facts.services. The
structure was confirmed by running it under ansible-core 2.21.3: a
dictionary keyed by service name, each value carrying name, source,
state and — for systemd units — status.
$ ansible -i localhost, -c local localhost -m service_facts"ModemManager.service": {
"name": "ModemManager.service",
"source": "systemd",
"state": "running",
"status": "enabled"
}
"NetworkManager.service": {
"name": "NetworkManager.service",
"source": "systemd",
"state": "stopped",
"status": "not-found"
}
"apparmor": {
"name": "apparmor",
"source": "sysv",
"state": "running"
}Two details in that output are worth carrying.
status: not-found with state: stopped. A unit name can appear in
the dictionary without the unit existing — systemd knows the name
because something references it. So a check written as “the key is
present and state is not running” will flag units that were never
installed. Check status too, or check for state == 'running'
directly.
source: sysv entries have no status key. A filter that assumes
status exists on every entry raises an undefined-attribute error on
the first host with a SysV init script. Use | default('').
- name: Collect service state
ansible.builtin.service_facts:
- name: Assert every service this host owns is running
ansible.builtin.assert:
that:
- ansible_facts.services[item] is defined
- ansible_facts.services[item].state | default('') == 'running'
fail_msg: >-
{{ inventory_hostname }}: {{ item }} is
'{{ ansible_facts.services[item].state | default("absent") }}'
after the reboot, not running.
loop: '{{ host_required_services }}'
loop_control:
label: '{{ item }}'host_required_services belongs in group_vars, next to the group that
defines what the host is for. A validation step that hard-codes nginx
is a validation step for one tier that gets copied and half-edited for
the next.
Readiness: mounts
A host that boots without one of its filesystems is the classic silent
post-reboot failure. The application starts, writes to what it thinks is
the data directory, and is actually writing to the root filesystem
underneath an empty mountpoint. Nothing errors. Disk usage on / climbs
for a week.
- name: Refresh facts after the reboot
ansible.builtin.setup:
gather_subset:
- '!all'
- '!min'
- hardware
- name: Assert every expected filesystem is mounted
ansible.builtin.assert:
that: >-
ansible_facts.mounts | selectattr('mount', 'equalto', item)
| list | length == 1
fail_msg: >-
{{ inventory_hostname }}: {{ item }} is not mounted after the
reboot. Anything writing there is writing to the parent filesystem.
loop: '{{ host_required_mounts }}'Health: the application probe
Readiness says the operating system is fine. Health says the service is serving.
- name: Wait for the application to report itself healthy
ansible.builtin.uri:
url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
return_content: true
status_code: [200]
timeout: 10
register: health
retries: 30
delay: 5
until:
- health.status | default(0) == 200
- health.json.database | default('') == 'connected'
- health.json.version | default('') == expected_version
changed_when: falseEvery default() in those conditions is chosen so an absent field
fails. health.json.database | default('') compared against
'connected' is false when the field is missing, which is the correct
reading: a health endpoint that stopped reporting a field is not a
health endpoint that is reporting good news.
The version check is the one people leave out and the one that catches
the worst outcome. A host that rebooted into the old kernel, or whose
package upgrade silently rolled back, will pass every other check in
this lesson. Asserting that the running version is the version you
deployed is the only check that catches it.
Health: cluster membership
For anything clustered — a database replica set, a Ceph OSD host, an etcd member, a message broker — “the process is running” is a particularly weak claim. A node can run, accept connections and be outside the cluster.
- name: Ask the cluster whether this node has rejoined
ansible.builtin.uri:
url: 'http://{{ cluster_api_host }}/v1/members'
return_content: true
delegate_to: '{{ groups["cluster"] | difference([inventory_hostname]) | first }}'
register: members
retries: 20
delay: 15
until: >-
members.json.members | default([])
| selectattr('name', 'equalto', inventory_hostname)
| selectattr('healthy', 'equalto', true)
| list | length == 1
changed_when: falseThe delegate_to is what makes this meaningful: the question is put to
a node other than the one being validated. A node asked about itself
will report itself present.
Placing validation so its failure matters
Everything above is worthless if a failure gets recorded and the run carries on.
- name: Patch and reboot with validation gating the next batch
hosts: '{{ patch_target }}'
become: true
serial: '{{ patch_serial }}'
max_fail_percentage: 0
tasks:
- name: Apply updates
ansible.builtin.include_tasks: patch.yml
- name: Reboot if required
ansible.builtin.reboot:
reboot_timeout: 900
post_reboot_delay: 30
when: reboot_required | default(false)
- name: Refresh facts, then validate
ansible.builtin.include_tasks: validate.ymlThree properties of that shape are worth stating explicitly.
Validation is a task in the same play, not a separate playbook run afterwards. A separate run cannot stop a batch that has already finished.
It comes after the reboot in the task list, so the batch is not considered complete until it passes.
ignore_errors appears nowhere. A validation task with
ignore_errors: true is a log line, not a gate, and the difference is
invisible in the playbook until the night it matters.
$ ansible-playbook -i inv10.ini batchstop_canary.ymlTASK [Post-reboot validation gate (h01 fails it)] ******************************
fatal: [h01]: FAILED! => {
"assertion": "inventory_hostname != 'h01'",
"changed": false,
"evaluated_to": false,
"msg": "h01 came back but its service did not start"
}
NO MORE HOSTS LEFT *************************************************************
NO MORE HOSTS LEFT *************************************************************
PLAY RECAP *********************************************************************
h01 : ok=1 changed=0 unreachable=0 failed=1
exit=2One host in the recap, out of ten in the inventory. The other nine were never touched. That is what a working gate looks like: the evidence of its success is a recap that is shorter than the fleet.
Knowledge check
Knowledge check · 4 questions
Q1. A host returns from a patch reboot and systemctl is-system-running reports degraded. What does that mean?
Q2. A patch play validates mounts and kernel version using ansible_facts immediately after the reboot task, without re-gathering. Why is this check useless?
Q3. Which of these correctly describe what a --check run of a patch play does and does not verify? Select all that apply.
Q4. Running the validation as a separate playbook after the patch playbook gives the same protection as putting it inside the patch play, as long as it runs immediately afterwards.
Passing score: 75%. Answers are checked in this browser.