Ansible basics and some tips to get you started

ansible

Ansible basics and some tips to get you started

From our blog post history  you might think that Puppet is the only configuration management tool we use.
While I don’t blame you for thinking that, you’d be surprised to find out that this it not true 🙂 .
Other than Puppet, we recently started to use Ansible as well and I’d like to say a few words about that.

First of all, why two different tools that accomplish the same thing?

We already talked about why Puppet here , so we won’t repeat it (It’s bad for SEO after all).

So, why Ansible?

First of all, we can start with it very simple.

pip install ansible  on your local machine (you might want to install cowsay as well for a more fun ansible experience), SSH access on the machines you want to manage and you’re good to go.

Well kind of. You fist need to compile an inventory of machines you manage.
The inventory is simply a list of machines grouped by some tag. Here is an example of what your inventory may look like

[all]
server1.com
server2.com
server3.com

[webserver]
server1.com
server2.com

[database]
server3.com

This groupings are useful as they allow you to configure multiple machines at once based on the tag.
Now that your are all set it’s time to write your playbooks.

A playbook is just a yaml file that contains tasks you wish to execute.
A task is the unit of action in Ansible, which executes a module. A module it the unit of code ansible executes.
Confused? It’s ok, we’ll jump to an example now.

Let’s create an Ansible playbook

Let’s say that you wish to say hello to the users server in the webserver group by showing them a motd (message of the day).
Manually editing the /etc/motd on more than one server is a hassle, so you decide to use ansible.
Great choice.

We will create a playbook named add-motd.yaml that will copy a file to replace the /etc/motd file on the remote server.

---
- name: Set motd on remote server
  hosts: webserver
  tasks:
    - name: "copy motd file to /etc/motd"
      copy:
        src: motd
        dest: /etc/motd
        owner: root
        group: root
        mode: '644'

This playbook has only one task which is to copy the “motd” file from our local machine to /etc/motd on the target machine.
We can run this playbook by using ansible-playbook add-motd.yaml.

Let’s go through the playbook line by line to get a better idea on what’s going on.

- name: Set motd on remote server 
  hosts: webserver

Here “name” is just a short description about what the playbook is doing. The hosts section indicates on which machines we’d like to execute the playbook.
In this case we’re targeting the webserver group from our inventory.

tasks: 
- name: "copy motd file to /etc/motd"
  copy: 
    src: motd
    dest: /etc/motd
    owner: root 
    group: root 
    mode: '644'

This section is where all the action happens. We defined only one task that needs to execute, named “copy motd file to /etc/motd” in which we use the copy module.
src is the location of the file on out local machine, dest is where we want the file to end up on the remote host and the other parameters set the permissions on the files.
You can find more extensive documentation on the copy module (and any other module you might want to use here)

Let’s get dynamic

Having the same motd on all the servers isn’t very exciting. Let’s say we want to add a personal touch to it, for example having the server hostname displayed in the motd.
We might edit the motd on our local machine and run the playbook separately on each host, but i think we can all agree that this approach is very inefficient.
Lucky for us, Ansible can help us take care of such issues by using variables and templates.
For templating , we can use the template module.

To use templating, we first need to prepare our template file. We create a new file named motd.j2 with the following content

Hello, and welcome to {{ ansible_facts['fqdn'] }}.
This message is brought to you by Ansible

note that the .j2 file extension is not necessary, it’s just a way to indicate template files, as  templating in ansible uses the Jinja2 templating engine.

We indicate variables in ansible with {{ variable_name }} syntax.

In our playbook, we need to change only the module used from copy to template and the src parameter from motd to motd.j2

---
- name: Set motd on remote server
  hosts: testing
  tasks:
    - name: "copy motd file to /etc/motd"
      template:
        src: motd.j2
        dest: /etc/motd
        owner: root
        group: root
        mode: '644'

We run the playbook with the same command as before. During the run, ansible will replace the value of {{ ansible_facts['fqdn'] }} with the servers fqdn.

You might be wondering where the fact does the{{ ansible_facts['fqdn'] }} variable come from.

Let’s get some facts about Ansible straight

At the beginning of any ansbile run, some facts about the remote host get collected by ansible. This facts get stored in the ansible_fact variable.
This variables can be used for templating and making decisions during the playbook run.
Note that the collecting facts is a relatively lengthy operation. If for whatever reason you wish to skip it you can add gather_facts: no  to your playbook.

