Skip to main content
RunBook Academy

AnsibleXXXI · Serial Execution and Failure ToleranceFailure Tolerance

Choosing a failure policy

Advanced⏱ ~22 minansible-playbook

What you'll learn

  • Choose between no policy, max_fail_percentage and any_errors_fatal for a stated change
  • Design a canary ramp and justify each stage of it
  • Explain why the default policy is the most dangerous of the three
  • Recognise that no failure policy protects against a fleet losing connectivity

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.

You have three failure policies available, and one of them is what you get by writing nothing.

PolicyStops whenScope of the stop
(none — the default)every host in a batch has failedthe play
max_fail_percentage: Nmore than N% of a batch has failedthe play
any_errors_fatal: trueany host failsthe playbook

The rest of this lesson is about choosing between them, but the row that deserves attention first is the one nobody chooses deliberately.

The default is a policy, and it is a bad one

Writing no failure keyword does not mean “no policy”. It means: keep going as long as at least one host is still working.

Verified on ansible-core 2.21.3 — six hosts, serial: 2, no failure keyword, and a health check that fails on both hosts of the first batch:

Read-only / Safewhat the default does when a whole batch fails
$ ansible-playbook -i inv6.ini nopolicy.yml
PLAY [batchlab] ****************************************************************

TASK [Health check] ************************************************************
fatal: [h01]: FAILED! => {"assertion": "inventory_hostname not in ['h01','h02']"}
fatal: [h02]: FAILED! => {"assertion": "inventory_hostname not in ['h01','h02']"}

PLAY RECAP *********************************************************************
h01                        : ok=0    changed=0    unreachable=0    failed=1
h02                        : ok=0    changed=0    unreachable=0    failed=1

The play stopped — but only because every host in the batch failed. Change the test so that just one of the two fails, and the rollout proceeds through all six hosts, breaking each batch in turn.

That is the default’s actual shape: it stops only when there is nothing left to break. As a safety mechanism it is the last one to fire, and by then it has done all the damage it can.

The decision

One question separates the two real policies:

If this change lands on part of the fleet and then stops, is that state better or worse than where you started?

Better — partial progress has value. Use max_fail_percentage. A fleet where 80% of hosts got the security patch is genuinely safer than one where none did, and the 20% that failed are a work item rather than an emergency.

Worse — partial application is its own failure. Use any_errors_fatal. A schema migration applied to half the fleet is not “half done”; it is a system where neither version works reliably, and every additional host makes the reversal harder.

flowchart TD
  A["A change to roll across the fleet"] --> B{"Is a partially applied<br/>change worse than<br/>no change at all?"}
  B -->|"Yes — hosts must agree"| C["any_errors_fatal: true"]
  B -->|"No — progress has value"| D["max_fail_percentage: N"]
  C --> E{"Does anything downstream<br/>have to run regardless<br/>of the outcome?"}
  E -->|"Yes"| F["Move it into block/always<br/>— a later play will not run"]
  E -->|"No"| G["Scope it: block level if only<br/>one section is critical"]
  D --> H["Set N from the batch size:<br/>largest integer strictly<br/>below 100 x k / n"]
  B -->|"Neither — cannot tolerate<br/>hosts being unreachable"| I["Neither keyword helps.<br/>Gate reachability in an<br/>unbatched pre-flight play"]

The third branch is the one this part keeps returning to, and it is not a failure policy at all. Both keywords respond to failed hosts. Both were verified to ignore unreachable ones. If the fleet going quiet is the risk you are managing, the control has to run before the rollout does.

The canary ramp

For most production changes the batch shape and the failure policy are chosen together, and the shape is:

Service impact possiblethe default shape for a service-impacting change
- name: Roll the application release across the fleet
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

Each stage answers a different question, and that is why the shape has three stages rather than one number.

Batch one: one host. Does this change work at all?

Most broken deploys are broken everywhere — a template that renders invalid configuration, a package version that does not exist, a unit file with a typo. One host finds all of those, at the cost of one host. It is also the only batch where you can reasonably expect a human to look at the result before the run continues.

Batch two: five hosts. Does it work on more than a lucky host?

The first host might have been the one with the cached package, the already-correct file, the unusual permissions. Five hosts drawn from the same inventory order are still not a random sample, but they will surface per-host variation that one host cannot.

Batch three onward: 25% at a time. Does it work at scale?

The remaining risk is different in kind — connection pool exhaustion, a shared dependency that cannot take the reconnect storm, a licence server that rate-limits. These only appear with volume, and the ramp is what produces volume in controlled increments.

Worked selections

Rolling an application release across 200 web servers. Partial progress is survivable for a short window if the versions are compatible, and intolerable if they are not — so the answer depends on skew, which the rolling part treats in full. Where skew is survivable: serial: [1, 5, 25%] with max_fail_percentage: 0. Stop at the first failure, but keep what has already landed and roll forward or back deliberately.

Rotating the SSH host trust configuration across the fleet. A host that ends up with the new configuration while the controller still presents the old key becomes unmanageable. Partial application is worse than none. any_errors_fatal: true, serial: [1, 5, 10%], and a block/always that restores the previous file if the block fails.

Monthly OS patching across a mixed fleet. Some hosts will fail for uninteresting reasons — a full /boot, a held package, a repository timeout. Stopping the whole window because three hosts out of four hundred had a full disk is worse than proceeding. max_fail_percentage: 9 with serial: "10%", and a report of the failed hosts as the deliverable.

Applying a database schema migration to a replica set. any_errors_fatal: true, and the batch shape barely matters because the first failure stops everything. What matters is that the migration is reversible and that the reversal is written before the forward migration runs.

Deploying a monitoring agent to every host in the estate. Best-effort convergence. max_fail_percentage set high or omitted entirely, serial chosen for controller load rather than for safety, and the value of the run is the list of hosts it could not reach.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play has serial: [1, 5, 25%] and no failure keyword at all. A broken build fails its health check on the first host. What happens?

  2. Q2. Which question best decides between max_fail_percentage and any_errors_fatal?

  3. Q3. Why does the canary ramp serial: [1, 5, 25%] have three stages instead of one fixed batch size? Select all that apply.

  4. Q4. Choosing the right failure policy will stop a rollout that is failing because hosts are becoming unreachable mid-run.

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