Ansible in a Container

Published
Updated

Intro

While preparing for my Devnet DevOps exam and now preparing for the DevCor, I realized is a great opportunity to create mini-projects from the topics I’m learning and at the end have a good network automation.

I’m aware of what Ansible is and what it does from some time ago, but I never played with it, as I prefer to go directly with Python. However, I see the worth to invest in Ansible since is another tool and sometimes you just want something that it works.

The instructions you will see below are not exclusive for Ansible. The procedure is the same if you intended to dockerized any application.

TL;DR

I get it, sometimes you are short on time and you are looking for something quickly, here is the juicy stuff.

  • Github source files are here
    • You may see reference to my docker image from Docker Hub, rather than the base image defined here. In the end, I want to build once and re-use many.
    • Update paths if you are not executing from the root dir.
    • This is a living project, see the main branch for the latest source code. All links in the post are for the “Ansible in a Container” release.
  • My Ansible image is here on docker Hub
  • If you don’t have network devices you can reserve a free sandbox with Cisco DevNet. Remember is Free!

To clone this specifc project do:

Clone this specific release
1
git clone -b v0.1.0 --single-branch https://github.com/jillesca/network-automation.git

Why?

First, I don’t want to maintain a VM just for Ansible or install it on my laptop and deal with Python versions and virtual environments. I don’t see the point, when you can use a container just for Ansible.

But, besides the first point, the main reason is the value you get when you use Ansible in a CI/CD pipeline. Having a container in the pipeline, will make it easier to implement and maintain since the containers are used only when needed.

A good side efect, is Consistency. Once you built a container, it will always work. Of course, you need to follow best practices when defining what is installed, and when to built or use an image.

In basic terms:

  • Be super specific on what needs to be installed, e.g. package versions
  • Use this container as a base image
  • Use this base image for deployments.

Create an Ansible Image

The first step I did, was to look for an official Ansible image, there is one, but the last update was 5 years ago. So I decided to build my own.

Since Ansible is based on Python, I decided to use Python as a based image. I also choose an Alpine variant, since is the smallest size. Size matters for the pipeline, since the container will be created and destroyed everytime we do changes with Ansible, therefore the image is transfered, ergo is good to have a small image. This could also be optimized.

Create a DockerFile

A dockerfile is basically a plain text file with instructions docker understands. For a full explanation on docker files check the dockerfile reference.

The dockerfile I used is the following:

docker
1
# syntax=docker/dockerfile:1
2
FROM python:alpine3.16 AS base
3
COPY ./docker/ansible/requirements.txt .
4
RUN pip3 install -r requirements.txt
5
WORKDIR home

What it says is:

  • First we define FROM which image we want to build. In this case python:alpine3.16.
    • You case see here, I’m not using python:latest since I don’t know which image could be, and newer images could break dependencies down the road.
  • Second, COPY a file inside the container.
    • Depending on which directory you are executing the docker build command, you need to adjust the first path, in this case ./docker/ansible/requirements.txt and if you pay attention you can see a . (dot) at the end
  • Third, RUN pip3 to install some dependencies
  • Finally, we change directory with WORKDIR

Like I commented before, is important to be super specific about which packages are installed in out image. For this I’m using a requirements.txt file. Filename could be anything, but this is the common convention for installing dependencies.

requirements.txt
1
ansible==6.5.0
2
ansible-core==2.13.5
3
certifi==2022.9.24
4
cffi==1.15.1
5
charset-normalizer==2.1.1
6
cryptography==38.0.1
7
idna==3.4
8
Jinja2==3.1.2
9
MarkupSafe==2.1.1
10
packaging==21.3
11
pycparser==2.21
12
pyparsing==3.0.9
13
PyYAML==6.0
14
requests==2.28.1
15
resolvelib==0.8.1
16
urllib3==1.26.12
17
paramiko==2.11.0

If you are wondering how I got those requirements, basically, I started a python:alpine3.16 container, installed Ansible and saw which dependecies were installed (pip freeze). This was a manual process and is a one-time, unless you need to update the image.

