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.
CONTROL NODE|| SSHvMANAGED 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.
# Debian / Ubuntu: Ansible installierensudo apt updatesudo apt install ansible# Prüfenansible --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.
# Red Hat / Rocky Linux / AlmaLinux: Ansible Core installierensudo dnf install ansible-core# Optional, wenn das Community-Paket in den aktivierten Repositories vorhanden istsudo dnf install ansible# Prüfenansible --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.
# SUSE Linux Enterprise / openSUSE: Ansible installierensudo zypper refreshsudo zypper install ansible# Prüfenansible --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.
# pipx installieren und Ansible isoliert im Benutzerkontext installierenpython3 -m pip install --user pipxpython3 -m pipx ensurepathpipx install --include-deps ansible# Neue Shell öffnen oder PATH neu laden, dann prüfenansible --versionansible-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.
# Projektlokales Python venvpython3 -m venv .venv. .venv/bin/activatepython -m pip install --upgrade pippython -m pip install ansible ansible-lint# Prüfenansible --version
Lab aufbauen
Jetzt entsteht ein kleines Testprojekt. Am Anfang ist die Struktur absichtlich minimal. Sie wächst im Artikel mit jedem Schritt.
mkdir -p ansible-chrony-labcd ansible-chrony-labmkdir -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.
[defaults]inventory = inventories/lab/hosts.ymlroles_path = rolesinterpreter_python = auto_silent[ssh_connection]pipelining = Truetimeout = 30
ansible-config viewansible-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.
all:children:linux:hosts:node01.example.org:ansible_user: adminnode02.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.
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.
---- name: Install Chrony for first contact with Ansiblehosts: linuxbecome: truetasks:- name: Install Chrony packageansible.builtin.package:name: chronystate: 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.
ansible-playbook playbooks/mini.yml --check --diffansible-playbook playbooks/mini.ymlansible-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.
ansible-galaxy role init --init-path roles chrony
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.
---chrony_package_name_map:Debian: chronyRedHat: chronySuse: chronychrony_service_name_map:Debian: chronyRedHat: chronydSuse: chronydchrony_config_path_map:Debian: /etc/chrony/chrony.confRedHat: /etc/chrony.confSuse: /etc/chrony.confchrony_package_state: presentchrony_service_enabled: truechrony_service_state: startedchrony_servers:- address: 0.pool.ntp.orgoptions: iburst- address: 1.pool.ntp.orgoptions: iburstchrony_driftfile: /var/lib/chrony/driftchrony_makestep: "1.0 3"chrony_rtcsync: truechrony_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.
---- name: Resolve OS-specific Chrony valuesansible.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 unsupportedansible.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 tasksansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"- name: Render Chrony configurationansible.builtin.template:src: chrony.conf.j2dest: "{{ chrony_config_path }}"owner: rootgroup: rootmode: "0644"notify: Restart chrony- name: Ensure Chrony service stateansible.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.
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
---- name: Install Chrony on Debian familyansible.builtin.package:name: "{{ chrony_package_name }}"state: "{{ chrony_package_state }}"
tasks/RedHat.yml
---- name: Install Chrony on Red Hat familyansible.builtin.package:name: "{{ chrony_package_name }}"state: "{{ chrony_package_state }}"
tasks/Suse.yml
---- name: Install Chrony on SUSE familyansible.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.
# 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.
---- name: Restart chronyansible.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.
---- name: Configure time synchronizationhosts: linuxbecome: truegather_facts: trueroles:- 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
chrony_servers:- address: 0.pool.ntp.orgoptions: iburst- address: 1.pool.ntp.orgoptions: iburstchrony_makestep: "1.0 3"chrony_rtcsync: true
inventories/lab/host_vars/node01.example.org.yml
chrony_servers:- address: time.cloudflare.comoptions: 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.
Quelle | Beispiel | Rolle im Beispiel------------------------------ | ------------------------ | -----------------------------Role defaults | chrony_servers: pool A | Standard der Rollegroup_vars/all | chrony_servers: pool B | Lab-weite Konfigurationhost_vars/node01 | chrony_servers: source C | Ausnahme für einen Hostplay vars | vars: ... | Wert direkt im Playrole vars | roles/chrony/vars | interne, starke Rollenwerteset_fact | chrony_service_name | Laufzeitwert aus Factsrole params | role: chrony, vars: ... | expliziter Rollenaufrufextra 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.
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --syntax-checkansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --check --diffansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --limit node01.example.org --diffansible-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.
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.
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.
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
---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.
git submodule add git@example.org:platform/ansible-roles.git roles/sharedgit clone --recurse-submodules git@example.org:platform/ansible-project.gitgit submodule update --init --recursivegit 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.



