
🌐 Auch auf: English · Français · Español
Dies ist der erste Teil einer kleinen Serie, die durch den Ansible-Code hinter diesem Blog und zwei Nextcloud-Servern führt – nicht Variable für Variable, sondern auf der Ebene „was passiert, in welcher Reihenfolge, und warum ist es so gebaut“. Der Code selbst ist öffentlich: github.com/aptupgrademe/www_k3s. Die ganze Serie beschreibt das Release v3.1.0 (Oktober 2026), und jeder Link auf eine Datei zeigt auf genau dieses Release – die Rollen werden sich weiter ändern, die Links zeigen weiterhin den Code, von dem der Text spricht.
Vor ein paar Monaten habe ich den Stack veröffentlicht, und seitdem ist das Repository erwachsen geworden: eine Qualitätsrunde hat jede Rolle idempotent gemacht, neun Verteidigungsschichten sind dazugekommen, und jede Rolle beginnt jetzt mit einem Kommentar, der sie jemandem erklärt, der sie noch nie gesehen hat. Was noch fehlte, ist das große Ganze. Wer das Repo heute öffnet, findet 34 Rollen, vier Playbooks, etwa 10.000 Zeilen Tasks und weitere 12.000 Zeilen Templates. Wo fängt man da überhaupt an?
Hier. Dieser Beitrag ist die Landkarte. Er erklärt, wie das Repository aufgebaut ist, wie ein Satz Rollen zwei sehr unterschiedliche Anwendungen bedient, was tatsächlich passiert, wenn ich ansible-playbook tippe, und welche Sicherheitsnetze meine Fehler abfangen, bevor ein Server sie sieht. Die späteren Teile zoomen dann hinein, vom Allgemeinen ins Spezielle – und zwei davon gehen quer durch alles, jeweils für nur ein Thema: Sicherheit und Monitoring.
🗺️ Die Tour auf einen Blick
| Teil | Thema | Rollen |
|---|---|---|
| 1 | Das Gerüst – Repo, Inventory, Playbooks, ein Lauf von Anfang bis Ende (dieser Beitrag) | Playbooks, common_version_check, die Verify-Rollen |
| 2 | Die Plattform – Auswahl des Servers, Grundsystem und Kubernetes, gemeinsam für beide Stacks | *_packages, common_sysctl, common_msmtp, common_dnf_automatic, common_logrotate, common_k3s, common_calico |
| 3 | Der Blog – WordPress auf K3s | blog_k3s_deploy, blog_wordpress, blog_visitor_stats, blog_verify |
| 4 | Nextcloud – Nextcloud, Collabora, Datenbank und Cache | next_mariadb, next_redis, next_k3s_deploy, next_config, nextcloud_verify |
| 5 | IT-Sicherheit – nur die Sicherheitsrollen, über beide Stacks hinweg | common_ssh, common_firewall, common_cis_hardening, common_selinux, common_auditd, common_aide_refresh, common_rkhunter, common_clamav, common_fail2ban |
| 6 | Monitoring – Metriken, Dashboards, Alarme, Wachhund | die Exporter, common_prometheus, common_grafana, common_monit |
Egal, welche Anwendung dich interessiert: Du liest höchstens fünf Beiträge – diesen, die Plattform, deine Anwendung, Sicherheit und Monitoring.
Das Ziel der Serie ist praktisch: Wer mit IT-Grundwissen sie gründlich liest, soll genug verstehen, um so eine Umgebung sicher zu betreiben – nicht nur, wie der Code funktioniert, sondern warum jedes Teil da ist, wo man hinschaut und was zu tun ist, wenn etwas schiefgeht. Jeder Teil endet deshalb mit einem Abschnitt „Im Betrieb“: die Befehle, die Routine, die Alarm-Mails und die ersten Schritte, wenn etwas kaputtgeht.
🧩 Ein Repository, zwei Anwendungen
Aus diesem Repository werden drei Server verwaltet: der Blog (WordPress) und zwei Nextcloud-Instanzen. Auf den ersten Blick haben sie wenig gemeinsam – ein PHP-Blog mit MariaDB im Container und eine Datei-Cloud mit Office-Suite, Datenbank auf dem Host und ein paar hundert Gigabyte Nutzerdaten. Darunter sind sie fast identisch: dasselbe AlmaLinux 9, dasselbe gehärtete SSH, dieselbe Firewall, dasselbe Single-Node-K3s mit demselben Ingress, dasselbe Monitoring, dieselben nächtlichen Scanner.
Genau so sind die Rollen aufgeteilt. Das Präfix einer Rolle verrät, wer sie nutzt:
| Präfix | Genutzt von | Anzahl | Beispiele |
|---|---|---|---|
common_* | beiden Stacks | 23 | common_ssh, common_firewall, common_k3s, common_grafana |
blog_* | nur dem Blog | 5 | blog_k3s_deploy, blog_wordpress |
next_* / nextcloud_verify | nur Nextcloud | 6 | next_mariadb, next_config |
Zwei Drittel des Codes sind gemeinsam. Eine Korrektur an der Firewall oder an der SSH-Härtung landet mit dem nächsten Lauf auf allen drei Servern, und was eine Nextcloud lehrt, schützt auch den Blog. Der Preis: Eine gemeinsame Rolle muss wissen, wo sie läuft – wie sie das herausfindet, ist eine der zentralen Ideen des Repositorys und bekommt unten einen eigenen Abschnitt.
📁 Wie das Repository aufgebaut ist
www_k3s/
├── blog.yml full run for the blog
├── blog-update.yml updates only (plugins, core, rebuilt images)
├── nextcloud-k3s.yml full run for Nextcloud
├── nextcloud-update.yml updates only (rebuilt images, occ upgrade, apps)
├── ansible.cfg how Ansible connects and behaves
├── requirements.yml Ansible collections (kubernetes.core, ...)
├── inventory/
│ ├── hosts.yml which host belongs to which group
│ ├── group_vars/all/ values for every host (main.yml, versions.yml)
│ └── host_vars/<host>/ values for one host (vars.yml, vault.yml)
├── roles/<role>/
│ ├── tasks/ what the role does
│ ├── handlers/ what runs once, at the end, if something changed
│ ├── templates/ Jinja templates (config files, manifests)
│ ├── files/ files copied as they are (scripts)
│ ├── defaults/ the role's own default values
│ └── meta/ dependencies + argument_specs (declared inputs)
├── scripts/ drift check, backup and restore helpers
├── tests/ K8s apply test, update scenario test
└── docs/ operations manuals (DE/EN/FR)Nichts Exotisches – das ist der übliche Ansible-Aufbau. Zwei Punkte sollte man kennen, weil der Rest der Serie darauf aufbaut. Erstens: Die Playbooks sind dünn. Sie enthalten fast keine Tasks, nur die Liste der Rollen in der richtigen Reihenfolge. Alles, was passiert, steckt in einer Rolle. Zweitens: Eine Rolle enthält nie hostspezifische Werte. Keine IP-Adresse, keine Domain, kein Passwort steht in roles/. All das kommt aus dem Inventory.
📒 Das Inventory: die Schaltzentrale
Wenn die Rollen die Maschine sind, ist das Inventory die Schaltzentrale, die entscheidet, was die Maschine auf welchem Server tut. Es beantwortet drei Fragen: Welche Server gibt es, welche Werte gelten für alle, und welche nur für einen? Ich gehe nicht jede Variable durch – es sind ein paar hundert –, aber den Aufbau zu verstehen lohnt sich, denn er erklärt, wie ein Repository drei verschiedene Server beschreiben kann.
Wer gehört wohin: hosts.yml
hosts.yml enthält nichts als Topologie – welcher Host zu welcher Gruppe gehört:
all:
children:
blog: # WordPress servers
hosts:
www.example.de: {}
test: {}
nextcloud: # Nextcloud servers
hosts:
instance1: {}
instance2: {}
test: {} # shared with the blog test serverDie Gruppe ist die wichtigste Eigenschaft eines Hosts. blog.yml beginnt mit hosts: blog, nextcloud-k3s.yml mit hosts: nextcloud – das Playbook wählt seine Server über die Gruppe aus, und innerhalb einer gemeinsamen Rolle entscheidet die Gruppe, was gerendert wird (dazu gleich mehr). Beachte, dass nicht einmal die IP-Adresse hier steht: Sie liegt in den eigenen Variablen des Hosts und existiert so an genau einer Stelle.
Was für alle gilt: group_vars/all/
Werte, die auf jedem Server gleich sind, liegen in group_vars/all/ – das Pod-Netz von K3s, die Pfade der Datenverzeichnisse, die Standard-Ressourcenlimits. Die wichtigste Datei dort ist versions.yml: jede Version, die die Playbooks installieren oder ausrollen, in einer Tabelle. K3s, Helm, Calico, die Helm-Charts, jedes Container-Image, die Monitoring-Programme:
# --- Kubernetes platform ---------------------------------------
k3s_version: "v1.xx.x+k3s1" # github.com/k3s-io/k3s/releases
helm_version: "v4.x.x" # github.com/helm/helm/releases
calico_version: "v3.xx.x" # (policy-only, see notes)
# --- WordPress containers --------------------------------------
blog_image_wordpress: "wordpress:x.y.z-php8.4-fpm"
blog_image_mariadb: "mariadb:10.11.xx" # 10.11 LTSAlles ist exakt festgelegt – nie latest, nie ein wandernder Tag. Das ist keine Pedanterie: K3s lädt ein Image nur, wenn es noch nicht auf dem Node liegt, also bleibt ein wandernder Tag still auf dem Build, der zufällig im Cache liegt. Mit einer exakten Festlegung hat „welche Version läuft wo“ genau eine Antwort, und ein Update ist eine einzeilige Änderung, die in git log auftaucht. Ein Bot (Renovate) beobachtet die Releases der Projekte und schlägt diese einzeiligen Änderungen als Pull Requests vor; ich prüfe sie und rolle sie mit dem Playbook aus.
Was für einen Server gilt: host_vars/<host>/
Jeder Server hat ein Verzeichnis mit zwei Dateien. vars.yml enthält die normalen Werte pro Host: Adressen, Hostnamen, Größen (die beiden Nextcloud-Server haben sehr unterschiedliche Hardware), Schalter wie den SSH-Länderfilter. vault.yml enthält die Geheimnisse – Datenbankpasswörter, Admin-Zugänge, Mail-Zugangsdaten – und ist mit ansible-vault verschlüsselt. Ansible entschlüsselt sie während des Laufs im Speicher; auf der Platte und in jedem Backup ist sie Chiffretext.
Keine der beiden Dateien liegt im öffentlichen Repository. Öffentlich sind die .example-Dateien daneben: derselbe Aufbau mit Platzhaltern, sodass jeder sieht, welche Schlüssel ein Host braucht, ohne meine zu sehen. Das Vault-Beispiel sieht ohne die Kommentare so aus:
email: ...
mysql_root_password: ...
nextcloud_admin_user: ...
nextcloud_admin_pass: ...
blog_db_pass: ...
blog_admin_pass: ...Wer gewinnt: die Rangfolge
Dieselbe Variable kann an mehreren Stellen definiert sein. Ansible löst das mit einer festen Rangfolge auf; für dieses Repository sind vier Stufen wichtig, von der schwächsten zur stärksten:
- Role defaults (
roles/<role>/defaults/main.yml) – der sinnvolle Ausgangspunkt der Rolle. - group_vars – was alle Hosts teilen.
- host_vars – was ein Host anders braucht.
- Während des Laufs gesetzte Facts (
set_fact) – Werte, die die Rolle selbst berechnet, zum Beispiel nachdem sie den Server etwas gefragt hat.
Ein konkretes Beispiel: der SSH-Länderfilter. Sein Default in common_firewall ist eine leere Liste – aus. Ein Server, der SSH nur aus Deutschland annehmen soll, setzt fw_ssh_geo_countries: [DE] in seiner vars.yml, und das gewinnt. Die Rolle selbst ändert sich nie; der Schalter sitzt im Inventory. Nach diesem Muster laufen die meisten Unterschiede zwischen den Hosts in diesem Repository.
Die Rolle prüft ihre Eingaben: argument_specs
Ein fehlender Wert im Inventory zeigte sich früher auf die harte Tour: mitten im Lauf, als kryptischer Template-Fehler. Heute deklarieren 23 Rollen ihre Eingaben in meta/argument_specs.yml – Name, Typ, Pflicht oder nicht, und ob es ein Geheimnis ist. Ansible prüft sie, bevor der erste Task der Rolle läuft. Eine vergessene server_ipv4 stoppt den Lauf jetzt am Anfang der Firewall-Rolle mit „required option missing“, nicht erst, nachdem das Regelwerk halb geschrieben ist, und ein Wert mit no_log taucht nie in der Ausgabe auf.
🔀 Eine Rolle, zwei Stacks: drei Muster
Das ist das Herzstück der gemeinsamen Rollen. Eine common_*-Rolle läuft auf dem Blog und auf Nextcloud, aber die beiden brauchen leicht unterschiedliche Dinge: Nextcloud hat MariaDB und Redis auf dem Host, der Blog in Containern; der Blog scannt Uploads, Nextcloud Nutzerdaten. Drei Muster regeln das, und wer sie einmal kennt, findet sie überall.
1. Die Gruppe fragen
Jeder Host kennt die Gruppen, zu denen er gehört (group_names). Ein Template oder ein Task kann einfach nachfragen. Das Firewall-Regelwerk öffnet zum Beispiel die Datenbank-Ports des Hosts für das Pod-Netz nur auf Nextcloud-Servern, weil nur dort die Pods eine Datenbank auf dem Host erreichen müssen:
{% if 'nextcloud' in group_names %}
# Allow K3s pods to reach MariaDB on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 3306 accept
# Allow K3s pods to reach Redis on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 6379 accept
{% endif %}Auf dem Blog gibt es diese Zeilen in der gerenderten Datei schlicht nicht. Dieselbe Rolle, dasselbe Template, zwei verschiedene Ergebnisse – entschieden vom Inventory.
2. Ein Profil aus dem Playbook übergeben
Manche Rollen erledigen auf beiden Stacks dieselbe Art von Arbeit, nur mit anderen Pfaden. Sie bekommen einen Profilnamen als Rollenparameter, direkt im Playbook:
# blog.yml
- { role: common_selinux, selinux_target: wordpress }
- { role: common_clamav, clamav_target: wordpress }
# nextcloud-k3s.yml
- { role: common_selinux, selinux_target: nextcloud }
- { role: common_clamav, clamav_target: nextcloud }In der Rolle wählt das Profil einen Block von Einstellungen aus ihren Defaults – bei SELinux die Verzeichnisse, die in die Pods eingehängt werden und für Container markiert sein müssen:
selinux_profiles:
wordpress:
- { label: WordPress www dir, path: "{{ blog_k3s_www_dir }}", owner: 33, group: 33 }
- { label: MariaDB data dir, path: "{{ blog_k3s_db_dir }}", owner: 999, group: 999 }
nextcloud:
- { label: Nextcloud www dir, path: "{{ nextcloud_k3s_www_dir }}", owner: 33, group: 33 }
- { label: Nextcloud data, path: "{{ nextcloud_data_dir }}", owner: 33, group: 33 }Die Tasks der Rolle laufen dann einfach über „die Verzeichnisse meines Profils“ und kümmern sich nicht darum, welcher Stack das ist. Eine dritte Art von Anwendung hieße: ein Profil mehr, keine Rolle mehr.
3. Erkennen statt festlegen
Das dritte Muster kommt ganz ohne Variable aus: Die Rolle fragt den Server. Die Firewall muss wissen, ob der Server seine Adresse per DHCP bekommt – wenn ja und die DHCP-Regeln fehlen, ist die Adresse bei der nächsten Lease-Erneuerung weg. Das könnte ein Schalter im Inventory sein, aber ein Schalter kann falsch sein oder veralten, wenn ein Anbieter etwas ändert. Also fragt die Rolle zu Beginn jedes Laufs den NetworkManager:
- name: Firewall | Detect whether IPv4/IPv6 are configured by DHCP
ansible.builtin.shell: |
dev=$(ip -4 route show default | awk '{print $5; exit}')
con=$(nmcli -g GENERAL.CONNECTION device show "$dev")
nmcli -g ipv4.method,ipv6.method connection show "$con"
register: _fw_ipmethods
changed_when: false
check_mode: false
- name: Firewall | Remember the address configuration for the template
ansible.builtin.set_fact:
fw_dhcp4: "{{ ... == 'auto' }}"Die Antwort landet in einem Fact, und das Template rendert die DHCP-Regeln nur dort, wo sie gebraucht werden. Auf meinen Servern ist das Ergebnis unterschiedlich – ein Host bekommt IPv4 per DHCP, die anderen sind komplett statisch –, und niemand muss sich merken, welcher welcher ist. Die allgemeine Regel in diesem Repository: Was der Server uns sagen kann, fragen wir den Server; das Inventory ist für Entscheidungen da, nicht für Fakten.
📜 Die vier Playbooks
Es gibt zwei Paare: ein volles Playbook und ein Update-Playbook pro Anwendung.
| Playbook | Was es tut | Wann ich es starte |
|---|---|---|
blog.yml / nextcloud-k3s.yml | Den ganzen Server, von den Paketen bis zu den Anwendungseinstellungen – jede Rolle, der Reihe nach. Jederzeit gefahrlos: Auf einem korrekten Server ändert es nichts. | Nach einer Änderung im Repo, einem Versionssprung in versions.yml oder einfach zur Kontrolle. |
blog-update.yml / nextcloud-update.yml | Nur die Anwendung: neu gebaute Container-Images, Plugin- und App-Updates, das Datenbank-Upgrade von Nextcloud. Fasst den Host nie an (SSH, Firewall, Pakete). | Wenn Updates verfügbar sind – die Routinearbeit. |
Das volle Playbook: Die Reihenfolge zählt
Ein volles Playbook ist eine Liste von etwa 25 Rollen. Die Reihenfolge ist nicht alphabetisch, sondern eine Kette von Abhängigkeiten. Hier die des Blogs, gekürzt:
roles:
- common_version_check # report: what is installed, pinned, available?
- blog_packages # repositories, system update, base packages
- common_ssh # SSH on its new port, key-only
- common_firewall # nftables
- common_sysctl # kernel parameters
- common_cis_hardening # CIS benchmark items
- common_selinux # label the data dirs <- before the deploy!
- common_k3s # K3s, Helm, cert-manager, ingress
- common_calico # NetworkPolicy enforcement
- blog_k3s_deploy # all Kubernetes objects of WordPress
# host-side services
- common_node_exporter
- common_prometheus
- common_grafana
- common_fail2ban
- ... # mail, updates, scanners, audit, watchdog
- blog_wordpress # configure WordPress inside the running pod
- blog_verify # smoke tests
- common_aide_refresh # always lastEinige dieser Positionen haben einen Grund, den man kennen sollte:
- SELinux vor dem Deploy. Die Pods hängen Verzeichnisse vom Host ein. Starten sie, bevor diese Verzeichnisse das Container-Label tragen, blockiert SELinux jeden Schreibzugriff, und MariaDB startet nicht.
- Monitoring und Schutz nach dem Deploy. Das sind Dienste auf dem Host, aber mehrere davon beobachten, was der Deploy gerade angelegt hat: Prometheus holt die Exporter der Anwendungs-Pods ab, fail2ban liest die Ingress-Logs, Monit prüft, ob die Pods laufen.
- Die WordPress-Konfiguration gegen Ende.
blog_wordpressspricht mit WordPress in dessen Pod, braucht also einen laufenden Pod. Dafür sorgt die Deploy-Rolle: Sie wartet, bis MariaDB, Redis und WordPress bereit sind, bevor sie endet. - AIDE immer zuletzt. AIDE vergleicht jede Nacht Systemdateien mit einer Referenz. Ein Playbook-Lauf ändert überwachte Dateien absichtlich, also erneuert die allerletzte Rolle die Referenz – genau dann, wenn dieser Lauf etwas geändert hat. Sonst würde die nächste Nacht meine eigenen Änderungen als Einbruch melden.
Tags: eine Rolle statt aller
Jede Rolle im Playbook trägt einen Tag mit ihrem eigenen Namen. Das macht aus einem vollen Playbook ein präzises Werkzeug:
# only the host package updates (e.g. a new Grafana package)
ansible-playbook blog.yml --limit <host> --tags blog_packages
# only the Kubernetes objects (e.g. after an image version bump)
ansible-playbook blog.yml --limit <host> --tags blog_k3s_deployDeshalb installieren die Update-Playbooks auch keine Host-Pakete: Sie sind bewusst auf die Anwendung beschränkt. Ein neues Grafana-Paket kommt mit dem nächsten vollen Lauf oder sofort mit --tags blog_packages – und Sicherheitsupdates warten auf keins von beiden, sie installiert dnf-automatic jede Nacht (Teil 2).
Die Update-Playbooks: zuerst die Leitplanken
Die Update-Playbooks starten die Anwendung neu, also sind sie defensiv geschrieben. Das für Nextcloud, der Reihe nach:
- Ohne
--limitverweigern.hosts: nextcloudwürde sonst alle Instanzen gleichzeitig aktualisieren. Updates sind bewusst, ein Host nach dem anderen. - Eine ungesunde Instanz nicht aktualisieren. Steckt Nextcloud schon im Wartungsmodus oder wartet auf ein Datenbank-Upgrade, würde ein Update das eigentliche Problem verschütten. Diese Leitplanke hat ihre eigene Geschichte.
- Die Arbeit in
block/rescueerledigen. Die Images finden, die upstream neu gebaut wurden, nur diese Deployments neu starten, das Datenbank-Upgrade ausführen, die Apps aktualisieren. Schlägt ein Schritt fehl, zeigt derrescue-Teil Status, Pods, Events und die letzten Logzeilen, verlässt den Wartungsmodus nur dort, wo das sicher ist, und hält an – ein definierter Fehlerweg statt einer halb aktualisierten Instanz.
Die Blog-Version folgt derselben Idee mit WordPress-Eigenheiten: Sie startet den nächtlichen Update-Job sofort, mit vorherigem Datenbank-Dump, und startet nur neu, was sich wirklich geändert hat. Die Update-Strategie in Schichten dahinter beschreibt ein älterer Beitrag; Teil 3 zeigt den aktuellen Code.
⚙️ Was bei einem Lauf passiert
Jetzt die Mechanik: was Ansible zwischen dem Druck auf Enter und der Schlusszusammenfassung tut. Wer das versteht, versteht fast jede Entwurfsentscheidung in den Rollen.
Verbinden und Facts sammeln
Ansible verbindet sich per SSH als root, mit einem Schlüssel – SSH akzeptiert nichts anderes. Verbindungen werden über Tasks hinweg wiederverwendet, Skripte werden durch die bestehende Verbindung geleitet, statt erst kopiert zu werden, und Ansible fordert kein Terminal an. Das letzte Detail ist wichtig für den SSH-Login-Alarm: Ohne Terminal hinterlässt ein Playbook-Lauf keinen Eintrag in den Anmeldeprotokollen, also schlägt der Alarm nur bei echten interaktiven Sitzungen an.
Dann sammelt Ansible Facts über den Server – aber nur den minimalen Satz. Die Rollen nutzen eine Handvoll (Distribution, Kernel, Hostname, Datum); der volle Satz würde Zeit für Netzwerkschnittstellen und Hardwaredetails kosten, die niemand liest.
Ein Bericht, bevor sich etwas ändert
Die erste Rolle, common_version_check, ändert nichts. Sie vergleicht für K3s, Helm, den Ingress und cert-manager drei Dinge: was auf dem Server installiert ist, was versions.yml festlegt und was das neueste Release auf GitHub ist. Heraus kommt eine kleine farbige Tabelle – vor jedem Lauf sehe ich, ob eine Festlegung veraltet ist oder der Server hinter dem Repository liegt. Interessant ist, wie es funktioniert: zwei set_fact-Schritte, der erste bringt die vielen verschiedenen Versionsformate in eines, der zweite vergleicht sie. Sammeln, aufbereiten, bewerten – dasselbe Drei-Schritt-Muster kehrt am Ende in den Verify-Rollen wieder.
Task für Task, Host für Host
Ein Play läuft Task für Task: Task 1 auf jedem Host des Plays, dann Task 2 auf jedem Host und so weiter. Starte ich das Nextcloud-Playbook ohne --limit, werden beide Instanzen parallel konfiguriert, Schritt für Schritt. Jeder Task meldet einen von drei Zuständen, und das ganze Repository ist darauf ausgelegt, dass diese Meldung ehrlich ist:
| Zustand | Bedeutung | Was der Code dafür leisten muss |
|---|---|---|
| ok | Schon korrekt, nichts getan. | Der Task muss den korrekten Zustand erkennen. Module wie copy oder template tun das selbst (sie vergleichen Prüfsummen). |
| changed | Etwas wurde geändert. | Bei command und shell kann Ansible das nicht wissen – die Rolle muss es sagen, mit changed_when. Eine reine Abfrage ist changed_when: false. |
| failed | Etwas ging schief; der Host stoppt. | Erwartete Exit-Codes ungleich null müssen mit failed_when erklärt werden, sonst bricht ein harmloses „nicht gefunden“ den Lauf ab. |
Das Ziel heißt Idempotenz: Ein zweiter Lauf direkt nach dem ersten meldet changed=0. Das klingt nach einem kosmetischen Detail, macht das Playbook aber erst zum Prüfwerkzeug. Meldet ein korrekter Server null Änderungen, ist jedes „changed“ in einem späteren Lauf eine echte Abweichung – jemand hat eine Datei von Hand bearbeitet, ein Paket hat einen Default geändert, ein Wert ist abgedriftet. Die Qualitätsrunde drehte sich vor allem darum, dorthin zu kommen.
Eine sehr häufige Form in den Rollen sieht deshalb so aus (vereinfacht) – Zustand lesen, nur bei Abweichung ändern:
- name: Read the current value
ansible.builtin.command: occ config:system:get trusted_domains
register: current
changed_when: false # reading changes nothing
failed_when: false # "not set yet" is not an error
check_mode: false # also run in --check, it is read-only
- name: Set the value only if it differs
ansible.builtin.command: occ config:system:set ...
when: current.stdout != wanted
changed_when: true # if it runs, it changed somethingHandler: einmal, am Ende, nur bei Bedarf
Viele Änderungen brauchen eine Folgeaktion: eine neue sshd_config einen sshd-Neustart, ein neues Firewall-Regelwerk ein Neuladen. Das im Task selbst zu tun, würde sshd fünfmal neu starten, wenn sich fünf Dateien ändern. Ansibles Antwort ist der Handler: Ein Task benachrichtigt einen Handler über dessen Namen, und der Handler läuft einmal, nach allen Tasks des Plays – und nur, wenn mindestens ein benachrichtigender Task wirklich „changed“ gemeldet hat.
# task
- name: Firewall | Upload nftables ruleset
ansible.builtin.template:
src: fw.nft.j2
dest: /etc/sysconfig/nftables.conf
validate: nft -c -f %s
notify: Reload nftables
# handlers/main.yml
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesZwei Details aus diesem Beispiel finden sich im ganzen Repository. Mit listen abonniert ein zweiter Handler dieselbe Benachrichtigung: Ein Firewall-Neuladen leert die Sperrlisten, also muss fail2ban seine Sperren zurückschreiben – und Handler laufen in der Reihenfolge, in der sie geschrieben sind, also passiert das nach dem Neuladen. Und wenn eine Folgeaktion nicht bis zum Ende warten kann – Redis muss mit seinem neuen Passwort laufen, bevor die nächste Rolle sich verbindet –, ruft die Rolle meta: flush_handlers auf, was alle anstehenden Handler genau an dieser Stelle ausführt.
Der Probelauf: –check –diff
Jedes Playbook kann im Check-Modus laufen: Ansible geht alle Tasks durch, ändert aber nichts und zeigt die Unterschiede, die es machen würde. Damit das nützt, achten die Rollen auf zwei Dinge. Reine Abfragen laufen trotzdem (check_mode: false), sonst würden spätere Tasks mit nichts vergleichen. Und Schritte, die von etwas abhängen, das nicht wirklich passiert ist – ein Archiv entpacken, das nicht wirklich heruntergeladen wurde –, werden im Check-Modus übersprungen, statt fehlzuschlagen.
Auf dem Check-Modus sitzt ein kleines Skript, scripts/check-drift.sh. Es startet ein Playbook mit --check --diff und zeigt nur die Tasks, die etwas ändern würden, samt Diff. Exit-Code 0 heißt: Der Server ist genau das, was der Code beschreibt. Ich starte es vor jedem echten Lauf, und direkt nach einem echten Lauf muss es nichts melden – alles, was es dann noch auflistet, ist ein Task, der noch nicht idempotent ist.
scripts/check-drift.sh nextcloud-k3s.yml <host>
scripts/check-drift.sh blog.yml <host> --tags blog_k3s_deployDas Urteil: die Verify-Rollen
Ein Lauf, der mit „ok“ endet, heißt nur, dass jeder Task getan hat, was man ihm gesagt hat. Er heißt nicht, dass die Website funktioniert. Deshalb endet jedes Playbook mit einer Verify-Rolle – blog_verify und nextcloud_verify, beide aufgebaut auf gemeinsamen Prüfungen in common_verify. Sie testen von außen nach innen: Dienste laufen, Pods sind bereit, HTTPS antwortet mit gültigem Zertifikat, Sicherheits-Header sind da, gesperrte Pfade sind wirklich gesperrt, Datenbank und Cache sind erreichbar.
Sie folgen bewusst dem Muster Sammeln–Aufbereiten–Bewerten aus dem Versions-Check. Alle Prüfungen laufen zuerst und halten nur ihr Ergebnis fest (failed_when: false). Dann macht set_fact aus den Rohergebnissen wahr/falsch. Dann gibt die Rolle einen Bericht aus und schickt ihn per Mail. Erst ganz am Ende lässt ein assert den Lauf scheitern – wenn eine kritische Prüfung rot ist. Der Vorteil gegenüber einem Abbruch beim ersten Problem: Ein Lauf zeigt alle Probleme auf einmal, nicht eins pro Versuch.
🧪 Die Sicherheitsnetze um den Code
Die Server sind Produktion – die Dateien anderer Leute, ein Blog mit Lesern. Also muss eine Änderung mehrere Netze passieren, bevor sie dort ankommt, vom schnellen und lokalen zum langsamen und echten:
| Netz | Was es fängt | Wann |
|---|---|---|
| pre-commit-Hooks | Secrets in einem Commit (gitleaks), YAML-Fehler, Befunde von ansible-lint, Reste von Merge-Konflikten | Vor jedem Commit, auf meinem Rechner |
| CI auf GitHub | Dasselbe Linting plus ein Secret-Scan der gesamten Historie – auch für einen Commit, der ohne die Hooks entstanden ist | Bei jedem Push |
K8s-Apply-Test (tests/k8s) | Kubernetes-Rollen, die nicht idempotent sind, ein Pod-Neustart, den niemand wollte, ein Versionssprung, den --check nicht bemerken würde | Vor Änderungen an den Kubernetes-Rollen – läuft gegen einen Wegwerf-API-Server von K3s in podman, ohne echten Server |
Update-Szenariotest (tests/update) | Die Update-Playbooks gegen simulierte Server: gesunde Instanz, neu gebautes Image, Fehler bei jedem Schritt – hinterlässt der Rescue einen sauberen Zustand? | Vor Änderungen an den Update-Playbooks |
| Drift-Check | Jede Abweichung zwischen Code und Server | Vor und nach jedem echten Lauf |
| Renovate | Neue Versionen von allem in versions.yml | Laufend; kommt als Pull Request |
Keines davon braucht jemanden, der an etwas denkt – genau darum geht es. Die eine Regel, die ich von Hand einhalte, ist die letzte: Echte Läufe gegen die Server starte ich selbst, einen Host nach dem anderen, nachdem der Probelauf gestimmt hat.
🛠️ Im Betrieb: wie eine Änderung auf einen Server kommt
Das ist die Routine für jede Änderung, vom einzeiligen Versionssprung bis zur neuen Rolle. Es sind jedes Mal dieselben sieben Schritte, und die Reihenfolge ist der Punkt:
- Ändern – die Rolle, das Template oder
versions.yml. - Linting – läuft beim Commit ohnehin, aber die Befunde jetzt zu sehen geht schneller.
- Ohne Server testen, wo es einen Test gibt:
tests/k8s/run.shfür die Kubernetes-Rollen,tests/update/run.shfür die Update-Playbooks. - Probelauf:
scripts/check-drift.sh <playbook> <host>. Lies die Liste der Änderungen und frag dich: Ist das genau das, was ich wollte? Alles andere auf der Liste ist eine Überraschung, die man zuerst verstehen muss. - Echter Lauf auf einem Server, mit
--tagsauf das beschränkt, was die Änderung betrifft. - Noch einmal der Drift-Check. Jetzt muss er „no drift“ sagen. Was noch aufgelistet ist, ist ein Task, der nicht idempotent ist – beheb ihn, bevor er beim nächsten Lauf eine echte Änderung verdeckt.
- Commit und Push. Die Hooks prüfen Linting und Secrets noch einmal, die CI die ganze Historie.
Zwei Gewohnheiten gehören dazu. Secrets werden nur mit ansible-vault edit inventory/host_vars/<host>/vault.yml bearbeitet – das entschlüsselt in eine temporäre Datei für den Editor, verschlüsselt beim Speichern wieder und löscht die temporäre Kopie. Was du nicht tun solltest: die Datei mit ansible-vault decrypt an Ort und Stelle entschlüsseln und das vergessen – dann liegt jedes Passwort im Klartext auf der Platte und landet in jedem Backup dieses Rechners. (In diesem Repository sind die echten Vault-Dateien zusätzlich per .gitignore aus Git herausgehalten, und der Secret-Scan in den Hooks ist ein zweites Netz.) Und ein Versionsupdate beginnt mit den Release Notes der Komponente, nicht mit dem Playbook: Der Pull Request des Bots sagt, dass es eine neue Version gibt, nicht, ob man sie gefahrlos nehmen kann.
📚 Die Begriffe an einem Ort
Die späteren Teile verwenden diese Begriffe, ohne sie noch einmal zu erklären:
| Begriff | Kurz gesagt |
|---|---|
| Rolle | Eine abgeschlossene Arbeitseinheit mit festem Aufbau (Tasks, Handler, Templates, Defaults). |
| Inventory / Gruppe / host_vars | Welche Server es gibt, in welcher Gruppe sie sind, welche Werte für alle oder einen gelten. |
Fact / set_fact | Ein Wert, den Ansible über einen Host kennt – zu Beginn gesammelt oder während des Laufs von einer Rolle berechnet. |
register | Das Ergebnis eines Tasks (Ausgabe, Exit-Code, „changed“) für spätere Tasks speichern. |
changed_when / failed_when | Ansible sagen, wann ein Task wirklich etwas geändert hat oder wirklich fehlgeschlagen ist. |
Handler / notify / listen | Folgeaktionen, die einmal laufen, am Ende, nur nach einer Änderung. |
flush_handlers | Anstehende Handler sofort ausführen statt am Ende. |
| idempotent | Ein zweiter Lauf ändert nichts; jedes spätere „changed“ ist echte Abweichung. |
| Check-Modus | --check --diff: zeigen, was sich ändern würde, nichts ändern. |
| Tag | Nur ausgewählte Rollen eines Playbooks ausführen. |
| Profil | Ein Rollenparameter, der pro Stack einen Block von Einstellungen auswählt. |
| Vault | Verschlüsselte Variablendatei für Secrets. |
| argument_specs | Die deklarierten Eingaben einer Rolle, geprüft, bevor sie startet. |
🔎 Wo man welches Ansible-Feature sieht
Die späteren Teile erklären jede Rolle vollständig – nicht nur ihre Tasks, sondern auch ihre Handler, die Eingaben, die sie in meta/ deklariert, die Schalter in ihren Defaults, die Logik in ihren Templates und die Skripte, die sie mitbringt. Manche Ansible-Features tauchen in vielen Rollen auf, aber jedes hat eine Stelle, an der es am leichtesten zu verstehen ist. Wer ein bestimmtes Feature lernen will, fängt dort an:
| Feature | Wofür | Am besten zu sehen in |
|---|---|---|
handlers, notify, listen | Folgeaktionen einmal, am Ende, in fester Reihenfolge | common_firewall (Teil 5) – neu laden, dann setzt fail2ban seine Sperren wieder |
meta: flush_handlers | Anstehende Handler jetzt ausführen, nicht am Ende | next_redis (Teil 4), common_k3s (Teil 2) |
meta/argument_specs.yml | Deklarierte und geprüfte Eingaben einer Rolle | jede Rolle; ausführlich erklärt bei common_firewall (Teil 5) |
defaults/ + Übersteuerung im Inventory | Ein Schalter, der in der Rolle aus und pro Host an ist | der SSH-Länderfilter, common_firewall (Teil 5) |
| Rollenparameter / Profile | Eine Rolle, unterschiedliche Einstellungen pro Stack | common_selinux, common_clamav (Teil 5) |
template + validate | Eine Konfigurationsdatei nur schreiben, wenn sie eine Syntaxprüfung besteht | common_firewall (nft -c), common_cis_hardening (visudo -cf, Teil 5) |
Jinja {% if %} / group_names | Ein Template, unterschiedliche Dateien pro Host | fw.nft.j2 (Teil 5) |
register → when | Zustand lesen, nur Abweichendes ändern | next_config (Teil 4), blog_wordpress (Teil 3) |
set_fact-Ketten | Sammeln, aufbereiten, bewerten | common_version_check, die Verify-Rollen (dieser Beitrag), Teil 3 und 4 |
changed_when / failed_when | Ehrlicher Status für command und shell | überall; die kniffligsten Fälle in common_aide_refresh (Teil 5) |
check_mode: false / ansible_check_mode | Den Probelauf aussagekräftig machen | die Exporter und common_k3s (Teile 2 und 6) |
block / rescue | Ein definierter Fehlerweg | die Update-Playbooks (Teile 3 und 4) |
assert, run_once | In einem falschen Zustand gar nicht erst starten | die Update-Playbooks, next_redis (Teil 4) |
import_tasks vs. include_tasks | Eine Rolle in Dateien teilen – statisch oder zur Laufzeit | next_k3s_deploy (Teil 4) vs. der Neustart beim ersten Lauf in common_firewall (Teil 5) |
async / poll | Etwas starten, das die eigene Verbindung kappt | common_ssh beim sshd-Neustart (Teil 5) |
until / retries | Auf einen Dienst warten statt zu schlafen | common_k3s (Teil 2), common_grafana (Teil 6) |
loop mit zip / selectattr | Listen kombinieren und filtern | common_monit (Teil 6) |
lookup('template') + from_yaml_all + kubernetes.core.k8s | Das K8s-Apply-Muster | common_calico (Teil 2), die Deploy-Rollen (Teile 3 und 4) |
kubernetes.core.helm | Helm-Releases mit echtem Diff | common_k3s (Teil 2) |
uri | Mit einer HTTP-API sprechen, erst nachsehen, dann anlegen | common_grafana (Teil 6) |
script | Ein lokales Skript auf dem Host ausführen, seine JSON-Ausgabe nutzen | common_image_refresh (Teile 3 und 4) |
creates: | Einen Befehl nur einmal ausführen | common_clamav (Teil 5), blog_visitor_stats (Teil 3) |
no_log | Secrets aus der Ausgabe heraushalten | common_msmtp (Teil 2), next_mariadb (Teil 4) |
🧭 Wie die Tour weitergeht
Teil 2 – die Plattform beginnt ganz unten: wie aus einem frischen AlmaLinux ein Grundsystem wird, wie K3s, Helm, cert-manager und der Ingress mit exakten Versionen installiert werden und warum Calico neben Flannel läuft, ohne selbst Netzwerk zu machen. Teile 3 und 4 setzen dann die Anwendungen obendrauf – WordPress und Nextcloud, jede in einem eigenen Beitrag. Teil 5 sammelt jede Sicherheitsrolle an einem Ort, von SSH bis zu den nächtlichen Scannern, als ausführliches Gegenstück zu Defense in Depth. Teil 6 macht dasselbe für das Monitoring.
Jeder davon folgt pro Rolle demselben Raster: was passiert, in welcher Reihenfolge – warum es so gebaut ist – und was unterwegs schiefging. Der letzte Punkt ist oft der nützlichste. Die meisten Kommentare in diesem Repository beginnen mit einem Datum und den Worten „found on“ (gefunden am).




