Skip to main content
RunBook Academy

AnsibleXLIV · Failure Modes and Partial Fleet FailureFailure Modes and Partial Fleet Failure

Stopping a bad change at host twelve

Expert⏱ ~26 minansible-playbook

What you'll learn

  • Combine serial, max_fail_percentage and any_errors_fatal into a bounded blast radius
  • State the exact evaluation rule for max_fail_percentage and why equalling it does not stop the play
  • Explain what force_handlers changes when a play aborts
  • Design a canary ramp that limits the first failure to a countable number of hosts

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.

Everything in this part so far has been about a failure that already happened. This lesson is about the four keywords that decide how big it is allowed to get.

The 300/30/50 scenario is a failure of scope control before it is anything else. A play with no serial, no failure threshold and no abort criteria hits all 300 hosts before anyone can react, because Ansible works through the fleet at forks hosts at a time and does not pause to ask. The same play with a canary batch fails at host 12, someone reads the error, and the other 288 never run.

The four controls, and the question each answers

KeywordQuestion it answersScope of its effect
serialHow many hosts change at once?Defines the batch. Everything else is relative to it
max_fail_percentageHow much failure within a batch is tolerable?Aborts the play after the current batch
any_errors_fatalIs one failure enough to stop everything?Aborts the play and the playbook
force_handlersDo notified handlers still run when a play aborts?Changes what state the aborted hosts are left in

They compose, and the composition is the point. serial alone limits concurrency but not total reach — without a threshold, a play with serial: 20 and a failure in every batch still visits all 300 hosts, twenty at a time, failing all the way.

serial is the unit of damage

serial decides the largest number of hosts that can be in a broken state before anything can stop the run, because thresholds are evaluated between batches.

That gives a rule of thumb worth stating directly: your first batch is your maximum acceptable loss. If losing twelve hosts simultaneously is survivable and losing thirty is not, the first batch is twelve, whatever the rest of the ramp looks like.

Service impact possiblea canary ramp with a bounded first failure
- name: Application rollout
hosts: appservers
become: true
serial:
  - 1        # one host. If this fails, the damage is one host
  - 11       # a dozen total. Enough to see a pattern
  - '10%'    # then ramp
  - '25%'
max_fail_percentage: 0
tasks:
  - name: Deploy the release
    ansible.builtin.include_role:
      name: app_release

The first two entries are the whole safety mechanism. Everything after them is throughput.

max_fail_percentage must be exceeded

This is the semantic that catches people, and it is worth stating with its arithmetic.

The play aborts when the percentage of failed hosts in the batch is greater than the configured value. Equal is not greater.

Batch sizemax_fail_percentageFailuresPercentageAborts?
2010210%No — equal, not exceeded
2010315%Yes
20015%Yes
330133.3%Yes
430125%No

Two consequences follow.

max_fail_percentage: 0 means “any failure stops the play”, because any non-zero percentage exceeds zero. It does not mean “tolerate no failures” in some special way; it is the ordinary rule applied to zero. This is the most useful setting for a change where consistency matters, and it is more precise than any_errors_fatal because it stops the play without stopping the whole playbook.

Small batches make the percentage coarse. With serial: 3, the available percentages are 0, 33, 67 and 100. A threshold of 30 and a threshold of 10 behave identically. During the canary phase of a ramp, you are effectively choosing between “stop on any failure” and “do not stop”, so choose deliberately rather than tuning a number that cannot express what you meant.

any_errors_fatal stops the playbook, not just the play

Covered in full in the failure-tolerance part; recalled here because its reach is the part that produces incidents.

Verified on 2.21.3: with any_errors_fatal: true, a failure in batch two of five means batches three, four and five never run and produce no recap lines at all, and a later play in the same playbook does not run either.

Read-only / Safeany_errors_fatal with serial 2 over six hosts, h03 fails — executed on ansible-core 2.21.3
$ ansible-playbook -i inv6.ini aef.yml
TASK [Health check] ************************************************************
fatal: [h03]: FAILED! => {"msg": "health check failed"}
ok: [h04] => { "msg": "HEALTHY h04" }

