June 30, 2021

Cloud automation with Ansible series: Inventory caching

WORDS BY   Sašo Stanovnik

POSTED IN   Ansible | inventory | cloud automation


We’ve previously talked about using a dynamic inventory for cloud automation with Ansible and promised to also explore inventory caching. Here we are! We’re looking at what inventory caching is, why we need it, and how it can speed up our day-to-day operations with Ansible.

Inventory caching basics

As Ansible uses a dynamic inventory, this means that it must read remote state before executing any operations. This is frequently done in batches of operations, called Ansible playbooks, where creating an inventory only occurs once at the beginning. Ignoring manually requesting refreshes, of course. Those are useful when your playbook creates a new host on a cloud provider, and you want the same playbook to be aware of that new host. It is also quite useful for updating the inventory cache for later executions, effectively simulating a static inventory.

For numerous ad-hoc commands however, connecting to an infrastructure provider’s API and establishing an inventory each and every time can get time-consuming. Think generating many different reports, executing quick fixups or queries against nodes, testing connectivity and so on. Let’s look at how inventory caching works in practice!

Creating some machines

But first, we need some machines to use our inventory to keep this example self-contained. Let’s just create one to keep things simple. Here’s our very simple playbook. Don’t worry about the loop, we’ll get to using that later.

- hosts: localhost
  vars:
    your_pubkey: YOUR_PUBKEY
  tasks:
    - community.digitalocean.digital_ocean_sshkey:
        name: steampunk-pubkey
        state: present
        ssh_pub_key: "{{ your_pubkey }}"
      register: pubkey

    - community.digitalocean.digital_ocean_droplet:
        name: "{{ item }}"
        unique_name: true
        ssh_keys:
          - "{{ pubkey.data.ssh_key.fingerprint }}"
        size: s-1vcpu-1gb
        region: fra1
        image: centos-8-x64
        state: present
      loop:
        - steampunk-cachelet-1

We’ll need to install the DigitalOcean community Ansible collection to do this. Since inventory caching isn’t available as a release for this collection (we’ll get to this later!), we install it directly from the upstream repository. Then, all that’s left is to run the playbook!

# this makes things easy to clean up
mkdir steampunk-trials && cd steampunk-trials/
python3 -m venv .venv && source .venv/bin/activate

pip install -U pip wheel
pip install ansible-core==2.11.1
ansible-galaxy collection install \
    git+https://github.com/ansible-collections/community.digitalocean.git

export DO_API_TOKEN=<YOUR API TOKEN>

ansible-playbook playbook.yml

Enabling and using the inventory cache

To use the inventory plugin, we need to create an appropriate file. We went through all the details in our previous blog post. Let’s call it digitalocean.yml with the following contents.

plugin: community.digitalocean.digitalocean

compose:
  ansible_host: do_networks.v4 | selectattr('type','eq','public') | map(attribute='ip_address') | first
  ansible_user: "'centos'"
  ansible_ssh_common_args: "'-o StrictHostKeyChecking=no'"

# define caching variables
cache: true
cache_plugin: ansible.builtin.jsonfile
cache_connection: digitalocean-cache

Now, every time the inventory plugin is run, the cache is consulted instead of the API. We do need to explain what each of the three new (bottommost) settings do.

cache: true just enables caching with any caching plugin. The second, cache_plugin, selects a caching plugin, which is a fancy way of saying how the cache is stored. jsonfile is a persistent cache, but there are also non-persistent, in-memory cache plugins, such as memory. Use ansible-doc -t cache -l to get a list of cache plugins installed on your system.

Lastly, cache_connection is a slightly complicated name in our case, as it doesn’t represent a connection, but instead a path to the directory that will store the cache files. In our case, this will be the digitalocean-cache/ directory in our current working directory.

These settings are also available as environment variables. You can read more about them in the plugin’s documentation page, where these are documented alongside other plugin-specific configuration settings.

Try caching out for yourself multiple times, and you’ll notice that the first invocation is quite a bit slower than later ones.

time ansible-inventory -i digitalocean.yml --graph
time ansible-inventory -i digitalocean.yml --graph

We get the same output each time, with different execution times:

all:
  |--@ungrouped:
  |  |--steampunk-cachelet
________________________________________________________
Executed in    1.04 secs   fish           external 
   usr time  308.45 millis    0.00 micros  308.45 millis 
   sys time   48.87 millis  1176.00 micros   47.69 millis 

@all:
  |--@ungrouped:
  |  |--steampunk-cachelet
________________________________________________________
Executed in  360.57 millis    fish           external 
   usr time  313.74 millis  879.00 micros  312.86 millis 
   sys time   47.91 millis   92.00 micros   47.81 millis 

Subsequent executions are three times faster! This results in a very noticeable speedup when running ad-hoc commands, as the command line is much more responsive. The test was, of course, repeated several times, and this is a representative example of the timings. We’re on quite a good and low-latency connection, with 5 milliseconds RTT to the DigitalOcean API, so any additional delays caused by a slower or unstable connection would only enlarge the difference.

Let’s now look at the contents of the cache file at digitalocean-cache/ansible_inventory_community.digitalocean.digitalocean*:

[
    {
        "backup_ids": [],
        "created_at": "2021-05-31T15:24:42Z",
        "disk": 25,
        "features": [
            "private_networking"
        ],
        "id": 8916516,
        "image": {...},
        "kernel": null,
        "locked": false,
        "memory": 1024,
        "name": "steampunk-cachelet",
        "networks": {...},
        "next_backup_window": null,
        "region": {...},
        "size": {...},
        "size_slug": "s-1vcpu-1gb",
        "snapshot_ids": [],
        "status": "active",
        "tags": [],
        "vcpus": 1,
        "volume_ids": [],
        "vpc_uuid": "a8f18t2-78ab-8168-0252-123456789123"
    }
]

The above output is a bit abbreviated for clarity, but what we see is a saved state of the inventory. Well, not quite. This is an implementation detail of the configuration plugin, which in this case, is a direct output from the API. To get the actual inventory, you need to run ansible-inventory, which will consult this file instead of querying a remote API.

How and why to invalidate the inventory cache

There are, of course, times when you need invalidate, or update, the inventory cache. A common occurrence is you adding a host in a playbook, where you need to access that additional host in subsequent plays. Let’s try to do an oopsie by modifying the playbook.

  [...]
  loop:
    - steampunk-cachelet-1
    - steampunk-cachelet-2

We’ve added another VM, leaving the previous one up. If you’ve been following the commands in this post, you’ll now get something strange when you run the inventory command again:

$ ansible-inventory -i digitalocean.yml --graph
@all:
  |--@ungrouped:
  |  |--steampunk-cachelet-1

There’s only one host! Indeed there is, and caching is to blame. To solve this quickly and with some brute force, we can just delete the caching directory, which in our case, is digitalocean-cache/.

$ rm -rf digitalocean-cache/
$ ansible-inventory -i digitalocean.yml --graph
@all:
  |--@ungrouped:
  |  |--steampunk-cachelet-1
  |  |--steampunk-cachelet-2

It works! This goes for removals as well as additions, so caching is a bit tricky to manage, since you need to be very sure that your backing state hasn’t changed.

To solve this a bit more cleanly, we can update the cache in the playbook directly with the inclusion of an Ansible meta task, which forces an inventory refresh. If you are using any new hosts in subsequent plays, you need to do this even if you are not using caching, since the inventory is only constructed at the beginning of the playbook.

- name: Refresh the inventory, whether cached or not
  meta: refresh_inventory

If you just want to have a TTL for the inventory cache, you can add the cache_timeout key to your inventory plugin configuration file. This variable contains, in seconds, how long the cache should be valid for. After this time, the cache is not valid and Ansible will automatically update your inventory from the source of truth.

[...]
# invalidate the cache after 60 minutes
cache_timeout: 3600
[...]

Of course, you can disable the cache in the configuration file. This will not invalidate it, but it will prevent Ansible from using it until you enable it again.

Caching does not come free

Inventory plugin maintainers must implement caching themselves, as this is not automatically done in the Ansible framework. If a user has caching turned on and the plugin doesn’t support it, Ansible will not show a warning but instead just disregard any caching directives that may be present.

As mentioned before, the latest stable DigitalOcean Ansible collection release has some issues with caching. Since we care about the community (and this blog post!), the Steampunk team prepared a fix for the DigitalOcean inventory plugin.

If you’d like to get assistance in developing high-quality Ansible content, you can contact XLAB Steampunk! Keep an eye out for our blog posts where we explore the deep trenches of Ansible, how to navigate them, and how to maintain best practices when writing your own collections.

If you’re into it, also sign up for our monthly newsletter to stay up-to-date with best practices and more!