Ansible 101: #004 - Playbooks, tasks and handlers

Welcome back to the Ansible 101 Guide. In this part of the tutorial, I will try to show you how to use playbooks. I will also show you what tasks and handlers are and how we use them.

Playbooks

We can use playbooks to define reusable Ansible code. A playbook consists of one or more plays. For example, we could use a playbook called "webserver.yml" to manage our web servers.

Let's try to learn the structure of a playbook using the following example:

- name: webserver install 
  hosts: webservers
  tasks: 
    - name: install apache2
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: yes
      become: true

    - name: enable and start apache2 systemd service
      ansible.builtin.systemd:
        name: apache2
        enabled: true
        state: started

Let's investigate this line by line!

Line 1 - name

A descriptive name for the play can be assigned in the ‘name’ field in line 1. This parameter is optional, but I recommend naming each play as meaningfully as possible.

Line 2 - hosts

In line 2, we define the target systems for this play with the ‘hosts’ field. We have several options here:

Group(s)
We can define one or more groups from our inventory as a target:

- name: Play for hosts in one group
  hosts: Group1
  tasks:
    - name: Example Task 1
      ansible.builtin.apt:
        name: apache2
        state: present
- name: Play for two groups
  hosts: 
    - Group1
    - Group2
  tasks:
    - name: Example Task 1
      ansible.builtin.apt:
        name: apache2
        state: present

Hosts

Alternatively, we can also specify the hosts from the inventory directly as the target:

- name: Play for one host
  hosts: webserver1
  tasks:
    - name: Example Task 1
      ansible.builtin.apt:
        name: apache2
        state: present
- name: Play for two hosts
  hosts: 
    - webserver1
    - webserver2
  tasks:
    - name: Example Task 1
      ansible.builtin.apt:
        name: apache2
        state: present

There are a few other options in this section that we can use to influence the target list. For example, we can exclude individual hosts or groups from the target definition. More on this in a later article.

Line 3 - Begin of task definitions

This is where the list of individual tasks for this play begins. Lets see how a task is build in Ansible.

Lines 4 to 9 - Task definition

This brings us to the structure of a task. Note the indentation of each element. We distinguish between parameters that belong to the task and parameters that belong to the module.

The 'name' field (line 4) is optional, as with Play, but highly recommended, as it will also appear later in the output. After all, we want to know what is happening. Line 5 is the name of the module we want to use. In this case we want to use the 'apt' module to install a package. Everything that appears one indent further down is a module-level parameter, i.e. it refers to the module, not the task itself. This is where many people get confused.

Task parameters are parameters that affect the task itself, i.e. they can be defined for each task. Module parameters, on the other hand, are different for each module. For example, the Copy module needs information about the source and destination paths, while the 'apt' module needs the name of the package to be installed.

In our example, we use the 'apt' module to install Apache2 with the following parameters

  • name: name of the package to install
  • state: target state:
    • present = installed
    • absent = uninstalled
    • latest = always keep on latest version
  • update_cache: forces to package cache to be updated beforehand

Line 9 contains the definition of another task level parameter (note the indentation). With 'become: true' we basically specify that superuser privileges are required to run this task. We will need this parameter very often, so I will devote a separate part of this series to it.

A note on module parameters

It is important to note that there are some module parameters that are optional and some that must be specified. However, the Ansible documentation is very helpful here. For our example, we can find the information on these pages:

Documentation for apt module

ansible.builtin.apt module – Manages apt-packages — Ansible Community Documentation

Documentation for systemd module

ansible.builtin.systemd module — Ansible Community Documentation

Example

Let us now apply what we have learned to our example scenario. Let's assume that our first two hosts are web servers and the third is a database host.

We will therefore adapt our inventory.txt accordingly:

~/ansible-guide/inventory.txt

[webservers]
ansible-guide-1 ansible_ssh_user=ansible ansible_host=192.168.0.11
ansible-guide-2 ansible_ssh_user=ansible ansible_host=192.168.0.12

[db]
ansible-guide-3 ansible_ssh_user=ansible ansible_host=192.168.0.13

I would now like to divide the setup into two playbooks. Depending on your own preferences and use case, we could also define two plays within one playbook.

Playbook 1: ~/ansible-guide/webservers.yml

- name: webserver setup
  hosts: webservers
  tasks:
    - name: Apache2 Setup
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: true
      become: true

    - name: start and enable apache2
      ansible.builtin.systemd:
        name: apache2
        state: started
        enabled: true
      become: true

Now we can run the playbooks:

cd ~/ansible-guide
ansible-playbook -i inventory.txt webservers.yml
PLAY [webserver setup] ****************************************************************************

TASK [Gathering Facts] ****************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [Apache2 Setup] ****************************************************************************
changed: [ansible-guide-1]
changed: [ansible-guide-2]

TASK [start and enable apache2] ****************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]


PLAY RECAP ****************************************************************************
ansible-guide-1                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 
ansible-guide-2                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

It is interesting to note that the task 'start and enable apache2' returns the status 'ok' and not 'changed' as expected. This is because enabling and starting the service is already defined in the installation package. So there is nothing for Ansible to do at this point.

Now we can quickly test that our installation has worked by entering one of the IP addresses in a browser. We should now see the default Apache web server page.

Playbook 2: ~/ansible-guide/database.yml

- name: database setup
  hosts: db
  tasks:
    - name: MySQL Setup
      ansible.builtin.apt:
        name: mysql-server
        state: present
        update_cache: true
      become: true

    - name: start and enable MySQL
      ansible.builtin.systemd:
        name: mysql
        state: started
        enabled: true
      become: true

We now do the same with the database server playbook:

cd ~/ansible-guide
ansible-playbook -i inventory.txt database.yml
PLAY [database setup] ****************************************************************************

TASK [Gathering Facts] ****************************************************************************
ok: [ansible-guide-3]

TASK [MySQL Setup] ****************************************************************************
changed: [ansible-guide-3]

TASK [start and enable MySQL] ****************************************************************************
ok: [ansible-guide-3]

PLAY RECAP ****************************************************************************
ansible-guide-3                 : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Deploy customized index.html

We have now done a standard installation of Apache2 and MySQL using Ansible. In the next step, I will show you how we can deploy our own index.html file, for example.

To do this, we will first create the file locally on the Ansible controller:

cd ~/ansible-guide
mkdir files
echo "<h1>My own index.html!</h1>" > files/index.html 

This results in the following file content:

<h1>My own index.html!</h1>

Now we add a task to our webservers.yml playbook:

- name: webserver setup
  hosts: webservers
  tasks:
    - name: Apache2 Setup
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: true
      become: true

    - name: start and enable apache2
      ansible.builtin.systemd:
        name: apache2
        state: started
        enabled: true
      become: true

    - name: copy index.html
      ansible.builtin.copy:
        src: files/index.html
        dest: /var/www/html/index.html
      become: true

We are using the Ansible 'copy' module here. This can be used to copy files from the Ansible controller to the target systems. We give the module 2 parameters:

src: Source file path on the Ansible controller
dest: destination path on the remote hosts

We also tell Ansible again with 'become: true' that root permissions are required for this process.

We now run the updated playbook:

ansible-playbook -i inventory.txt webservers.yml
LAY [webserver setup] ****************************************************************************

TASK [Gathering Facts] ****************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [Apache2 Setup] ****************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [start and enable apache2] ****************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [copy index.html] ****************************************************************************
changed: [ansible-guide-1]
changed: [ansible-guide-2]

PLAY RECAP ****************************************************************************
ansible-guide-1                  : ok=4    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
ansible-guide-2                  : ok=4    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Let's check that with curl:

apt install -y curl
curl http://192.168.0.11
<h1>My own index.html!</h1>

Handlers

We have now installed our web server and a database. We have also uploaded our own content to our web server. Finally , I would like to show you how we can also manage the Apache configuration file using Ansible. We want to notify the
web server when changes are made to it (and only then!). For this
handlers.

So we store the Apache configuration on the
Ansible controller. To do this, I simply copied the contents of /etc/apache2 from one of the test hosts
I simply copied the contents of /etc/apache2/apache2.conf from one of the test hosts and trimmed the
comment lines a little to make the whole thing a little more
clearer.

We put it back in our files subdirectory:

~/ansible-guide/files/apache2.conf
DefaultRuntimeDir ${APACHE_RUN_DIR}
PidFile ${APACHE_PID_FILE}

