Best Practices for Optimizing Large-Scale Ansible Deployments
Practical ways to speed up large Ansible runs with forks, strategy plugins, fact caching, SSH reuse, and better playbook design.
Best Practices for Optimizing Large-Scale Ansible Deployments
Large Ansible deployments usually get slow for plain reasons: too many SSH handshakes, too much fact gathering, a controller with too little CPU, or a playbook that makes every host wait for the slowest one. The fix is rarely one setting. You get the best result by reducing connection overhead, tuning concurrency, and writing plays that do less work per host.
I would not define "large scale" by a strict host count. A 300-host inventory can feel large if each task installs packages over slow links. A 3,000-host inventory can be manageable if the controller is sized well and the playbooks are tight. Treat the numbers below as starting points, then measure with your own inventory and modules.
Tune Parallelism Before Blaming Ansible
Parallelism is usually the first lever to test because Ansible spends a lot of time waiting on remote hosts. The goal is not "highest forks wins." The goal is enough concurrency to keep the controller busy without overwhelming SSH, privilege escalation, package repositories, or the targets themselves.
Controlling Concurrency with forks
The forks parameter defines the number of parallel process workers the Ansible controller can spawn. Finding the optimal number requires balancing controller resources (CPU and memory) against the target environment's connection limits.
Set forks in your ansible.cfg or via the command line (-f or --forks).
[defaults]
forks = 100
Start lower than you think you need. Run the same play against the same host group with 25, 50, 100, and 200 forks while watching CPU, memory, SSH failures, and runtime. If CPU stays mostly idle and hosts spend time waiting, raise forks. If the controller starts swapping, Python processes pile up, or targets reject connections, back down.
Choosing the Right Strategy Plugin
Ansible's default execution strategy is linear, meaning tasks must complete on all targeted hosts before moving to the next task in the playbook. For thousands of nodes, a single slow host can bottleneck the entire run.
For some large deployments, use the free strategy.
Free Strategy (strategy = free):
free allows hosts to proceed independently through the playbook as soon as they complete a task, without waiting for slower hosts. It can improve throughput when tasks are independent. Do not use it blindly for rolling deploys, shared migrations, or plays where task order across the fleet matters.
# Example playbook definition
---
- hosts: all
strategy: free
tasks:
- name: Ensure service is running
ansible.builtin.service:
name: httpd
state: started
Cache Facts When You Reuse Them
Fact gathering is useful, but it is easy to pay for it repeatedly. If your playbooks use facts across several runs, cache them. If a play does not need host facts at all, disable gathering for that play.
Using External Caches (Redis or Memcached)
For a single controller, JSON file caching may be enough. For multiple controllers or automation workers, use an external cache such as Redis or Memcached so every worker sees the same fact cache.
Actionable Configuration in ansible.cfg:
[defaults]
gathering = smart
fact_caching = redis
fact_caching_timeout = 7200 ; Cache facts for 2 hours (in seconds)
fact_caching_prefix = ansible_facts
; If using Redis
fact_caching_connection = localhost:6379:0
Set gathering = smart when cached facts are part of your workflow. If you only need a small slice of host data, use gather_subset instead of collecting everything.
3. Optimizing Connection and Transport
Reducing the overhead associated with establishing connections is paramount when dealing with thousands of concurrent SSH sessions.
SSH Pipelining
Pipelining reduces the number of SSH round trips Ansible uses for many module executions. It is often worth enabling, but test it with your privilege escalation rules.
SSH Connection Reuse (ControlPersist)
For Unix-like targets, the ControlMaster and ControlPersist settings prevent Ansible from initiating a brand new SSH session for every single task. It keeps a control socket open for a specified duration, allowing subsequent tasks to use the existing connection.
Actionable Configuration in ansible.cfg:
[ssh_connection]
pipelining = True
; Use aggressive connection reuse (e.g., 30 minutes)
ssh_args = -C -o ControlMaster=auto -o ControlPersist=30m -o ServerAliveInterval=15
Pipelining can conflict with sudo configurations that require a TTY. If you still have Defaults requiretty in sudoers, remove it for the automation user or keep pipelining disabled for those hosts.
Windows Optimization (WinRM)
When targeting Windows nodes, tune WinRM separately. Kerberos is usually a better production choice than Basic authentication, and WinRM service limits may need review if many jobs connect at once.
4. Inventory Management for Scale
Static inventory files become painful when hosts are created and destroyed often. Dynamic inventory is not mandatory for every large environment, but it is the right default for cloud fleets, autoscaling groups, and CMDB-backed infrastructure.
Dynamic Inventory Sources
Utilize inventory plugins for your cloud provider (AWS EC2, Azure, Google Cloud) or CMDB system. Dynamic inventory ensures that Ansible only targets active hosts with up-to-date data.
# Example: Running against a dynamically filtered AWS inventory
ansible-playbook -i aws_ec2.yml site.yml --limit 'tag_Environment_production'
Smart Targeting and Filtering
Avoid running playbooks against the entire inventory (hosts: all) unless absolutely necessary. Use granular groups, limits (--limit), and tags (--tags) to ensure the execution target set is minimized.
5. Architectural Considerations and Controller Sizing
For large-scale deployments, the environment where Ansible runs must be appropriately provisioned.
Controller Sizing
Ansible is highly resource-dependent on the controller, primarily CPU and RAM, due to the need to fork processes for parallel execution.
- CPU: More forks usually means more Python work on the controller. Watch load average and per-core saturation during real playbook runs.
- RAM: Each fork consumes memory. Large templates, big variables, and chatty callback plugins can raise memory use quickly.
- Storage I/O: Fast local storage helps when the controller writes many temporary files, logs, artifacts, or file-based fact cache entries.
Utilizing Automation Platforms
For teams that need scheduling, RBAC, audit trails, and multiple execution workers, use Ansible Automation Platform or AWX rather than a single long-lived shell session on one control node.
AAP provides:
- Job Scheduling and History: Centralized logging and auditing.
- Execution Environments: Consistent, reproducible runtime environments.
- Clustering and Scaling: Distribute execution across multiple worker nodes to handle massive concurrency needs without overloading a single controller.
- Credential Management: Secure handling of secrets at scale.
6. Playbook Design for Efficiency
Even with optimized infrastructure, poorly written playbooks can negate performance gains.
Minimize Fact Gathering
If you use cached facts (Section 2), actively disable redundant fact gathering where possible:
- hosts: web_servers
gather_facts: no # Disable fact gathering for this play
tasks:
# ... only run tasks that do not rely on gathered system facts
Use run_once and delegate_to Sparingly
Tasks that must run sequentially or centrally (e.g., initiating a rolling deployment, updating a load balancer) should be handled via run_once: true and delegate_to: management_node. This avoids wasteful parallelism when only one host should perform the action.
Prefer Batch Operations
Whenever possible, use modules that handle batch operations natively (e.g., package managers like apt or yum that accept a list of packages) rather than iterating through a large list using a loop or with_items over separate package tasks.
# Better: one package task with a list
- name: Install necessary dependencies
ansible.builtin.package:
name:
- nginx
- python3-pip
- firewall
state: present
7. Measure the Playbook, Not Just the Host Count
When an Ansible run is slow, add timing before changing more knobs. The built-in profile_tasks callback is a good first pass:
[defaults]
callbacks_enabled = profile_tasks, timer
Run the playbook once against a representative host group and look at the slowest tasks. You may find that most of the time is spent in one package install, one template rendering step, or one command that waits on an external service. In that case, increasing forks just creates more pressure on the same bottleneck.
For a repeatable test, keep the inventory slice stable:
ansible-playbook -i inventory site.yml --limit 'web:&production' -f 50
ansible-playbook -i inventory site.yml --limit 'web:&production' -f 100
ansible-playbook -i inventory site.yml --limit 'web:&production' -f 200
Record total runtime, failed hosts, controller CPU, controller memory, and any target-side SSH or sudo errors. Also watch the package repository or artifact server during the test. A playbook can look like an Ansible problem when the real issue is every host downloading the same package at once from one overloaded internal mirror.
8. Reduce Work Before Increasing Concurrency
Large Ansible runs often improve more from doing less than from doing it faster. A few examples show up repeatedly:
- A template task renders a large config file on every run even when only a small include changed.
- A shell task runs a discovery command on every host even though the value is already in inventory.
- A role installs packages one at a time in a loop.
- A handler restarts a service after several unrelated template changes when one reload would be enough.
Use module idempotence instead of shell where possible. A shell command that always reports changed can trigger handlers across hundreds of hosts and turn a harmless check into a rolling restart. If you must use command or shell, set changed_when and creates or removes carefully.
- name: Initialize application directory once
ansible.builtin.command: /usr/local/bin/app-init /srv/app
args:
creates: /srv/app/.initialized
That small guard prevents repeated work and avoids false change reports.
9. Use Batches for Risk Control
Performance is not the only concern at scale. Sometimes the fastest playbook is operationally dangerous. For service fleets, use serial to control blast radius:
- hosts: app_servers
serial: 10%
max_fail_percentage: 5
tasks:
- name: Deploy application package
ansible.builtin.package:
name: myapp
state: latest
serial will make the run longer than firing at every host at once, but it gives load balancers, monitoring, and humans time to react. It also protects shared dependencies. A package mirror, database migration endpoint, or secret manager may not survive thousands of simultaneous requests.
Large Ansible deployments stress systems that are easy to forget: DNS resolvers, package repositories, secret stores, logging pipelines, and monitoring endpoints. If a playbook slows down only at higher fork counts, check those shared services before blaming Ansible.
Also keep callback output under control. Very verbose logs are useful during debugging, but they can slow large runs and bury the real failure. Use high verbosity for a narrow host slice, then return to normal output for full-fleet execution.
10. Split Plays by Failure Domain
One overlooked scaling trick is to stop treating the whole estate as one deployment unit. If database hosts, web hosts, queues, and cache nodes all live in the same giant play, one slow or broken group can delay unrelated work. Separate plays by failure domain and dependency order.
For example, run base OS configuration broadly, but deploy application code by service tier. Update cache nodes in their own play. Drain and restart web nodes in batches. Apply database configuration with extra checks and smaller concurrency. This makes retries safer because you can rerun the failed part without repeating work across every host.
It also makes ownership clearer. The team responsible for a service can tune its batch size, health checks, and rollback behavior without changing global automation defaults. Large-scale Ansible stays maintainable when the playbook structure matches the way the infrastructure actually fails during real incidents and maintenance windows.
The highest-impact Ansible performance work is usually simple: reuse SSH connections, avoid unnecessary fact gathering, right-size forks, and stop playbooks from doing repeated tiny operations. After that, look at architecture. If one controller cannot keep up cleanly, split work across execution nodes and make inventory, credentials, and logging repeatable.