Build a Base Image

Having those two files is enough to start building our image. To build your image use the docker build command.

docker
1
docker build --file docker/ansible/base-ansible.dockerfile --tag ansible:version1.0 .

Then to run the container, use docker run.

docker
1
docker run -dt --name ansible ansible:version1.0

Finally, to enter the container, open an interactive session on /bin/sh. From there you can verify the Ansible installation.

docker
1
docker exec -it ansible /bin/sh
2
/home # ansible --version
3
ansible [core 2.13.5]
4
config file = /home/ansible.cfg
5
configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
6
ansible python module location = /usr/local/lib/python3.10/site-packages/ansible
7
ansible collection location = /root/.ansible/collections:/usr/share/ansible/collections
8
executable location = /usr/local/bin/ansible
9
python version = 3.10.8 (main, Oct 13 2022, 23:21:19) [GCC 11.2.1 20220219]
10
jinja version = 3.1.2
11
libyaml = False
12
/home #

Note, if you want to upload/push your images to your docker registry, update the tag so docker knows where to push it. Don’t forget to login into your registry from docker first.

Developing with the Base Image

Using a container directly is very useful for automation, but no much for developing with it. An super important consideration when working with containers is the following:

If you need to do changes, perform the changes on the repository, then you build a new image and this image will have your changes. Sounds simple, right?. The truth is that for developing and testing is not efficient.

Docker Compose

This is where docker-compose comes to the help. This is a tool already included with docker that helps you create services.

In our case, compose helps us to create a volume where we can mount our Ansible files inside the docker container. This means that changes we do on our repo, will be automatically sync inside the container, making developing and testing easier.

For this project, I created the following directory structure Inside my repository.

Project Structure
1
╰─ tree
2
.
3
...
4
├── ansible
5
...
6
└── docker
7
├── ansible
8
├── base-ansible.dockerfile
9
└── requirements.txt
10
└── ansible.docker-compose.yml

You can see, I separated docker related files from Ansible files, so I can easily distinguish between services when working with a pipeline and testing.

Now, first create a docker-compose file.

docker-compose.yml
1
version: "3.8"
2
3
services:
4
ansible:
5
image: ansible:version1.0
6
container_name: ansible
7
tty: true
8
volumes:
9
- ./../ansible:/home

Then we tell docker-compose to create a container and use a volume to mount our Ansible files inside the container. This is the key line: ./../ansible:/home, where we are telling compose where our Ansible files are, and where to mount them, in this case in the home directory of the container.

Then we can bring the container to life.

docker-compose
1
docker-compose -f ./docker/ansible.docker-compose.yml up -d

Finally we enter the container, from here, we can execute our Ansible commands.

docker
1
docker exec -it ansible /bin/sh

As a side note, you can build directly in compose. If you go to the docker-compose file at Github, you will see I commented a build option, with this, you can build directly your image.

However I recommend, build your image separately, push your image to your registry, e.g. Docker Hub and consume it from it. If you go to the Github docker-compose you will see I’m using a image I created and pushed to Docker Hub

docker-compose.yml
1
image: jillesca/ansible:version1.0

Ansible Configuration

Before we can verify our work, is important to create an Ansible configuration, so we can use for testing.

Below is the base Ansible directory I use.

ansible
1
╰─ tree
2
.
3
...
4
├── ansible
5
├── ansible.cfg
6
├── group_vars
7
└── all.yml
8
├── host_vars
9
├── dist-rtr01.yml
10
└── dist-rtr02.yml
11
├── inventory.yml
12
└── snmp_test.yml
13
└── docker

And below you can see how my files look like. You can also check them on Github. If you want to see the latest code, see the main branch.