Let’s jump back to our motd playbook and let’s say we want to target only Debian based systems. We would like to avoid situation where we accidentally run playbooks, by making sure the tasks will only execute on Debian systems.
Before running the task that sets the motd, we could run a task that checks if the remote system is a Debian system:

- name: Stop if target OS is not supported
  assert:
    that:
      - ansible_facts['distribution'] == "Debian"
    fail_msg: "This playbook doesn't support the target system."

Note that this will make the playbook run fail when it’s not a Debian system, which is the behavior we want to achieve.

You can check the available facts and how may define you own here.

One extra tip that may make your ansible experience more pleasant. In you ansible config file, you can activate fact caching, so that the facts are read from your local cache instead of the remote server, which is usually quicker.

You can find more info on fact caching here.

Yeah, but what if…

Sometimes, you might want specific tasks to execute when a certain condition is met without exiting from the playbook run.
Fortunately for us, ansible supports conditionals
To show how to use conditionals, we will rewrite our motd playbook, by eliminating the task that stops the run and we’ll add a check at the end of the template task.

- name: "copy motd file to /etc/motd"
  template:
    src: motd.j2
    dest: /etc/motd
    owner: root
    group: root
    mode: '644'
  when: ansible_facts['distribution'] == 'Debian'

This is how we can tell ansible that we want the facts to execute only when the condition is met.

Do i have to manually restart services to apply configuration changes??!?

No.

Maybe a few more words about this would help.
Most of the services you want to manage with ansible will require a service restart after a configuration change. Ansible provides a mechanism called handlers  that allows you to run operations after changes.

Let’s say you have a task that copies varnish config files.

- name: "configure varnish" 
  copy: 
    src: "config/varnish/" 
    dest: "/etc/varnish/"

To apply the configuration you just copied, you need to restart varnish on the remote machine. You can do this by defining a handler.

handlers:
  - name: restart varnish
    service:
      name: varnish
      state: restarted

The handler by itself does nothing if it’s not notified. To notify it, at the end of your task you can add

- name: "configure varnish"
  copy:
    src: "config/varnish/"
    dest: "/etc/varnish/"
  notify: restart varnish

A couple of things to note about handlers. A handler will run only after the executed task returns a status changed. In our example, if no files have changed after the copy action, the handler will not run and varnish won’t restart.

If you have multiple actions that invoke the same handler, ansible will run it only once at the end of the playbook run to avoid multiple unnecessary restarts.
This is usually the desired behavior. However, if you need to run the handlers before the playbook end, you can use the meta module with the meta: flush_handlersparameter.

This is how i role

When you start using ansible extensively you can easily find yourself with a mess of playbooks, config files and templates all over the place. Ansible provides a mechanism to organize your playbooks in a more efficient way by using roles.

A role is a self contained collection of playbook, handlers, files, templates and variables.  To get started with roles, you can run ansible-galaxy init my-role-name

This will create a new directory called my-role-name with all the directories and files you need to get started. We’ll go through the most important ones.

  • tasks/main.yaml is the entry point of your role. This is the playbook that will be executed when you import your role
  • defaults/main.yaml are the default role variables. Variables defined here are the lowest priority ones (this means that the will be easily overridable)
  • vars/main.yaml other role variables
  • files/ directory containing role files you wish to deploy
  • tempates/ directory containing templates
  • handlers/main.yaml contains the role handlers. The handles can be used within and outside of the role.

One thing to note, when you use the copy, template or some other module that deals with file, as the source you only need to specify the file name inside the files or templates directory, no need to define the full path or the path relative to your playbook.

For example if we have the following structure:

files/service.conf
files/service.d/00_service.conf
tasks/main.yaml

If we wish to copy service.conf  and 00_service.conf file to the remote machine we will write our task as follows:

- name: "copy to remote machine"
  copy:
    src: service.conf
    dest: "/path/on/remote/machine/service.conf"
  copy:
    src: service.d/00_service.conf
    dest: "/path/on/remote/machine/service.d/00_service.conf"

Roles in your Ansible playbook

To use roles in your playbooks, you can define a role section with the roles you wish to apply:
This is an example playbook to setup a webstack with php version 7.4.
Note that here we set the variable phpversion that is defined in the php-fpm role.

