Making scalable dynamic configuration changes

While the preceding examples resolve many of the challenges of making automated configuration changes at scale in an enterprise, it is noticeable that our final example was somewhat inefficient. We deployed a static, version-controlled configuration file, and made a change to it using the lineinfile module again.

This allowed us to insert an Ansible variable into the file, which in many instances is incredibly useful, especially when configuring more complex services. However, it is—at best—inelegant to split this change across two tasks. Also, reverting to the use of the lineinfile module again exposes us to the risks we discussed earlier and means we would need one lineinfile task for every variable we wish to insert into a configuration.

Thankfully, Ansible includes just the answer to such a problem. In this case, the concept of Jinja2 templating comes to our rescue.

Jinja2 is a templating language for Python that is incredibly powerful and easy to use. As Ansible is coded almost entirely in Python, it lends itself well to the use of Jinja2 templates. So, what is a Jinja2 template? At its most fundamental level, it is a static configuration file, such as the one we deployed for the SSH daemon earlier, but with the possibility of variable substitutions. Of course, Jinja2 is far more powerful than that—it is, in essence, a language in its own right, and features common language constructs such as for loops and if...elif...else constructs, just as you would find in other languages. This makes it incredibly powerful and flexible, and entire sections of a configuration file (for example) can be omitted, depending on how an if statement evaluates.

As you can imagine, Jinja2 deserves a book of its own to cover the detail of the language—however, here, we will provide a practical hands-on introduction to Jinja2 templating for the automation of configuration management in an enterprise.

Let's go back to our SSH daemon example for a minute, where we wanted to put the target hostname into a comment at the head of the file. While this is a contrived example, progressing it from the copy/lineinfile example to a single template task will show the benefits that templating brings. From here, we can progress to a more comprehensive example. To start with, let's define our Jinja2 template for the sshd_config file, as follows:

# Configured by Ansible {{ inventory_hostname }}
ChallengeResponseAuthentication no
UsePAM yes
X11Forwarding no
PrintMotd no
AcceptEnv LANG LC_*
Subsystem sftp /usr/lib/openssh/sftp-server
PasswordAuthentication no
PermitRootLogin no

Notice that the file is identical to the file we deployed using the copy module earlier, only now, we have included the comment in the file header and used the Ansible variable construct (denoted by pairs of curly braces) to insert the inventory_hostname variable.

Now, for the sake of our sanity, we will call this file sshd_config.j2 to ensure we can differentiate templates from flat configuration files. Templates are normally placed into a templates/ subdirectory within the role, and so are subject to version control in the same way that playbook, roles, and any associated flat configuration files are.

Now, rather than copying the flat file and then performing substitutions with one or more lineinfile tasks, we can use the Ansible template module to deploy this template and parse all Jinja2 constructs.

Thus, our tasks now look like this:

---
- name: Copy SSHd configuration to target host
template:
src: templates/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: 0644
notify:
- Restart SSH daemon

Notice that the task is almost identical to our earlier copy task and that we call our handler, just as before.

The completed module directory structure now looks like this:

roles
└── securesshd
├── handlers
│ └── main.yml
├── tasks
│ └── main.yml
└── templates
└── sshd_config.j2

Let's run this and evaluate the results, which can be seen in the following screenshot:

As can be seen here, the template has been copied across to the target host, and the variable in the header comment has been processed and the appropriate value substituted.

This becomes incredibly powerful as our configuration becomes more complex as, no matter how large and complex the template, the role still only requires the one template task. Returning to our MariaDB server, suppose that we want to set a number of parameters on a per-server basis to effect tuning appropriate to the different workloads we are deploying. Perhaps we want to set the following:

  • The server bind-address, defined by bind-address
  • The maximum binary log size, defined by max_binlog_size
  • The TCP port that MariaDB listens on, as defined by port

All of these parameters are defined in /etc/mysql/mariadb.conf.d/50-server.cnf. However, as discussed earlier, we need to also ensure the integrity of /etc/mysql/mariadb.cnf to ensure it includes this (and other) files, to reduce the possibility of someone overriding our configuration. Let's start building our templates—first of all, a simplified version of the 50-server.cnf file, with some variable substitutions. The first part of this file is shown in the following code—note the port and bind-address parameters, which are now defined using Ansible variables, denoted in the usual manner with pairs of curly braces:

