Von null zur wiederverwendbaren Chrony-Rolle

Ansible ist ein Automatisierungswerkzeug, mit dem ein gewünschter Zustand von Systemen als Code beschrieben und wiederholbar angewendet wird. Dieser Artikel baut Schritt für Schritt ein kleines Chrony-Lab auf: erst Installation, dann Inventory, erster Verbindungstest, Mini-Playbook, Rolle, Variablen, Template, Handler und Ausführung.

Chrony ist dafür ein gutes Lernbeispiel: Der Zweck ist leicht verständlich, der Dienst existiert auf vielen Linux-Distributionen, es gibt Paketinstallation, Service-Management, Konfigurationsdatei, Templates, Defaults, Facts, Conditions, Handler und Inventory-Overrides. Die echte lokale ForgeOne-Rolle chronyd diente als Praxisquelle. Für diesen Artikel wurde daraus eine neutrale Lehrversion abgeleitet: keine internen NTP-Server, keine Kundenhosts, keine internen Repository-URLs, keine Secrets.

Was ist Ansible?

Ansible läuft auf einem Control Node. Das ist der Rechner, auf dem Sie ansible oder ansible-playbook ausführen. Von dort verbindet sich Ansible per SSH zu Managed Nodes. Managed Nodes sind die Zielsysteme, die konfiguriert werden. Auf den Zielsystemen muss normalerweise kein Agent laufen; Ansible nutzt SSH und führt Module aus. Für viele Linux-Module braucht der Managed Node eine nutzbare Python-Umgebung.

markdown
CONTROL NODE
|
| SSH
v
MANAGED NODE
  • Inventory: beschreibt, wo Automation läuft, also Hosts und Gruppen.
  • Playbook: beschreibt, was passieren soll und auf welchen Hosts.
  • Task: ein einzelner Schritt in einem Playbook.
  • Module: die technische Arbeitseinheit, zum Beispiel package, template oder systemd_service.
  • Role: wiederverwendbare Struktur aus Tasks, Defaults, Templates, Handlern und Metadaten.
  • Collection: Paketformat für Module, Rollen, Plugins und Dokumentation.
  • FQCN: Fully Qualified Collection Name, zum Beispiel ansible.builtin.package.

Bei einem Lauf wie ansible-playbook playbooks/site.yml passiert vereinfacht: Ansible liest Konfiguration, lädt das Inventory, bestimmt Hosts und Gruppen, kombiniert Variablen, baut SSH-Verbindungen auf, sammelt Facts, wertet das Playbook aus, lädt Rollen, führt Tasks aus, startet Module auf Managed Nodes, löst bei Änderungen Handler aus und gibt am Ende ein Ergebnis aus. Diese Reihenfolge ist wichtiger als sofort viel YAML zu sehen.

Ansible installieren

Wählen Sie genau einen Installationsweg für Ihren Control Node. Für erste Tests sind Distributionspakete einfach. pipx ist gut für Admin-Workstations, weil Ansible isoliert im Benutzerkontext installiert wird. Ein Python venv ist gut für Projekt-Repositories und CI, weil Versionen explizit kontrolliert werden.

Debian und Ubuntu

Debian und Ubuntu nutzen APT. Führen Sie diesen Block auf dem Control Node aus, wenn Ihr Arbeitsrechner oder Ihre Admin-VM Debian oder Ubuntu ist.

bash
# Debian / Ubuntu: Ansible installieren
sudo apt update
sudo apt install ansible
# Prüfen
ansible --version

Red Hat, Rocky Linux und AlmaLinux

RHEL-kompatible Systeme nutzen DNF. Führen Sie diesen Block auf dem Control Node aus, wenn Ihr Arbeitsrechner Red Hat Enterprise Linux, Rocky Linux oder AlmaLinux verwendet.

bash
# Red Hat / Rocky Linux / AlmaLinux: Ansible Core installieren
sudo dnf install ansible-core
# Optional, wenn das Community-Paket in den aktivierten Repositories vorhanden ist
sudo dnf install ansible
# Prüfen
ansible --version

