Chapter 11: Configuration, Plugins & Performance
Topic 66

Performance Tuning

PerformanceTuning

Because Ansible has no agent doing local work, almost all of its runtime is SSH and process overhead — every task is potentially a new connection, a module copy, and a Python invocation on the target. Tuning Ansible is therefore tuning that overhead, not optimizing some local engine, because there is no local engine on the host to optimize.

The single biggest win is pipelining, which collapses the multiple SSH operations per task into one. Stack pipelining, connection multiplexing, more forks, and skipping unneeded fact gathering, and a deploy across Larkspur's 6 prod web hosts goes from minutes to a fraction of that. These settings are complementary — you turn them all on, not pick one — and the sections below take them in order of impact.

The complementary speedups — turn them all on, in order of impact
Pipelining
The biggest single win. Feeds the module over the existing SSH session — roughly halves SSH ops per task. Needs requiretty off in sudoers.
ControlPersist
Keeps one SSH master socket open and reuses it, so a 40-task play handshakes once. On by default with ssh.
Raise forks
More hosts in parallel. Default 5 runs a 6-host tier in two waves; set it to the size of the largest tier.
Skip facts
gather_facts: false when no fact is read, or scope with gather_subset — the cheapest gathering is the one you don't run.

Pipelining — the Biggest Single Win

pipelining = True, set under [ssh_connection], feeds the module to the remote Python over the existing SSH session instead of copying a temp file and executing it as a separate step. That cuts the SSH operations per task roughly in half — one round-trip where there were two or three. The one requirement is that the target's sudoers has no requiretty, because pipelining runs the module without an allocated TTY; if sudoers still demands a TTY, escalated tasks fail. Clear that requirement once and pipelining costs nothing thereafter.

ControlPersist and SSH Multiplexing

The ssh connection's ControlPersist keeps one SSH master socket open and routes every subsequent task through it, so a 40-task play pays one handshake instead of forty. This is on by default with the ssh plugin — you inherit it for free — and the only thing you usually tune is the persist timeout, with ssh_args = -o ControlPersist=60s for long runs where the master would otherwise time out between phases. Pipelining and multiplexing stack: one reuses the connection, the other reduces operations per task on top of it.

forks — Host Parallelism

forks sets how many hosts Ansible acts on at once, and it defaults to 5. With Larkspur's 6 prod web hosts and the default, the sixth host waits for a slot — the deploy runs in two serial waves, five hosts then one, for no reason but a low default. Raising forks to 20 lets the whole web tier run in a single parallel wave, and the deploy finishes in one pass. The rule of thumb is to set forks at least to the size of your largest tier.

The performance section of the Larkspur ansible.cfg
[defaults]
forks = 20                  # the whole 6-host web tier runs in one wave
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .ansible_facts

[ssh_connection]
pipelining = true           # biggest single win — needs requiretty off in sudoers
ssh_args = -o ControlPersist=60s

Every line here trims overhead Ansible would otherwise pay on each run: pipelining halves SSH operations per task, ControlPersist holds the master open across tasks, forks = 20 parallelizes the tier, and fact caching avoids re-gathering. None of them change what the playbook does — they change how much wire and process cost the same playbook pays.

Skipping Fact Gathering

gather_facts: false on a play that reads no facts skips an entire setup round-trip per host — the implicit fact-gathering task is one of the most expensive things a play does, and paying for it when nothing references a fact is pure waste. When you need only some facts, gather_subset: ['!all', 'min'] (or naming specific subsets) gathers the minimum instead of everything. The cheapest fact gathering is the one you don't run, and the second cheapest is the one scoped to exactly what the play reads.

Fact Caching

fact_caching — backed by jsonfile or redis — stores gathered facts between runs so repeated plays reuse them instead of re-gathering. That turns a per-run cost into a periodic one: the facts are collected when the cache is cold or stale, and every run in between reads them from the cache. It is worth enabling when the same fleet is targeted often, exactly as Larkspur's CI does on every push, where re-gathering the same six hosts' facts on every pipeline run is cost you can amortize away.