inventory.yml
1
---
2
dist:
3
hosts:
4
dist-rtr01:
5
ansible_host: 10.10.20.176
6
dist-rtr02:
7
ansible_host: 10.10.20.175
ansible.cfg
1
[defaults]
2
host_key_checking = False
group_vars/all.yml
1
---
2
ansible_ssh_pass: cisco
3
ansible_user: cisco
4
ansible_network_os: ios
5
6
snmp_community: cisco_test
7
snmp_location: Lisbon_HQ
8
snmp_contact: Jesus_Illescas
host_vars/dist-rtr01.yml
1
---
2
snmp_location_floor: floor_2
host_vars/dist-rtr02.yml
1
---
2
snmp_location_floor: floor_1
snmp_test.yml
1
---
2
- name: MAKE CONFIG CHANGES USING GROUP_VARS
3
hosts: all
4
connection: network_cli
5
gather_facts: no
6
7
tasks:
8
- name: CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS
9
ios_config:
10
commands:
11
- snmp-server community {{ snmp_community }} RO
12
- snmp-server location {{ snmp_location }} {{ snmp_location_floor }}
13
- snmp-server contact {{ snmp_contact }}

Verification

After all of this, is time to verify our Ansible container is working as expected.

The first requirement you need is, IP Connectivity from the host where the container is being executed to your network devices. I’m my case I’m using the sandbox through a VPN and I can SSH the devices directly.

Now, let’s see what is on our devices

dist-rtr01
1
dist-rtr01#show run | i snmp
2
dist-rtr01#
dist-rtr02
1
dist-rtr02#show run | i snmp
2
dist-rtr02#

Access the container and review what is inside.

docker
1
docker exec -it ansible /bin/sh
2
/home # ls -l
3
total 12
4
-rw-r--r-- 1 root root 266 Oct 23 13:46 ansible.cfg
5
drwxr-xr-x 3 root root 96 Oct 23 13:46 group_vars
6
drwxr-xr-x 4 root root 128 Oct 23 13:46 host_vars
7
-rw-r--r-- 1 root root 411 Oct 23 13:46 inventory.yml
8
-rw-r--r-- 1 root root 415 Oct 23 13:46 snmp_test.yml
9
/home #

From the container, use ansible-playbook to run the playbook snmp_test.yml

ansible
1
/home # ansible-playbook -i inventory.yml snmp_test.yml -v
2
Using /home/ansible.cfg as config file
3
4
PLAY [MAKE CONFIG CHANGES USING GROUP_VARS] *****************************************************
5
6
TASK [CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS] ****************************
7
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
8
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
9
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be
10
similar to how they appear if present in the running configuration on device
11
changed: [dist-rtr02] => {"banners": {}, "changed": true, "commands": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_1", "snmp-server contact Jesus_Illescas"], "updates": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_1", "snmp-server contact Jesus_Illescas"]}
12
changed: [dist-rtr01] => {"banners": {}, "changed": true, "commands": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_2", "snmp-server contact Jesus_Illescas"], "updates": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_2", "snmp-server contact Jesus_Illescas"]}
13
14
PLAY RECAP **************************************************************************************
15
dist-rtr01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
16
dist-rtr02 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
17
18
/home #

If we look for snmp in our devices, we can see the changes were done

dist-rtr01
1
dist-rtr01#show run | i snmp
2
snmp-server community cisco_test RO
3
snmp-server location Lisbon_HQ floor_1
4
snmp-server contact Jesus_Illescas
5
dist-rtr01#
dist-rtr02
1
dist-rtr02#show run | i snmp
2
snmp-server community cisco_test RO
3
snmp-server location Lisbon_HQ floor_2
4
snmp-server contact Jesus_Illescas
5
dist-rtr02#

This may be a simple change, but the purpose of this verification is to test that our image works rather than to do a complex change.

What’s next?

Well, it was a long post, but actually this is just one of the foundations for the ideas I have in mind.

There three more thoughts I have for Ansible.

  • First do a more sophisticated change, snmp is just too simple.
  • Add Ansible to a pipeline, so we can see its potential in CI/CD.
  • Play with tests in Ansible.

After this, I’m thinking to integrate other tools to the network automation project I’m building.