Chapter 10: Orchestration & Scale
Topic 61

Batching and Throttling

ScaleSafety

A change that is correct on one host and catastrophic on sixty is the failure mode this topic prevents. serial bounds how many hosts a bad release reaches per wave, max_fail_percentage aborts the whole play once too many hosts in a batch fail, throttle caps a single dangerous task, and --limit carves the run down to a subset. Together they are the guardrails that stop a push-based tool from pushing a mistake to the entire fleet at once.

These are complementary, not alternatives — each addresses a different way a fleet-wide change goes wrong, and a serious production rollout layers all four. The topic takes them one at a time, then shows how they stack.

serial as Blast-Radius Control

Beyond rolling deploys, serial is the primary limit on how many hosts a change can break before you notice. It takes a count, a list, or a percentage, and whatever the form, the effect is the same: a bad config at serial: 1 fails on one host, not all six. The wave size is the blast radius. Choosing it is choosing how much damage a wrong change is allowed to do before the run reaches a point where you can stop it.

max_fail_percentage — Stop the Bleed

max_fail_percentage: 20, paired with serial, aborts the entire play once more than twenty percent of a batch fails. Inside a wave each task runs on all of its hosts at once, up to the forks limit, so a broken step lands on all six hosts of a six-host wave together, and Ansible checks the threshold right after it. When two of the six fail, the play stops cold: the four survivors skip the remaining tasks and no later wave starts. This matters because the default behavior is far more forgiving: without the setting, Ansible ends the play only when every host in a batch has failed. Two broken hosts out of six is not that, so the other four finish, the next wave begins, and a change that breaks a third of every wave rolls on across the whole fleet. max_fail_percentage turns "carry on while any host survives" into "fail a little, then halt."

serial plus max_fail_percentage — batch the rollout, abort on early failure
- hosts: web
  serial: 2                  # two hosts per wave
  max_fail_percentage: 20    # abort the whole play if >20% of a wave fails
  become: true
  tasks:
    - name: Deploy the new release
      ansible.builtin.git:
        repo: https://github.com/larkspur-io/app.git
        dest: /opt/larkspur
        version: "{{ release_tag }}"

throttle — Cap One Hot Task

throttle: 2 on a specific task limits it to two hosts at a time regardless of forks or serial. It is for the one step that hammers a shared resource — a license server, a package mirror, an upstream API — that would buckle if the whole batch hit it simultaneously. throttle is scoped to the task, not the play: the rest of the wave runs at full width, and only the dangerous task is squeezed down to a rate the shared resource can survive.

--limit Batches — Manual Carving

--limit web1.larkspur.io:web2.larkspur.io runs the play only against the named subset, and --limit @retry_file runs it only against the hosts a previous run recorded as failed. This is the operator-driven complement to serial — a human carving the run by hand to canary a change on two named hosts, or to re-run just the hosts that failed instead of the whole fleet. Where serial batches automatically, --limit is you drawing the boundary for one invocation.

Layering the Guards

The four guards stack because each catches a different failure. serial sets the wave size so a bad change reaches only a few hosts at a time. max_fail_percentage sets the abort threshold so a wave that starts failing halts the rollout. throttle protects the one task that pulls on a shared, rate-limited resource. --limit is the human override for canarying or re-running failures. Use one where you meant another and you either flood a shared resource, slow the whole play needlessly, or let a broken change roll on past the point it should have stopped.

There is a sharp ordering dependency between the first two. max_fail_percentage is evaluated per batch, so it only does its job when serial has created batches to evaluate against. Set the threshold without batching and it is measured against the whole play at once, which defeats the early-abort intent on a large run. The two are a pair: serial creates the waves, max_fail_percentage decides when a wave's failures stop the rollout.

Common Mistakes
  • Running a fleet-wide play with serial but no max_fail_percentage — the play stops only when every host in a wave has failed, so a change that breaks two hosts out of six lets the other four finish and the next wave start, and the broken release rolls on through the fleet.
  • Setting max_fail_percentage without serial — with no batching the threshold is evaluated against the whole play at once, which defeats the early-abort intent on a large run.
  • Confusing throttle with serial — throttle limits one task's concurrency within a wave, serial limits the wave size; using one where you meant the other either floods a shared resource or needlessly slows the whole play.
  • Hammering a shared package mirror by deploying sixty hosts at full forks with no throttle on the fetch task — the mirror rate-limits or falls over and the deploy fails mid-fleet.
  • Trusting --limit to persist — it applies to one invocation, so the next run without it is back to the full fleet; an exclusion is not a permanent fence.
Best Practices
  • Pair serial with max_fail_percentage on every production rollout, so a wave that starts failing aborts before it touches the rest of the fleet.
  • Set a low max_fail_percentage — single digits — on critical tiers; one or two failed hosts in a batch should stop the rollout, not be tolerated.
  • Add throttle: N to any task that pulls on a shared, rate-limited resource, sizing N to what that resource can take rather than to the batch size.
  • Use --limit with the generated retry file to re-run only the hosts that failed, instead of re-applying the play to the whole fleet after a partial failure.
  • Confirm scope with --list-hosts before a --limit run against prod, so the subset you carved is the subset that actually resolves.
Comparable tools Kubernetes rolling-update maxUnavailable plus a failure threshold, the closest analog to serial + max_fail_percentage Spinnaker canary and automated rollback at the pipeline layer Terraform no batch-and-abort primitive for applying a change across many resources

Knowledge Check

Why can serial without max_fail_percentage still carry a broken change across the fleet?

  • A wave halts the play only if all of its hosts fail, so a partly broken wave lets the next wave begin
  • serial only batches the connections, not the play itself, so every host in the fleet still receives the change at once
  • serial is silently ignored at runtime unless max_fail_percentage is also set alongside it on the play
  • Inside a wave the hosts take the change one after another, and only all of them failing stops the queue

What does max_fail_percentage evaluate against, and when does it work as intended?

  • It is evaluated per batch, so it delivers early abort only when serial has created batches to measure against
  • It is evaluated against the control node's forks count, completely independent of whatever serial is set to
  • It is evaluated just once at the very end of the play, after every host in the fleet has already run
  • It is evaluated against the whole fleet at once and works best with no serial batching at all

What is the difference between throttle and serial?

  • throttle caps one task's concurrency within a wave; serial caps the wave size for the whole play
  • throttle sets the size of each wave for the whole play while serial caps concurrency on a single task
  • They are really the same underlying setting, only applied at two different scopes within the play run
  • throttle aborts the whole play the moment a task fails, while serial only slows the rollout down instead

Does a --limit carve persist across runs?

  • No — it applies to one invocation, so the next run without it is back to the full fleet
  • Yes — it writes a permanent host exclusion directly into the inventory file on disk for you
  • Yes — it persists across every later run on this control node until the node is next rebooted
  • It persists across runs only when it is combined with a serial batch size

You got correct