strategy: free

By default the linear strategy makes every host finish a task before any host starts the next, so the slowest host paces the whole play. strategy: free lets each host race ahead through its own task list, finishing fast hosts early instead of holding them at a barrier. It is good for independent per-host work, but the tradeoff is real: ordering across hosts is no longer lockstep, so anything that depends on hosts moving together — a coordinated rolling sequence — must stay on linear.

Common Mistakes
  • Enabling pipelining = True while the target's sudoers still has requiretty, so privilege-escalated tasks fail with a TTY error; pipelining needs requiretty off in sudoers to run modules without a pseudo-TTY.
  • Leaving forks at the default 5 against a 6+ host tier and watching the deploy run in two serial waves, then blaming SSH when it's just parallelism set too low.
  • Gathering full facts on every play (gather_facts: true) when the play references none of them, paying a setup round-trip per host for data nobody reads.
  • Turning on strategy: free for a rolling deploy that depends on ordered, lockstep behavior, so hosts race ahead and the "drain one, deploy, return it" sequencing breaks.
  • Setting forks to a huge number on a constrained CI runner, exhausting its file descriptors and memory because each fork is a process and an SSH connection.
Best Practices
  • Set pipelining = True in the repo ansible.cfg and confirm requiretty is off in the fleet's sudoers — it's the single biggest speedup and costs nothing once the TTY requirement is cleared.
  • Raise forks to at least the size of the largest tier (20 for Larkspur's prod web hosts) so the whole tier runs in one parallel wave, bounded by what the CI runner can handle.
  • Set gather_facts: false on plays that read no facts, and use gather_subset to collect the minimum when you need only a few.
  • Enable fact_caching (jsonfile or redis) for a fleet you target repeatedly, so facts are gathered periodically instead of on every CI run.
  • Reserve strategy: free for independent per-host work and keep ordered rolling deploys on linear, where lockstep sequencing is the whole point.
Comparable tools SaltStack persistent minions sidestep this overhead by avoiding per-task SSH Puppet · Chef agents do local work, so have no SSH-per-task cost SSH ControlMaster the same multiplexing Ansible relies on

Knowledge Check

Why is pipelining the single biggest performance win, and what does it require?

  • It feeds the module over the existing SSH session instead of copying and executing a temp file, roughly halving SSH ops per task — and it needs requiretty off in sudoers
  • It runs every single task in parallel across the whole fleet simultaneously, and it requires raising the forks value high enough to match the full inventory host count exactly
  • It caches gathered facts between separate runs, and it requires a reachable redis server to hold the cache between plays
  • It compresses the module output on the wire, and it requires the managed target to have the gzip binary installed

What does ControlPersist save across the tasks of a play?

  • The SSH handshake — one master socket stays open and every task routes through it, so a 40-task play handshakes once instead of forty times
  • The fact-gathering step, by reusing the facts the previous run already gathered from each host
  • Memory on the controller node, by capping how many parallel forks are allowed to run at any one time
  • The privilege escalation prompt, by caching the entered sudo password once on the first task and silently replaying it on all later tasks of the play

A deploy targets 6 web hosts but runs in two serial waves of five then one. What setting fixes it?

  • Raise forks above the default 5 — at 20 the whole tier runs in a single parallel wave
  • Enable strategy: free, which is documented as the only supported way to act on more than five hosts at once
  • Turn on pipelining, which widens the concurrency window and parallelizes hosts as a side effect
  • Disable ControlPersist so that each host opens its own fresh, independent connection

What is the tradeoff of strategy: free versus the default linear?

  • Hosts race ahead through their own task lists so fast hosts finish early, but ordering across hosts is no longer lockstep — bad for coordinated rolling sequences
  • free is always strictly faster with absolutely no downside whatsoever, so it really should be the chosen default for every single play in every environment everywhere
  • free quietly skips the fact-gathering step entirely, which breaks any play that later reads a host fact
  • free disables cross-host parallelism, running each host fully to completion before the next one even starts

You got correct