Advanced

Ansible for Enterprise Automation

Automate IaC, compliance checking and CI/CD with Ansible, then scale it to the enterprise.

Lab environment: RHEL 9.5 · Ansible 2.16 (Ansible Automation Platform 2.5) · Python 3.11


Table of Contents

  1. Course Overview
  2. Module 1 — Automating Infrastructure as Code (IaC)
  3. Module 2 — Compliance Checking with Ansible
  4. Module 3 — Building CI/CD Pipelines
  5. Module 4 — Scaling Ansible to Enterprise
  6. Quick Reference — Key Commands
  7. 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:

ComponentVersion
OSRHEL 9.5
RAM2 GB
Ansible2.16
Python3.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: true from capturing the root user when adding to the Docker group, use become: false locally with a set_fact to 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:

CommandDescription
terraform initDownloads the provider (communication driver)
terraform applyCreates the resources
terraform destroyRemoves 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.tf file is placed in a tf/ 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.service does 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_service variable is defined once → less repetition
  • Supports both Debian (chrony.service) and Red Hat (chronyd.service)
  • The block protects 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:

  1. Create a Blank Project
  2. Name the project ansible-cicd-demo
  3. Choose Public or Private visibility
  4. Do not select a deployment target (configured in the YAML)
  5. 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:

OptionRequired behavior
--listReturn 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:

ApproachSpeedExact StatusScalability
p1 — synchronousSlowYesLow
p1 + -f 10MediumYesLimited by controller
p2 — async poll=0Very fastNo (always “changed”)Excellent
p3 — async + statusFastYesExcellent

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

Interested in this course?

Contact us to book it or get a custom training plan for your team.