How to create LXD Containers with Ansible 2.2

  |   Source

Ansible

LXD (Working example from this post you can find on my GitHub Page)

While working with Ansible since a couple of years now and working with LXD as my local test environment I was waiting for a simple solution to create LXD containers (locally and remote) with Ansible from scratch. Not using any helper methods like shell: lxd etc.

So, since Ansible 2.2 we have native LXD support. Furthermore, the Ansible Team actually showed some respect to the Python3 Community, and has implemented Python3 Support.

Preparations

First of all, you need to have the latest Ansible Release, or install it in a Python3 Virtual Environment via pip install ansible.

Create your Ansible directory layout

To make your life later a little bit easier, create your Ansible directory structure and turn it to a Git repository.

user@home: ~> mkdir -p ~/Projects/git.ansible/lxd-containers
user@home: ~> cd ~/Projects/git.ansible/lxd-containers
user@home: ~/Projects/git.ansible/lxd-containers> mkdir -p {inventory,roles,playbooks}

Create your inventory file

Imagine, you want to create 5 new LXD containers. You can create 5 playbooks to do it, or you can be smart, and let Ansible do it for you. Working with inventory files is easy, it's simply a file with an INI file structure.

Let's create an inventory file for new LXD containers in ~/Projects/git.ansible/lxd-containers/inventory/containers:

[local]
localhost

[containers]
blog-01 ansible_connection=lxd
blog-02 ansible_connection=lxd
blog-03 ansible_connection=lxd
blog-04 ansible_connection=lxd
blog-05 ansible_connection=lxd

We defined now 5 containers.

Create a playbook for running Ansible

We need now an Ansible playbook.

A playbook is just a simple YAML file. You can edit this file with your editor of choice. I personally like Sublime Text 3 or GitHubs Atom, but any other editor (like Vim or Emacs) will do.

Create a new file under ~/Projects/git.ansible/lxd-containers/playbooks/lxd_create_containers.yml:

- hosts: localhost
  connection: local
  roles:
    - create_lxd_containers

Let's go shortly through this:

  • hosts: defines: the hosts to run Ansible on. Using it like this means, this playbook runs on your local machine.
  • connection: local: Ansible will use a local connection, like sshing into your local box.
  • roles: ...: is a list of Ansible roles to be used during this playbook.

You could also write all Ansible tasks in this playbook, but as you want to reuse several tasks for certain workloads, it's a better idea to divide them into roles.

Create the the Ansible role

Ansible Roles are being used for separating repeating tasks from the playbooks.

Think about this example: You have a playbook for all your webservers like this:

- hosts: webservers
  tasks:
    - name: apt update
      apt: update_cache=yes

and you have a playbook for all your database servers like this:

- hosts: databases
  tasks:
    - name: apt update
      apt: update_cache=yes

What do you see? Yes, two times the same task, namely "apt update".

To make our lives easier, instead of writing in every playbook a task to update the systems package archive cache, we create an Ansible role.

Ansible Roles do have a special directory structure, I advise to read the good documention over at the Ansible HQ

Let's start with our role for creating LXD containers:

Create the directory structure

user@home: ~> cd ~/Projects/git.ansible/lxd-containers/roles/
user@home: ~/Projects/git.ansible/lxd-containers/roles/> mkdir -p create_lxd_containers/tasks

Now create a new YAML file and name it ~/Projects/git.ansible/lxd-containers/roles/create_lxd_containers/tasks/main.yml with this content:

- name: Create LXD Container
  connection: local
  become: false
  lxd_container:
    name: "{{item}}"
    state: started
    source:
      type: image
      mode: pull
      server: https://cloud-images.ubuntu.com/releases
      protocol: simplestreams
      alias: 16.04/amd64
    profiles: ['default']
    wait_for_ipv4_addresses: true
    timeout: 600
  with_items:
    - "{{groups['containers']}}"

- name: Check if Python2 is installed in container
  delegate_to: "{{item}}"
  raw: dpkg -s python
  register: python_check_is_installed
  failed_when: python_check_is_installed.rc not in [0,1]
  changed_when: false
  with_items:
    - "{{groups['containers']}}"

- name: Install Python2 in container
  delegate_to: "{{item.item}}"
  raw: apt-get update && apt-get install -y python
  when: "{{item.rc == 1}}"
  with_items:
    - "{{python_check_is_installed.results}}"

Let's go through the different tasks

Create the LXD Container

- name: Create LXD Container
  connection: local
  become: false
  lxd_container:
    name: "{{item}}"
    state: started
    source:
      type: image
      mode: pull
      server: https://cloud-images.ubuntu.com/releases
      protocol: simplestreams
      alias: 16.04/amd64
    profiles: ['default']
    wait_for_ipv4_addresses: true
    timeout: 600
  with_items:
    - "{{groups['containers']}}"
  • connection: local: means it's only running on your local machine.
  • become: false: don't use su or sudo to become a superuser.
  • lxd_container: ...: this is the Ansible LXD module definition. Read the documentation about this module here: Ansible LXD Documentation
  • with_items: ...: this is one of the many Ansible loop statements. In this case, we are looping over the Inventory Group 'containers' (which we defined in the inventory file earlier).