SUSE Linux Enterprise und openSUSE

SUSE-Systeme nutzen zypper. Führen Sie diesen Block auf dem Control Node aus, wenn Ihr Arbeitsrechner SUSE Linux Enterprise oder openSUSE verwendet.

bash
# SUSE Linux Enterprise / openSUSE: Ansible installieren
sudo zypper refresh
sudo zypper install ansible
# Prüfen
ansible --version

pipx und Python venv

pipx installiert Ansible isoliert im Benutzerkontext. Das ist praktisch, wenn das Betriebssystem konservative Paketversionen liefert, Sie aber keine projektlokale virtuelle Umgebung aktivieren wollen.

bash
# pipx installieren und Ansible isoliert im Benutzerkontext installieren
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install --include-deps ansible
# Neue Shell öffnen oder PATH neu laden, dann prüfen
ansible --version
ansible-community --version

Ein projektlokales venv ist sinnvoll, wenn Repository, CI und Team dieselbe Ansible-Version nutzen sollen. Führen Sie den Block im Projektverzeichnis aus.

bash
# Projektlokales Python venv
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install ansible ansible-lint
# Prüfen
ansible --version

Lab aufbauen

Jetzt entsteht ein kleines Testprojekt. Am Anfang ist die Struktur absichtlich minimal. Sie wächst im Artikel mit jedem Schritt.

bash
mkdir -p ansible-chrony-lab
cd ansible-chrony-lab
mkdir -p inventories/lab/group_vars inventories/lab/host_vars playbooks roles

Legen Sie danach ansible.cfg an. Diese Datei macht das Projektverhalten reproduzierbar: Ansible weiß, welches Inventory und welchen Rollenpfad es verwenden soll.

ini
[defaults]
inventory = inventories/lab/hosts.yml
roles_path = roles
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
timeout = 30
bash
ansible-config view
ansible-config dump --only-changed

Inventory erstellen

Das Inventory sagt primär: Wo läuft Automation? Das Playbook und die Rolle sagen: Was soll passieren? Für das Lab nutzen wir zwei Beispielhosts in einer Gruppe linux. Ersetzen Sie die Hostnamen und den SSH-Benutzer durch Ihre echten Zielsysteme.

yaml
all:
children:
linux:
hosts:
node01.example.org:
ansible_user: admin
node02.example.org:
ansible_user: admin

all ist die Wurzel des Inventories. children enthält Gruppen. linux ist in diesem Beispiel eine Gruppe. hosts enthält die konkreten Managed Nodes. ungrouped wäre die automatisch verwendete Gruppe für Hosts, die keiner eigenen Gruppe zugeordnet sind. Parent- und Child-Groups ordnen Gruppen hierarchisch; das ist aber noch keine klassische objektorientierte Vererbung.

Erste Verbindung testen

Bevor wir Chrony konfigurieren, prüfen wir die Verbindung. Das Ansible-ping-Modul ist kein ICMP-Ping. Es testet im Wesentlichen SSH, Python und ob Ansible ein Modul auf dem Managed Node ausführen kann.

bash
ansible all -m ansible.builtin.ping

Wenn das fehlschlägt, prüfen Sie zuerst DNS/Hostname, SSH-Key, Benutzer, sudo-Rechte und Python auf dem Zielsystem. Erst wenn dieser Schritt funktioniert, lohnt sich das nächste Playbook.

Erstes Mini-Playbook

Das erste Playbook macht nur eine Sache: Es stellt sicher, dass das Paket chrony installiert ist. Damit lernen Sie Play, hosts, become, tasks, name, Modulparameter und state ohne Rollenstruktur.

yaml
---
- name: Install Chrony for first contact with Ansible
hosts: linux
become: true
tasks:
- name: Install Chrony package
ansible.builtin.package:
name: chrony
state: present

