November 12, 2020

Getting started with Ansible

WORDS BY   Tadej BorovÅ¡ak


In our previous blog post, we looked at what configuration management tools bring to the table. And in this post, we will take a closer look at Ansible, our configuration management tool of choice. So if you want to learn what Ansible is, what it does, and how it works, this is the post for you.

We will start by introducing Ansible and some of the benefits we gain when using it. Next, we will describe the differences between three different Ansible packages and give some advice on picking the right one for our use case. We will continue with the installation instructions and finally wrap up with an interactive walkthrough of Ansible’s basic functionality like running Ansible playbooks and installing extra Ansible content.

What is Ansible

We all probably heard about automation systems that suffer from managing the management and automating your automation issues. Ansible not only brings order to our infrastructure, but it also does it sustainably.

Because Ansible is agentless, dealing with daemons being down, upgrading agents, and patching agent security vulnerabilities are a thing of the past. All we need to do is keep our control node (the computer where the Ansible runs) updated, and we should be safe.

But what does Ansible do exactly? Ansible’s primary responsibility is to reliably enforce the desired state on a set of targets.

We usually keep the state description in a relatively human-friendly and machine-executable file called an Ansible playbook. We have a bit more flexibility when it comes to storing information about targets. We can use:

  1. Static inventory files that contain login information for our targets.
  2. Inventory plugins that fetch connection information from some canonical sources like IaaS API.

Enforcing the desired state in the Ansible world can mean almost anything: writing a configuration file, creating a virtual machine in the cloud, adding a VLAN to a network switch, etc. All we need is an Ansible module for performing the task at hand. And there are a lot of them available out there. We will talk more about how to obtain them in the last part of this post.

Target (or host) is also quite a broad term in the Ansible world. Historically, the primary target type was a virtual machine that Ansible managed via the Secure Shell (SSH) protocol. But today, Ansible can control many other things like containers, networking equipment, and various Infrastructure as a Service (IaaS) platforms. And because Ansible is agentless, getting it to work with a specific piece of software or hardware is usually much easier compared to other configuration management tools.

Now we are ready to start talking about Ansible versions.

What Ansible package to use

Today, there are three Ansible packages that we can use for our day-to-day automatization tasks: ansible-2.9, ansible-2.10, and ansible-base-2.10. Why? Because the monolithic Ansible package was split into a core engine and a few bundles of Ansible content called Ansible Collections for the 2.10 release.

But fortunately for us, selecting the correct package is relatively simple. Just answer the following questions in order and stop when you answer “yes” for the first time:

  1. Are you a Red Hat subscriber or want to purchase support? Use ansible-2.9.
  2. Do you have existing playbooks and no time to learn about Ansible Collections? Use ansible-2.10.
  3. Use ansible-base-2.10.

We made sure examples from this blog post work with all of the versions mentioned above, so you should have no trouble following along.

How to install Ansible

We only need to install Ansible onto the control node (the computer or virtual machine you will run Ansible commands on). The most flexible way of installing Ansible is using pip:

$ pip install --user "ansible==2.9.*"  # For ansible 2.9
$ pip install --user ansible           # For ansible 2.10 and newer
$ pip install --user ansible-base      # For ansible-base

This will install Ansible only for our user account and not mess up the system if anything goes wrong.

We can also install Ansible using our distribution’s package manager (for example, dnf on RHEL 8 or apt on Debian-derived distributions).

$ yum install ansible  # RHEL and CentOS 7
$ dnf install ansible  # RHEL and CentOS 8
$ apt install ansible  # Debian and Ubuntu

But bear in mind that most distributions do not package ansible-base and ansible 2.10 yet, so you might need to resort to the pip method for the time being. Official installation instructions contain even more installation-related details.

Unfortunately, we cannot use a Windows machine as an Ansible control node. Matt Davis, one of the core Ansible developers, wrote a blog post about the reasons. Matt also gives some hints about what Windows users can do to remedy the situation, so make sure you check his post out.

Once the installation is behind us, we should be able to run the following command without errors:

$ ansible --version
ansible 2.9.14
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3.8/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 3.8.6 (default, Sep 25 2020, 00:00:00) [GCC 10.2.1 20200723]

If nothing went awry, we are ready to start playing with Ansible.

Taking Ansible for a spin

In this last section, we will play around a bit with Ansible. We will start by analyzing a small Ansible playbook and explaining the purpose of its components, continue with discussing the Ansible inventory, and conclude our escapade with some custom Ansible content installation.

If you would like to follow along with us, you will need a Linux computer with Ansible and Docker installed.

Ansible playbook

Let us start with something relatively simple:

---
- name: Configure local computer
  hosts: localhost

  tasks:
    - name: Create config file
      ansible.builtin.copy:
        dest: /tmp/my.cfg
        content: Hello, World!

Even if you never heard about Ansible playbook or YAML before, you probably guessed that we want to create the /tmp/my.cfg file with the Hello, World! inside it on our local computer. Let us test this. When we run the ansible-playbook command (we assumed that the playbook is stored in the play.yaml file), we will see something like this:

$ ansible-playbook play.yaml
[WARNING]: provided hosts list is empty, only localhost is available. Note
  that the implicit localhost does not match 'all'

PLAY [Configure local computer] ******************************************

TASK [Gathering Facts] ***************************************************
ok: [localhost]

TASK [Create config file] ************************************************
changed: [localhost]

PLAY RECAP ***************************************************************
localhost              : ok=2    changed=1    unreachable=0    failed=0
                         skipped=0    rescued=0    ignored=0

And sure enough, if we open the /tmp/my-cfg.txt file, the hello is there ;) But how did Ansible know what to do? Explanation time ;)

Ansible playbook is nothing more than a list of plays that Ansible executes one after another. For the sake of simplicity, our example contains a single Configure local computer play. The hosts: localhost line informed Ansible that we will be messing with our local computer. This is the target that we talked about previously.

All real action happened in the tasks section of the play. The ansible.builtin.copy: line told Ansible that we want to use the built-in copy module to create a file. And the dest: /tmp/my-cfg.txt and content: Hello parameter lines provided information to the copy module about our file’s location and what to place inside it, respectively. The line that starts with - name serves as documentation for us and is printed to the output when Ansible starts executing the task at hand.

Technically speaking, Ansible did not create a file. It would be more accurate to say that Ansible made sure our text file exists and has the correct content. We can see this technical nitpick in action if we run the same ansible-playbook command again:

$ ansible-playbook play.yaml
[WARNING]: provided hosts list is empty, only localhost is available. Note
  that the implicit localhost does not match 'all'

PLAY [Configure local computer] ******************************************

TASK [Gathering Facts] ***************************************************
ok: [localhost]

TASK [Create config file] ************************************************
ok: [localhost]

PLAY RECAP ***************************************************************
localhost              : ok=2    changed=0    unreachable=0    failed=0
                         skipped=0    rescued=0    ignored=0

Notice how in the previous run, Ansible reported our file creation task as changed, and in this last run, the output changed to ok. This happened because Ansible determined that the file’s current and desired state are precisely the same, and there is nothing to do.

But our configuration files are rarely entirely static, meaning that we often need to customize their content based on some external information. And this is where variables and ansible.builtin.template module come into play - pun intended ;).

Using variables in templates is reasonably straightforward: we just need to place the variable name inside double curly braces, and the template module will replace it with the variable’s value. For example, this is how our parameterized configuration file could look:

{{ greeting }} from {{ ansible_distribution }}, World!

But before we can use the variable, we need to define it somewhere. There are many places where Ansible will look for variable definitions, but we will keep it simple and provide them as part of the playbook (the vars section in the listing below).

---
- name: Configure local computer
  hosts: localhost

  vars:
    greeting: Welcome

  tasks:
    - name: Create config file
      ansible.builtin.template:
        src: my.cfg.j2
        dest: /tmp/my.cfg

We assumed that we saved our template in a file called my.cfg.j2 next to our playbook. If we now re-run the ansible-playbook command and open the /tmp/my.cfg file, it will look something like this:

Welcome from Fedora, World!

Did you notice that we did not supply the value for the ansible_distribution variable, and yet nothing failed? And that we did not talk at all about that gathering facts task that just materializes out of nowhere? Looks like we have some explaining to do ;)

If not instructed otherwise, Ansible will collect some information about the target before running playbook tasks. And this gives us access to quite a few magic variables such as ansible_distribution. The full list is available in the official Ansible documentation.

Most often than not, when we update the configuration for a service, we also need to restart the service itself. And Ansible has us covered here too. We already saw how Ansible reports back if the task execution changed anything. And we can use that information to notify a handler that makes sure changes are correctly propagated.

This is how our demonstration playbook looks like if we add a dummy handler:

---
- name: Configure local computer
  hosts: localhost

  vars:
    greeting: Welcome

  tasks:
    - name: Create config file
      ansible.builtin.template:
        src: my.cfg.j2
        dest: /tmp/my.cfg
      notify: Restart stuff

  handlers:
    - name: Restart stuff
      ansible.builtin.debug:
        msg: Restarting stuff

We can use any module in the handler section. In our case, we used the ansible.builtin.debug module that just prints a message, but in production-ready scenarios, most of the time we use modules like ansible.builtin.service to restart the processes.

If we were to re-run our playbook now, the handler would not be executed because the configuration file did not change. But if we update the greetings variable and re-run Ansible, we should see a handler running:

$ ansible-playbook play.yaml
[WARNING]: provided hosts list is empty, only localhost is available. Note
  that the implicit localhost does not match 'all'

PLAY [Configure local computer] ******************************************

TASK [Gathering Facts] ***************************************************
ok: [localhost]

TASK [Create config file] ************************************************
changed: [localhost]

RUNNING HANDLER [Restart stuff] ******************************************
ok: [localhost] => {
    "changed": false,
    "msg": "Restarting stuff"
}

PLAY RECAP ***************************************************************
localhost              : ok=3    changed=1    unreachable=0    failed=0
                         skipped=0    rescued=0    ignored=0

And although we can use the same modules in both tasks and handlers sections, there is one crucial difference. Tasks enforce the desired state while handlers execute an action. For example, when we use the ansible.builtin.service module in the tasks section, we set the state parameter to started or stopped while in the handlers section, restarted or reloaded make more sense.

Ansible inventory

We did not need an inventory until now because we were playing on our Ansible control node. But once we start managing remote targets, we will need a place to store access information about our targets.

But inventory does not only store our targets. It can also arrange them into different groups. We can then run our Ansible playbook against a group of targets instead of a single host and manage them in bulk.

We can write our static inventory files in quite a few different formats. We will use the YAML format in this tutorial (we already use that to write Ansible playbooks, so using it for inventory files keeps things consistent). For example, this is how a typical inventory file looks like:

---
all:
  children:
    webservers:
      hosts:
        web1:
          ansible_host: ws1.example.com
          ansible_user: ws1_user
          greeting: Ciao
        web2:
          ansible_host: 10.3.2.14
          ansible_user: ws2_user
          greeting: Hola
    containers:
      hosts:
        demo:
          ansible_connection: community.docker.docker
          greeting: Bonjour

In the example above, we defined three groups of hosts: all, webservers, and containers. The webservers group contains two hosts (web1 and web2), and the containers group has a single target (demo). The top-level group all includes hosts from both children groups.

The key-value pairs listed under each host are variable definitions available to Ansible playbook tasks when they execute on the selected host. Do note that variables with the ansible_ prefix have a special meaning. They control things like what address and user to use when connecting to the remote target (when connecting using SSH) or what kind of connection to establish if the default SSH is not what we want.

We do not have access to the fictional web servers from our inventory, but we can create our own Docker container called demo and play with that. The command that will bring our Docker container to life is:

$ docker run --rm -d --name demo centos:8 \
    bash -c 'while true; do sleep 10000; done'

Now that we have our container up and running, we can replace the localhost with containers in our Ansible playbook. This small change will allow us to manage our newly created Docker container.

---
- name: Configure docker containers
  hosts: containers

  # Rest of the playbook omitted for brevity.

On our next run, we also need to update our ansible-playbook command a bit and provide an inventory file using the -i command-line switch:

$ ansible-playbook -i inventory.yaml play.yaml

PLAY [Configure docker containers] ***************************************

TASK [Gathering Facts] ***************************************************
fatal: [demo]: FAILED! => {
  "msg": "the connection plugin 'community.docker.docker' was not found"
}

PLAY RECAP ***************************************************************
demo                   : ok=0    changed=0    unreachable=0    failed=1
                         skipped=0    rescued=0    ignored=0

If everything went according to our plans, things failed in a rather spectacular way because we are missing a piece of Ansible functionality that is not part of the base package.

Note: If you are reading this tutorial far enough in the future and using the ansible package, you might not see the error above. Uninstall the ansible package and install ansible-base if you are missing some excitement in your life ;)

Ansible Collections

You probably noticed that all Ansible module names that we used this far were composed of three parts, separated by a dot. This triplet is called a fully-qualified collection name (FQCN). The last part is the bare module name. The middle part is the collection name that the module lives in. The first part is a namespace that the collection belongs to.

The ansible.builtin collection that we used most of the time comes bundled with all Ansible versions from 2.8 forward. And this is why we had no trouble when we played with our configuration file on the localhost. But when we wanted to connect to our Docker container using the plugin from the community.docker Ansible Collection, things failed.

Ansible comes bundled with the ansible-galaxy tool that we can use to install additional content. In our case, we want to install the Docker Ansible Collection, which we can do by running the following command:

$ ansible-galaxy collection install community.docker
Process install dependency map
Starting collection install process
Installing 'community.docker:0.1.0' to
  '/home/tadej/.ansible/collections/ansible_collections/community/docker'

If we re-run our ansible-playbook command that failed previously, we should see no error:

$ ansible-playbook -i inventory.yaml play.yaml

PLAY [Configure docker containers] ***************************************

TASK [Gathering Facts] ***************************************************
ok: [demo]

TASK [Create config file] ************************************************
changed: [demo]

RUNNING HANDLER [Restart stuff] ******************************************
ok: [demo] => {
    "changed": false,
    "msg": "Restarting stuff"
}

PLAY RECAP ***************************************************************
demo                   : ok=3    changed=1    unreachable=0    failed=0
                         skipped=0    rescued=0    ignored=0

And we can now also confirm that the configuration file is indeed present in our Docker container:

$ docker exec demo cat /tmp/my.cfg
Wellcome from CentOS, World!

But how did we know that we must use community.docker.docker as the ansible_connection value? And where did the Docker Ansible Collection come from?

Well, to be completely honest, we knew the exact FQCN because some of us spend our days lurking in various Ansible channels on IRC ;) But you do not have to.

What you should do instead is visit Ansible Galaxy, a community portal for the distribution of Ansible content. You can search for the relevant content there, obtain installation instructions for the collection you are interested in, and follow the documentation links to learn more about it.

For example, if we search for docker on Ansible Galaxy and click on the first returned Ansible collection, we will end up on the Docker Ansible Collection overview page.

Ansible Galaxy is also a default source of collections for the ansible-galaxy tool. But you probably already guessed that by now, right? ;)

And this concludes our tutorial. But before we head off to the parting words, we should probably clean up the mess we made during the experimentation:

$ docker kill demo
$ rm /tmp/my.cfg

Conclusion

We hope our short introductory tutorial demonstrated well enough why Ansible is one of the big players in the automation space. Designed to be minimal yet extensible, Ansible offers the lowest learning curve for administrators, developers, and other IT specialists. And with its agentless design, Ansible lets us get up and running faster than any other configuration management tool.

With Ansible’s split into the core engine and other content, we can tightly control what modules are available to our Ansible playbooks. But with great power also comes great responsibility, and now we are solely responsible for the quality of Ansible content we use. Feel free to visit our post about high-quality Ansible Collections, where we give some hints on how to assess the quality of Ansible content.

Cheers!