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
Documentation for systemd module
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