3 Things I’ve Learned about Ansible (The Hard Way)

Ansible’s simple requirements make it very easy to get started. Overall, it works extremely well, but once you get a bit deeper some things might end up causing discomfort. Here are 3 things I’ve learned about Ansible (or re-learned) the hard way.

Tags Don’t Go as Far as You’d Expect

Ansible tags are a powerful way to limit the amount of work that gets done. Generally, the playbooks will run all the way through because you made them idempotent, but sometimes it’s nice to just target a very specific part. After all, why run through all the database-related tasks when you’re simply looking to change a setting in Nginx?

Clearly, tags are great for simplifying work, but they have a very clear limitation. Once a play matches a tag, it will also run every task that is part of that play regardless of other tags.

Let’s take a look at an example. The following is a very simple top-level play:

- include: sub.yml
  tags: top

That file includes the following sub.yml:

- hosts: localhost

  tasks:
    - debug: msg="sub and top"
      tags: sub,top

    - debug: msg="sub"
      tags: sub

    - debug: msg="blank"

Running ansible-playbook without any --tags produces all the debug message (that should be obvious since Ansible’s default is --tags all). Running ansible-playbook with --tags top will also produce all the output:

$ ansible-playbook --tags top ./top.yml

PLAY [localhost] ***************************************************************

TASK [setup] *******************************************************************
ok: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] => {
    "msg": "sub and top"
}

TASK [debug] *******************************************************************
ok: [localhost] => {
    "msg": "sub"
}

TASK [debug] *******************************************************************
ok: [localhost] => {
    "msg": "blank"
}

PLAY RECAP *********************************************************************
localhost                  : ok=4    changed=0    unreachable=0    failed=0

This information is mentioned in the documentation, though it stops far short of belaboring the point:

"All of these apply the specified tags to EACH task inside the play, included file, or role, so that these tasks can be selectively run when the playbook is invoked with the corresponding tags."

Thus, if you were hoping to use the tag within the included play to continue to isolate things, you’ll be disappointed.

It is possible to further limit what happens at execution by adding --skip-tags:

$ ansible-playbook --tags top --skip-tags sub ./top.yml

PLAY [localhost] ***************************************************************

TASK [setup] *******************************************************************
ok: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] => {
    "msg": "blank"
}

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

In this case, the ambiguous first task in sub.yml matches and doesn’t. The effect is that it does not run.

Lastly, if the top level is skipped, nothing happens:

$ ansible-playbook --tags sub --skip-tags top ./top.yml                                 

PLAY [localhost] ***************************************************************

PLAY RECAP *********************************************************************

There is still a lot of power in the --tags, but I know I spent too much time figuring out why something did or did not run. As a rule of thumb, I try to use tags with role and include statements. If things need to be treated sensitively, I generally set a boolean variable and default it to falseor no rather than having things happen accidentally.

A Hash is a Hash until You Trample on it

Working with hashes in can be tricky in Ansible due to the hash merge behavior of either replace(the default) or merge.

So what does that mean? Let’s use an example:

mj:
    name: M Jay
    job: hash merger
    skill: intrepid

Say we want to augment that hash by adding another bit to mj:

mj:
  eye_color: blue

With the default behavior of replace, you’ll end up with only mj’s eye color. Setting hash_behaviour = merge in the ansible.cfg will get you a hash that has all of mj’s attributes.

Now the problem with overriding the setting in the config file is that it might come back to bite you. If that file gets munged or it’s run on another system, etc., you’ll likely end up with some very strange effects. The way around it in Ansible 2 is to use the combine filter to explicitly merge.

The documentation on this topic is pretty good. For our example above, it would look like this:

- hosts: localhost

  vars:
    var1:
      mj:
        name: M Jay
        job: hash merger
        skill: intrepid
    var2:
      mj:
        eye_color: blue

  tasks:
    - debug: var=var1
    - debug: var=var2
    - set_fact:
      combined_var: "{ { var1 \ combine(var2, recursive=True) } }"
    - debug: var=combined_var

You’ll end up with the following:

$ ansible-playbook hash.yml                                                              

PLAY [localhost] ***************************************************************

TASK [setup] *******************************************************************
ok: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] => {
    "var1": {
        "mj": {
            "job": "hash merger",
            "name": "M Jay",
            "skill": "intrepid"
        }
    }
}

TASK [debug] *******************************************************************
ok: [localhost] => {
    "var2": {
        "mj": {
            "eye_color": "blue"
        }
    }
}

TASK [set_fact] ****************************************************************
ok: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] => {
    "combined_var": {
        "mj": {
            "eye_color": "blue",
            "job": "hash merger",
            "name": "M Jay",
            "skill": "intrepid"
        }
    }
}

PLAY RECAP *********************************************************************
localhost                  : ok=5    changed=0    unreachable=0    failed=0

Again, my advice: don’t change the default, and learn to love the combine filter.

set_fact is Very Sticky

There are multiple ways to set variables in Ansible: include_vars, vars:, passing them as part of the play and, of course, set_fact. The thing to realize is that once you use set_fact to set a variable, you can’t change it unless you use set_fact again. I suppose it’s technically more correct to say that fact has higher precedence, but the effect is the same. Take the following example:

# note that the included play only contains a debug
# statement like the others listed in the example below.

- hosts: localhost

  vars:
    var1: foo

  tasks:
    # produces foo (defined in vars above)
    - debug: msg="original value is "

    # also produces foo
    - include: var_sub.yml

    # produces bar (since it's being passed in)
    - include: var_sub.yml var1=bar

    # produces foo (we're back to the original scope)
    - debug: msg="value after passing to include is "

    # now it get's interesting
    - set_fact:
        var1: baz

    # produces baz
    - debug: msg="value after set_fact is "

    # also produces baz
    - include: var_sub.yml

    # baz again!!! since set_fact carries the precedence
    - include: var_sub.yml var1=bar

    # using set_fact we can change the value
    - set_fact:
        var1: bat

    # the rest all produce bat
    - debug: msg="value after set_fact is "
    - include: var_sub.yml
    - include: var_sub.yml var1=bar

The moral is that running with -vvv or adding debug tasks is a good idea when you’re seeing some odd behavior.

The New Thing: Running Roles as a Task!

I was excited to learn that with Ansible version 2.2 it became possible to run roles as tasks. That may not sound like much, but it’s very powerful and could have saved me a lot of weird code I used to write and/or duplicate.

The magic incantation is include_role.

This new feature is very powerful and allows you to write smaller roles that are easy to include. For example, you can easily create a role to manage your database tables or Elasticsearch indexes. I’ve come across a few use cases. With this new play, it’s now possible to do things like:

- include_role: create-db
  with_items:
    - 'test1'
    - 'tester'
    - 'monkey'
    - 'production'

It’s so powerful that I can’t believe it wasn’t there a long time ago.

Now, if only I could loop over blocks …

What's Next?

DEVOPS CONSULTING - Leverage our decades of large-scale DevOps expertise to migrate to the cloud, automate your infrastructure and take your SaaS and web apps to the next level.

DEVOPS-AS-A-SERVICE - Partner with experts that can maintain your DevOps platform and be responsible for day-to-day operational issues, allowing you to develop and ship your product without the need for internal DevOps hires.

Want a world-class infrastructure without a world-class maintenance headache?

Learn more about DevOps-as-a-Service

Share with your Colleagues: