
🌐 Auch auf: English · Français · Español
Angefangen hat es mit einer etwas eitlen Frage. Mein Ansible-Repository betreibt diesen Blog und zwei Nextcloud-Server: 34 Rollen, vier Playbooks, rund 9.000 Zeilen YAML, fast 200 Commits. Die meisten dieser Zeilen habe nicht ich geschrieben, sondern mein KI-Co-Admin (Claude) – ich habe entschieden, getestet und die Playbooks ausgeführt. Also habe ich gefragt: Wenn ich den Großteil dieses Codes verstehe, bin ich dann ein fortgeschrittener Ansible-Nutzer?
Die Antwort war ehrlich und ein bisschen unbequem: fortgeschritten im Lesen und Betreiben, ja. Fortgeschritten im Schreiben von Grund auf, noch nicht bewiesen. Und dann kamen zehn Punkte, die den Code selbst besser machen würden. Ich sagte: Mach alle zehn. Teste sie ordentlich. Und dann schreib darüber. Das ist dieser Beitrag.
Spielregeln wie immer: Alles passiert auf einem eigenen Git-Branch, nichts berührt einen Produktivserver, bevor ich es ausführe, und jede Behauptung wird getestet. „Getestet“ wurde der spannende Teil.
📋 Die zehn Punkte
| # | Verbesserung | Warum |
|---|---|---|
| 1 | Kubernetes-Objekte über kubernetes.core.k8s statt kubectl apply per shell | idempotent, echte Diffs, funktioniert im Check-Modus |
| 2 | --check wirklich brauchbar machen | ein Probelauf vor jedem Produktivlauf |
| 3 | ansible-lint, yamllint und ein Secret-Scanner als Pre-Commit-Hook und GitHub Action | Fehler und geleakte Secrets abfangen, bevor sie landen |
| 4 | Automatischer Idempotenz-Test | der zweite Lauf muss changed=0 melden |
| 5 | rescue-Pfade in den Update-Playbooks | eine definierte Reaktion, wenn ein Update mittendrin scheitert |
| 6 | Die großen Task-Dateien aufteilen | eine Datei mit 912 Zeilen ist niemandes Freund |
| 7 | meta/argument_specs.yml für die Rollen | eine fehlende Variable scheitert am Anfang, nicht mittendrin |
| 8 | Doppelte Rollen zusammenführen | eine ClamAV-Rolle statt zwei fast gleicher |
| 9 | Collection-Versionen exakt pinnen | >= lässt ein inkompatibles Major-Release unbemerkt herein |
| 10 | Renovate für die Versions-Pins | Update-Vorschläge als Pull Request statt Handrecherche |
Mittendrin kam ein elfter Wunsch dazu: alle Versions-Pins an einer Stelle, damit ich auf einen Blick sehe, was wo läuft. Mehr dazu unter Punkt 10.
🧪 Erstes Problem: Wie testet man Kubernetes-Code, ohne die Produktion anzufassen?
Im Inventory stand früher ein Testserver. Als Claude ihn erreichen wollte, verweigerte SSH: host key verification failed. Die IP gehört inzwischen vermutlich jemand anderem, also wurde bewusst keine Verbindung aufgebaut. Und mein Vault-Passwort gebe ich nicht in einem Chat heraus – also konnte auch kein Playbook gegen die echten Server laufen.
Die Lösung war elegant: ein Wegwerf-K3s in rootless Podman auf meinem Jump-Host. Ein vollständiges K3s scheiterte sofort (failed to find cpuset cgroup – rootless Container bekommen diesen Controller nicht delegiert). Um zu testen, ob Manifeste idempotent angewendet werden, braucht man aber gar keine laufenden Pods, nur den API-Server. K3s hat genau dafür einen Schalter:
podman run -d --name k3s-test --privileged -p 127.0.0.1:16443:6443 \
docker.io/rancher/k3s:v1.36.5-k3s1 server --disable-agent \
--disable traefik --disable servicelb --disable metrics-serverEin kleines Ersatzskript spielt k3s kubectl: Es reicht alles an den Container weiter und beantwortet die Befehle, die laufende Pods bräuchten (exec, rollout status, wait), mit „passt“. Der Test macht dann das Entscheidende: alles mit dem alten Code aus main einspielen (das ist der Stand der Server), dann den neuen Code darüber laufen lassen – Probelauf, echter Lauf, noch ein echter Lauf, noch ein Probelauf.
1️⃣ kubernetes.core statt kubectl apply
Das alte Muster kam 29-mal vor: Template nach /tmp rendern, kubectl apply -f, Datei löschen und „changed“ raten, indem man die Ausgabe nach dem Wort configured durchsucht. Das neue Muster rendert im Speicher und spricht direkt mit der API:
- name: Redis | Apply Deployment + Service
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ lookup('ansible.builtin.template', 'redis-deployment.yml.j2') | ansible.builtin.from_yaml_all | select | list }}"
loop_control:
label: "{{ item.kind }}/{{ item.metadata.name }}"Drei Dinge kamen nur durch den Test ans Licht:
- Der erste Probelauf meldete bei fast allem „changed“ – ohne Diff. Es stellte sich heraus, dass das Modul keinen Diff zeigt, wenn man ihm eine Liste von Objekten übergibt. Eine Schleifenrunde pro Objekt löste das, und jetzt sagt der Diff genau
image: redis:7.4.11-alpine → redis:7.4.12-alpine. - Die einmalige Umstellung schreibt nur die Annotation
last-applied-configurationneu (kubectl schreibt ein leeres"annotations":{}hinein, das Modul nicht). Spec und Pod-Template bleiben identisch, also keine Pod-Neustarts – geprüft über die ReplicaSet-Revisionen, alle weiterhin bei 1. - Helm:
helm upgrade --installlief früher bei jedem Play und stapelte jedes Mal eine neue Revision auf.kubernetes.core.helmvergleicht Chart-Version und Werte und tut nichts, wenn sie übereinstimmen. Haken: Helm 4 wird erst ab Collection-Version 6.5.0 unterstützt; das Distributionspaket brachte 6.1.0 mit.
Nebeneffekt, den ich sehr mag: Kubernetes-Secrets landen nicht mehr als Datei in /tmp auf dem Server.
2️⃣ Ein Probelauf, der etwas bedeutet
Im Check-Modus überspringt Ansible jeden shell/command-Task – auch die harmlosen, die nur etwas lesen. Spätere Tasks, die deren Ergebnis auswerten, scheitern dann oder entscheiden auf Basis von nichts. 94 reine Abfragen (Versionsprüfungen, occ config:system:get, systemctl is-active …) tragen jetzt check_mode: false. Alles, was schreibt, bleibt übersprungen – und wo das eine anstehende Änderung verstecken würde, meldet ein kleiner Zusatz-Task sie.
3️⃣ Lint, Secret-Scan, Hooks
Der erste ansible-lint-Lauf meldete 811 Funde. Rund 600 waren Stilregeln, die bewussten Entscheidungen widersprechen (ausgerichtete Spalten, gemeinsame Variablennamen ohne Rollen-Präfix) – die sind mit schriftlicher Begründung abgeschaltet. Der Rest wurde behoben. Zwei echte Funde:
ansible.builtin.ini_filegibt es nicht. Es funktionierte nur über eine alte Weiterleitung zucommunity.general– einer Collection, die nicht einmal inrequirements.ymlstand. Auf einem Controller mit reinem ansible-core wäre das Nextcloud-Playbook genau dort stehen geblieben.- 27 Shell-Pipes ohne
pipefail. Meist harmlos, aber in der Versionsprüfung konntek3s --version | awk … || echo 'not installed'nie „not installed“ ausgeben: Ohnepipefailzählt nur der Exit-Code vonawk.
gitleaks hat alle 191 Commits der Historie durchsucht: ein Treffer, ein Platzhalter PASTE_CONTENT_OF_id_rsa_HERE zwischen zwei Schlüssel-Markierungen in einer alten Beispieldatei. Bewusst akzeptiert, dokumentiert. Die Hooks laufen jetzt bei jedem Commit – und der erste Commit, den sie gestoppt haben, war ein paar Stunden später Claudes eigener (falsche YAML-Einrückung in generierten Dateien). Genau dafür sind sie da.
4️⃣ Idempotenz, getestet
Der Podman-Test ist jetzt Teil des Repos (tests/k8s/run.sh). Er schlägt fehl, wenn der zweite Lauf noch etwas ändert, ein Pod neu ausgerollt würde, ein Helm-Release eine neue Revision bekommt – oder wenn ein absichtlich geänderter Image-Tag nicht erkannt wird. Letzteres ist wichtig: Ein Test, der nie fehlschlagen kann, beweist nichts.
Für die echten Server gibt es scripts/check-drift.sh <playbook> <host>: ein Probelauf, der nur zeigt, was sich ändern würde, mit lesbarem Diff. Direkt nach einem echten Lauf muss NO DRIFT kommen.
Unterwegs wurden ein paar Tasks repariert, die bei jedem Lauf „changed“ meldeten (Signatur-Download, ein Datenbankbenutzer, ein dnf-Modul). Der fieseste Fund war das Gegenteil: Die neun SMTP-Einstellungen von Nextcloud wurden bei jedem Lauf neu geschrieben und meldeten immer „ok“. Ein geändertes Mail-Passwort wäre nie als Änderung aufgetaucht. Jetzt werden sie erst gelesen, verglichen und nur abweichende Werte geschrieben – übergeben per argv, damit ein Anführungszeichen im Passwort den Befehl nicht zerlegen kann.
5️⃣ Wenn ein Update mittendrin scheitert
Claudes erster Vorschlag war: „im Rescue den Wartungsmodus sauber verlassen“. Beim genaueren Nachdenken hat es sich selbst korrigiert: Nach einem abgebrochenen occ upgrade setzt das Abschalten des Wartungsmodus die Nutzer auf eine halb aktualisierte Instanz. Die Logik jetzt:
- Eine Vorprüfung außerhalb des Blocks: installiert, nicht im Wartungsmodus, kein ausstehendes DB-Upgrade – sonst verweigert das Playbook den Start. Bewusst außerhalb: Das Rescue darf sich nur um Zustände kümmern, die der Lauf selbst verursacht hat.
- Das Rescue sammelt occ-Status, Pods, Events und die letzten Zeilen von
nextcloud.logund schaltet den Wartungsmodus nur ab, wennneedsDbUpgradefalse ist. Dann stoppt es, damit kein zweiter Server angefasst wird. - Bonus: Die Update-Playbooks haben früher bei jedem Lauf Nextcloud und Collabora neu gestartet. Eine kleine neue Rolle vergleicht den Digest jedes frisch gezogenen Images mit dem, was die Pods gerade laufen haben, und startet nur neu, was tatsächlich neu gebaut wurde – Exporter und notify_push inklusive.
Getestet mit nachgebautem kubectl/crictl in 14 Szenarien. Und dann der wichtige Teil: das Rescue absichtlich kaputt machen (Wartungsmodus immer abschalten) und den Test erneut laufen lassen. Er wurde genau beim richtigen Szenario rot. Davor hatte derselbe Test einmal aus dem falschen Grund „alles grün“ gezeigt – zwei gleichnamige Szenarien teilten sich ein Verzeichnis und vermischten ihre Logs. Grün bedeutet wenig, solange man den Test nicht hat scheitern sehen.
6️⃣ bis 9️⃣ Die leiseren
- 6 – Aufteilen: Die WordPress-Rolle (912 Zeilen) besteht jetzt aus sieben thematischen Dateien, Grafana aus drei, die Verify-Rollen aus je zwei. Beweis, dass sich nichts geändert hat:
--list-tasks --list-tagsvorher und nachher, identisch. - 7 – Argument-Specs: 23 Rollen deklarieren ihre Eingaben. „Pflicht“ wird pro Host-Typ berechnet: Gemeinsame Rollen lesen auf dem einen Server
blog_hostnameund auf dem anderennextcloud_hostname, also darf keines davon Pflicht sein. Geprüft gegen die echten Variablen aller drei Server. Vault-Werte sindno_log– geprüft, dass sie weder mit-vvvnoch in einem Validierungsfehler auftauchen. - 8 – Zusammenführen: eine ClamAV-Rolle und eine SELinux-Rolle mit einem Profil pro Stack, dazu 16 gemeinsame Smoke-Checks. Falle vermieden: Ansible erkennt Cron-Einträge am Namen, die Namen mussten also exakt gleich bleiben – sonst wäre der alte Job neben dem neuen weitergelaufen.
- 9 – Pins: Aus
>=3.0.0wurde3.15.0. Der Probelauf von Renovate hat gezeigt, warum das wichtig ist: Die Major-Versionen 5.x und 13.x warteten schon.
🔟 Renovate und alle Versionen in einer Datei
Alle Versions-Pins liegen jetzt in inventory/group_vars/all/versions.yml: die Werte kompakt oben, Begründungen und Historie darunter. Renovate bekommt Regex-Manager für genau diese Zeilen und schlägt Updates als Pull Request vor – wöchentlich, nie automatisch gemergt. Ein lokaler Probelauf mit dem Renovate-Container fand alle 21 verfolgten Pins und schlug prompt nginx 1.31 vor. Das ist der Mainline-Zweig; ich fahre den stabilen (gerade Minor-Version). Deshalb gibt es jetzt Leitplanken:
- nginx bleibt auf geraden Minor-Versionen.
- Major-Versionen (Nextcloud 35, MariaDB, Redis 8) und Minor-Sprünge von K3s/Calico nur nach Freigabe im Dependency Dashboard, mit einem Hinweis, was zu tun ist.
- WP-CLI-PRs erinnern mich daran, auch die SHA-512-Prüfsumme anzupassen – Renovate kann sie nicht berechnen, und ohne sie würde der Init-Container des Blogs den Download ablehnen.
📚 Und die Dokumentation
Neu im Repo: „So funktioniert der Ansible-Code“ auf Deutsch, Englisch und Französisch – Variablen und ihre Rangfolge, was bei einem Lauf passiert, wie die Kubernetes-Module arbeiten, Idempotenz-Muster, Fehlerbehandlung, die Tests und ein Lernpfad. Beim Aktualisieren der bestehenden Anleitungen kamen als Bonus ein paar veraltete Angaben zum Vorschein: eine alte Image-Version, eine Calico-Beschreibung mit „nur Nextcloud“, eine Variablentabelle, die auf die falsche Datei zeigte.
🚀 Der Rollout
Die lokalen Tests waren grün. Dann habe ich die Datei mit dem Vault-Passwort selbst im Terminal angelegt (nie im Chat), und Claude ließ den Drift-Check gegen die echten Server laufen. Gleich der erste Lauf gegen den Blog stürzte ab – Claudes eigenes Skript stolperte über das dnf-Modul, das seine Ergebnisse als Liste von Strings statt als Objekte liefert. Behoben, erneut gestartet, und dann kamen die echten Funde. Die meisten steckten in Code, der schon seit Monaten lief.
Was erst die echten Server gezeigt haben
- Die Grafana-Rolle konnte überhaupt keinen Probelauf. Sie fragt die API per
uri-Modul „gibt es die Datenquelle schon?“ – das überspringt Ansible im Check-Modus –, und der nächste Task wertete dann ein Ergebnis aus, das es nicht gab. Harmlos in echten Läufen, fatal für--check. - Meine eigene Korrektur aus Punkt 4 war unvollständig. Ich hatte
creates:ergänzt, damitfreshclamnur einmal läuft – aberchanged_when: truestehen lassen, und das überschreibt das Ergebnis „übersprungen, Datei existiert“. Es meldete weiter jedes Mal „changed“, während es nichts tat. - Ein Besitzer-Pingpong: Das Playbook setzte
publickey.ascauf root, etwas anderes setzte es wieder auf www-data zurück – bei jedem Lauf, monatelang unbemerkt. - Die Monitoring-Programme von Nextcloud wurden nach jedem Neustart erneut heruntergeladen. Die Archive liegen in
/tmp, ein Neustart leert es, und der Download-Task hatte keine Bedingung. Jetzt prüft er zuerst die installierte Version. - Ein umbenanntes Recht: Der MariaDB-Exporter-Benutzer wurde bei jedem Lauf neu berechtigt. Der Code verlangte
REPLICATION CLIENT, MariaDB 10.11 meldet dasselbe Recht alsBINLOG MONITOR, und das Modul sah einen Unterschied, der keiner war. - 46 Deprecation-Warnungen für Fakten als Top-Level-Variablen (
ansible_kernelstattansible_facts['kernel']) – heute in Ordnung, in einer künftigen Ansible-Version kaputt.
Eine Frage brauchte mehr als ein Log: Würde sich das Nextcloud-Deployment ändern? Sein Task ist no_log, weil er Secrets enthält, also ist der Diff verborgen. Ein Neustart bedeutet eine kurze Unterbrechung (Strategie Recreate). Claude hat das laufende Objekt mit einem serverseitigen Probelauf des Apply verglichen und nur die Namen abweichender Felder ausgegeben, nie Werte: identisch bis auf die Annotation last-applied. Kein Neustart.
Schneller, wo wir schon dabei waren
Ich habe gefragt, ob Rollen parallel laufen könnten, um Zeit zu sparen. Ehrliche Antwort: nicht auf einem Host – Ansible arbeitet die Tasks eines Hosts nacheinander ab, und die echten Race Conditions wären dnf-Sperren und AIDE/rkhunter, die den Endzustand sehen müssen. Die sicheren Gewinne lagen woanders: AIDE vergleicht und schreibt seine Basis jetzt in einem Scan statt zwei (30 Sekunden bis 3 Minuten pro Lauf auf dem Blog), und die Kubernetes-Objekte eines Templates gehen bei normalen Läufen in einem Aufruf an die API – pro Objekt nur mit --diff, wo der lesbare Diff zählt. Calico allein fiel von etwa 30 Sekunden auf unter 2.
Die echten Läufe
- Blog: MariaDB und Redis unangetastet, WordPress einmal neu gestartet – ich habe nachgesehen, warum: Der einzige Unterschied zwischen altem und neuem Pod-Template war der
restartedAt-Stempel vom gewollten Neustart nach Plugin-Updates. Die Helm-Releases blieben bei Revision 87 (cert-manager) und 72 (ingress-nginx). Diese Zahlen sagen alles: Der alte Code hatte pro Lauf eine Revision hinzugefügt. Drift-Check danach: NO DRIFT. - sofie: kein Pod neu gestartet, Helm-Revisionen unverändert, notify_push-Selbsttest grün, NO DRIFT.
- cvjm (die große Nextcloud): gleiches Bild – kein Pod neu gestartet, Helm-Revisionen unverändert, der Exporter-Benutzer hat jetzt
BINLOG MONITORund wird nicht mehr neu berechtigt, NO DRIFT.
🎯 Fazit
In Zahlen: 16 Commits, 165 geänderte Dateien, 0 Lint-Fehler, zwei neue Testsuiten, drei umgestellte Server – jeder mit NO DRIFT am Ende. Um die Zahlen geht es aber nicht. Die meisten echten Funde kamen aus Tests, die scheitern durften: die versteckte Collection-Abhängigkeit, das Mail-Passwort, das nie als Änderung auftauchte, der fehlende Diff, der Test, der aus dem falschen Grund grün war – und, sobald echte Server im Spiel waren, ein halbes Dutzend kleiner Lügen in Code, der monatelang still vor sich hin lief.
Und meine Ausgangsfrage? Das Repository hat jetzt einen Lernpfad. Schritt drei lautet: eine Rolle selbst schreiben, ohne Vorlage. Der geht auf mich.