Timeout 300
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5
User ${APACHE_RUN_USER}
Group ${APACHE_RUN_GROUP}

HostnameLookups Off

ErrorLog ${APACHE_LOG_DIR}/error.log
LogLevel warn

IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf

Include ports.conf

<Directory />
	Options FollowSymLinks
	AllowOverride None
	Require all denied
</Directory>

<Directory /usr/share>
	AllowOverride None
	Require all granted
</Directory>

<Directory /var/www/>
	Options Indexes FollowSymLinks
	AllowOverride None
	Require all granted
</Directory>

AccessFileName .htaccess

<FilesMatch "^\.ht">
	Require all denied
</FilesMatch>

LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent

IncludeOptional conf-enabled/*.conf
IncludeOptional sites-enabled/*.conf

We extend our playbook with another task:

~/ansible-guide/webservers.yml

- name: webserver setup
  hosts: webservers
  tasks:
    - name: Apache2 Setup
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: true
      become: true

    - name: start and enable apache2
      ansible.builtin.systemd:
        name: apache2
        state: started
        enabled: true
      become: true

    - name: copy index.html
      ansible.builtin.copy:
        src: files/index.html
        dest: /var/www/html/index.html
      become: true

    - name: copy apache2.conf
      ansible.builtin.copy:
        src: files/apache2.conf
        dest: /etc/apache2/apache2.conf
      become: true

If we were to run the whole thing again now, Ansible would copy both apache2.conf and index.html to the target systems. However, we also want to implement the automatic restart of the Apache service when changes are made. So we will add a handler and call it directly in the new task. I have added comments to the most important lines:

- name: webserver setup
  hosts: webservers
  handlers:                     
    - name: restart-apache      
      ansible.builtin.systemd:
        name: apache2
        state: restarted
      become: true
  tasks:
    - name: Apache2 Setup
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: true
      become: true

    - name: start and enable apache2
      ansible.builtin.systemd:
        name: apache2
        state: started
        enabled: true
      become: true

    - name: copy index.html
      ansible.builtin.copy:
        src: files/index.html
        dest: /var/www/html/index.html
      become: true

    - name: copy apache2.conf
      ansible.builtin.copy:
        src: files/apache2.conf
        dest: /etc/apache2/apache2.conf
      notify: restart-apache                 
      become: true

We have now created another list on the same level as "Tasks". This contains our handler definitions. A handler has exactly the same structure as a task, but is not automatically executed when the playbook is called. This only happens if at least two things are specified:

  • A task has the name of our handler - here "restart-apache" - defined in the "notify" parameter.
  • At least one task that fulfills point 1 returns the status "changed". Only then will the handlers be executed.

Now we run the playbook again:

ansible-playbook -i inventory.txt webserver.yml
PLAY [webserver setup] ***********************************************************************************

TASK [Gathering Facts] ***********************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [Apache2 Setup] ***********************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [start and enable apache2] ***********************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [copy index.html] ***********************************************************************************
ok: [ansible-guide-1]
ok: [ansible-guide-2]

TASK [copy apache2.conf] ***********************************************************************************
changed: [ansible-guide-1]
changed: [ansible-guide-2]

RUNNING HANDLER [restart-apache] ***********************************************************************************
changed: [ansible-guide-1]
changed: [ansible-guide-2]

PLAY RECAP ***********************************************************************************
ansible-guide-1                  : ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 
ansible-guide-2                  : ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

The apache2.conf is now also managed by Ansible and the service will be restarted if necessary! Try this out a few more times by changing some parameters in the configuration and running the playbook.

Important note about handlers

As you can see in the output above, handlers are always executed at the end of the play and not immediately after the calling task. This ensures that, for example, the Apache restart is only executed once, even if multiple tasks make changes and call the handler.

If you want to explicitly restart at this point, you must define the restart as a task.

That's it for now!

In the next part I will show you how to put values into variables and where you can define them. I will also introduce you to some other useful modules!

From my own experience, I can strongly recommend that you just try it all out for yourself and try to apply what you have learned to your own use cases. It's the best way to learn :)

As always, I welcome feedback and suggestions for improvement. Feel free to use the comment function here or send an email to: [email protected].

Have fun trying it out!

Mow