Führen Sie das Playbook zuerst im Check-Modus aus, dann real und danach ein zweites Mal. Beim ersten echten Lauf darf changed erscheinen. Beim zweiten Lauf sollte der Task ok melden, wenn das Paket bereits installiert ist. Das ist Idempotenz: Ansible beschreibt einen Zustand, nicht nur eine Befehlsliste.

bash
ansible-playbook playbooks/mini.yml --check --diff
ansible-playbook playbooks/mini.yml
ansible-playbook playbooks/mini.yml

Warum daraus eine Rolle wird

Das Mini-Playbook reicht nicht mehr, sobald Debian andere Pfade nutzt als Red Hat, SUSE einen anderen Servicenamen hat, NTP-Server konfigurierbar sein sollen, eine Konfigurationsänderung den Dienst neu starten muss oder dieselbe Logik in zehn Projekten verwendet werden soll. Deshalb bauen wir daraus eine Rolle.

Eine Rollenstruktur ist kein ForgeOne-Sonderweg. ansible-galaxy erzeugt eine Standardstruktur, die Sie danach bewusst vereinfachen können.

bash
ansible-galaxy role init --init-path roles chrony
markdown
roles/chrony/
├── defaults/
├── files/
├── handlers/
├── meta/
├── tasks/
├── templates/
├── tests/
└── vars/

Unsere Lehrrolle braucht defaults, tasks, handlers, templates, meta und README. files bleibt leer, weil die Konfiguration aus Variablen gerendert wird. vars bleibt leer, weil Benutzeroptionen nicht in vars gehören: role vars haben deutlich höhere Precedence als defaults und sind schwerer zu überschreiben. Die nächsten vollständigen Dateien liegen im Lab unter roles/chrony/defaults/main.yml, roles/chrony/tasks/main.yml, roles/chrony/tasks/Debian.yml, roles/chrony/tasks/RedHat.yml, roles/chrony/tasks/Suse.yml, roles/chrony/templates/chrony.conf.j2 und roles/chrony/handlers/main.yml.

Chrony-Rolle Schritt für Schritt

defaults/main.yml ist die öffentliche konfigurierbare Schnittstelle der Rolle. Benutzer können diese Werte über group_vars, host_vars oder andere Quellen überschreiben. Deshalb stehen Paketnamen, Servicenamen, Pfade, Serverliste und Service-Zustand hier.

yaml
---
chrony_package_name_map:
Debian: chrony
RedHat: chrony
Suse: chrony
chrony_service_name_map:
Debian: chrony
RedHat: chronyd
Suse: chronyd
chrony_config_path_map:
Debian: /etc/chrony/chrony.conf
RedHat: /etc/chrony.conf
Suse: /etc/chrony.conf
chrony_package_state: present
chrony_service_enabled: true
chrony_service_state: started
chrony_servers:
- address: 0.pool.ntp.org
options: iburst
- address: 1.pool.ntp.org
options: iburst
chrony_driftfile: /var/lib/chrony/drift
chrony_makestep: "1.0 3"
chrony_rtcsync: true
chrony_logdir: /var/log/chrony

tasks/main.yml sammelt die Logik. Zuerst werden OS-spezifische Werte aus ansible_facts["os_family"] berechnet. Danach wird bei unbekannten Systemfamilien sauber abgebrochen, die passende OS-Datei eingebunden, das Template gerendert und der Dienst aktiviert/gestartet.

yaml
---
- name: Resolve OS-specific Chrony values
ansible.builtin.set_fact:
chrony_package_name: "{{ chrony_package_name_map.get(ansible_facts['os_family']) }}"
chrony_service_name: "{{ chrony_service_name_map.get(ansible_facts['os_family']) }}"
chrony_config_path: "{{ chrony_config_path_map.get(ansible_facts['os_family']) }}"
changed_when: false
- name: Stop when the operating system family is unsupported
ansible.builtin.fail:
msg: "Unsupported operating system family: {{ ansible_facts['os_family'] }}"
when: chrony_package_name is not defined or chrony_package_name is none
- name: Run OS-specific Chrony tasks
ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"
- name: Render Chrony configuration
ansible.builtin.template:
src: chrony.conf.j2
dest: "{{ chrony_config_path }}"
owner: root
group: root
mode: "0644"
notify: Restart chrony
- name: Ensure Chrony service state
ansible.builtin.systemd_service:
name: "{{ chrony_service_name }}"
enabled: "{{ chrony_service_enabled }}"
state: "{{ chrony_service_state }}"

