Ansible in a Container
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!
- For this post I'm using Cisco Modeling Labs Enterprise, so you can follow along.
To clone this specifc project do:
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:
# syntax=docker/dockerfile:1
FROM python:alpine3.16 AS base
COPY ./docker/ansible/requirements.txt .
RUN pip3 install -r requirements.txt
WORKDIR home
What it says is:
- First we define
FROM
which image we want to build. In this casepython: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.
- You case see here, I'm not using
- Second,
COPY
a file inside the container.- Depending on which directory you are executing the
docker build
command, you need to adjust the firstpath
, in this case./docker/ansible/requirements.txt
and if you pay attention you can see a.
(dot) at the end
- Depending on which directory you are executing the
- 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.
ansible==6.5.0
ansible-core==2.13.5
certifi==2022.9.24
cffi==1.15.1
charset-normalizer==2.1.1
cryptography==38.0.1
idna==3.4
Jinja2==3.1.2
MarkupSafe==2.1.1
packaging==21.3
pycparser==2.21
pyparsing==3.0.9
PyYAML==6.0
requests==2.28.1
resolvelib==0.8.1
urllib3==1.26.12
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.
All commands used assume they are executed from the root directory. This is key, since docker and docker-compose need to know the path to the files or mounts specified, if you are not in the root directory, you need to update this part.
docker build --file docker/ansible/base-ansible.dockerfile --tag ansible:version1.0 .
Then to run the container, use docker run
.
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 exec -it ansible /bin/sh
/home # ansible --version
ansible [core 2.13.5]
config file = /home/ansible.cfg
configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/local/lib/python3.10/site-packages/ansible
ansible collection location = /root/.ansible/collections:/usr/share/ansible/collections
executable location = /usr/local/bin/ansible
python version = 3.10.8 (main, Oct 13 2022, 23:21:19) [GCC 11.2.1 20220219]
jinja version = 3.1.2
libyaml = False
/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:
A container is Ephemeral, therefore is not intended by design to do changes on the container at runtime, since those changes will be lost once the container is destroyed
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.
╰─ tree
.
...
├── ansible
│ ...
└── docker
├── ansible
│ ├── base-ansible.dockerfile
│ └── requirements.txt
└── 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.
version: "3.8"
services:
ansible:
image: ansible:version1.0
container_name: ansible
tty: true
volumes:
- ./../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 -f ./docker/ansible.docker-compose.yml up -d
Finally we enter the container, from here, we can execute our Ansible commands.
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
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.
╰─ tree
.
...
├── ansible
│ ├── ansible.cfg
│ ├── group_vars
│ │ └── all.yml
│ ├── host_vars
│ │ ├── dist-rtr01.yml
│ │ └── dist-rtr02.yml
│ ├── inventory.yml
│ └── snmp_test.yml
└── 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.
---
dist:
hosts:
dist-rtr01:
ansible_host: 10.10.20.176
dist-rtr02:
ansible_host: 10.10.20.175
[defaults]
host_key_checking = False
---
ansible_ssh_pass: cisco
ansible_user: cisco
ansible_network_os: ios
snmp_community: cisco_test
snmp_location: Lisbon_HQ
snmp_contact: Jesus_Illescas
---
snmp_location_floor: floor_2
---
snmp_location_floor: floor_1
---
- name: MAKE CONFIG CHANGES USING GROUP_VARS
hosts: all
connection: network_cli
gather_facts: no
tasks:
- name: CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS
ios_config:
commands:
- snmp-server community {{ snmp_community }} RO
- snmp-server location {{ snmp_location }} {{ snmp_location_floor }}
- 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#show run | i snmp
dist-rtr01#
dist-rtr02#show run | i snmp
dist-rtr02#
Access the container and review what is inside.
docker exec -it ansible /bin/sh
/home # ls -l
total 12
-rw-r--r-- 1 root root 266 Oct 23 13:46 ansible.cfg
drwxr-xr-x 3 root root 96 Oct 23 13:46 group_vars
drwxr-xr-x 4 root root 128 Oct 23 13:46 host_vars
-rw-r--r-- 1 root root 411 Oct 23 13:46 inventory.yml
-rw-r--r-- 1 root root 415 Oct 23 13:46 snmp_test.yml
/home #
From the container, use ansible-playbook
to run the playbook snmp_test.yml
/home # ansible-playbook -i inventory.yml snmp_test.yml -v
Using /home/ansible.cfg as config file
PLAY [MAKE CONFIG CHANGES USING GROUP_VARS] *****************************************************
TASK [CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS] ****************************
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be
similar to how they appear if present in the running configuration on device
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"]}
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"]}
PLAY RECAP **************************************************************************************
dist-rtr01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
dist-rtr02 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
/home #
If we look for snmp
in our devices, we can see the changes were done
dist-rtr01#show run | i snmp
snmp-server community cisco_test RO
snmp-server location Lisbon_HQ floor_1
snmp-server contact Jesus_Illescas
dist-rtr01#
dist-rtr02#show run | i snmp
snmp-server community cisco_test RO
snmp-server location Lisbon_HQ floor_2
snmp-server contact Jesus_Illescas
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.