[server]
[mysqld]
user = mysql
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
port = {{ mariadb_port }}
basedir = /usr
datadir = /var/lib/mysql
tmpdir = /tmp
lc-messages-dir = /usr/share/mysql
skip-external-locking
bind-address = {{ mariadb_bind_address }}

The second part of this file looks as follows—you will observe here the presence of the mariadb_max_binlog_size variable, while all other parameters remain static:

key_buffer_size = 16M
max_allowed_packet = 16M
thread_stack = 192K
thread_cache_size = 8
myisam_recover_options = BACKUP
query_cache_limit = 1M
query_cache_size = 16M
log_error = /var/log/mysql/error.log
expire_logs_days = 10
max_binlog_size = {{ mariadb_max_binlog_size }}
character-set-server = utf8mb4
collation-server = utf8mb4_general_ci
[embedded]
[mariadb]
[mariadb-10.1]

Now, let's also add in a templated version of /etc/mysql/mariadb.cnf, as follows:

[client-server]
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/

This file might be short, but it serves a really important purpose. It is the first file that is read by the MariaDB service when it loads, and it references other files or directories to be included. If we did not maintain control of this file using Ansible, then anyone with sufficient privileges could log in and edit the file, possibly including entirely different configurations and bypassing our Ansible-defined configuration entirely. Whenever you deploy configuration with Ansible, it is important to consider factors such as this, as otherwise, your configuration changes might be bypassed by a well-meaning (or otherwise) administrator.

A template doesn't have to have any Jinja2 constructs in it—if there are no variables to insert, as in our second example, the file will simply be copied as-is to the target machine.

Obviously, it would be slightly more efficient to use the copy module to send this static configuration file to the remote server, but this requires two tasks, where we can use just one with a loop to process all our templates. Such an example is shown in the following code block:

---
- name: Copy MariaDB configuration files to host
template:
src: {{ item.src }}
dest: {{ item.dest }}
owner: root
group: root
mode: 0644
loop:
- { src: 'templates/mariadb.cnf.j2', dest: '/etc/mysql/mariadb.cnf' }
- { src: 'templates/50-server.cnf.j2', dest: '/etc/mysql/mariadb.conf.d/50-server.cnf' }
notify:
- Restart MariaDB Server

Finally, we define a handler to restart MariaDB if the configuration has changed, as follows:

---
- name: Restart MariaDB Server
service:
name: mariadb
state: restarted

Now, before we run this, a word on variables. In Ansible, variables can be defined at a wide number of levels. In a case such as this, where we are applying a different configuration to different hosts with differing purposes, it makes sense to define the variables at the host or hostgroup level. However, what happens if someone were to forget to put these in the inventory, or in another appropriate location? Fortunately, we can leverage the variable precedence order of Ansible to our advantage here and define default variables for our role. These are second lowest on the order of precedence, so are almost always overridden by another setting elsewhere, yet they provide a safety net, should they be missed accidentally. As our preceding templates have been written, if the variables are not defined anywhere, the configuration file will be invalid and the MariaDB server will refuse to start—a case we would definitely like to avoid.

Let's define the default values for these variables in our role now under defaults/main.yml, as follows:

---
mariadb_bind_address: "127.0.0.1"
mariadb_port: "3306"
mariadb_max_binlog_size: "100M"

With this complete, our role structure should look like this:

roles/
└── configuremariadb
├── defaults
│ └── main.yml
├── handlers
│ └── main.yml
├── tasks
│ └── main.yml
└── templates
├── 50-server.conf.j2
└── mariadb.cnf.j2

Naturally, we want to override the default values, so we will define these in our inventory grouping—this is a good use case for inventory groups. All MariaDB servers that serve the same function would go in one inventory group, and then have a common set of inventory variables assigned to them, such that they all receive the same configuration. However, the use of templates in our role means that we can reuse this role in a number of situations, simply by providing differing configurations through variable definition. We will create an inventory for our test host that looks like this:

[dbservers]
ubuntu-testhost

[dbservers:vars]
mariadb_port=3307
mariadb_bind_address=0.0.0.0
mariadb_max_binlog_size=250M

With this complete, we can finally run our playbook and observe what happens. The result is shown in the following screenshot:

With this successfully run, we have shown a complete end-to-end example of how to manage configuration on an enterprise scale, all while avoiding the pitfalls of regular expression substitutions and multi-part configurations. Although these examples are simple, they should serve as the basis for any well-thought-out enterprise automation strategy where a configuration is required.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset