Lab environment: RHEL 9.5 · Ansible 2.16 (Ansible Automation Platform 2.5) · Python 3.11
Table of Contents
- Course Overview
- Module 1 — Automating Infrastructure as Code (IaC)
- 2.1 Introduction to Ansible as an IaC Tool
- 2.2 Installing Docker CE with Ansible
- 2.3 Managing Docker Containers with Ansible
- 2.4 Interaction Between Ansible and Terraform
- 2.5 Installing Terraform with Ansible
- 2.6 Creating a Terraform Project
- 2.7 Managing Terraform via Ansible
- 2.8 Consuming Terraform Outputs
- Module 2 — Compliance Checking with Ansible
- Module 3 — Building CI/CD Pipelines
- Module 4 — Scaling Ansible to Enterprise
- Quick Reference — Key Commands
- Overall Architecture
1. Course Overview
This material covers four major areas of advanced Ansible usage in enterprise environments:
mindmap
root((Ansible for Enterprise Automation))
Module 1 - IaC
Docker CE
Terraform
community.docker
community.general
Module 2 - Compliance
Facts
Service facts
Blocks
Collections
Roles
Logging
Module 3 - CI/CD
GitLab pipelines
ansible-lint
Rollback
Versioned symlinks
Module 4 - Scale
Vagrant multi-VM
Dynamic inventory
Forks
Async tasks
async_status
2. Module 1 — Automating Infrastructure as Code (IaC)
2.1 Introduction to Ansible as an IaC Tool
Infrastructure as Code involves describing and managing infrastructure through versioned, reproducible, and auditable code. Ansible fits naturally into this paradigm and can handle the entire IaC lifecycle — from installing Docker to orchestrating Terraform.
flowchart LR
A[Ansible Playbook] --> B{Target}
B --> C[Docker Engine]
B --> D[Terraform]
C --> E[Containers]
D --> F[Cloud/Local Resources]
Lab environment:
| Component | Version |
|---|---|
| OS | RHEL 9.5 |
| RAM | 2 GB |
| Ansible | 2.16 |
| Python | 3.11 |
2.2 Installing Docker CE with Ansible
The playbook below automates the complete Docker CE installation on RHEL 9.5: adding the official repository, installing packages, starting the service, installing the Python Docker module, and adding the current user to the docker group.
Key point: To prevent
become: truefrom capturing the root user when adding to the Docker group, usebecome: falselocally with aset_factto capture the user via an environment variable.
---
- name: Deploy Docker on RHEL 9.5
hosts: localhost
become: true
gather_facts: false
tasks:
- name: Add Docker CE repository
ansible.builtin.yum_repository:
name: docker-ce-stable
description: Docker CE Stable - x86_64
baseurl: https://download.docker.com/linux/rhel/9/x86_64/stable/
gpgcheck: yes
gpgkey: https://download.docker.com/linux/rhel/gpg
enabled: yes
state: present
- name: Install Docker CE packages
ansible.builtin.dnf:
name:
- docker-ce
- docker-ce-cli
- containerd.io
state: present
- name: Enable and start Docker service
ansible.builtin.systemd:
name: docker
state: started
enabled: yes
- name: Install Python 3 and pip
ansible.builtin.dnf:
name:
- python3.11
- python3.11-pip
state: present
- name: Install Python Docker SDK
ansible.builtin.pip:
name: docker
state: present
- name: Capture active user from environment variable
ansible.builtin.set_fact:
active_user: "{{ lookup('env', 'USER') }}"
connection: local
become: false
- name: Add captured user to the docker group
ansible.builtin.user:
name: "{{ active_user }}"
groups: docker
append: yes
2.3 Managing Docker Containers with Ansible
The community.docker collection provides docker_image and docker_container. The collection must be installed first:
ansible-galaxy collection install community.docker
---
- name: Deploy Nginx Container
hosts: localhost
become: false
gather_facts: false
tasks:
- name: Pull NGINX Docker image
community.docker.docker_image:
name: nginx:latest
source: pull
- name: Create NGINX container
community.docker.docker_container:
name: proxy_server
image: nginx:latest
state: started
ports:
- "9080:80"
restart_policy: always
- name: Open firewall port for the container
ansible.posix.firewalld:
port: 9080/tcp
permanent: yes
state: enabled
notify: Reload firewall
become: true
handlers:
- name: Reload firewall
ansible.builtin.systemd:
name: firewalld
state: reloaded
become: true
sequenceDiagram
participant A as Ansible Controller
participant D as Docker Engine
participant R as Docker Hub Registry
A->>R: Pull nginx:latest
R-->>A: Image downloaded
A->>D: Create container proxy_server (port 9080:80)
D-->>A: Container started
A->>A: Open port 9080 (firewalld)
2.4 Interaction Between Ansible and Terraform
Terraform (HashiCorp) is often already present in organizations. Rather than starting from scratch, Ansible can orchestrate Terraform to preserve existing investments.
flowchart TD
A[Ansible Playbook] --> B[community.general.terraform]
B --> C{terraform_state}
C -->|present| D[terraform init + apply]
C -->|absent| E[terraform destroy]
D --> F[Resources created]
E --> G[Resources removed]
Basic Terraform commands:
| Command | Description |
|---|---|
terraform init | Downloads the provider (communication driver) |
terraform apply | Creates the resources |
terraform destroy | Removes the resources |
2.5 Installing Terraform with Ansible
---
- name: Install Terraform on RHEL 9.5
hosts: localhost
gather_facts: false
become: true
tasks:
- name: Add HashiCorp GPG key
ansible.builtin.rpm_key:
key: https://rpm.releases.hashicorp.com/gpg
state: present
- name: Add HashiCorp repository
ansible.builtin.yum_repository:
name: hashicorp
description: HashiCorp Stable - $basearch
baseurl: https://rpm.releases.hashicorp.com/RHEL/$releasever/$basearch/stable
enabled: true
gpgcheck: true
gpgkey: https://rpm.releases.hashicorp.com/gpg
- name: Install Terraform package
ansible.builtin.dnf:
name: terraform
state: present
2.6 Creating a Terraform Project
The Terraform configuration (main.tf) describes the resources to create. Here, an Nginx container via the Docker provider:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0.0"
}
}
}
provider "docker" {}
resource "docker_image" "webserver" {
name = "nginx:latest"
keep_locally = true # Retain the image locally even after destroy
}
resource "docker_container" "webserver" {
image = docker_image.webserver.image_id
name = "webserver_container"
ports {
internal = 80
external = 9080
}
restart = "always"
}
output "container_id" {
description = "ID of the Docker container"
value = docker_container.webserver.id
}
output "webserver_access" {
description = "URL to access the Nginx server"
value = "http://localhost:9080"
}
Folder structure: The
main.tffile is placed in atf/subdirectory relative to the playbooks working directory.
2.7 Managing Terraform via Ansible
The community.general collection is required:
ansible-galaxy collection install community.general
---
- name: Terraform Orchestration
hosts: localhost
become: false
gather_facts: false
vars:
terraform_state: "present" # Override via -e terraform_state=absent
tasks:
- name: Initialize and apply/destroy Terraform configuration
community.general.terraform:
project_path: "tf/"
state: "{{ terraform_state }}"
force_init: true # Runs init automatically if needed
register: tf_result
- name: Display Terraform outputs
ansible.builtin.debug:
msg:
- "Container ID: {{ tf_result.outputs.container_id.value }}"
- "Webserver Access URL: {{ tf_result.outputs.webserver_access.value }}"
when: terraform_state == "present" and tf_result.outputs is defined
Usage:
# Create resources
ansible-playbook ansible_terraform.yaml
# Destroy resources
ansible-playbook ansible_terraform.yaml -e terraform_state=absent
2.8 Consuming Terraform Outputs
Terraform outputs are accessible via the registered variable:
tf_result.outputs.<output_name>.value
Example access:
- "Container ID: {{ tf_result.outputs.container_id.value }}"
- "Webserver Access URL: {{ tf_result.outputs.webserver_access.value }}"
3. Module 2 — Compliance Checking with Ansible
3.1 Using Facts to Audit the Python Version
Ansible facts allow you to collect information about target hosts. Although they are often disabled to save time, they are essential for compliance tasks.
---
- name: Retrieve Python version from hosts
hosts: localhost
gather_facts: true
tasks:
- name: Show Python version
ansible.builtin.debug:
msg: "Python version: {{ ansible_facts['python']['version']['major'] }}.{{ ansible_facts['python']['version']['minor'] }}.{{ ansible_facts['python']['version']['micro'] }}"
- name: Display full Python info
ansible.builtin.debug:
var: ansible_facts['python']
verbosity: 1 # Only shown with -v
Available Python facts:
ansible_facts['python']['version']['major']
ansible_facts['python']['version']['minor']
ansible_facts['python']['version']['micro']
3.2 Verifying Service State
The ansible.builtin.service_facts module collects the state of all services on the target system.
Step 1 — Basic version (chrony_1.yaml):
- name: Check chronyd service status
hosts: localhost
gather_facts: true
tasks:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Print status when running
ansible.builtin.debug:
msg: "Chrony is running on {{ ansible_facts['hostname'] }}"
when: ansible_facts.services['chronyd.service'].state == 'running'
- name: Print status when stopped
ansible.builtin.debug:
msg: "Chrony is not running on {{ ansible_facts['hostname'] }}"
when: ansible_facts.services['chronyd.service'].state != 'running'
Identified issue: If
chronyd.servicedoes not exist in the services dictionary, Ansible will throw an error.
3.3 Playbook Best Practices — Blocks and Variables
Step 2 — Version with block and variables (chrony_2.yaml):
Using a block with a single when condition avoids duplication and guards against missing services.
- name: Check chronyd service status
hosts: localhost
gather_facts: true
vars:
time_services:
Debian: chrony.service
RedHat: chronyd.service
time_service: "{{ time_services[ansible_os_family] | default('chronyd.service') }}"
tasks:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Evaluate chronyd tasks
block:
- name: Print status when running
ansible.builtin.debug:
msg: "Chrony is running on {{ ansible_facts['hostname'] }}"
when: ansible_facts.services[time_service].state == 'running'
- name: Print status when stopped
ansible.builtin.debug:
msg: "Chrony is not running on {{ ansible_facts['hostname'] }}"
when: ansible_facts.services[time_service].state != 'running'
when: time_service in ansible_facts.services
flowchart TD
A[Gather service_facts] --> B{time_service in ansible_facts.services?}
B -->|No| C[Block skipped - no error]
B -->|Yes| D{Service state?}
D -->|running| E[Display: Chrony is running]
D -->|stopped| F[Display: Chrony is not running]
Advantages of this approach:
- The
time_servicevariable is defined once → less repetition - Supports both Debian (
chrony.service) and Red Hat (chronyd.service) - The
blockprotects against missing services
3.4 Persisting Data to Log Files
Step 3 — Version with logging (chrony_3.yaml):
The ansible.builtin.lineinfile module with delegate_to: localhost writes results to the Ansible controller, creating a centralized compliance history.
---
- name: Check chronyd service status and log to controller
hosts: localhost
gather_facts: true
vars:
log_file: "chronyd_audit.log"
time_services:
Debian: chrony.service
RedHat: chronyd.service
time_service: "{{ time_services[ansible_os_family] }}"
tasks:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Process chronyd service if present
block:
- name: Set service status fact when running
ansible.builtin.set_fact:
chrony_status: "running"
when: ansible_facts.services[time_service].state == 'running'
- name: Set service status fact when stopped
ansible.builtin.set_fact:
chrony_status: "stopped"
when: ansible_facts.services[time_service].state != 'running'
- name: Print status
ansible.builtin.debug:
msg: "Chrony is {{ chrony_status }} on {{ ansible_facts['hostname'] }}"
- name: Log status to controller
ansible.builtin.lineinfile:
path: "{{ log_file }}"
line: "{{ ansible_date_time.iso8601 }} - Host: {{ ansible_facts['hostname'] }} - Chrony Status: {{ chrony_status }}"
create: yes
delegate_to: localhost # Write to controller, not the remote host
when: time_service in ansible_facts.services
delegate_to: localhost: Redirects the task to the Ansible controller, even when the play targets remote hosts. Essential for centralizing compliance logs.
3.5 Understanding Collections
Ansible collections organize reusable code within a structured namespace:
namespace.collection_name
└── roles/
└── role_name/
├── tasks/main.yml
├── defaults/main.yml
├── vars/main.yml
└── tests/test.yml
Structure of the example.compliance collection:
~/.ansible/collections/ansible_collections/
└── example/
└── compliance/
└── roles/
└── chrony/
├── defaults/
│ └── main.yml # Overridable variables
├── vars/
│ └── main.yml # OS-specific fixed variables
├── tasks/
│ └── main.yml # Role tasks
└── tests/
└── test.yml # Test playbook
3.6 Building Enterprise-Ready Code in Collections
Initializing the collection and role:
# Navigate to the collections path
cd ~/.ansible/collections/ansible_collections
# Create the collection (namespace: example, collection: compliance)
ansible-galaxy collection init example.compliance
# Create the role within the collection
cd example/compliance/roles
ansible-galaxy role init chrony
defaults/main.yml — Variables with overridable default values:
---
# defaults file for chrony
log_file: "chronyd_audit.log"
vars/main.yml — OS-specific variables (not easily overridden):
---
# vars file for chrony
time_services:
Debian: chrony.service
RedHat: chronyd.service
time_service: "{{ time_services[ansible_os_family] }}"
tasks/main.yml — Role tasks:
---
- name: Gather service facts
ansible.builtin.service_facts:
- name: Process chronyd service if present
block:
- name: Set service status fact when running
ansible.builtin.set_fact:
chrony_status: "running"
when: ansible_facts.services[time_service].state == 'running'
- name: Set service status fact when stopped
ansible.builtin.set_fact:
chrony_status: "stopped"
when: ansible_facts.services[time_service].state != 'running'
- name: Print status
ansible.builtin.debug:
msg: "Chrony is {{ chrony_status }} on {{ ansible_facts['hostname'] }}"
- name: Log status to controller
ansible.builtin.lineinfile:
path: "{{ log_file }}"
line: "{{ ansible_date_time.iso8601 }} - Host: {{ ansible_facts['hostname'] }} - Chrony Status: {{ chrony_status }}"
create: yes
delegate_to: localhost
when: time_service in ansible_facts.services
tests/test.yml — Simplified test playbook (uses the role via its qualified name):
---
- hosts: localhost
roles:
- example.compliance.chrony
Result: The operations playbook contains just a single role line; all complexity is encapsulated within the collection.
4. Module 3 — Building CI/CD Pipelines
4.1 Preparing GitLab
GitLab provides a robust platform for CI/CD pipelines. Pipelines automate the testing and deployment of Ansible code through containers, ensuring a consistent test environment.
flowchart LR
A[git push] --> B[GitLab Pipeline triggered]
B --> C[Stage: lint\nansible-lint]
C --> D[Stage: test\nsyntax-check]
D --> E[Stage: deploy\nansible-playbook]
E --> F[dev/prod environment]
GitLab prerequisites:
- Account created with email/phone verified
- Project created (60-day free trial for pipeline minutes)
- SSH or Personal Access Token authentication configured
Project structure after cloning:
git clone https://gitlab.com/<username>/ansible-cicd-demo
cd ansible-cicd-demo
# Create the role structure manually (without ansible-galaxy)
mkdir -p roles/app_deploy/tasks
touch roles/app_deploy/tasks/main.yml
touch deploy.yml
touch .gitlab-ci.yml
4.2 Creating a New GitLab Project
Steps in the GitLab interface:
- Create a Blank Project
- Name the project
ansible-cicd-demo - Choose Public or Private visibility
- Do not select a deployment target (configured in the YAML)
- Create the project
4.3 Developing Ansible Content
deploy.yml — Main playbook:
---
- name: Deploy Application
hosts: localhost
become: true
roles:
- app_deploy
roles/app_deploy/tasks/main.yml — Role tasks (with correct FQCN names):
---
- name: Create deployment directory
ansible.builtin.file:
path: /opt/app-release
state: directory
mode: '0755'
- name: Create release info file
ansible.builtin.copy:
content: "Release timestamp: {{ ansible_date_time.iso8601 }}"
dest: /opt/app-release/release_info.txt
mode: '0644'
.gitlab-ci.yml — Pipeline definition:
image: python:3.10-slim
stages:
- lint
- test
- deploy
before_script:
- pip install ansible ansible-lint
lint:
stage: lint
script:
- ansible-lint deploy.yml
test:
stage: test
script:
- ansible-playbook deploy.yml --syntax-check
deploy_dev:
stage: deploy
script:
- echo "ansible-playbook deploy.yml -i inventory/dev"
only:
- main
environment:
name: development
4.4 Fixing Linter-Reported Errors
ansible-lint requires the use of Fully Qualified Collection Names (FQCN) for all modules. For example, file: must become ansible.builtin.file:.
sequenceDiagram
participant D as Developer
participant G as GitLab
participant L as ansible-lint
D->>G: git push (code with short names)
G->>L: Launch lint stage
L-->>G: FAILURE - unqualified names
G-->>D: Failure email + logs
D->>D: Fix: file → ansible.builtin.file
D->>G: git push (corrected code)
G->>L: Launch lint stage
L-->>G: SUCCESS
4.5 Rollback Strategy with Ansible
Rollback leverages symlinks and a versioned directory system:
/var/www/
├── app/
│ ├── v1.0/
│ │ └── index.html ("Version: v1.0")
│ ├── v2.0/
│ │ └── index.html ("Version: v2.0")
│ └── v3.0/
│ └── index.html ("Version: v3.0")
└── html -> /var/www/app/v2.0 (active symlink)
To deploy a new version → update the symlink to the new directory.
To roll back → redirect the symlink to a previous version.
4.6 Version Management with Ansible
---
- name: Deploy or Rollback Web Application
hosts: localhost
gather_facts: false
become: true
vars:
app_base_path: "/var/www/app"
html_symlink: "/var/www/html"
version_number: "{{ version_number | mandatory }}" # Required
rollback: false
index_content: "<html><body><h1>Release: {{ version_number }}</h1></body></html>"
tasks:
- name: Create version directory
ansible.builtin.file:
path: "{{ app_base_path }}/{{ version_number }}"
state: directory
mode: '0755'
when: not rollback | bool
- name: Deploy index.html
ansible.builtin.copy:
content: "{{ index_content }}"
dest: "{{ app_base_path }}/{{ version_number }}/index.html"
mode: '0644'
when: not rollback | bool
register: deploy_result
- name: Update symlink to point to version
ansible.builtin.file:
src: "{{ app_base_path }}/{{ version_number }}"
dest: "{{ html_symlink }}"
state: link
force: yes
register: symlink_result
- name: Display deployment result
ansible.builtin.debug:
msg: "Successfully deployed version {{ version_number }}"
when: not rollback | bool and deploy_result.changed
- name: Display rollback result
ansible.builtin.debug:
msg: "Successfully rolled back to version {{ version_number }}"
when: rollback | bool and symlink_result.changed
Usage:
# Deploy version v2.0
ansible-playbook web_rollback.yaml -e version_number=v2.0
# Roll back to version v1.0
ansible-playbook web_rollback.yaml -e version_number=v1.0 -e rollback=true
5. Module 4 — Scaling Ansible to Enterprise
5.1 Introduction to the Enterprise Environment
To demonstrate scalability challenges, this module uses 10 Rocky Linux 9 VMs managed via Vagrant.
flowchart TD
A[Ansible Controller\nnode-1] --> B[node-1]
A --> C[node-2]
A --> D[node-3]
A --> E[...]
A --> F[node-10]
G[Vagrant + VirtualBox/VMware] --> B
G --> C
G --> D
G --> E
G --> F
H[CSV Inventory\nansible_inventory.csv] --> A
Vagrant (HashiCorp) manages VMs without being a hypervisor itself. Compatible with:
- VirtualBox
- VMware
- Hyper-V
- Libvirt
5.2 Deploying VMs with Vagrant
The Vagrantfile creates 10 Rocky Linux 9 VMs in a loop, configures DHCP networks, installs Ansible on node-1, generates SSH keys, and automatically builds a CSV inventory file:
Vagrant.configure("2") do |config|
config.vm.box = "rockylinux/9"
(1..10).each do |i|
config.vm.define "node-#{i}" do |node|
node.vm.hostname = "node-#{i}"
node.vm.network "private_network", type: "dhcp"
node.vm.provider "vmware_desktop" do |vmw|
vmw.memory = "1024"
vmw.cpus = 1
end
# Provisioning common to all nodes
node.vm.provision "shell", inline: <<-SHELL
sudo yum install -y epel-release
SHELL
# Provisioning specific to node 1 (controller)
if i == 1
node.vm.provision "shell", inline: <<-SHELL
sudo yum install -y ansible sshpass
sudo -u vagrant ssh-keygen -t rsa -b 4096 \
-f /home/vagrant/.ssh/ansible.id -N ""
cp /home/vagrant/.ssh/ansible.id.pub /vagrant/ansible.pub
SHELL
end
# All nodes: import the public key and register in the CSV
node.vm.provision "shell", inline: <<-SHELL
cat /vagrant/ansible.pub >> /home/vagrant/.ssh/authorized_keys
IP=$(ip addr show | grep -E "inet .* (eth|ens)" | \
awk '{print $2}' | cut -d/ -f1 | head -n 1)
echo "node-#{i},$IP,/usr/bin/python3,ssh" >> /vagrant/ansible_inventory.csv
SHELL
end
end
end
Starting the cluster:
vagrant up
5.3 Configuring a Dynamic Inventory
A dynamic inventory generates the host list on the fly, which is particularly useful with DHCP where IPs can change. The Python script reads a CSV file generated during provisioning.
ansible.cfg — Configuration pointing to the dynamic inventory:
[defaults]
inventory = /vagrant/vagrant_inventory.py
host_key_checking = False
remote_user = vagrant
private_key_file = ~/.ssh/ansible.id
forks = 10
JSON structure returned by a dynamic inventory:
{
"_meta": { "hostvars": { "node-1": { "ansible_host": "192.168.x.x" } } },
"all": { "hosts": ["node-1", "node-2", ...], "children": ["ungrouped"] },
"ungrouped": { "hosts": ["node-1", "node-2", ...] }
}
inventory.py — Dynamic inventory script from CSV:
#!/usr/bin/env python3
import csv, json, os, argparse
from pathlib import Path
class AnsibleInventory:
def __init__(self, csv_file):
self.csv_file = csv_file
self.inventory = {
"_meta": {"hostvars": {}},
"all": {"hosts": [], "children": ["ungrouped"]},
"ungrouped": {"hosts": []}
}
def read_csv(self):
if not os.path.isfile(self.csv_file):
return False
with open(self.csv_file, 'r') as f:
for row in csv.DictReader(f):
host = row.get('host', '').strip()
if not host:
continue
self.inventory["all"]["hosts"].append(host)
self.inventory["ungrouped"]["hosts"].append(host)
self.inventory["_meta"]["hostvars"][host] = {
"ansible_host": row.get('ansible_host', '').strip()
}
return True
def output_list(self):
self.read_csv()
print(json.dumps(self.inventory, indent=2))
def output_host(self, host):
self.read_csv()
print(json.dumps(
self.inventory["_meta"]["hostvars"].get(host, {}), indent=2
))
def main():
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--list', action='store_true')
group.add_argument('--host')
parser.add_argument('--csv',
default=str(Path(__file__).parent / 'ansible_inventory.csv'))
args = parser.parse_args()
inv = AnsibleInventory(args.csv)
if args.list:
inv.output_list()
elif args.host:
inv.output_host(args.host)
if __name__ == '__main__':
main()
Dynamic inventory script requirements:
| Option | Required behavior |
|---|---|
--list | Return all hosts as JSON |
--host <name> | Return host variables as JSON |
5.4 Running Standard Playbooks
Without optimization, each task runs sequentially — Ansible waits for completion on one host before moving to the next (default: 5 hosts in parallel via the forks parameter).
p1.yaml — Baseline playbook (reference for benchmarks):
---
- name: Install packages and start services
hosts: all
gather_facts: yes
become: true
vars:
packages_to_install:
- httpd
- mysql-server
services_to_start:
- httpd
- mysqld
tasks:
- name: Install packages
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop: "{{ packages_to_install }}"
- name: Start services
ansible.builtin.systemd:
name: "{{ item }}"
state: started
enabled: yes
loop: "{{ services_to_start }}"
5.5 Increasing Speed with Forks
By default, Ansible only opens 5 simultaneous connections. Increasing forks allows more hosts to be processed in parallel.
# Run with 10 forks
ansible-playbook p1.yaml -f 10
Limit: The number of forks is constrained by the controller’s capacity (memory, CPU, network connections). With 100 hosts, 100 forks are not feasible.
5.6 Asynchronous Tasks
p2.yaml — Launch tasks without waiting for completion:
---
- name: Install packages and start services in parallel
hosts: all
gather_facts: yes
become: true
vars:
packages_to_install:
- httpd
- mysql-server
services_to_start:
- httpd
- mysqld
tasks:
- name: Install packages in parallel
ansible.builtin.dnf:
name: "{{ item }}"
state: present
async: 600 # 10-minute timeout
poll: 0 # Do not wait for response
loop: "{{ packages_to_install }}"
- name: Start services in parallel
ansible.builtin.systemd:
name: "{{ item }}"
state: started
enabled: yes
async: 300 # 5-minute timeout
poll: 0 # Do not wait for response
loop: "{{ services_to_start }}"
Drawback: With
poll: 0, Ansible does not retrieve the actual status — it always reports “changed” even if nothing changed.
flowchart LR
A[Ansible Controller] -->|Launch async task| B[node-1]
A -->|Launch async task| C[node-2]
A -->|Launch async task| D[node-10]
B -->|Job ID returned| A
C -->|Job ID returned| A
D -->|Job ID returned| A
A -->|Continue without waiting| E[Next task]
5.7 Configuring Forks in ansible.cfg
Instead of passing -f 10 with every command, configure forks in ansible.cfg:
[defaults]
inventory = /vagrant/vagrant_inventory.py
host_key_checking = False
remote_user = vagrant
private_key_file = ~/.ssh/ansible.id
forks = 10
5.8 Async Tasks with Status Checking
p3.yaml — The complete version: async launch + status checking with async_status:
---
- name: Install packages and start services with status reporting
hosts: all
gather_facts: yes
become: true
vars:
packages_to_install:
- httpd
- mysql-server
services_to_start:
- httpd
- mysqld
tasks:
- name: Install packages in parallel
ansible.builtin.dnf:
name: "{{ item }}"
state: present
async: 600
poll: 0
loop: "{{ packages_to_install }}"
register: pkg_install_results
- name: Wait for package installations to complete
ansible.builtin.async_status:
jid: "{{ async_result_item.ansible_job_id }}"
register: async_job_result
until: async_job_result.finished
retries: 60
delay: 10
with_items: "{{ pkg_install_results.results }}"
loop_control:
loop_var: async_result_item
label: "{{ async_result_item.item }}"
- name: Collect package installation status
ansible.builtin.set_fact:
pkg_install_statuses: "{{ pkg_install_results.results | map(attribute='item') | list }}"
- name: Report package installation results
ansible.builtin.debug:
msg: "Package {{ item }} installation completed"
with_items: "{{ pkg_install_statuses }}"
- name: Start services in parallel
ansible.builtin.systemd:
name: "{{ item }}"
state: started
enabled: yes
async: 300
poll: 0
loop: "{{ services_to_start }}"
register: svc_start_results
- name: Wait for service starts to complete
ansible.builtin.async_status:
jid: "{{ async_result_item.ansible_job_id }}"
register: async_job_result
until: async_job_result.finished
retries: 30
delay: 10
with_items: "{{ svc_start_results.results }}"
loop_control:
loop_var: async_result_item
- name: Report service start results
ansible.builtin.debug:
msg: "Service {{ item }} start completed"
with_items: "{{ svc_start_results.results | map(attribute='item') | list }}"
sequenceDiagram
participant C as Controller
participant N as node-1..10
C->>N: Launch install packages (async, poll=0)
N-->>C: ansible_job_id returned
C->>N: Launch start services (async, poll=0)
N-->>C: ansible_job_id returned
loop Check every 10s (max 60 retries)
C->>N: async_status (jid=...)
N-->>C: finished? True/False
end
C->>C: Final status report
Comparison of approaches:
| Approach | Speed | Exact Status | Scalability |
|---|---|---|---|
| p1 — synchronous | Slow | Yes | Low |
p1 + -f 10 | Medium | Yes | Limited by controller |
| p2 — async poll=0 | Very fast | No (always “changed”) | Excellent |
| p3 — async + status | Fast | Yes | Excellent |
6. Quick Reference — Key Commands
# Collections
ansible-galaxy collection install community.docker
ansible-galaxy collection install community.general
ansible-galaxy collection init example.compliance
# Roles
ansible-galaxy role init chrony
# Run with forks
ansible-playbook playbook.yaml -f 10
# Extra variables
ansible-playbook playbook.yaml -e "version_number=v2.0"
ansible-playbook playbook.yaml -e "terraform_state=absent"
ansible-playbook playbook.yaml -e "rollback=true version_number=v1.0"
# Dynamic inventory — verification
python3 inventory.py --list
python3 inventory.py --host node-1
# Vagrant
vagrant up # Start all VMs
vagrant destroy # Remove all VMs
vagrant ssh node-1
7. Overall Architecture
flowchart TB
subgraph IaC ["Module 1 - IaC"]
A1[install_docker_rhel9.yaml] --> A2[docker_nginx.yaml]
A3[install_terraform.yaml] --> A4[main.tf]
A4 --> A5[ansible_terraform.yaml]
end
subgraph Compliance ["Module 2 - Compliance"]
B1[python_version.yaml]
B2[chrony_1.yaml] --> B3[chrony_2.yaml]
B3 --> B4[chrony_3.yaml]
B4 --> B5[Collection example.compliance]
B5 --> B6[Role chrony]
end
subgraph CICD ["Module 3 - CI/CD"]
C1[.gitlab-ci.yml] --> C2[lint stage]
C2 --> C3[test stage]
C3 --> C4[deploy stage]
C5[web_rollback.yaml] --> C6[Versioned symlinks]
end
subgraph Scale ["Module 4 - Scale"]
D1[Vagrantfile] --> D2[10x Rocky Linux 9 VMs]
D3[inventory.py] --> D4[Dynamic CSV]
D5[p1.yaml] --> D6[p2.yaml async]
D6 --> D7[p3.yaml async+status]
D8[ansible.cfg] --> D9[forks = 10]
end
Ansible[Ansible Controller\nRHEL 9.5 / node-1] --> IaC
Ansible --> Compliance
Ansible --> CICD
Ansible --> Scale
Search Terms
ansible · enterprise · automation · infrastructure · ci/cd · devops · terraform · checking · collections · configuring · docker · forks · gitlab · iac · installing · managing · tasks · version