Ansible Deep Dive Part 7: ansible.cfg, Performance, and Scaling to Hundreds of Hosts
Everything so far has been correctness — getting the right state onto the right hosts. This post is about speed, because a playbook that’s provably correct but takes forty minutes to run against your full fleet gets run less often, tested less thoroughly, and trusted less by the people who have to wait on it.
ansible.cfg: where it comes from and what actually matters
Ansible reads configuration from the first of these it finds, in order: ANSIBLE_CONFIG environment variable, ./ansible.cfg in the current directory, ~/.ansible.cfg, then /etc/ansible/ansible.cfg. That first-match-wins order is exactly why ansible --version’s “config file” line from Part 1 is worth checking any time behaviour seems off — a project-local ansible.cfg you forgot about will silently beat everything else.
[defaults]
inventory = ./inventory.yml
remote_user = ansible
host_key_checking = False
forks = 20
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600
retry_files_enabled = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path = /tmp/ansible-ssh-%%h-%%p-%%r
host_key_checking = False is a lab/CI convenience, not a production recommendation — leave it on for anything where you actually care about detecting a changed host key. retry_files_enabled = False stops Ansible littering .retry files after every failed run, which I always turn off; they’re rarely useful and just add clutter.
Forks: how many hosts run in parallel
By default Ansible processes only 5 hosts at a time (forks = 5). Against a fleet of 200 devices, that’s 40 sequential batches for every single task. Bumping forks to 20 or 50 (control-node CPU and memory allow) is usually the single biggest speed win available, and it’s a one-line config change with no playbook risk at all.
ansible-playbook site.yml --forks 50
Strategy: linear vs free
The default linear strategy runs each task across all targeted hosts before any host moves to the next task — meaning the whole batch waits for the slowest single host at every step. free lets each host run through the entire playbook at its own pace, independent of the others:
- hosts: webservers
strategy: free
tasks: ...
free is faster whenever hosts have meaningfully different response times (mixed hardware, mixed geography, a couple of slow legacy boxes in an otherwise fast fleet) — but it sacrifices the lockstep guarantee that everything is at task N before anything reaches task N+1, which matters for playbooks with genuine ordering dependencies between hosts (a database primary that must finish migrating before replicas start). Default to linear; reach for free deliberately, not as a blanket performance setting.
SSH pipelining and ControlPersist
Without pipelining, every module invocation is: open SSH connection → copy the module’s Python file to a temp path on the remote host → execute it → clean up → close the connection. That’s multiple round trips per task, per host. pipelining = True (shown above) executes the module by piping its content directly over the existing SSH session’s stdin, skipping the separate file-copy step entirely — a large win, especially over higher-latency links.
ControlPersist keeps the underlying SSH TCP connection open and reuses it across multiple tasks and even multiple playbook runs within the persist window, instead of renegotiating a fresh SSH handshake for every single task. Combined, these two settings are usually a bigger win than raising forks further once you’re already reasonably parallel.
One caveat: pipelining requires requiretty to be disabled in the remote host’s sudoers file, or become tasks fail. Worth knowing before you enable it fleet-wide and get a wave of confusing sudo failures on hosts with a stricter default sudoers config.
Fact caching: don’t re-gather what hasn’t changed
gather_facts (Part 4) hits every host at the start of every play. fact_caching = jsonfile (shown in the config above) stores gathered facts on disk between runs, valid for fact_caching_timeout seconds — 3600 above, one hour. Within that window, subsequent playbook runs skip the live fact-gathering round trip entirely and read from cache, which matters a lot when the same fleet is targeted by several different playbooks back to back within a short window, as happens constantly in CI.
Redis is the other common backend (fact_caching = redis) — worth it once more than one control node or CI runner needs to share the same cache rather than each keeping its own local file.
Mitogen: the option worth knowing about even if you don’t reach for it
Mitogen replaces Ansible’s default connection and execution layer with a persistent Python interpreter per host, avoiding the repeated process-spawn overhead of the standard SSH+pipelining path. Benchmarks commonly show 2-7x wall-clock improvements on task-heavy playbooks. I mention it rather than walk through installing it because it’s had patchy compatibility with newer ansible-core releases and some collections over the years — check current compatibility against your specific ansible-core version before adopting it, rather than assuming last year’s benchmark still holds. For most estates, forks + pipelining + fact caching gets you most of the available win with none of that risk.
Part 8 covers testing — Molecule for actually exercising a role against a disposable container or VM, ansible-lint for catching style and correctness issues before a playbook ever runs, and wiring both into a CI pipeline so a broken playbook fails in a pull request rather than at 2am against production.