Woher kennt Ansible os_family? Durch gather_facts. Für Debian/Ubuntu ist der Wert typischerweise Debian, für RHEL-kompatible Systeme RedHat und für SUSE Suse. Prüfen können Sie das gezielt mit dem setup-Modul.

bash
ansible all -m ansible.builtin.setup -a 'filter=ansible_os_family'
ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution'

include_tasks wird hier verwendet, weil die Datei zur Laufzeit anhand der Facts gewählt wird. import_tasks ist statisch und wird schon beim Parsen verarbeitet. Für diese Lernrolle ist include_tasks verständlicher, weil der Zusammenhang zu den gesammelten Facts sichtbar bleibt.

tasks/Debian.yml

yaml
---
- name: Install Chrony on Debian family
ansible.builtin.package:
name: "{{ chrony_package_name }}"
state: "{{ chrony_package_state }}"

tasks/RedHat.yml

yaml
---
- name: Install Chrony on Red Hat family
ansible.builtin.package:
name: "{{ chrony_package_name }}"
state: "{{ chrony_package_state }}"

tasks/Suse.yml

yaml
---
- name: Install Chrony on SUSE family
ansible.builtin.package:
name: "{{ chrony_package_name }}"
state: "{{ chrony_package_state }}"

Das Template erzeugt die Chrony-Konfiguration aus Variablen. {{ variable }} setzt Werte ein, for iteriert über chrony_servers, if rendert optionale Direktiven. template ist hier richtig, weil die Datei aus Variablen entsteht. copy wäre passend, wenn eine statische Datei unverändert kopiert werden soll.

jinja2
# Ansible managed
{% for server in chrony_servers %}
server {{ server.address }} {{ server.options | default('iburst') }}
{% endfor %}
driftfile {{ chrony_driftfile }}
{% if chrony_makestep | length > 0 %}
makestep {{ chrony_makestep }}
{% endif %}
{% if chrony_rtcsync | bool %}
rtcsync
{% endif %}
logdir {{ chrony_logdir }}

Der Handler startet Chrony nur neu, wenn das Template wirklich geändert wurde. notify markiert die Änderung im Task, der Handler läuft gebündelt am Ende. Das verhindert unnötige Restarts während eines Laufs.

yaml
---
- name: Restart chrony
ansible.builtin.systemd_service:
name: "{{ chrony_service_name }}"
state: restarted

Playbook mit Rolle ausführen

Jetzt wird das Playbook klein und lesbar. Die wiederverwendbare Logik steckt in der Rolle; das Playbook wählt nur Zielgruppe, Rechte und Rolle aus.

yaml
---
- name: Configure time synchronization
hosts: linux
become: true
gather_facts: true
roles:
- role: chrony

Die Gruppe linux kommt aus dem Inventory. become: true bedeutet, dass die Tasks mit erhöhten Rechten laufen, typischerweise über sudo. gather_facts: true ist wichtig, weil die Rolle os_family verwendet.

Variablen und Precedence verstehen

Jetzt entsteht Precedence aus einem echten Problem: Die Rolle hat Defaults, das Lab überschreibt die Zeitserver für alle Hosts, und node01 bekommt eine eigene Ausnahme.

inventories/lab/group_vars/all.yml

yaml
chrony_servers:
- address: 0.pool.ntp.org
options: iburst
- address: 1.pool.ntp.org
options: iburst
chrony_makestep: "1.0 3"
chrony_rtcsync: true

inventories/lab/host_vars/node01.example.org.yml

yaml
chrony_servers:
- address: time.cloudflare.com
options: iburst

