If you have ever found yourself managing more than one machine, be it servers, IoT devices or others, you may have found it challenging to keep the configuration across all these machines consistent. To make things worse, as you added more machines, you found it quickly became almost impossible to keep up.
Configuration-as-code (CaC) tools like Ansible allow you to manage the configuration of machines as code, that is, write your configuration in code where it can be version controlled, reviewed, or re-used. CaC tools typically also allow you to manage the configuration of many machines at once, which saves a lot of time!
What is Ansible?
Ansible is an open-source tool for configuration management, software deployment and provisioning. The latter is a use-case less often seen as other more purpose-built tools exist which are better suited to provisioning, but it’s worth mentioning. Ansible is praised for being approachable, agentless and straightforward.
Approachable
Ansible “playbooks” and other files are written in the YAML format; this makes Ansible easily human-readable and approachable by people who have never written code. Ansibles setup is also straightforward, and it’s excellent documentation makes it easy to learn and get started quickly. These qualities make Ansible very approachable compared to its competitors.
Agentless
An important point on Ansible is this it is agentless, meaning no agent software needs to be installed on hosts for Ansible to configure them. Ansible operates by connecting to host systems over SSH (WinRM on windows). Ansible then loads its modules to the host, where it will execute its tasks through the host systems Python interpreter.
Agentlessness is very useful as the only requirement for Ansible to manage a host system is for that system to be accessible via SSH and for the host system to have Python installed.
Idempotent
An essential concept in Ansible and other tools is idempotence. Idempotence is the concept that an operation can be applied many times and not make changes beyond its initial purpose. Take a red off switch on a factory machine as an example; this is an idempotent operation. You can press the off switch to turn off the device, you can then continue to push the off switch again and again, but nothing beyond switching off the machine will occur.
Idempotence is important to keep in mind when writing playbooks as Ansible can be very flexible and does not strictly ensure idempotency on its own. A well-written playbook will provide idempotency, repeatedly running the playbook on the same machine. It will only make configuration changes necessary to achieve the desired state if changes are needed.
I will demonstrate idempotence at the end of this post, but discussing where Ansible can become non-idempotent goes beyond the scope of this post.
First steps
All examples in this post were created and tested using the following versions:
Name | Version |
---|---|
Ansible | 2.12.1 |
Python | 3.10.2 |
Jinja | 3.0.3 |
Vagrant | 2.2.19 |
Installation
Please refer to Ansible’s documentation for installation instructions here.
Note: at the time of writing, you cannot run Ansible from Windows without workarounds.
Optional requirements
For this demonstration, it’s recommended to install Vagrant as we will use Vagrant throughout for creating disposable test environments. The aim of this post is not to introduce you to Vagrant, but it will be a valuable tool for this, and I will provide the necessary Vagrant files and commands.
Note: Vagrant can use several providers, example providers include VirtualBox, Hyper-V and Docker. Ensure you have a supported provider installed on your machine before attempting to run Vagrant.
Setting up Vagrant
After installing Vagrant, you’ll need a Vagrantfile
to get started. Create a file named Vagrantfile
at the root of your project with the below contents.
Vagrant.configure("2") do |config|
boxes = [
{
:name => "ubuntu-test",
:box => "generic/ubuntu2010",
:http_port => 8080
},
{
:name => "fedora-test",
:box => "generic/fedora34",
:http_port => 8081
}
]
boxes.each do |opts|
config.vm.define opts[:name] do |config|
config.vm.box = opts[:box]
config.vm.network "forwarded_port", guest: 80, host: opts[:http_port]
if opts[:name] == boxes.last[:name]
config.vm.provision "ansible" do |ansible|
ansible.playbook = "playbook.yml"
ansible.limit = "all"
end
end
end
end
end
Basic commands
To follow along, you will only need a few commands.
To create your Vagrant boxes, run:
vagrant up
Note: During
vagrant up
, the boxes will automatically be created and provisioned with ourplaybook.yml
playbook.
To remove the boxes you’ve created, run:
vagrant destroy
To rerun the provisioning step (Ansible in this case) on existing boxes, run:
vagrant provision
Ad-hoc commands
Let us begin with running a simple ad-hoc command. Assuming you have followed Ansible’s installation instructions correctly, you should be able to run the below ad-hoc command.
ansible localhost -m ping
To better understand what’s happening here, let’s deconstruct this command. We call Ansible, providing localhost
as our first variable; this first variable tells Ansible on what machines to execute its tasks, in this case on our local device as indicated by using localhost
. We also provide the -m
option, which allows us to specify an Ansible module to run. Here, we have chosen ping
, which reaches out to check if a system is reachable.
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}
The output should be a ‘pong’ response like the example above.
Ad-hoc commands can be a handy tool, especially for once-off, single-step commands like rebooting machines, gathering facts and any other module available in Ansible.
Our first playbook
Ad-hoc commands are great, but you will often have a whole set of tasks to run and may need to rerun them in the future. Playbooks allow us to maintain more complex sets of functions in YAML files.
We can start simple and replicate our ping task as a playbook for the first playbook. First, create a project folder and within, a file called ping_me.yml
like below:
---
- name: My first playbook
hosts: localhost
tasks:
- name: Ping me!
ping:
Firstly, we begin our playbook with ---
, your playbook will likely run without this, but it is outlined in the YAML specification
here. The ---
is used as a separator for YAML
directives (we won’t need them here) or to indicate the beginning of a file if no directives exist (like the example above).
Next, we define some information about this playbook. Firstly we give it a name, this can be anything, but it’s best to keep it meaningful and descriptive, then we define the hosts
, here we use localhost
, same as with the ad-hoc task, hosts
tells Ansible on what machines to execute its tasks.
Lastly, we provide a list of tasks, just one task for now. We first define a name for our task; again, it’s best to keep it meaningful and descriptive. The final line, ping
, tells Ansible what module we wish to use. Most modules will require additional information, which I will demonstrate later.
You can then run your playbook with:
ansible-playbook ping_me.yml
You should get an output like below.
PLAY [My first playbook] **********************************************************************************************************************************************************************
TASK [Gathering Facts] ************************************************************************************************************************************************************************
ok: [localhost]
TASK [Ping me!] *******************************************************************************************************************************************************************************
ok: [localhost]
PLAY RECAP ************************************************************************************************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
This output looks a lot different from the ad-hoc command. At the top, we see the play’s name; we then see two tasks have run Gathering Facts followed by our Ping me! task, both have run successfully. Finally, we get a recap of our tasks.
So, from where did Gathering Facts come? At the beginning of every playbook, Ansible will run some initial tasks, including this task to gather facts about the host machine. These facts can then be used in your playbooks like variables; further on, we will demonstrate the use of facts.
Note: Gathering Facts can be explicitly enabled or disabled with
gather_facts
but is enabled by default.
Inventory
Inventory is where Ansible retrieves what machines exist and how to connect to them. When using the Ansible Vagrant provisioner, Vagrant creates and manages our inventory for us. We won’t explore inventory in-depth here as this post is aimed at the first steps, but it is essential to know its existence.
After running vagrant up
, you can view the inventory created by Vagrant here:
.vagrant/provisioners/ansible/inventory/vagrant_ansible_inventory
A practical playbook
I find the best way to learn is through action, and having a practical goal is even better! We will create a simple playbook to install an apache server and learn some more Ansible along the way.
Start with a new playbook and create a new file called playbook.yml
. You can start this playbook like below:
---
- name: Install apache server
hosts: all
tasks:
Tasks
A playbook with no tasks is not very useful at all. Ansible tasks call on modules to get work done; Ansible has a long list of modules in the builtin colllection and even more, community-supported modules and collections packaged with Ansible or through Ansible Galaxy.
Let’s add our first task. We can use the package
module to manage packages on a host. Here we will use this module to install the apache server. Add the below task to your playbook under tasks
.
- name: Install Apache
package:
name: apache2
state: present
The package module accepts a package name with name
and a state
, which tells Ansible what state you wish the package to be. Common states are present
, absent
and latest
.
Let’s create out boxes and run this playbook with:
vagrant up
You should see some permission errors; this leads us nicely to the next section about become.
Become
When we usually install packages on a Linux system via the command line, we may need to enter a command like sudo apt install
. The sudo
command elevates this command’s privileges, so we must do the same in Ansible through the become
directive.
After the name, add become: true
to your install Apache task. See below.
- name: Install Apache
become: true
package:
name: apache2
state: present
Now rerun this playbook with:
vagrant provision
This time, we should see that the Ubuntu machine has changed, but our Fedora machine tells us the package is not available. Why? Ubuntu and Fedora use different names for their Apache package, apache2
and httpd
, respectively.
To deal with variants like this, we’ll need to use variables.
Variables
Here is where Ansible facts and the Gather Facts step mentioned earlier become useful. One fact gathered from hosts is distribution
, which tells us what operating system distribution a host is running.
There are many options and ways to work with variables; today, we will load variables based on this distribution
fact. We can use the vars_files
directive to load variables from a file.
Add the below before your tasks
section.
vars_files:
- "vars/{{ ansible_facts.distribution | lower }}.yml"
Take note of | lower
; this is a Jinja filter. Filters are used to manipulate data and be very powerful when needed; lower here will convert the string to lower case.
Next, create a vars
directory, and place two files within, vars/ubuntu.yml
and vars/fedora.yml
.
Contents of vars/ubuntu.yml
:
---
apache_package: apache2
apache_service: apache2
Contents of vars/fedora.yml
:
---
apache_package: httpd
apache_service: httpd
Now change your single install apache task to use the apache_package
variable like below and rerun your provisioning.
- name: Install Apache
become: true
package:
name: "{{ apache_package }}"
state: present
All your tasks should complete successfully, stating either ok
or changed
. Apache should now be on both VMs; let us try to connect to them. In your browser, enter 127.0.0.1:8080
for our Ubuntu machine. You should be greeted with a placeholder webpage.
Next, try 127.0.0.1:8081
for our Fedora machine. This time it will not work, this is because after installing Apache on this version of Fedora, we must also start and enable the service and open a firewall port.
First, let’s add a new task after ‘Install Apache’ to ensure the service is running and enabled; we can do this with the service
module.
- name: Ensure Apache is running and enabled
service:
name: "{{ apache_service }}"
state: started
enabled: yes
Using Collections
Next, we want to ensure port 80 is allowed; for this post, we will add a simple task to enable HTTP where the firewalld
service is present; this will allow us to explore and demonstrate collections and conditionals.
To allow HTTP traffic through the firewalld
service, we will need to use the ansible.posix
collection. First, let’s add a requirements file, requirements.yml
, at the root of your project.
Contents of requirements.yml
:
---
collections:
- name: ansible.posix
next, to install our requirements, run:
ansible-galaxy install -r requirements.yml
Finally, we add a new task to allow Apache access to port 80, but we need to ensure this only runs where firewalld
is present to prevent error, so let’s talk conditionals.
Conditionals
Ansible has a useful when
directive. This directive will only allow a playbook object to be run if the when
condition evaluates to true; if false, the object will be skipped.
Add the below tasks before enabling Apache; this second task tells firewalld
to allow access on port 80. Take note of the when
condition here.
- name: Collect service facts
service_facts:
- name: Permit http traffic
become: true
ansible.posix.firewalld:
service: http
permanent: yes
immediate: yes
state: enabled
when: ansible_facts.services['firewalld.service'] is defined
Now, if we rerun our playbook and try access 127.0.0.1:8080
or 127.0.0.1:8081
, you should see the placeholder page.
Idempotence Example
Now that our machines are configured, rerun your playbook and observe the difference in the play recap. You should notice that all actions are listed as ok
or skipped
; because Ansible had nothing further to do, the desired state is already met. As our playbook is idempotent, no action is taken.
Conclusion
We’ve discussed what Ansible is, some of its pros, and even created a simple yet working playbook to install and configure an Apache server. We explored tasks, variables, and collections and used directives such as become
and when
to achieve this. This knowledge should act as a good starting point to begin exploring more about Ansible and be on your way to automating more with configuration-as-code.
This post only scratches the surface of what Ansible is capable of, and we have yet to see handlers, roles, and so much more. I encourage you, the reader, to take this foundation and build upon it, take the following steps, learn, create and automate.