I first learned about Ansible at Jeff Geerling’s session DevOps for Humans: Ansible for Drupal Deployment Victory! at DrupalCon Austin. I also read (parts of) Jeff’s book Ansible for DevOps. So what is Ansible you ask? Ansible is a command line tool used for automation.
I have used Ansible a few times now, but not for a year or two. I recently started work on a project where Ansible is a good fit, and I had to re-learn some of the basics. I decided to write the tutorial that I wish had been available when I started back up again.
Ansible, good gosh! What is it good for?
To begin, Ansible is designed to manage remote computers. A few key points:
- The managed computer needs Python installed, but nothing specific to Ansible.
- By default, set up the managed computer so you can log in with SSH using a key.
- Ansible is especially good at running the same commands on multiple computers.
- Ansible is also good at handling communications between computers.
If you do not have SSH access to the managed computer, then there are alternatives. That is beyond the scope of this tutorial; see Remote Connection Information if you want to know more.
Communication between computers includes our local machine. For example, we can use Ansible to back up a database on a remote server and then copy the file to our local computer, a different remote machine, or a storage service.
A key feature of Ansible is that most of its operations are idempotent by default. This means we can do an operation twice and it will have the same effect as doing it once. We can add a line to a configuration file without accidentally duplicating it, or we can safely configure a web server even if it has already been configured.
Ansible shines with complex tasks that involve many related servers, but this tutorial is an invitation to start using it for some simpler tasks.
There are some reasons you might not want to use Ansible for automation. Shell scripts are appropriate for many tasks, and a lot more people are familiar with shell scripts than with Ansible. Also, Ansible is still evolving pretty rapidly. If you want your Ansible scripts to keep working for a few years, then you should expect to do some maintenance when you update to the latest version.
Step 0: Installation
I use Homebrew to manage packages on both Mac and Linux, so I install Ansible with
brew install ansible
For other options, and detailed requirements for the managed computers as well as the one running Ansible, see the Installation Guide. According to that page,
Windows isn't supported for the control node.
Step 1: Are you there? (How to check that Ansible is installed)
The simplest way to check that the installation was successful is to ping yourself:
$ ansible -m ping localhost
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}
(The $
is the command prompt. Do not type it.)
Step 2: Inventory (Tell Ansible where to find our computers)
The inventory file contains a list of computers that we will manage with Ansible. By default, Ansible looks for /etc/ansible/hosts
, and it is common practice to name the file hosts
or hosts.yml
. For this tutorial we will use a minimal inventory called my_inventory.yml
:
# File my_inventory.yml
---
all:
hosts:
localhost:
ansible_connection: local
ansible_python_interpreter: auto_silent
If I knew that you had SSH access to some server, then I would include that, but the only thing I can count on is that you have access to localhost
. Since we do not need SSH to connect to localhost
, I included ansible_connection: local
. The last line specifies a strategy for finding python
on the managed computer. See Interpreter Discovery in the official documentation if this does not work for you.
To test that this is working, ping localhost
again:
$ ansible -i my_inventory.yml -m ping all
localhost | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Note that we specify the inventory with -i my_inventory.yml
and that Ansible tells us where it found python
. (Your results may be different.)
Step 3: The playbook (A reusable set of Ansible commands)
In real life, we want to run more than one command at a time, so we create a "playbook". For starters, this one will do the same thing as Step 2:
# File playbook.yml
---
- hosts: all
gather_facts: false
tasks:
- name: Ping
ping:
Since the first line says to use "all" hosts, we will not need to specify that on the command line. The gather_facts: false
line just saves a little time. The last line specifies which Ansible module to use; it is the equivalent of -m ping
in the previous commands.
Run a playbook with ansible-playbook
instead of ansible
:
$ ansible-playbook -i my_inventory.yml playbook.yml
PLAY [all] ************************************************************************************************
TASK [Ping] ***********************************************************************************************
ok: [localhost]
PLAY RECAP ************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Step 4: Tasks (Organizing our Ansible playbook)
We can add more plays to our playbook, and each play can have multiple steps. For better organization and for reuse, we can also create separate tasks:
# File tasks/hello.yml
---
- name: Echo
command: echo hello, world
register: hello_var
changed_when: false
- name: Hello feedback
debug:
msg: "{{ hello_var.stdout }}"
This introduces two more modules:
command
: execute a command on the target, in this case echo hello, world.debug
: print a message or a variable when run.
There is also a shell
module. It is similar to command
, but it uses /bin/sh
instead of executing the command directly.
This example also shows how to save the output of a command in a variable: register: hello_var
. That variable is available to later steps in the task and the playbook. To see the full variable, we could replace the msg:
line with var: hello_var
.
Step 5: Using tags (Run selected task from our Ansible playbook)
In order to run the task from Step 4, update the playbook:
# File playbook.yml
---
- hosts: all
gather_facts: false
tasks:
- name: Ping
ping:
tags: ping
- name: Hello
import_tasks: tasks/hello.yml
tags: hello
We can now run the entire playbook as before or we can run just the new task by specifying the hello
tag:
$ ansible-playbook -i my_inventory.yml playbook.yml -t hello
PLAY [all] ************************************************************************************************
TASK [Echo] ***********************************************************************************************
ok: [localhost]
TASK [Hello feedback] *************************************************************************************
ok: [localhost] => {
"msg": "hello, world"
}
PLAY RECAP ************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The import_tasks
module imports tasks statically. We can also import them dynamically with include_tasks
. I chose the former because I want to see the output, but there are more significant differences between the two. See Including and Importing in the Ansible documentation.
Step 6: Create a file (Use Ansible to manage a config file)
Here is an example of something more useful: creating and modifying a configuration file. If we want to connect to a remote server over SSH, and we do not want to type the user, full server name, and possibly a non-default SSH key every time we connect, then it is helpful to have an SSH config file. This file is at ~/.ssh/config
and should have permissions 600
(read/write by user, no other access).
For this tutorial, we will create a file in the /tmp/ directory
rather than ~/.ssh/
.
Start by creating a template:
# File templates/ssh_config.j2
# {{ ansible_managed }}
Host mywebserver
HostName 127.0.0.1
User webuser
Then create a task:
# File tasks/ensure_ssh_config.yml
---
- name: Ensure the file is present
template:
src: templates/ssh_config.j2
dest: /tmp/config
mode: '0600'
Reference the new task from the playbook:
# File playbook.yml
# ... some lines skipped
- name: Config file
import_tasks: tasks/ensure_ssh_config.yml
tags: ensure_config_file
Now we can run the playbook, specifying the ensure_ssh_config
tag:
$ ansible-playbook -i my_inventory.yml playbook.yml -t ensure_ssh_config
PLAY [all]
************************************************************************************************
TASK [Ensure the file is present]
*************************************************************************
changed: [localhost]
PLAY RECAP
************************************************************************************************
localhost : ok=1 changed=1 unreachable=0 failed=0
skipped=0 rescued=0 ignored=0
Confirm that the file has been created:
$ cat /tmp/config
# Ansible managed
Host mywebserver
HostName 127.0.0.1
User webuser
Note that the expression {{ ansible_managed }}
in the template has been replaced with "Ansible managed". In real life, the values for Host, HostName, and User would also be variables in the template.
Step 7: What has changed? (Making sense of Ansible's feedback)
Look again at the messages from Ansible in Step 6: changed: [localhost]
when running the task and changed=1
in the recap at the end. Note what happens if we run the task again:
$ ansible-playbook -i my_inventory.yml playbook.yml -t ensure_ssh_config
PLAY [all]
************************************************************************************************
TASK [Ensure the file is present]
*************************************************************************
ok: [localhost]
PLAY RECAP
************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0
skipped=0 rescued=0 ignored=0
Now we get ok: [localhost]
and ok=1
. The template
module, like most Ansible modules, keeps track of whether anything changes. If nothing will change, then the module does nothing. We can confirm this by checking the timestamp (ls -l /tmp/config
), running ansible-playbook
again, and checking the timestamp again.
One exception is the command
module: it cannot tell whether anything has changed. That is why I added changed_when: false
in tasks/hello.yml
after command: echo hello, world
.
Step 8: Add a line to a file (Using the lineinfile Ansible module)
Add two more steps to the config-file task:
# File tasks/ensure_ssh_config.yml
---
- name: Ensure the file is present
template:
src: templates/ssh_config.j2
dest: /tmp/config
mode: '0600'
- name: Add a comment to the file
lineinfile:
path: /tmp/config
line: '# Favorite number: 17'
- name: Demonstrate that the line will not be added again
lineinfile:
path: /tmp/config
line: '# Favorite number: 17'
This introduces another module, lineinfile
, and it also emphasizes that most modules do not make unnecessary changes. They are idempotent: repeating them more than once has the same effect as doing them once.
$ ansible-playbook -i my_inventory.yml playbook.yml -t ensure_ssh_config
PLAY [all] ************************************************************************************************
TASK [Ensure the file is present] *************************************************************************
ok: [localhost]
TASK [Add a comment to the file] **************************************************************************
changed: [localhost]
TASK [Demonstrate that the line will not be added again] **************************************************
ok: [localhost]
PLAY RECAP ************************************************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Note that lineinfile
reports changed
the first time and ok
the second time. We can also check that the line has been added only once:
$ cat /tmp/config
# Ansible managed
Host mywebserver
HostName 127.0.0.1
User webuser
# Favorite number: 17
Step 9: Less typing (Use a shell script to wrap our Ansible commands)
For convenience, we can add a simple shell script as a wrapper for the ansible-playbook command:
#!/bin/bash
# File bin/tutorial.sh
if [ $# -gt 0 ]; then
ansible-playbook -i my_inventory.yml --tags="$1" playbook.yml
else
cat <<-EOS
Call $0 with one of these arguments. For example, '$0 hello'.
- ping: Simple connection test.
- hello: Standard message.
- ensure_ssh_config: Create /tmp/config.
EOS
fi
Make this file executable (chmod a+x bin/tutorial
). Now it is easier to run commands:
$ bin/tutorial.sh
Call bin/tutorial.sh with one of these arguments. For example, 'bin/tutorial.sh hello'.
- ping: Simple connection test.
- hello: Standard message.
- ensure_ssh_config: Create /tmp/config.
$ bin/tutorial.sh hello
PLAY [all] ************************************************************************************************
TASK [Echo] ***********************************************************************************************
ok: [localhost]
TASK [Hello feedback] *************************************************************************************
ok: [localhost] => {
"msg": "hello, world"
}
PLAY RECAP ************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
We can even run more than one at a time: try bin/tutorial.sh hello,ping
.