PLAY RECAP *********************************************************************
h01   : ok=3  changed=0  unreachable=0  failed=0
h02   : ok=3  changed=0  unreachable=0  failed=0
h03   : ok=1  changed=0  unreachable=0  failed=1
h04   : ok=2  changed=0  unreachable=0  failed=0

The operational consequence is the one to carry into a design review: a patching playbook whose first play silences monitoring and whose last play re-enables it will, on a single failure, leave the entire fleet unmonitored. Anything that must happen regardless of outcome belongs in a block/always, never in a later play.

Neither of them sees unreachable hosts

Both keywords react to task failures. A host that cannot be connected to never returns a task result, so neither threshold counts it.

Verified on 2.21.3 with any_errors_fatal: true, serial: 3 and one documentation-range address in batch one: the batch containing the unreachable host was cut, and then later batches ran to completion and the subsequent play ran too, exiting 4. Compare the failed-host run above, where batch two never started.

force_handlers, and the hosts left drained

When a play aborts, notified handlers that have not yet run are discarded. That is usually right — you do not want a service restart firing on a fleet you just decided to stop changing.

It is wrong in one specific shape, and it is the shape rolling deployments have. If the play drains a host from the load balancer, changes it, and returns it to service via a handler, then aborting between those steps leaves the host drained. The abort protected the fleet from the change and left the canary out of rotation.

Service impact possibleforce_handlers so an abort does not strand a drained host
- name: Application rollout
hosts: appservers
become: true
serial: [1, 11, '25%']
max_fail_percentage: 0
force_handlers: true
tasks:
  - name: Deploy configuration
    ansible.builtin.template:
      src: app.conf.j2
      dest: /etc/app/app.conf
      mode: '0640'
      validate: '/usr/sbin/appd -t -c %s'
    notify: Restart app

handlers:
  - name: Restart app
    ansible.builtin.systemd_service:
      name: appd
      state: restarted

force_handlers is not a general safety improvement — it means handlers run on a play you decided to abort, which is sometimes precisely what you did not want. The right question is the same one as everywhere else in this part: if this play stops here, what state is each host left in, and does a handler need to run to make that state coherent?

The general answer for cleanup work is block/always, which runs regardless of outcome and is not subject to the notification mechanism at all. force_handlers is for the case where the coherence-restoring action is genuinely a handler and you cannot restructure it.

The design, assembled

For the 300-host scenario, the play that would have limited it:

Service impact possiblescope control applied to the signature scenario
- name: Pre-flight — the fleet must actually be there
hosts: appservers
gather_facts: false
tasks:
  - name: Prove every target is reachable
    ansible.builtin.ping:

  - name: Refuse to start if the reachable count is short
    ansible.builtin.assert:
      that: ansible_play_hosts | length == ansible_play_hosts_all | length
      fail_msg: >-
        {{ ansible_play_hosts_all | difference(ansible_play_hosts) | join(', ') }}
        did not answer. Resolve or re-scope before changing anything.
    run_once: true

- name: Rollout
hosts: appservers
become: true
serial: [1, 11, '10%', '25%']
max_fail_percentage: 0
force_handlers: true
tasks:
  - name: Apply the change
    ansible.builtin.include_role:
      name: patch_and_configure

Under that play, the same underlying problems produce a very different night: the 50 unreachable hosts stop the run before it starts, and if they had appeared later, the first task failure ends the rollout inside the canary batch.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play has serial: 20 and max_fail_percentage: 10 over 300 hosts. Exactly two hosts fail in each of the fifteen batches. What happens?

  2. Q2. Setting any_errors_fatal: true protects a rollout against hosts that become unreachable partway through.

  3. Q3. Which statements about serial in a rolling change are accurate? Select all that apply.

  4. Q4. A rolling play drains each host from the load balancer, changes it, and returns it to service through a notified handler. The play aborts on a failure. What is the risk, and what addresses it?

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