node02 verwendet die Server aus group_vars/all.yml. node01 verwendet die Server aus host_vars/node01.example.org.yml, weil host_vars in diesem praktischen Fall spezifischer sind. Technisch ist das keine klassische objektorientierte Vererbung. Ansible kombiniert Variablen aus mehreren Quellen nach definierten Precedence-Regeln. Die Inventory-Hierarchie all -> parent group -> child group -> host ist nur ein Teil davon.

markdown
Quelle | Beispiel | Rolle im Beispiel
------------------------------ | ------------------------ | -----------------------------
Role defaults | chrony_servers: pool A | Standard der Rolle
group_vars/all | chrony_servers: pool B | Lab-weite Konfiguration
host_vars/node01 | chrony_servers: source C | Ausnahme für einen Host
play vars | vars: ... | Wert direkt im Play
role vars | roles/chrony/vars | interne, starke Rollenwerte
set_fact | chrony_service_name | Laufzeitwert aus Facts
role params | role: chrony, vars: ... | expliziter Rollenaufruf
extra vars | -e key=value | stärkster bewusster Override

Die Tabelle ist eine praktische Orientierung. Die vollständige Liste ist detaillierter und enthält weitere Quellen und Sonderfälle. Für Details siehe die offizielle Ansible-Dokumentation zur Variable Precedence.

Deployment und Ergebnis prüfen

Jetzt ist das Projekt ausführbar. Prüfen Sie zuerst Syntax, dann Dry Run, dann einen einzelnen Host und erst danach die gesamte Gruppe.

bash
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --syntax-check
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --check --diff
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --limit node01.example.org --diff
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml

Hören Sie nicht beim PLAY RECAP auf. Prüfen Sie auf einem Managed Node, ob der Dienst läuft und ob Chrony Quellen und Tracking-Daten sieht. Der Dienst heißt je nach Distribution chrony oder chronyd; deshalb enthält der Prüfblock beide Varianten für systemctl.

bash
ssh admin@node01.example.org 'systemctl status chrony || systemctl status chronyd'
ssh admin@node01.example.org 'chronyc sources'
ssh admin@node01.example.org 'chronyc tracking'

Führen Sie die Rolle danach erneut aus. Wenn bereits alles korrekt ist, sollte changed möglichst bei 0 liegen. Falls changed wiederholt steigt, suchen Sie nach nicht-idempotenten Tasks oder Templates, die bei jedem Lauf andere Inhalte erzeugen.

bash
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --diff

Die vollständige Beispielstruktur

Alle Dateien aus dem Lab sind hier vollständig enthalten. Es gibt kein Download-Gate und keinen Login. Kopieren Sie jede Datei in den angegebenen Pfad.

markdown
ansible-chrony-lab/
├── ansible.cfg
├── requirements.yml
├── inventories/
│ └── lab/
│ ├── hosts.yml
│ ├── group_vars/
│ │ └── all.yml
│ └── host_vars/
│ └── node01.example.org.yml
├── playbooks/
│ ├── mini.yml
│ └── site.yml
└── roles/
└── chrony/
├── defaults/
│ └── main.yml
├── handlers/
│ └── main.yml
├── meta/
│ └── main.yml
├── tasks/
│ ├── Debian.yml
│ ├── main.yml
│ ├── RedHat.yml
│ └── Suse.yml
├── templates/
│ └── chrony.conf.j2
└── README.md

requirements.yml

yaml
---
collections: []
roles: []

Dieses Beispiel benötigt keine externe Collection. Alle verwendeten Module stammen aus ansible.builtin. In realen Projekten können externe Collections und Rollen in requirements.yml versioniert werden.

Collections, Rollen und Git Submodules

Eine Role ist wiederverwendbare Konfigurationslogik. Eine Collection ist ein Paketformat für Rollen, Module, Plugins, Playbooks und Dokumentation. FQCN wie ansible.builtin.package machen sichtbar, aus welcher Collection ein Modul stammt.

