AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture
Where the controller lives
What you'll learn
- Place the controller in the wider automation pipeline rather than treating it as a machine with Ansible installed
- Estimate the wall-clock cost that link latency adds to a fleet run
- Weigh a central controller against regional controllers using failure domain, latency and consistency
- Identify the changes that can sever a controller from the fleet, and the controls that make that recoverable
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
At small scale the controller is wherever you happen to be. At fleet scale it is a named piece of infrastructure with a capacity, a failure domain, a network position, and a role in a pipeline that starts in a Git repository and ends on several thousand machines.
Getting its placement wrong does not produce an error message. It produces runs that take four hours instead of forty minutes, a change window that closes before the change finishes, and — in the worst case — a controller that has just reconfigured the network it reaches the fleet through.
The shape of fleet automation
The controller is one box in a longer chain. Drawing the whole chain first is worth the space, because most placement arguments are really arguments about which box owns which decision.
flowchart TB
DEV["Engineer<br/>writes a change"] --> GIT["Git repository<br/>roles, playbooks, inventory data"]
GIT --> CI["CI validation<br/>lint, syntax, check-mode gate, molecule"]
CI -->|"merge to main"| CTL["Automation controller<br/>schedules and executes runs"]
CMDB["Inventory source / CMDB<br/>which hosts exist, and what they are"] --> CTL
VAULT["Secret store<br/>credentials for the run"] --> CTL
CTL --> W0["Wave 0<br/>tooling"]
W0 -->|"gate"| W1["Wave 1<br/>canary"]
W1 -->|"gate"| W2["Wave 2<br/>internal prod"]
W2 -->|"gate"| W3["Wave 3..n<br/>customer prod"]
CTL --> LOG["Run artefacts<br/>logs, diffs, recap, commit SHA"]
LOG --> DEV
Two things fall out of that picture immediately.
The controller is not the source of truth for anything. Code comes from Git, host membership comes from the inventory source, credentials come from the secret store. If the controller is the only place some piece of configuration exists, it has become a snowflake, and rebuilding it becomes an archaeology exercise.
The controller is the only box that touches everything. It can read the secret store and it can reach every managed host. That combination is what makes its placement a security question and not just a performance one.
What latency costs you
A task is not one packet. Even with connection reuse, running a module on a host involves transferring the module payload, executing it, reading the JSON result and cleaning up — several round trips over the SSH channel, per task, per host.
Take a plausible set of numbers: 4 round trips per task, 200 tasks in the play, and a controller 150 ms away from the target region.
| Quantity | Value |
|---|---|
| Latency per task per host | 4 × 150 ms = 0.6 s |
| Latency per host for the play | 200 × 0.6 s = 120 s |
| Hosts | 2,000 |
Effective concurrency (forks) | 50 |
| Sequential host slots | 2000 / 50 = 40 |
| Wall-clock from latency alone | 40 × 120 s ≈ 80 minutes |
Eighty minutes in which nothing computed anything. Move the controller into the region and take the round trip to 2 ms, and the same arithmetic gives roughly one minute.
# one module round trip against a nearby host
time ansible -i inventory/ web014.example.com -m ansible.builtin.ping
# and against a host across the link
time ansible -i inventory/ web811.example.com -m ansible.builtin.pingCentral or regional
Neither answer is universally right, and the trade is not primarily about speed.
A single central controller gives you one place where runs happen, one audit trail, one version of Ansible, one set of collections, and one credential store integration. It also gives you one failure domain: when it is down, nothing is automated anywhere, and when the link to a region degrades, every run into that region degrades with it.
Regional controllers give you short links, a firewall scope per
region rather than one flat path to everything, and independent failure
domains. They cost you consistency. Three controllers are three chances
for “it works on mine” — a different ansible-core minor version, a
collection installed by hand eight months ago, an ansible.cfg with a
setting nobody remembers adding.
ansible --version
ansible-config dump --only-changed
ansible-galaxy collection list
python3 --versionansible-config dump --only-changed is the important one: it prints
only settings that differ from the defaults, which is precisely the list
of ways this controller is not a stock controller.
The controller is inside its own blast radius
Here is the part that gets skipped, and it is the one that turns a bad afternoon into a multi-day recovery.
The controller reaches the fleet over the network, through DNS, using SSH, authenticating with keys, trusting a CA bundle. Every one of those is also a thing the fleet’s automation manages.
Which means a run can sever the controller from its fleet:
- A play that tightens
sshd_configand getsAllowUserswrong locks the automation account out of every host it succeeded on. - A firewall role that stops permitting the controller’s source range removes the path it needed to fix the firewall role.
- A change to the internal resolver, the internal CA, or the NTP source can break authentication or name resolution fleet-wide.
- Renewing the automation account’s key without distributing the new public key first breaks the next run and every run after it.
The failure has a distinctive signature: unreachable counts climbing
in step with the batches that succeeded. The hosts the change did not
reach yet are still fine. That inversion — success causing
unreachability — is the tell, and it is why the obvious diagnosis
(“network problem”) wastes the first thirty minutes.
Sizing, briefly
Each fork is a controller-side process holding an SSH connection, a
Python interpreter’s worth of memory, and file descriptors. The default
forks is 5, which is wrong for a fleet in the obvious direction — and
raising it consumes controller CPU, RAM and descriptors in a way that
starts presenting as target-side failures when you run out.
The controller readiness questions before a fleet run:
- CPU and RAM headroom at the fork count you intend to use.
- File descriptor limits for the account running the play.
- Disk for logs and artefacts. A verbose run over 2,000 hosts produces a log measured in gigabytes, and a controller whose disk fills mid-run fails in confusing ways.
- Inventory freshness, and credentials that will not expire mid-window.
Measuring and tuning these properly — finding the fork ceiling empirically rather than guessing — is Part XXXIV’s subject. What belongs here is that they are placement inputs: a controller sized for 5 forks in a region 150 ms away is a different machine from one sized for 100 forks next door.
Knowledge check
Knowledge check · 4 questions
Q1. A fleet-wide sshd hardening run is halfway through when unreachable counts start climbing. The hosts going unreachable are the ones earlier batches already completed. What is the most likely cause?
Q2. What is the strongest argument against a single central controller for a globally distributed fleet?
Q3. Which of these belong on a controller readiness checklist before a fleet-wide run? Select all that apply.
Q4. Excluding the controller from a risky fleet-wide play is better done by leaving it out of the target group than by adding a when: inventory_hostname != controller condition inside the role.
Passing score: 75%. Answers are checked in this browser.