Ansible: Best Practice
Ansible ist sehr flexibel, wenn es um Struktur und Aufbau der eigenen Playbooks geht. Das ist auf der einen Seite natürlich schön, allerdings macht es gerade den Einstieg natürlich etwas schwerer. Ich möchte daher hier mal meine persönlichen Best Practice Regeln vorstellen, von denen ich denke, dass sie meine Playbooks übersichtlicher und besser wartbar machen.
Der grundlegende Aufbau meiner Rollen/Playbooks ist immer wie folgt:
.
├── defaults
│ └── main.yml
├── files
│ └── example
├── tasks
│ ├── client.yml
│ ├── config.yml
│ ├── install.yml
│ ├── main.yml
│ ├── preflight.yml
│ └── server.yml
├── templates
│ └── example.j2
└── vars
├── debian-11.yml
└── debian-12.yml
Die Rolle nehme ich immer wie folgt in mein Hauptplaybook (site.yml
) auf:
roles:
- {role: "example", tags: "example"}
So ist sichergestellt, dass ich die Rolle jederzeit über ihren tag
auch einzeln aufrufen kann, ohne hierfür ein eigenes Playbookfile zu benötigen.
In defaults/main.yml
führe ich immer alle Variablen auf, die das Playbook verarbeiten kann, inkl. Beispielen ihrer Nutzung. Auch dann, wenn ich hierfür gar keine Defauls setzen möchte:
# docker:
# zfs_pool: tank/data
#
# instances:
# grav:
# volumes:
# /srv/docker/grav/www:/var/www/html
# gitea:
# volumes:
# server:
# /srv/docker/gitea/www:/data
# /etc/timezone: /etc/timezone:ro
# /etc/localtime: /etc/localtime:ro
# db:
# /srv/docker/gitea/postgres:/var/lib/postgresql/data
# db: gitea
# db_user: gitea
# db_pass: !vault |
So ist sichergestellt, dass ich - z.B. beim Erstellen eines neuen Hosts - keine wichtigen Einstellungen vergesse und mein Playbook auch nach Jahren noch schnell verstehen kann. Das ist insbesondere dann hilfreich, wenn man im Playbook bereits Konfigurationen vorausgeplant hat, die bisher noch nicht verwendet werden (z.B. die Nutzung von Let's Encrypt mittels dns-01
statt http-01
). Eine solche Vorausplanung finde ich sehr sinnvoll, da man zum Zeitpunkt der Playbookerstellung wahrscheinlich oft besonders tief im Thema ist. So erspart man es sich selbst bestenfalls sich noch einmal tiefer mit dem Thema beschäftigen zu müssen.
Die Verzeichnisse files
und templates
nutze ich natürlich nur dann, wenn es entsprechende Dateien gibt. Beide Verzeichnisse untergliedere ich meist noch weiter (z.B. in bin
, conf
, etc.). Bei Templates hänge ich zudem an den eigentlichen Dateinamen immer die Endung .j2
an.
Am wichtigsten ist aber natürlich der Aufbau von tasks
. Ich versuche hier stets meine Tasks-Dateien möglichst kurz zu halten und auf die verschiedenen Schritte (wie z.B. Installation und Konfiguration) aufzuteilen. Oft kommt es auch vor, dass ein Tool eine Server/Client Architektur voraussetzt. Hier könnte man natürlich stets eine example-server
und eine separate example-client
Rolle oder Playbook anlegen, ich mache es jedoch meist so, dass ich für den jeweilign Part eine eigene Datei (z.B. server.yml
und client.yml
) anlege.
Je nach Betriebssystem bzw. Betriebssystemversion weichen die Konfigurationsdateien oder z.B. auch Paketnamen leicht voneinander ab. Oft reicht es hier einfach mit Variablen zu arbeiten. Ist dies der Fall lege ich für jedes Betriebssystem bzw. jede -version eine Variablendatei in vars
ab und füge meiner tasks/main.yml
folgenden Tasks hinzu (sinnvollerweise an den Beginn der Datei):
- name: Gather variables for each operating system
include_vars: "{{ item }}"
with_first_found:
- files:
- "{{ ansible_distribution | lower }}-{{ ansible_distribution_version | lower }}.yml"
- "{{ ansible_distribution | lower }}-{{ ansible_distribution_major_version | lower }}.yml"
- "{{ ansible_os_family | lower }}-{{ ansible_distribution_major_version | lower }}.yml"
- "{{ ansible_distribution | lower }}.yml"
- "{{ ansible_os_family | lower }}.yml"
skip: true
Die genutzten Variablen werden von Ansible automatisch bereitgestellt, wenn gather_facts
auf true
gesetzt wurde. ansible_distribution
enthält hierbei den Namen der Distribution als z.B. debian
, pop!_os
oder ubuntu
. ansible_distribution_version
ist z.B. 12.01
oder 22.04
wo hingegen ansible_distribtution_major_version
hier dann nur 12
oder 22
enthält. ansible_os_family
enthält den Namen der Distribution auf der das genutzte Betriebssystem basiert, also z.B. debian
für ubuntu
und pop!_os
. Letzeres kann hilfreich sein um zu entscheiden, welchen Paketmanager man für die Installation von Softwarepaketen nutzt (z.B. apt
oder yum
).
Durch die Anweisung with_first_found
müssen nicht alle möglichen Dateien vorhanden sein, Ansible nimmt einfach die erste Datei, die passt. Daher ist natürlich auch die Reihenfolge der files
Liste wichtig. Ich kann also sowohl die Datei ubuntu-22.yml
als auch einfach ubuntu-22.04.yml
anlegen oder schlicht debian.yml
, je nachdem wie fein die Unterscheidung sein muss. skip: true
sorgt dafür, dass Ansible die Verarbeitung nicht abbricht, wenn keine der Dateien gefunden werden konnte. Da in diesem Fall die Variablen gar nicht gesetzt würden, sollten diese daher auch in defaults/main.yml
definiert sein, dort dann eben mit den Werten, die von den meisten OS-Versionen genutzt werden.
Ein Beispiel könnten z.B. die unterschiedlichen Pfade und Namen für die Konfigurationsdatei von dropbear
unter Debian 11 und 12 sein:
debian-12.yml:
dropbear_config_dir: "/etc/dropbear/initramfs/"
dropbear_config_file: "dropbear.conf"
debian-11.yml:
dropbear_config_dir: "/etc/dropbear-initramfs/"
dropbear_config_file: "config"
An sich wäre es natürlich auch ganz nett die Anweisung für das Einlesen der Variablen direkt in meine site.yml
schreiben zu können, aber dort ist include_vars
leider nicht erlaubt und vars_files
kann nicht mit with_first_found
umgehen.
Eine weitere Besonderheit ist die Datei preflight.yml
, hier definiere ich Checks die vor der eigentlich Ausführung der Tasks bestanden werden müssen. Dies ist insbeondere dann hilfreich, wenn man include_vars
und skip: true
nutzt, denn während des Preflights kann man prüfen, ob wirklich alle benötigten Variablen gesetzt sind:
- name: Fail when dropbear config vars are not set
fail:
msg: "Please specify dropbear config vars"
when:
- dropbear_config_dir is not defined or
dropbear_config_file is not defined
Meine main.yml
baue ich nach folgendem Schema auf:
---
- name: Gather variables for each operating system
include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_distribution | lower }}-{{ ansible_distribution_version | lower }}.yml"
- "{{ ansible_distribution | lower }}-{{ ansible_distribution_major_version | lower }}.yml"
- "{{ ansible_os_family | lower }}-{{ ansible_distribution_major_version | lower }}.yml"
- "{{ ansible_distribution | lower }}.yml"
- "{{ ansible_os_family | lower }}.yml"
tags:
- example_install
- example_config
- example_server
- example_client
- include: preflight.yml
tags:
- example_install
- example_config
- example_server
- example_client
- include: install.yml
become: true
tags:
- example_install
- include: config.yml
become: true
tags:
- example_config
- include: server.yml
when: example_server == true
tags:
- example_server
- include: client.yml
when: example_client == true
tags:
- example_client
Ein weiterer Vorteil dieser Vorgehensweise, man kann Anweisungen wie tags
, become
und when
nutzen, ohne sie an jedes einzelne Play in den Tasks schreiben zu müssen. Außerdem bleibt es so sehr übersichtlich, auf welchen Hosts welcher Task ausgeführt wird.
Die Dateien install.yml
, config.yml
, server.yml
und client.yml
sind dann ganz normale Tasks für die Installation, die gemeingültige Konfiguration (sofern vorhanden), sowie für Server- und Clientkonfiguration.
Wer möchte kann noch einen meta
Ordner für jede Role anlegen und dort in einer main.yml
einige weiterführende Informationen zur Role anlegen, z.B.:
---
galaxy_info:
author: gpkvt
description: Example
license: MIT
company: none
min_ansible_version: 2.10
platforms:
- name: Debian
versions:
- bullseye
- bookworm
galaxy_tags:
- example
dependencies: []
Diese Angaben dienen in erster Linie zur Veröffentlichung einer Role via ansible-galaxy
, kann aber auch für die eigene Nutzung hilfreich sein. Insbesondere natürlich um zu erkennen, welche Betriebssysteme in welcher Version von der Role unterstützt werden.