The "{{item}}" will be prefilled by the loop from with_items:..., again a hint to read the good documentation of Ansible about loops.

Check if Python2 is installed inside the container

- name: Check if Python2 is installed in container
  delegate_to: "{{item}}"
  raw: dpkg -s python
  register: python_check_is_installed
  failed_when: python_check_is_installed.rc not in [0,1]
  changed_when: false
  with_items:
    - "{{groups['containers']}}"
  • delegate_to:...": this key tells ansible to not use the default connection anymore, but to delegate the connection and the work to the host mentioned in delegate_to.
  • raw:...: This key advises Ansible to use the raw module. Raw means, we don't actually have anything running, no Python for example, which we need for Ansible. So it just using an SSH connection (by default) or for now, it's using a local LXD connection (like lxc exec <container-name> -- <command>). In this case we are executing dpkg -s python, we want to find out of if Python2 is installed.
  • register: ...: during execution of the raw: ... command, Ansible is able to catch the output (stdout, stderr) and the result code of the raw: ... command. register: ... will define a "variable" to store this result. Normally this "variable" is a Python/JSON dictionary for a particular host, but as we are iterating through the 'containers' inventory group, this 'variable' has a results array (which we will use in the next task), where Ansible stores all outputs of all hosts checks. During the task execution but, this 'variable' is still usable as a single result set.
  • failed_when: ...: this will stop the task, if the registered 'variable' is not accessible or the return code is not 0 or 1 (so command returned no success or no real fail, but something else). (more documentation you can find here)
  • changed_when: false: so whenever this tasks runs, it will always change it status, and this would mean Ansible would report one change (i.e. return code changed). To prevent this, we set this to false.(more documentation you can find here)
  • with_items: ...: this is one of the many Ansible loop statements. In this case, we are looping over the Inventory Group 'containers' (which we defined in the inventory file earlier).

The "{{item}}" will be prefilled by the loop from with_items:..., again a hint to read the good documentation of Ansible about loops.

Install Python2 if it is not installed in the container

- name: Install Python2 in container
  delegate_to: "{{item.item}}"
  raw: apt-get update && apt-get install -y python
  when: "{{item.rc == 1}}"
  with_items:
    - "{{python_check_is_installed.results}}"
  • delegate_to:...": this key tells ansible to not use the default connection anymore, but to delegate the connection and the work to the host mentioned in delegate_to.
  • raw:...: This key advises Ansible to use the raw module. Raw means, we don't actually have anything running, no Python for example, which we need for Ansible. So it just using an SSH connection (by default) or for now, it's using a local LXD connection (like lxc exec <container-name> -- <command>). In this case we are executing dpkg -s python, we want to find out of if Python2 is installed.
  • when: ...: this is a conditional. It says, that this task only executes when the codition is met. In this case when the return code equals to 1. This is true when the Python2 install check returned, that Python2 was not installed.
  • with_items: ...: this is one of the many Ansible loop statements. In this case, we are looping over the Inventory Group 'containers' (which we defined in the inventory file earlier).

The "{{item}}" will be prefilled by the loop from with_items:..., again a hint to read the good documentation of Ansible about loops. In this case, we are looping through the result sets of the Python2 install check and the collected results in the 'variable' python_check_is_installed.

Some more informations

In the playbook and in the first task (create LXD containers) we used the a local connection, which means nothing else than Ansible should work on your local workstation. Inside the Inventory INI file there is this key/value pair: ansible_connection=lxd.

So when the two other tasks who were delegated to the created containers, Ansible would normally use an SSH connection attempt (when you remove the ansible_connection=lxd). With this special configuration in the Inventory INI file it won't try to use SSH towards the containers, but the local LXD connection.

Bringing it all together

Let's start Ansible to do the work we want it to do:

~/Projects/git.ansible/lxd-containers > ansible-playbook -i inventory/inventory playbooks/lxd_create_containers.yml

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

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

TASK [create_lxd_containers : Create LXD Container] ****************************
changed: [localhost] => (item=blog-01)
changed: [localhost] => (item=blog-02)
changed: [localhost] => (item=blog-03)
changed: [localhost] => (item=blog-04)
changed: [localhost] => (item=blog-05)

TASK [create_lxd_containers : Check if Python2 is installed in container] ******
ok: [localhost -> blog-01] => (item=blog-01)
ok: [localhost -> blog-02] => (item=blog-02)
ok: [localhost -> blog-03] => (item=blog-03)
ok: [localhost -> blog-04] => (item=blog-04)
ok: [localhost -> blog-05] => (item=blog-05)

