Optimizing anssible code
Some ansible code can take a long time to run, this is in most cases due to a design choice in the playbook. Mostly when using loops where they are not needed, or loops to parse large datasets, this takes lots of time.
For a few of these optimizations there are things that can be done to improve performance:
Avoid large loops on registered vars
In many online examples you will find loops that use "with_items".
This is the old way, it is better to use "loop" according to the best practices.
- name: An old loop using with_items
ansible.builtin.set_fact:
small_fact: "[] + [{{ item }}]"
with_items: "{{ var }}"
when: "'text' in item"
This will still work, but when using multiple of these with_item loops in the same playbook, you will have a variable "item" that has a value at the start of the loop. It could have some nasty side effects, when this value is used somewhere else in the play.
The new approach looks like this:
- name: A new loop with loop
ansible.builtin.set_fact:
small_fact: "[] + [{{ small_var }}]"
loop: "{{ var }}"
loop_control:
loop_var: small_var
when: "'text' in small_var"
The above loop is the same as the with_items loop, but if you use different loop_vars for each loop, there is no chance of using a value where it shouldn't be used.
If loops must itterate over a lot of items, the efficiency is not great and will take a lot of time to complete. Imagine your playbook has to loop over the registered output of a previous task and that regisered variable contains 10000 lines.
If you do this with a standard loop (as shown above), it will take a very long time.
Example:
- name: Generate 10000 lines of output
ansible.builtin.shell: |
for i in $(seq 1 10000);
do
echo "${i} this is a line";
done
register: _output
- name: Loop over the output to find 55
ansible.builtin.set_fact:
line_number: "{{ ansible_loop.index0 | int }}"
when:
- "'55' in _line"
loop: "{{ _output.stdout_lines }}"
loop_control:
loop_var: _line
extended: true
label: "{{ ansible_loop.index0 }}"
This is a valid playbook and checks out great in code linting and syntax checks.
As with the code it looks nice and structured as we would like to see it.
But, when we execute this playbook, it wil take 10 minutes to complete, giving 9999 times skipped as output, and just 1 OK.
There must be room for improvement here...
The trick here is using a jinja2 filter to parse the output and if we replace the loop with a jinja2 filter, we end up with the following playbook:
- ansible.builtin.shell: |
for i in $(seq 1 10000);
do
echo "${i} this is a line";
done
register: _output
- name: Loop over the output to find 55
ansible.builtin.set_fact:
line_number: >-
{%- for linenum in range(_output.stdout_lines | length) -%}
{%- if '55' in _output.stdout_lines[linenum] -%}
{{ linenum | int }}
{%- endif -%}
{%- endfor -%}
In the above play, we still loop throug the output, but we do this with a single jinja2 filter, which is much more efficient than an ansible loop.
It also removes all the 9999 non-matching lines in the output.
The performance/runtime difference is vast at these volumes:
| playbook | number of records | runtime |
|---|---|---|
| loop ansible | 10000 | 600 sec |
| loop ansible | 1000 | 30 sec |
| jinja | 10000 | 1,2 sec |
| jinja | 1000 | 1 sec |
Avoid using include_tasks in a loop
Somtimes you'll need an inner- and outer loop to handle some variables in multiple instances. In this example we will create a standard set of files in each directory, the file sto create and the list of directories are given by 2 variables.
WARNING Jinja2 always returns a string value, so formatting is verry important when you need a list or a dict returned.
Example:
In this example we want the same set of files to be templated for every environment.
When you do this in a loop, you generally put the template tasks in a separate file and include this in an inner of two loops (1 for the environments and 1 for the files)
to render all files in 1 run.
So you will be using a nested loop.
In the example below, we don't create a nested loop, but generate a string variable, in which all combinations of both lists are present.
Then we loop over this list to generate the file needed.
There is no nested loop and no include_tasks itteration.
This will improve afficiency a great deal.
- name: Example play
hosts: localhost
gather_facts: false
vars:
template_files:
- credentials
- inventory
- notifications
- organization
- projects
- roles
- schedules
- teams
- templates
- users
- workflows
environments:
- dev
- test
- accp
- prod
tasks:
- name: Create the loop var
ansible.builtin.set_fact:
template_loop: >
{%- set return_value = [] -%}
{%- for env in environments -%}
{%- for file in template_files -%}
{%- set _ = return_value.append( env + ',' + file ) -%}
{%- endfor -%}
{%- endfor -%}
{{ return_value }}
- name: "Template all files loop"
ansible.builtin.template:
src: "{{ curr_file.split(',')[-1] }}.yml.j2"
dest: "/tmp/{{ project_name }}/group_vars/{{ curr_file.split(',')[0] }}/{{ curr_file.split(',')[-1] }}.yml"
lstrip_blocks: true
mode: "0640"
loop: "{{ template_loop }}"
loop_control:
loop_var: curr_file
The "Create the loop var" task, creates a list of all combinations of environments and files.
All fields are comma separated.
A smal piece looks like this:
template_loop: ["dev,credentials","dev,inventory","dev,notifications", .... etc.. ]"
The "Template all files loop" uses a standard single loop. We don't need a include_tasks to create the templated files.
This makes the playbook more readable and more efficient.
The loop_var "curr_file" is filled with the next part of the template_loop variable.
Like: curr_var: "dev,credentials"
To be able to use these variables to create the file we need, we must split them.
We do that with split, but we directly set the var with the part we want to use there.
For example:
- The first part, the environment: "{{ curr_file.split(',')[0] }}"
- The last part, the filename: '"{{ curr_file.split(',')[-1] }}"
If there are more elements, these can be addressed through a zero based index number.
By applying the above examples, the number of tasks will be reduced, performance will increase and the number of yaml files will decrease.
The code will even be more readable.
Create variables using jinja2
It is possible to build variables fast and consistent with jinja2.
Jinja2 is much faster than any ansible code to construct variables and here we find a way to make a playbook run much faster.
By getting the variables straight out of a multilayer dict (eg. a satellite inventory or something like that), we can filter a subset to target only the systems we need.
That is a performance gain (but we could do that with a inventory filter too), but a jinja filter is more dynamic and works inside the playbook.
First we go back to some jinja basics to create variable types:
String
String is the default type for the return value.
Integer
When you want to return an integer, you must convert it to integer.
In the task below, we loop through the output of a shell task in ansible to find a string and return the line number in a variable.
Since the linenumber is returned as a string, we need to convert it to number again.
- name: Loop over the output to find 'text'
ansible.builtin.set_fact:
line_number: >-
{%- for i in range(_output.stdout_lines | length) -%}
{%- if 'text' in _output.stdout_lines[i] -%}
{{ i | int }}
{%- endif -%}
{%- endfor -%}
NOTE Each time the searchstring is found, a line number is added to the output, be sure you use the correct separators, if needed.
List
Verified with ansible version 2.15 and ansible 2.19.
A list is defined in python (therefore in ansible too) as:
['item1','item2',]
To return this structure in jinja is easy when you know how. A pain in the ... if you don't.
We take the previous example en make a few modifications:
- name: Loop over the output to find 'text'
ansible.builtin.set_fact:
line_number: >
{%- set return_value = [] -%}
{%- for i in range(_output.stdout_lines | length) -%}
{%- if 'text' in _output.stdout_lines[i] -%}
{%- set _ = return_value.append( i | int ) -%}
{%- endif -%}
{%- endfor -%}
{{ return_value }}
The code is almost identical to the previous example, but to create a list variable we added some lines:
We first create a list return_value variable (list definition) and then append the values as needed.
Be sure to place these on separate lines as shown in the example, or it won't work and a string will be returned instead.
The return value is now placed between single quotes, so these will be added to the output as these are wanted in the list definition.
In a list there must be a comma separator placed between each item, after the last item there is no comma, this is why we use the loop.last variable to determine if we need a comma or not.
Dictionary
Verified with ansible version 2.15 up to ansible 2.19.
A dict in python (and therefore ansible) is defined as follows:
{ "key": "value", "key2": "value2" }
To return this in a jinja created structure, we use the following jinja:
- name: Loop over the output to find 'text'
ansible.builtin.set_fact:
line_number: >
{%- set return_val = dict() -%}
{%- for i in range(_output.stdout_lines | length) -%}
{%- if 'text' in _output.stdout_lines[i] -%}
{%- set _ = return_val.update({ i : _output.stdout_lines[i] }) -%}
{%- endif -%}
{%- endfor -%}
{{ return_val }}
First we create the return_value as a dict in jinja.
We add items using the jinja "update" method, which will add an item to the dict.
We now get a dict returned in which there are, not only the line number, but also the contents of that particular line.
This way we can extract variables from an output or any text variable in a fast and predictable way.
Jinja has lost of other filters and search options, which makes it possible to search for elements in dicts, like select or select_attr.
Read the jinja2 documentation for more information on this.
Building a list of dicts
Verified with ansible version 2.15, in ansible 2.19, they revised the jinja2 engine and the return value will always be a string, killing this optimization.
Somtimes we have a list of dicts with a number of items that misses a number of values you want in there.
As the lists are differrent in size, a lists_mergeby is not working.
This is a solution I cresated for the configuration as code for ansible automation platform 2.5, where the role user assignment must be done on organization_id. Initially this will work fine installation works, but as time progresses, organizations will be deleted and added. This will disrupt the numbering of the organizations in your config on recovery and will render your configuration as code useless.
The code below is created to make the mapping dynamicly, so the configuration
Let's give an example:
List 1: ( this is a result from a query in rhaap )
"gateway_organizations": [
{
"description": "The default organization for Ansible Automation Platform",
"id": 1,
"name": "Default",
},
{
"description": "Automation platform managent id=2",
"id": 2,
"name": "MGT",
},
{
"description": "Database team collection based id=5",
"id": 5,
"name": "ORACLE_COLL",
},
{
"description": "Database team role based id=8",
"id": 9,
"name": "ORACLE_REQ",
},
{
"description": "Organization for team TST id=9",
"id": 8,
"name": "ORG_TST",
},
{
"description": "RHEL Organization collection based id=3",
"id": 3,
"name": "RHEL_COLL",
},
{
"description": "RHEL Organization role based id=6",
"id": 7,
"name": "RHEL_REQ",
},
{
"description": "WEB server team collection based id=4",
"id": 4,
"name": "WEB_COLL",
},
{
"description": "WEB server team role based id=7",
"id": 6,
"name": "WEB_REQ",
}
]
The above result is very much edited, only the items we need are remaining.
The list we need the information for is the following:
gateway_role_user_assignments_all:
- role_definition: Organization Member
user: wilco
name: MGT
- role_definition: Organization Member
user: coll_upload
name: MGT
- role_definition: Team Member
user: coll_upload
name: MGT
- role_definition: Organization Member
user: coll_get
name: MGT
- role_definition: Team Member
user: coll_get
name: MGT
- role_definition: Organization Member
user: ee_upload
name: MGT
- role_definition: Team Member
user: ee_upload
name: RHEL_COLL
- role_definition: Organization Member
user: ee_pull
name: MGT
- role_definition: Team Member
user: ee_pull
name: RHEL_COLL
- role_definition: Organization Admin
user: mgt
name: MGT
- role_definition: Organization Admin
user: CaC_admin_MGT
name: MGT
- role_definition: Organization Admin
user: CaC_admin_RHEL_COLL
name: RHEL_COLL
- role_definition: Organization Admin
user: CaC_admin_RHEL_REQ
name: RHEL_REQ
- role_definition: Organization Admin
user: CaC_admin_WEB_COLL
name: WEB_COLL
- role_definition: Organization Admin
user: CaC_admin_WEB_REQ
- role_definition: Organization Admin
user: CaC_admin_ORACLE_COLL
name: ORACLE_COLL
- role_definition: Organization Admin
user: CaC_admin_ORACLE_REQ
name: ORACLE_REQ
- role_definition: Organization Admin
user: CaC_admin_TST
name: ORG_TST
In this list, we added the organization name instead of the organization id.
The mapping will be done by the following jinja2 task:
- name: Create the gateway_role_user_assignments variable
ansible.builtin.set_fact:
gateway_role_user_assignments: >-
{%- set return_value = [] }
{%- for item in gateway_role_user_assignments_all -%}
{%- set ret = dict() -%}
{%- set _ = ret.update('role_definition': item.role_definition) -%}
{%- set _ = ret.update('user': item.user) -%}
{%- set _ = ret.update('object_id': orgs_list|selectattr('name', '==', item.name)|first).id) -%}
{%- set _ = return_value.append( ret) -%}
{%- endfor -%}
{{ return_vaule }}
This will create the variable that the config as code needs to map all users to their roles using the organization id.
And there is no problem when a organization is deleted and a recovery is done of the complete configuration (the gap
is resolved by the script ).
Json
To return any of the above variables as a json variable, just use a set_fact.
ansible.builtin.set_fact:
json_var: "{{ jinja_built_var | to_nice_json }}"
This should work.
Avoid jinja spacing errors in linting
Nothing can be more frustrating as receiving a jinja spacing error while linting your code. . You know that there is nothing wrong with it, it runs without any errors and still every time you run the linter, this error pops up: Improve jinja spacing....
The code:
- name: Add the controller to the names of the templates
ansible.builtin.set_fact:
ee_on_templates: |
{%- set return_value = [] -%}
{%- for i in range(_template_list | length) -%}
{% set _ = return_value.append( inventory_hostname + i) -%}
{%- endfor -%}
{{ return_value }}
In the above code, we did everything to remove spaces... like {%- -%} and still the linter complains sometimes.
It looks like valid code, and it is... But:
WARNING Listing 1 violation(s) that are fatal
jinja[spacing]: Jinja2 spacing could be improved
The final trick is not to try to ignore the error, but change the set_fact ever so slightly:
- name: Add the controller to the names of the templates
ansible.builtin.set_fact:
ee_on_templates: >
{%- set return_value = [] -%}
{%- for i in range(_template_list | length) -%}
{% set _ = return_value.append( inventory_hostname + i) -%}
{%- endfor -%}
{{ return_value }}
The | instead of > makes all the difference..
Creating complex loops using jinja2
In some cases you would create a loop within a loop to perform a complex task that could look like this:
In main.yml:
- name: "Loop to write secure credentials"
ansible.builtin.include_tasks:
file: secure_group.yml
vars:
curr_env2: "{{ lcred[0] }}"
creds: "{{ lcred[1] }}"
loop_control:
loop_var: lcred
loop: |-
[
{%- for key, env_item in code_environment_vars.items() -%}
{%- if env_item.credentials != [] -%}
{%- for cred in env_item.credentials -%}
['{{ key }}',{{ cred }}],
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
]
when: creds.credential_type != 'Machine'
no_log: true
The jinja code in the above task creates a list of comma separated items from a dict, as specified in the automate organization addition.
Link to vars structure
Specificly the list will contain credential items per environment.
And the loop will itterate over this list that is created dynamicly and will include the file with the tasks for every item. This is a loop that is used very much in ansible code, and this is not so efficient, but to be able to run multiple tasks it is sometimes the only way.
In secure_group.yml:
What we did here, is create a vaulted credential in a rather complex manner, and we needed multiple tasks to do it, so we had to use include_tasks.
---
- name: Be sure to clean the file with unencrypted value
block:
- name: Write token to file
ansible.builtin.copy:
content: "{{ creds.encrypt }}"
dest: /tmp/file
mode: "0600"
- name: Encrypt a var and register result
ansible.builtin.command:
argv:
- ./encrypt.sh
- /tmp/file
- dummy
- "{{ team_password }}"
register: _vaulted
changed_when: false
no_log: true
always:
- name: Clean the unencrypted file
ansible.builtin.file:
path: /tmp/file
state: absent
- name: Write the encrypted credential to file
ansible.builtin.blockinfile:
path: "/tmp/{{ team_project_name }}/group_vars/{{ curr_env2 }}/controller_credentials.yml"
insertafter: EOF
marker: ''
block: |
{% filter indent(width=2, first=true) %}
- name: {{ creds.name }}
{% if cred.description is defined %}
description: {{ creds.description }}
{% else %}
description:
{% endif %}
credential_type: {{ creds.credential_type }}
organization: {{ organization_long_name | upper }}
inputs:
{% if creds.credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
auth_url: ''
token: {{ '"{{ ahub_token }}"' }}
url: '{{ creds.url }}'
update_secrets: true
{% endif %}
{% if creds.credential_type == 'Source Control' %}
ssh_key_data: {{ _vaulted.stdout }}
username: {{ creds.username }}
{% endif %}
{% if creds.credential_type == 'Vault' %}
vault_password: {{ _vaulted.stdout }}
{% endif %}
{% if creds.credential_type == 'Container Registry' %}
host: {{ creds.host }}
username: {{ creds.username }}
password: {{ _vaulted.stdout }}
verify_ssl: {{ creds.verify_ssl | lower }}
{% endif %}
{% endfilter %}
- name: Remove empty lines at end of file # noqa: command-instead-of-module
ansible.builtin.shell:
cmd: sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' controller_credentials.yml
chdir: "/tmp/{{ team_project_name }}/group_vars/{{ curr_env2 }}"
changed_when: true
Now we know better and merged the above code into 1 task: And it still does the same...
- name: "Loop to write secure credentials" # noqa: jinja[spacing]
ansible.builtin.blockinfile:
path: "/tmp/{{ team_project_name }}/group_vars/{{ lcred[0] }}/controller_credentials.yml"
insertafter: EOF
marker: ''
block: |
{% filter indent(width=2, first=true) %}
- name: {{ lcred[1].name }}
{% if lcred[1].description is defined %}
description: {{ lcred[1].description }}
{% else %}
description:
{% endif %}
credential_type: {{ lcred[1].credential_type }}
organization: {{ organization_long_name | upper }}
inputs:
{% if lcred[1].credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
auth_url: ''
token: {{ '"{{ ahub_token }}"' }}
url: '{{ lcred[1].url }}'
update_secrets: true
{% endif %}
{%- endfilter %}
{% if lcred[1].credential_type == 'Source Control' %}
{% filter indent(width=2, first=true) %}
ssh_key_data: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% filter indent(width=2, first=true) %}
username: {{ lcred[1].username }}
{%- endfilter %}
{% endif %}
{% if lcred[1].credential_type == 'Vault' %}
{% filter indent(width=2, first=true) %}
vault_password: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% endif %}
{% if lcred[1].credential_type == 'Container Registry' %}
{% filter indent(width=2, first=true) %}
host: {{ lcred[1].host }}
username: {{ lcred[1].username }}
password: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% filter indent(width=2, first=true) %}
verify_ssl: {{ lcred[1].verify_ssl | lower }}
{%- endfilter %}
{% endif %}
loop_control:
loop_var: lcred
loop: |-
[
{%- for key, env_item in code_environment_vars.items() -%}
{%- if env_item.credentials != [] -%}
{%- for cred in env_item.credentials -%}
['{{ key }}',{{ cred }}],
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
]
when: lcred[1].credential_type != 'Machine'
no_log: true
As the loop is not altered, we will not explain that.
But the jinja in the blokinfile is rather complex, but if you obide by a few rules, you can write this yourself.
The result of this code is this:
(I deleted some lines, to keep this readable)
The base layout of the file was already templated, this just adds the credentials.
---
controller_credentials:
- name: INFRA_automation_hub_image_pull_secret
description:
credential_type: Container Registry
organization: ORG_INFRA
inputs:
host: rhaap25.homelab
username: ee_pull
password: !vault |
$ANSIBLE_VAULT;1.1;AES256
65316138303936616434353063336661326162303462376434336461323666396637383064616538
3334363164616263650a373932663635633864663139343366343864616464623263386436653632
6565
verify_ssl: false
- name: INFRA_gitlab
description:
credential_type: Source Control
organization: ORG_INFRA
inputs:
ssh_key_data: !vault |
$ANSIBLE_VAULT;1.1;AES256
62616339383061323463616162346534336534326266396662336666333265333163386534646562
3635633330663165633163633165663331643262356530370a336635353066366664363730393435
32626361656666356666306331613633373163383662656664653736653333656232373666363666
613232323138333630326430386333373231
username: AAP_user
- name: INFRA_vault
description:
credential_type: Vault
organization: ORG_INFRA
inputs:
vault_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66333662343338353062353038326430373161643638636264386336643462373737396664356663
31386565313562366336376665333631653630613034663533656631653930376439
As you can see, all credentials are nicely formatted and vaulted in one loop.The loop will add the credentials in the inventory in a number of directories, that are the environments in the source dict.
Let's breakup the jinja that renders this: Indentation is used here to clarify the code, do not use this in your code...
# We start with the correct indentation for our yaml file
{% filter indent(width=2, first=true) %}
- name: {{ lcred[1].name }}
# an IF statement must complete within the same FILTER
{% if lcred[1].description is defined %}
description: {{ lcred[1].description }}
{% else %}
description:
{% endif %}
credential_type: {{ lcred[1].credential_type }}
organization: {{ organization_long_name | upper }}
inputs:
{% if lcred[1].credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
auth_url: ''
token: {{ '"{{ ahub_token }}"' }}
url: '{{ lcred[1].url }}'
update_secrets: true
{% endif %}
{%- endfilter %}
{% if lcred[1].credential_type == 'Source Control' %}
# the same goes for a FILTER within an IF statement it must complete before ENDIF
{% filter indent(width=2, first=true) %}
ssh_key_data: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% filter indent(width=2, first=true) %}
username: {{ lcred[1].username }}
{%- endfilter %}
{% endif %}
# Here we start a new IF..
{% if lcred[1].credential_type == 'Vault' %}
{% filter indent(width=2, first=true) %}
vault_password: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% endif %}
# Here we start a new IF
{% if lcred[1].credential_type == 'Container Registry' %}
{% filter indent(width=2, first=true) %}
host: {{ lcred[1].host }}
username: {{ lcred[1].username }}
password: !vault |
{% endfilter %}
{% filter indent(width=10, first=true) %}
{{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
{%- endfilter %}
{% filter indent(width=2, first=true) %}
verify_ssl: {{ lcred[1].verify_ssl | lower }}
{%- endfilter %}
{% endif %}
This looks like the number of filter statements could be reduced, but ansible-lint will fail with a load error, without a clear error message. Keep these rules in mind when writing complex jinja code.
Dynamic variable names
When running large playbooks with multiple scenario's in them, you might want to have some variable created only in a cetain situation, dependent of the value in a variable. Something like wyou have 3 variables with different content for use in a certain environemnt, when starting the playbook there is a variable in the inventory or even a value in the hostvars that combined with a prefix will point to a variable you defined and you want this to be dynamic.
Let me give you an example:
You want to define a separate timezone for each ditribution:
vars:
timezone_redhat: UTC
timezone_ubuntu: Europe/Amsterdam
timezone_alpine: Europe/Switzerland
timezone_centos: America/NewYork
tasks:
- name: Set timezone for distro
community.general.timezone:
name: "{{ 'timezone_' + vars['ansible_distribution'] | lower }}
Avoid host_key error when redeploying a host
When you use your infrastructure playbook to remove- and create a host with the same name (redeploy), the host key changes. Sometimes the play will fail after (re)creating the host with a host key error. To prevent this, use the following in the deployment play only:
- name: Configure the host on infrastructure
hosts: "{{ instances | default('localhost') }}"
gather_facts: false
vars:
ansible_ssh_host_key_checking: false
WARNING
Do not use this in your inventory, it reduces security for all hosts in the inventory.
If you just use this in your deployment play, you will be safe and the play won't fail
on this error.