---
- name: "webstack install"
  hosts: webserver
  roles:

    - role: apache

    - role: php-fpm
      vars:
        - phpversion: 7.4
    - role: haproxy

    - role: varnish

    - role: nginx

    - role: acme.sh
 

When you include roles in your playbook, ansible will look for them relative to your playbook path in a directory called “roles”. You can also define in you ansible config the default_roles_path variable
to tell ansible where to look for roles.
You can also import roles in your playbook by using include_role or import_role.
If you wonder why there are two modules to accomplish the same thing. We will find the answer in the next paragraph.

Imports vs Includes

This is a fun one. Ansible allows you to re use playbook, tasks, roles by using either import or include statements.
Import_* statements are processed before the playbook executes, while Include_* statements are processed when they are encountered.
An example of the difference in behavior.

---
- name: include vs import
  hosts: localhost
  tasks:
    - name: "import some tasks"
      import_tasks: this_playbook_doesnt_exist.yaml

If the playbook “this_playbook_doesnt_exist.yaml” doesn’t exists the run will fail immediately. However, if you use include_tasks instead

---
- name: include vs import
  hosts: localhost
  tasks:
    - name: "include some tasks"
      include_tasks: this_playbook_doesnt_exist.yaml

The playbook will fail when the task is executed.  While this behavior may seem the same to you, suppose you have multiple tasks that run before your include/import.
With import_tasks you will be notified immediately that something is wrong, while with include_tasks all the other tasks will be executed before encountering the problematic include.

This does not mean you should rewrite all your playbook to use imports instead of includes.  Includes can speed up your playbook execution.
Let’s consider the following example where we want to run specific tasks if we’re targeting a Debian system or a CentOS system:

---
- name: include vs import
  hosts: webserver
 
  tasks:
 
    - name: "import debian tasks"
      import_tasks: "debian.yaml"
      when: ansible_distribution == Debian 
    - name: "import centos tasks"
      import_tasks: "centos.yaml"
      when: ansible_distribution == CentOS

When you run this playbook on a CentOS target, all the tasks included in debian.yaml will also be imported in the playbook and for each task you’ll get notified that it will not be executed because the condition ansible_distribution==CentOS is not matched.

This is a bit annoying as it clutters the output. If you rewrite your playbook to use include_tasks, the debian.yaml include will be skipped and you’ll have a faster execution and less cluttered output.
Another thing we can do with inlcudes which is impossible to do with imports is to use variables in the include statement.
If we take the previous example, we can rewrite it as:

- name: "include tasks"
  include_tasks: "{{ ansible_distribution |lower }}.yaml" 

This will automatically look for a debian.yaml or centos.yaml file depending on the remote target OS.

Tag all things

Tags are a pretty nice feature that Ansible provides. They allow you to run only specific tasks that match the provided tags.
If we jump back to our webstack install example we showed while talking about roles, it would be nice to have the opportunity to run only specific roles without having to write a new playbook.
This is where tags come in handy. We can add tags to our roles as follows:

---
- name: "webstack install"
  hosts: webserver
  roles:

    - role: apache
      tags:
        - apache
        - apache-php
        - all

    - role: php-fpm
      vars:
        - phpversion: 7.4
      tags:
        - php-fpm
        - apache-php
        - all
    - role: haproxy
      tags:
        - haproxy
        - all
    - role: varnish
      tags:
        - varnish
    - role: nginx
      tags:
        - nginx
        - all
    - role: acme.sh
      tags:
        - acme.sh
        - all

Now if we wish to include only the nginx role, we can run our playbook with --tags nginxflag and only the nginx role will be run. We can also use the same tags for multiple roles, like we have for the all and apache-php tags.

It is also possible to skip certain tags by using the --skip-tags flag.
We can use Tags also on specific tasks, blocks,…
I found they are great for debugging purposes when you have lots of tasks to execute before you arrive at the one you wish to debug.

Conclusion about Ansible

Ansible as a powerful tool that can be used and abused as you wish. We have found that it is a good fit for one time tasks and bootstrapping services on machines we manage.
If you are new to Ansible, i hope this post gave you some insight and ideas on how it can be used to make your life easier. If you have already used Ansible and wish to share your experiences and some tips, feel free to drop us a comment, don’t be shy.

Share this post