TASK [create_lxd_containers : Install Python2 in container] ********************
changed: [localhost -> blog-01] => (item={'changed': False, 'stdout': u'', '_ansible_no_log': False, '_ansible_delegated_vars': {'ansible_host': u'blog-01'}, '_ansible_item_result': True, 'failed': False, 'item': u'blog-01', 'rc': 1, 'invocation': {'module_name': u'raw', 'module_args': {u'_raw_params': u'dpkg -s python'}}, 'stdout_lines': [], 'failed_when_result': False, 'stderr': u"dpkg-query: package 'python' is not installed and no information is available\nUse dpkg --info (= dpkg-deb --info) to examine archive files,\nand dpkg --contents (= dpkg-deb --contents) to list their contents.\n"})
changed: [localhost -> blog-02] => (item={'changed': False, 'stdout': u'', '_ansible_no_log': False, '_ansible_delegated_vars': {'ansible_host': u'blog-02'}, '_ansible_item_result': True, 'failed': False, 'item': u'blog-02', 'rc': 1, 'invocation': {'module_name': u'raw', 'module_args': {u'_raw_params': u'dpkg -s python'}}, 'stdout_lines': [], 'failed_when_result': False, 'stderr': u"dpkg-query: package 'python' is not installed and no information is available\nUse dpkg --info (= dpkg-deb --info) to examine archive files,\nand dpkg --contents (= dpkg-deb --contents) to list their contents.\n"})
changed: [localhost -> blog-03] => (item={'changed': False, 'stdout': u'', '_ansible_no_log': False, '_ansible_delegated_vars': {'ansible_host': u'blog-03'}, '_ansible_item_result': True, 'failed': False, 'item': u'blog-03', 'rc': 1, 'invocation': {'module_name': u'raw', 'module_args': {u'_raw_params': u'dpkg -s python'}}, 'stdout_lines': [], 'failed_when_result': False, 'stderr': u"dpkg-query: package 'python' is not installed and no information is available\nUse dpkg --info (= dpkg-deb --info) to examine archive files,\nand dpkg --contents (= dpkg-deb --contents) to list their contents.\n"})
changed: [localhost -> blog-04] => (item={'changed': False, 'stdout': u'', '_ansible_no_log': False, '_ansible_delegated_vars': {'ansible_host': u'blog-04'}, '_ansible_item_result': True, 'failed': False, 'item': u'blog-04', 'rc': 1, 'invocation': {'module_name': u'raw', 'module_args': {u'_raw_params': u'dpkg -s python'}}, 'stdout_lines': [], 'failed_when_result': False, 'stderr': u"dpkg-query: package 'python' is not installed and no information is available\nUse dpkg --info (= dpkg-deb --info) to examine archive files,\nand dpkg --contents (= dpkg-deb --contents) to list their contents.\n"})
changed: [localhost -> blog-05] => (item={'changed': False, 'stdout': u'', '_ansible_no_log': False, '_ansible_delegated_vars': {'ansible_host': u'blog-05'}, '_ansible_item_result': True, 'failed': False, 'item': u'blog-05', 'rc': 1, 'invocation': {'module_name': u'raw', 'module_args': {u'_raw_params': u'dpkg -s python'}}, 'stdout_lines': [], 'failed_when_result': False, 'stderr': u"dpkg-query: package 'python' is not installed and no information is available\nUse dpkg --info (= dpkg-deb --info) to examine archive files,\nand dpkg --contents (= dpkg-deb --contents) to list their contents.\n"})

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

~/Projects/git.ansible/lxd-containers > lxc list
+---------+---------+-----------------------+------+------------+-----------+
|  NAME   |  STATE  |         IPV4          | IPV6 |    TYPE    | SNAPSHOTS |
+---------+---------+-----------------------+------+------------+-----------+
| blog-01 | RUNNING | 10.139.197.44 (eth0)  |      | PERSISTENT | 0         |
+---------+---------+-----------------------+------+------------+-----------+
| blog-02 | RUNNING | 10.139.197.10 (eth0)  |      | PERSISTENT | 0         |
+---------+---------+-----------------------+------+------------+-----------+
| blog-03 | RUNNING | 10.139.197.188 (eth0) |      | PERSISTENT | 0         |
+---------+---------+-----------------------+------+------------+-----------+
| blog-04 | RUNNING | 10.139.197.221 (eth0) |      | PERSISTENT | 0         |
+---------+---------+-----------------------+------+------------+-----------+
| blog-05 | RUNNING | 10.139.197.237 (eth0) |      | PERSISTENT | 0         |
+---------+---------+-----------------------+------+------------+-----------+

Awesome, 5 containers created and Python2 installed.

Now it's time do to the real work (like installing your app and testing them)