Git Submodules sind etwas anderes: Sie sind eine Git- und Repository-Strategie. ForgeOne nutzt in bestimmten Projekten separat versionierte Rollen-Repositories und bindet sie per Submodule ein. Das ist keine Ansible-Pflicht und kein pauschaler Best-Practice-Zwang, sondern eine Option für Teams, die Rollen unabhängig von Inventories und Playbooks versionieren wollen.

bash
git submodule add git@example.org:platform/ansible-roles.git roles/shared
git clone --recurse-submodules git@example.org:platform/ansible-project.git
git submodule update --init --recursive
git submodule status

Alternativen sind Standalone Roles über requirements.yml, Collections, private Automation-Hub- oder Galaxy-artige Repositories. Die richtige Wahl hängt von Teamgröße, Release-Prozess, Wiederverwendung und Governance ab.

Best Practices aus dem Beispiel

  • defaults sind die öffentliche Schnittstelle der Rolle.
  • group_vars beschreiben gemeinsame Werte für eine Umgebung oder Gruppe.
  • host_vars bleiben echte Ausnahmen.
  • Handler verhindern unnötige Restarts.
  • Templates sind richtig, wenn Dateien aus Variablen entstehen.
  • FQCN reduziert Namenskonflikte und macht Herkunft sichtbar.
  • Module sind shell-Befehlen vorzuziehen, wenn ein passendes Modul existiert.
  • Git macht Änderungen reviewbar.
  • Dependency Pinning macht Builds reproduzierbarer.
  • --check, --diff und --limit reduzieren Risiko vor produktiven Läufen.

Typische Anfängerfehler sind: alles in site.yml schreiben, Werte hart in Tasks codieren, shell statt Module verwenden, Secrets ins Inventory legen, Benutzeroptionen in vars statt defaults packen, alles per extra-vars überschreiben, Rollen an einzelne Hosts koppeln, Dependencies nicht versionieren, ohne --limit und --check direkt gegen Produktion laufen.

Molecule ist für Rollen-Tests sinnvoll, aber kein Muss für den ersten Artikel. Die lokale ForgeOne-Chrony-Rolle hat eine minimale Molecule-Struktur mit Syntax-Test. Ausführliche Molecule-Tests sind ein eigenes Thema.

AWX und AAP

Heute reicht ansible-playbook für das Lab. Wenn mehrere Teams, Credentials, RBAC, Inventories, Job Templates, Scheduling, Workflows, Audit, API und Execution Environments gebraucht werden, werden AWX oder Red Hat Ansible Automation Platform relevant. Eine AWX-Installation gehört nicht in diesen Grundlagenartikel.

Fachlich passt das in größere Automation & GitOps-Architekturen: Infrastruktur kann mit OpenTofu beschrieben werden, während Ansible Systeme, Dienste und Betriebsabläufe konfiguriert.

Fazit

Wir haben bei null angefangen: Ansible installiert, ein Inventory gebaut, SSH/Python mit ansible.builtin.ping geprüft, Chrony erst mit einem Mini-Playbook installiert und daraus eine wiederverwendbare Rolle entwickelt. Die Rolle nutzt Defaults, Facts, OS-spezifische Includes, Templates, Handler und Inventory-Overrides. Danach haben wir Ausführung, Prüfung, Idempotenz, Collections, Rollen und Git Submodules eingeordnet.

Professionelle Unterstützung

Wer das Beispiel selbst nachbauen möchte, hat alle notwendigen Dateien und Schritte im Artikel. Wenn daraus eine größere Automatisierungsumgebung, zentrale Rollenbibliothek, CI/CD-Strecke oder AWX/AAP-Plattform werden soll, unterstützt ForgeOne bei Architektur, Umsetzung, Migration und Betrieb.

Automation mit ForgeOne planen

Wir helfen dabei, aus einzelnen Playbooks eine wartbare Rollenbibliothek, CI/CD-Strecke oder AWX/AAP-Plattform zu machen.