Chapter #3: Ansible Playbooks
In the previous tutorial, you learned how to use Ansible ad-hoc commands to run a single task on your managed hosts. In this tutorial, you will learn how to automate multiple tasks on your managed hosts by creating and running Ansible playbooks.
To better understand the differences between Ansible ad-hoc command and Ansible playbooks; you can think of Ansible ad-hoc commands as Linux commands and playbooks as bash scripts.
Ansible ad-hoc commands are ideal to perform tasks that are not executed frequently such us getting servers uptime, retrieving system information, etc.
On the other hand, Ansible playbooks are ideal to automate complex tasks like system patches, application deployments, firewall configurations, user management, etc.
Please notice that I have included all the playbooks, scripts, and files that I am going to discuss in this series in this GitHub repository.
Before you follow this Ansible Playbook tutorial, you should refer to the setup mentioned in the first chapter of the Ansible series.
Creating your first Ansible playbook
Playbooks are written in YAML (Yet Another Markup Language) format. If you don’t know YAML; I have included the most important YAML syntax rules in the figure below so you can easily follow along with all the playbook examples:
You should also be aware that YAML files also must have either a .yaml or .yml extension. I personally prefer .yml because it’s less typing, and I am lazy.
Also, YAML is indentation sensitive. A two-spaces indentation is the recommended indentation to use in YAML; however, YAML will follow whatever indentation system a file uses as long as it’s consistent.
It is beyond annoying to keep hitting two spaces in your keyboard and so do yourself a favor and include the following line in ~/.vimrc file:
autocmd FileType yaml setlocal ai ts=2 sw=2 et
This will convert the tabs into two spaces whenever you are working on a YAML file. Liked this handy Vim tip? You can get this book for advanced Vim tips.
Now let’s create your first playbook. In your project directory, create a file named first-playbook.yml that has the following contents:
[elliot@control plays]$ cat first-playbook.yml
---
- name: first play
hosts: all
tasks:
- name: create a new file
file:
path: /tmp/foo.conf
mode: 0664
owner: elliot
state: touch
This playbook will run on all hosts and uses the file module to create a file named /tmp/foo.conf; you also set the mode: 0664 and owner: elliot module options to specify the file permissions and the owner of the file. Finally, you set the state: touch option to make sure the file gets created if it doesn’t already exist.
To run the playbook, you can use the ansible-playbook command followed by the playbook filename:
ansible-playbook first-playbook.yml
Here's the complete output of the above command:
[elliot@control plays]$ ansible-playbook first-playbook.yml
PLAY [first play] **************************************************************
TASK [Gathering Facts] *********************************************************
ok: [node4]
ok: [node3]
ok: [node1]
ok: [node2]
TASK [create a new file] *******************************************************
changed: [node4]
changed: [node3]
changed: [node1]
changed: [node2]
PLAY RECAP *********************************************************************
node1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node2 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node3 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node4 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The output of the playbook run is pretty self-explanatory. For now, pay special attention to changed=1 in the PLAY RECAP summary which means that one change was executed successfully on the managed node.
Let’s run the following ad-hoc command to verify that the file /tmp/foo.conf is indeed created on all the managed hosts:
[elliot@control plays]$ ansible all -m command -a "ls -l /tmp/foo.conf"
node4 | CHANGED | rc=0 >>
-rw-rw-r-- 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node1 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node2 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node3 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
Notice that you can also run an Ansible ad-hoc command that will do exactly the same thing as first-playbook.yml playbook:
ansible all -m file -a "path=/tmp/foo.conf mode=0664 owner=elliot state=touch"
To read more about the file module, check its Ansible documentation page:
[elliot@control plays]$ ansible-doc file
Running multiple plays with Ansible Playbook
You have only created one play that contains one task in first-playbook.yml playbook. A playbook can contain multiple plays and each play can in turn contains multiple tasks.
Let’s create a playbook named multiple-plays.yml that has the following content:
[elliot@control plays]$ cat multiple-plays.yml
---
- name: first play
hosts: all
tasks:
- name: install tmux
package:
name: tmux
state: present
- name: create an archive
archive:
path: /var/log
dest: /tmp/logs.tar.gz
format: gz
- name: second play
hosts: node4
tasks:
- name: install git
apt:
name: git
state: present
This playbook has two plays:
- First play (contains two tasks) - runs on all hosts.
- Second play (contains one task) - only runs on node4.
Notice that I used the package module on the first play as it is the generic module to manage packages and it autodetects the default package manager on the managed nodes. I used the apt module on the second play as I am only running it on an Ubuntu host (node4).
The yum and dnf modules also exists and they work on CentOS and RHEL systems.
I also used the archive module to create a gzip compressed archive /tmp/logs.tar.gz that contains all the files in the /var/log directory.
Go ahead and run the multiple-plays.yml playbook:
[elliot@control plays]$ ansible-playbook multiple-plays.yml
PLAY [first play] **************************************************************
TASK [install tmux] ************************************************************
changed: [node4]
changed: [node2]
changed: [node3]
changed: [node1]
TASK [create an archive] *******************************************************
changed: [node2]
changed: [node3]
changed: [node1]
changed: [node4]
PLAY [second play] *************************************************************
TASK [install git] *************************************************************
changed: [node4]
PLAY RECAP *********************************************************************
node1 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node2 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node3 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node4 : ok=3 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Everything looks good. You can quickly check if the /tmp/logs.tar.gz archive exists on all nodes by running the following ad-hoc command:
[elliot@control plays]$ ansible all -m command -a "file -s /tmp/logs.tar.gz"
node4 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:46 2020, max compression
node1 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 107458560
node3 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 75560960
node2 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 52326400
I also recommend you check the following Ansible documentation pages and check the examples section:
[elliot@control plays]$ ansible-doc package
[elliot@control plays]$ ansible-doc archive
[elliot@control plays]$ ansible-doc apt
[elliot@control plays]$ ansible-doc yum
Verifying your playbooks (before you run it)
Although I have already shown you the steps to run Ansible playbooks, it is always a good idea to verify your playbook before actually running it. This ensures that your playbook is free of potential errors.
You can use the --syntax-check
option to check if your playbook has syntax errors:
[elliot@control plays]$ ansible-playbook --syntax-check first-playbook.yml
playbook: first-playbook.yml
You may also want to use the --check
option to do a dry run of your playbook before actually running the playbook:
[elliot@control plays]$ ansible-playbook --check first-playbook.yml
PLAY [first play] **************************************************************
TASK [Gathering Facts] *********************************************************
ok: [node4]
ok: [node3]
ok: [node1]
ok: [node2]
TASK [create a new file] *******************************************************
ok: [node4]
ok: [node1]
ok: [node2]
ok: [node3]
PLAY RECAP *********************************************************************
node1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node3 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node4 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Notice that dry running a playbook will not commit any change on the managed nodes.
You can use the --list-options
to list the hosts of each play in your playbook:
[elliot@control plays]$ ansible-playbook --list-hosts multiple-plays.yml
playbook: multiple-plays.yml
play #1 (all): first play TAGS: []
pattern: ['all']
hosts (4):
node4
node2
node1
node3
play #2 (node4): second play TAGS: []
pattern: ['node4']
hosts (1):
node4
You can also list the tasks of each play in your playbook by using the --list-tasks
option:
[elliot@control plays]$ ansible-playbook --list-tasks multiple-plays.yml
playbook: multiple-plays.yml
play #1 (all): first play TAGS: []
tasks:
install tmux TAGS: []
create an archive TAGS: []
play #2 (node4): second play TAGS: []
tasks:
install git TAGS: []
You can also check the ansible-playbook man page for a comprehensive list of options.
Re-using tasks and playbooks
You may find yourself writing multiple playbooks that all share a common list of tasks. In this case, it’s better to create a file that contains a list of all the common tasks and then you can reuse them in your playbooks.
To demonstrate, let’s create a file named group-tasks.yml that contains the following tasks:
[elliot@control plays]$ cat group-tasks.yml
- name: create developers group
group:
name: developers
- name: create security group
group:
name: security
- name: create finance group
group:
name: finance
Now you can use the import_tasks module to run all the tasks in group-tasks.yml in your first playbook as follows:
[elliot@control plays]$ cat first-playbook.yml
---
- name: first play
hosts: all
tasks:
- name: create a new file
file:
path: /tmp/foo.conf
mode: 0664
owner: elliot
state: touch
- name: create groups
import_tasks: group-tasks.yml
You can also the import_playbook module to reuse an entire playbook. For example, you can create a new playbook named reuse-playbook.yml that has the following content:
[elliot@control plays]$ cat reuse-playbook.yml
---
- name: Reusing playbooks
hosts: all
tasks:
- name: Reboot the servers
reboot:
msg: Server is rebooting ...
- name: Run first playbook
import_playbook: first-playbook.yml
Also notice that you can only import a playbook on a new play level; that is, you can’t import a play within another play.
You can also use the include module to reuse tasks and playbooks. For example, you can replace the import_playbook statement with the include statement as follows:
[elliot@control plays]$ cat reuse-playbook.yml
---
- name: Reusing playbooks
hosts: all
tasks:
- name: Reboot the servers
reboot:
msg: Server is rebooting ...
- name: Run first playbook
include: first-playbook.yml
The only difference is that the import statements are pre-processed at the time playbooks are parsed. On the other hand, include statements are processed as they are encountered during the execution of the playbook. So, in summary, import is static while include is dynamic.
Running selective tasks and plays with Ansible playbook
You can choose not to run a whole playbook and instead may want to run specific task(s) or play(s) in a playbook. To do this, you can use tags.
For example, you can tag the install git task in the multiple-plays.yml playbook as follows:
[elliot@control plays]$ cat multiple-plays.yml
---
- name: first play
hosts: all
tasks:
- name: install tmux
package:
name: tmux
state: present
- name: create an archive
archive:
path: /var/log
dest: /tmp/logs.tar.gz
format: gz
- name: second play
hosts: node4
tasks:
- name: install git
apt:
name: git
state: present
tags: git
Now you can use the --tags options followed by the tag name git to only run the install git task:
[elliot@control plays]$ ansible-playbook multiple-plays.yml --tags git
PLAY [first play] **************************************************************
PLAY [second play] *************************************************************
TASK [install git] *************************************************************
ok: [node4]
PLAY RECAP *********************************************************************
node4 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
As you can see, the first two plays were skipped and only the install git did run. You can also see changed=0 in the PLAY RECAP and that’s because git is already installed on node4.
You can also apply tags to a play in a similar fashion.
Alright! This takes us to the end of the Ansible playbooks guide.
Stay tuned for next tutorial as you are going to learn how to work with Ansible variables, facts, and registers. Don't forget to become a pro member of Linux Handbook.