Skip to main content
RunBook Academy

AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture

Scheduling and the thundering herd

Advanced⏱ ~22 minansible-playbook

What you'll learn

  • Identify the shared dependencies a fleet-wide run concentrates load onto
  • Choose between serial, throttle and jitter for a given concentration problem
  • Implement deterministic per-host jitter that survives across runs
  • Schedule around business hours and change windows on a fleet that spans time zones

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 first fleet-wide patch run most organisations do is also the first time they take down their own package mirror.

The playbook is fine. The change is fine. Two thousand hosts request four hundred megabytes of packages within the same ninety seconds, the mirror’s connection limit is reached, half the hosts get a timeout, and the run reports several hundred failures that look like a package problem and are actually a load problem you created.

That is the thundering herd, and at fleet scale it is not an edge case. It is the default outcome of running the same thing on every host at once.

What a fleet run concentrates onto

Every fleet-wide run has a small number of choke points. Naming them before the run is most of the work.

Shared dependencyWhat concentrates onto itSymptom when it saturates
Package mirror / artifact storeEvery host downloading the same packagesTimeouts, partial downloads, 503
LDAP / directoryEvery host authenticating or reloading nsswitchLogin delays estate-wide, including for humans
DNS resolverEvery host resolving the mirror, the API, each otherSlow tasks everywhere, unreachable hosts
NTP sourceEvery host restarting chrony at onceTime steps, and services that dislike them
Licence serverEvery host checking out a licence on service restartSome hosts start unlicensed
Config/secret APIEvery host fetching credentialsRate limiting, which looks like an auth failure
Monitoring ingestEvery host reporting a state changeAlert storm, then dropped data
The controller itselfFork count, descriptors, log writesFailures that look target-side

The last row is the one people forget, and Part XXXIV covers it properly. The others are external, and the run is the load event.

Three tools, three different jobs

They are routinely confused, and they do not substitute for each other.

serial shapes how many hosts are inside the play at a time. It is a play-level keyword and it applies to the whole play — every task, for the whole batch.

throttle shapes how many hosts run one particular task at a time. It applies at play, role, block, task and handler level, and it can only lower concurrency, never raise it: it cannot exceed forks or serial.

Jitter shapes when a scheduled run starts on each host, or when a particular task begins. It is the only one of the three that helps when the concentration comes from many independent runs rather than one big one.

Configuration changethrottle on the one task that hits the mirror
- name: patch the web tier
hosts: wave3_domain_a
serial: 200
tasks:
  - name: refresh package metadata and upgrade
    ansible.builtin.package:
      name: nginx
      state: latest
    throttle: 20          # never more than 20 clients on the mirror

  - name: render the configuration
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
      mode: '0644'
    notify: reload nginx

The distinction is worth stating plainly: dropping serial to 20 to protect the mirror also slows every other task in the play by a factor of ten. throttle on the one task that touches the shared resource costs you nothing anywhere else.

Jitter that is stable per host

The standard idiom is to give each host a random offset. The naive version — a fresh random number every run — has a real drawback: a host’s slot moves every night, so you can never say “web014 patches at 02:07”, and correlating an incident with a run becomes guesswork.

Seeding the random filter on the hostname fixes that. Verified on 2.21.3:

Read-only / Safedeterministic per-host offsets
$ ansible-playbook -i inventory.ini jitter.yml
ok: [web01] => {"msg": "web01 -> 181s"}
ok: [web02] => {"msg": "web02 -> 249s"}
ok: [web03] => {"msg": "web03 -> 3s"}
ok: [web04] => {"msg": "web04 -> 125s"}
ok: [web05] => {"msg": "web05 -> 176s"}
ok: [web06] => {"msg": "web06 -> 162s"}
ok: [web07] => {"msg": "web07 -> 54s"}
ok: [web08] => {"msg": "web08 -> 188s"}
ok: [web09] => {"msg": "web09 -> 249s"}
ok: [web10] => {"msg": "web10 -> 76s"}

The expression is {{ 300 | random(seed=inventory_hostname) }}: an integer from 0 to 299, derived from the hostname, identical on every run.

Two ways to use it, and they are not equivalent.

Configuration changejitter inside a run, and jitter on the schedule
# (a) inside a play: stagger the moment each host hits the mirror
- name: stagger before touching the shared mirror
ansible.builtin.wait_for:
  timeout: "{{ 300 | random(seed=inventory_hostname) }}"

# (b) on the schedule: spread independently-triggered runs
- name: nightly convergence, staggered across the hour
ansible.builtin.cron:
  name: ansible-converge
  minute: "{{ 60 | random(seed=inventory_hostname) }}"
  hour: '2'
  job: /usr/local/bin/converge.sh

Windows, and why “02:00” is not a time

On a fleet in one building, 02:00 is a quiet moment. On a fleet across three regions it is three different moments, and scheduling by UTC or by local time are meaningfully different decisions:

  • By UTC, every region runs simultaneously. The herd is maximal and the shared dependencies see the whole fleet at once — but the change lands everywhere at a single, easily-recorded instant.
  • By local time, load is naturally spread across the day, but the fleet is in a mixed state for many hours, and “is the change live?” has no single answer while it rolls.

Neither is wrong. What is wrong is not choosing, because the default — cron entries in each host’s local time, applied by a role that assumed one time zone — gives you the mixed-state downside without anyone having decided to accept it.

Additional scheduling constraints worth writing down explicitly:

  • Business hours for the service, which may not be the business hours of the team running the change.
  • Change freeze periods: quarter end, retail peaks, election nights, regulatory reporting dates.
  • Backup windows, which compete for the same I/O and network the run needs.
  • Other automation. A patching run and a compliance-scan run that both start at 02:00 are two herds, and neither team knows about the other.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play patches 200 hosts per wave and one of its twelve tasks downloads packages from a mirror that accepts 20 concurrent clients. What is the least costly fix?

  2. Q2. A colleague adds ansible.builtin.pause with a random number of seconds to stagger 500 hosts before they hit an API. What actually happens?

  3. Q3. Which statements about jitter and throttle are accurate? Select all that apply.

  4. Q4. Scheduling a global fleet by UTC and by host-local time are different decisions with different downsides, and failing to choose gives you the downsides of local time without the benefit.

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