
🌐 Auch auf: English · Français · Español
Teil 3 der Ansible-Tour. Teil 1 hat das Gerüst behandelt, Teil 2 die Plattform darunter – Grundsystem und Kubernetes. Dieser Teil setzt den Blog obendrauf. Wie immer beschreibt er das Release v3.2.0.
Dieser Blog ist WordPress. Vier Rollen bringen ihn von einem leeren Kubernetes-Cluster zu einer laufenden, konfigurierten, überwachten Website, und ein Update-Playbook hält ihn aktuell:
| Rolle / Playbook | Was sie tut |
|---|---|
blog_k3s_deploy | Jedes Kubernetes-Objekt des Blogs: Datenbank, Cache, WordPress, Webserver, Routing, geplante Jobs, Netzwerkregeln, Image-Regeln |
blog_wordpress | Konfiguriert das laufende WordPress von außen: Installation, Plugins, Einstellungen, kleine Must-Use-Plugins, Updates |
blog_visitor_stats | Zählt jede Nacht die Besucher aus den Logs, anonym, auf dem Server |
blog_verify + common_verify | Smoke-Tests am Ende jedes Laufs |
blog-update.yml | Das Routine-Update: Plugins, Core, neu gebaute Images – mit einem festgelegten Weg für den Fehlerfall |
Vor jedem Code hilft es aber zu wissen, was gebaut wird und warum jedes Teil da ist. Deshalb beginnt dieser Teil mit der Architektur.
🏗️ Was läuft, und warum
Folgen wir einer Anfrage. Ein Leser öffnet einen Beitrag; die Anfrage kommt auf Port 443 des Servers an.
- Der Ingress-Controller (aus Teil 2) terminiert TLS – er hält das Let’s-Encrypt-Zertifikat, entschlüsselt die Anfrage und prüft ein paar Regeln (Ratenbegrenzung auf der Login-Seite, gesperrte Pfade, Sicherheits-Header). Dann reicht er die einfache HTTP-Anfrage im Cluster an den WordPress-Service weiter, einen festen internen Namen für „den Pod, auf dem gerade WordPress läuft“.
- nginx im WordPress-Pod nimmt sie an. Gibt es im Seiten-Cache schon eine fertige HTML-Kopie der Seite, schickt nginx diese Datei zurück und ist fertig – ganz ohne PHP. Bilder, CSS und JavaScript liefert nginx ebenfalls direkt aus, mit langen Cache-Headern.
- Nur wenn die Seite erzeugt werden muss, gibt nginx die Anfrage an PHP-FPM im selben Pod weiter. FPM („FastCGI Process Manager“) hält einen Vorrat an PHP-Arbeitsprozessen bereit; einer davon führt den PHP-Code von WordPress für diese Anfrage aus.
- WordPress braucht Daten: den Beitrag, das Menü, Dutzende Einstellungen. Zuerst fragt es Redis, einen Speicher im Arbeitsspeicher. Was dort nicht liegt, liest es aus MariaDB, der Datenbank, und legt es für das nächste Mal in Redis ab.
Jedes dieser Teile beantwortet eine konkrete Frage:
| Komponente | Warum sie da ist |
|---|---|
| nginx + PHP-FPM in einem Pod | Das offizielle Image wordpress:fpm enthält PHP, aber keinen Webserver. nginx liefert statische Dateien und gecachte Seiten viel günstiger aus, als PHP es könnte; FPM läuft nur, wenn PHP wirklich gebraucht wird. Im selben Pod teilen sich beide die WordPress-Dateien und sprechen über 127.0.0.1:9000 miteinander – ein Sidecar: ein Hilfscontainer neben dem Hauptcontainer. |
| Seiten-Cache (WP Super Cache) | Die meisten Besucher – und fast alle Bots – sind anonym und sehen dieselbe Seite. Das Plugin speichert das fertige HTML; nginx liefert es aus, ohne PHP zu starten. Bei einem Ansturm von Scannern ist das der Unterschied zwischen einem ruhigen Server und einem vollen FPM-Pool. |
| Redis (Objekt-Cache) | WordPress fragt die Datenbank pro Seite viele Male ab – Einstellungen, Menüs, Beitrags-Metadaten, Übersetzungen. Ein Objekt-Cache hält diese Ergebnisse über Anfragen hinweg im Speicher, sodass eine Seite, die doch erzeugt werden muss, weit weniger Abfragen braucht. Redis ist hier ein reiner Cache: Nichts wird auf die Platte geschrieben, und ihn zu verlieren heißt nur ein paar langsamere Seitenaufrufe. |
| MariaDB | WordPress speichert alles in einer MySQL-kompatiblen Datenbank: Beiträge, Benutzer, Einstellungen, Plugin-Daten. Auf dem Blog läuft sie wie alles andere als Pod im Cluster – Teil 4 zeigt, warum Nextcloud das anders macht. |
| Host-Verzeichnisse (hostPath) | Die WordPress-Dateien und die Datenbankdateien liegen in zwei Verzeichnissen auf dem Server, in die Pods eingehängt. Pods können jederzeit neu erzeugt werden; die Daten bleiben. Das nächtliche Backup kopiert diese Verzeichnisse. |
| Ein CronJob statt WP-Cron | WordPress hat einen eigenen Planer, der „beim nächsten Besuch“ läuft – unzuverlässig auf einer ruhigen Seite, und er bremst diesen Besucher aus. Stattdessen führt ein Kubernetes-CronJob alle fünf Minuten wp-cron.php aus. |
| Ein nächtlicher Update-Job | Plugin-, Theme-, Übersetzungs- und kleinere Core-Updates, einmal pro Nacht, mit vorherigem Datenbank-Dump. |
| Exporter | Zwei weitere Sidecars übersetzen die internen Zähler von MariaDB und PHP-FPM in Metriken für Prometheus (Teil 6). |
| NetworkPolicies und eine Image-Richtlinie | Regeln, die begrenzen, welcher Pod mit welchem sprechen darf und welche Container-Images überhaupt laufen dürfen. Sicherheit ist Teil 5 – aber diese beiden gehören zum Blog, deshalb kommen sie hier. |
Zwei Kubernetes-Begriffe tauchen unten ständig auf. Ein Deployment beschreibt „halte einen Pod dieser Art am Laufen“ – stirbt der Pod, startet Kubernetes einen neuen. Seine Strategie ist hier immer Recreate: erst den alten Pod stoppen, dann den neuen starten. Zwei Pods, die gleichzeitig in dasselbe Host-Verzeichnis schreiben, sind der Weg, auf dem Datenbanken kaputtgehen; ein paar Sekunden Ausfall bei einem Neustart sind der Preis. Eine ConfigMap ist ein Satz Konfigurationsdateien, im Cluster gespeichert und in einen Pod eingehängt; ein Secret ist dasselbe für Passwörter.
🚀 blog_k3s_deploy: der ganze Blog als Kubernetes-Objekte
Die Rolle macht aus der Architektur oben Objekte. Jede Komponente ist ein Jinja-Template in templates/ und eine Task-Datei in tasks/, angewendet mit dem K8s-Apply-Muster aus Teil 2. Die Hauptdatei ist eine Checkliste in der Reihenfolge der Abhängigkeiten:
- import_tasks: dirs.yml # host directories with the right owners
- import_tasks: namespace_secrets.yml # namespace, Secret, ConfigMaps
- import_tasks: mariadb.yml # database (+ wait until ready)
- import_tasks: external_services.yml # a name for Grafana on the host
- import_tasks: redis.yml # object cache (+ wait)
- import_tasks: wordpress.yml # WordPress + nginx + exporter (+ wait)
- import_tasks: ingress.yml # routing from the internet
- import_tasks: cronjob.yml # wp-cron every 5 min, nightly updates
- import_tasks: exporters.yml # metrics ports for Prometheus
- import_tasks: networkpolicy.yml # who may talk to whom
- import_tasks: image_policy.yml # which images may runWordPress braucht eine laufende Datenbank, also wartet die Rolle nach MariaDB, Redis und WordPress – mit kubectl rollout status oder kubectl wait, die blockieren, bis die neuen Pods sich bereit melden. Wenn die Rolle fertig ist, läuft der Blog.
Eine Wache vor allem anderen
- name: blog_k3s_deploy | Check that the exporter DB password is present in the vault
ansible.builtin.assert:
that:
- blog_db_exporter_pass is defined
- blog_db_exporter_pass | length > 0
fail_msg: >-
blog_db_exporter_pass is missing from this host's vault.yml. Add a line
like blog_db_exporter_pass: "<a fresh random password>" ...Diese Variable kam später dazu, als sich der Datenbank-Exporter nicht mehr als root anmeldete. Ältere Vaults haben sie nicht. Ohne diese Prüfung würde der Lauf auf halbem Weg mit „undefined variable“ in einem Template scheitern. Mit ihr scheitert er am Anfang, und die Meldung sagt genau, was ergänzt werden muss. assert ist das Werkzeug für solche Wachen: eine Liste von Bedingungen, alle müssen wahr sein, und eine Meldung für den Fall, dass sie es nicht sind. (Die argument_specs der Rolle führen sie als optional, diese Prüfung allein würde es also nicht fangen.)
dirs.yml: Besitzer, die stimmen bleiben
Zwei Host-Verzeichnisse werden mit dem Modul file angelegt: die WordPress-Dateien, im Besitz von uid 33 (www-data im Container), und die Datenbankdateien, im Besitz von uid 999 (mysql). Die Zahlen sind wichtig. Eine frühere Fassung legte sie als root an. Die Init-Container der Pods (mehr dazu unten) korrigierten den Besitzer beim Start – und der nächste Playbook-Lauf setzte ihn wieder auf root. Alles lief weiter, bis ein Plugin eine Datenbanktabelle anlegen wollte und mit einem irreführenden „Permission denied“ scheiterte. Die Ursache war gar nicht die Datenbank, sondern dieser Task, der die Arbeit des Init-Containers rückgängig machte. Seitdem setzt der Task denselben Besitzer, den die Container erwarten – und seit dem 16.10.2026 auch dieselben Rechte: Der Init-Container der Datenbank setzte das Verzeichnis noch auf 0755, die Rolle auf 0750, also wich es nach jedem Pod-Neustart wieder ab. Jetzt sagen beide 0750, und die beiden streiten nie. Dieselbe Datei legt auch den öffentlichen PGP-Schlüssel aus dem Impressum ins Web-Root.
namespace_secrets.yml: der Rahmen
Ein Namespace ist ein benannter Bereich im Cluster; alles vom Blog lebt in wordpress. Drei Details:
- Pod-Security-Labels, nur warnen und protokollieren. Kubernetes kann „Pod Security Standards“ durchsetzen. Der Standard „baseline“ verbietet Host-Verzeichnisse in Pods – genau so speichert dieser Blog seine Daten. Ihn durchzusetzen würde jeden Pod ablehnen, auch den Cronjob alle fünf Minuten. Also bekommt der Namespace die Labels im Modus warn und audit: Verstöße werden gemeldet, nichts wird blockiert. Dieser Task nutzt
k8sohneapply: Ein einfacher Merge fügt die Labels zu den vorhandenen hinzu, statt das ganze Namespace-Objekt zu ersetzen. - Kein Service-Account-Token. Derselbe Patch wie in Teil 2, für den Namespace, den es damals noch nicht gab.
- Das Secret, mit
no_log. Datenbankpasswörter, Admin-Login, die Zugangsdaten des Exporters. Das Template wird im Speicher gerendert und an die API geschickt;no_log: truehält alles aus der Ausgabe heraus, selbst bei einem Probelauf mit--diff.
Dann die ConfigMaps: die nginx-Site-Konfiguration, die PHP-Einstellungen (Upload-Größe, Speicher, OPcache) und der PHP-FPM-Pool (höchstens zwölf Worker, eine Statusseite für den Exporter). Im nginx-Teil wird viel vom Verhalten des Blogs festgelegt: welche Dateien nie ausgeliefert werden (wp-config.php, Backup-Endungen wie .bak oder .save, .phar, die Exporte eines Migrations-Plugins), wie der Seiten-Cache ausgeliefert wird, bevor PHP überhaupt gefragt wird, und wie lange Browser Bilder und Skripte cachen dürfen. Jedes gesperrte Muster hat einen Kommentar mit dem Grund, mehrere davon mit Datum – zum Beispiel ein Backup-Plugin, das komplette Website-Exporte samt Datenbank in einen über das Web erreichbaren Ordner schreibt und sich auf eine .htaccess-Datei verlässt, die nginx ignoriert.
Ein Pod startet neu, wenn sich seine Konfiguration ändert
Eine Falle, in die man leicht tappt: Die Konfigurationsdateien werden mit subPath in den Pod eingehängt, und solche Einhängungen aktualisieren sich in einem laufenden Pod nie. Eine geänderte ConfigMap wirkte erst beim nächsten zufälligen Neustart. Die Lösung ist eine Annotation im Pod-Template von WordPress:
template:
metadata:
annotations:
checksum/config: "{{ lookup('ansible.builtin.template', 'nginx-cm.yml.j2') | hash('sha256') }}"Das Template der ConfigMaps wird hier noch einmal gerendert und auf eine SHA-256-Prüfsumme reduziert. Ändert sich die Konfiguration, ändert sich die Prüfsumme – und mit ihr das Pod-Template, woraufhin Kubernetes einen neuen Pod ausrollt. Hat sich nichts geändert, bleibt die Prüfsumme gleich und der Pod in Ruhe. Ein kleiner Trick, der aus „an den Neustart denken“ etwas macht, das man nicht vergessen kann.
mariadb.yml: die Datenbank und ein Benutzer mit minimalen Rechten
Das MariaDB-Deployment nutzt das offizielle Image, das beim ersten Start aus Umgebungsvariablen die Datenbank und den WordPress-Benutzer anlegt. Ein paar Dinge im Template verdienen einen Hinweis:
- Passwörter als Dateien, nicht als Umgebungsvariablen. Umgebungsvariablen sind in
/proc/<pid>/environ, inkubectl describeund in manchen Fehlerseiten sichtbar. Das Image unterstützt die KonventionMARIADB_ROOT_PASSWORD_FILE: Es liest das Passwort stattdessen aus einer Datei, und die Datei kommt aus dem Secret, schreibgeschützt eingehängt. - Ein Init-Container läuft, bevor die Datenbank startet, und setzt den Besitzer des Datenverzeichnisses. Ein Init-Container ist ein kurzlebiger Container, der fertig sein muss, bevor die Hauptcontainer starten – der richtige Ort für einmalige Vorbereitung.
- Probes:
mysqladmin pingsagt Kubernetes, wann die Datenbank Verbindungen annehmen kann (Readiness) und wann sie hängt und neu gestartet werden sollte (Liveness). - Ein abgeriegelter Container: alle Linux-Capabilities entzogen und nur die fünf zurück, die der Entrypoint braucht, keine Rechteausweitung, ein schreibgeschütztes Root-Dateisystem mit zwei kleinen beschreibbaren Volumes für den Socket und temporäre Tabellen. Das Volume für Temp-Tabellen liegt seit dem 16.10.2026 im RAM (vorher landeten rund 2.000 Temp-Tabellen pro Tag auf der Platte); weil ein RAM-Volume zum Speicherlimit des Containers zählt, stieg das Limit von 768 MiB auf 1280 MiB. Teil 5 erklärt diese Einstellungen allgemein.
Nach dem Rollout legt die Rolle einen Datenbankbenutzer für den Metrik-Exporter an. Früher meldete er sich als root an – voller Zugriff für ein Programm, das nur Zähler liest. Jetzt hat er genau drei Rechte und Lesezugriff auf das Performance Schema, nichts auf den WordPress-Tabellen. Interessant ist, wie das idempotent geht, ohne ein MySQL-Modul, das in den Pod hineinreichen könnte:
- name: MariaDB | Check the mysqld_exporter database user
ansible.builtin.command:
argv:
- k3s
- kubectl
- exec
- deployment/mariadb
- --namespace=wordpress
- --container=mariadb
- --
- sh
- -c
- >-
mariadb -N -u root -p"$(cat /etc/secrets/db-root-password)" -e "
SELECT
(SELECT COUNT(*) FROM mysql.user WHERE User = 'exporter' ...
AND authentication_string = PASSWORD('...'))
+ (SELECT COUNT(*) FROM mysql.db WHERE User = 'exporter' ...
AND Db = 'performance_schema' AND Select_priv = 'Y');"
register: exporter_user_check
changed_when: false
no_log: true
- name: MariaDB | Create least-privilege mysqld_exporter database user
ansible.builtin.command:
argv: [ ... CREATE OR REPLACE USER ... GRANT ... ]
when: exporter_user_check.stdout | default('') | trim != '4'
no_log: trueLies es als Frage mit einer Zahl als Antwort. Das SQL zählt zwei Dinge: Benutzer namens „exporter“ mit genau diesem Passwort (zwei Hosts: localhost und 127.0.0.1) und das Leserecht auf das Performance Schema für beide. Alles richtig heißt 4. Jede andere Zahl – der Benutzer fehlt, das Passwort im Vault wurde geändert, ein Recht ging verloren – und der zweite Task legt den Benutzer mit CREATE OR REPLACE neu an. Das macht auch das Wechseln des Passworts trivial: im Vault ändern, Playbook laufen lassen.
Beachte argv statt eines Befehlsstrings: Jedes Listenelement wird als genau ein Argument übergeben, ohne eine Shell dazwischen, die Anführungszeichen oder Dollarzeichen auslegen würde. Das SQL kommt unversehrt im Pod an, wo sh -c es ausführt – und das root-Passwort wird im Pod aus der eingehängten Datei gelesen, taucht also nie in einer Befehlszeile auf dem Host auf.
redis.yml und ein Entrypoint, der nie lief
Redis läuft mit abgeschalteter Persistenz (--save "" --appendonly no), einem Speicherlimit von 80 MB und der Verdrängungsregel allkeys-lru: Ist der Speicher voll, fliegt der am längsten nicht genutzte Eintrag. Die 80 MB liegen bewusst deutlich unter dem Container-Limit von 128 MB, weil die eigene Speicherrechnung von Redis bei fast einem Drittel des echten Verbrauchs gemessen wurde – Redis soll selbst verdrängen, lange bevor der Kernel es beendet. WordPress setzt zusätzlich eine maximale Lebensdauer von einem Tag pro Cache-Eintrag, sodass sich der Cache selbst aufräumt.
Das Template trägt auch einen der lehrreicheren Fehler dieses Repositorys. Der Container wird mit einem eigenen command: gestartet – und in Kubernetes ersetzt command den Entrypoint des Images. Dieser Entrypoint ist das Skript, das von root auf den Benutzer redis gewechselt hätte. Bis Oktober 2026 lief Redis also als root, mit zwei zusätzlichen Capabilities für einen Benutzerwechsel, der nie stattfand. Die Lösung ist nicht, den Entrypoint wiederherzustellen, sondern den Container direkt als den richtigen Benutzer zu starten:
securityContext:
runAsUser: 999
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000Als uid 999 gestartet, braucht Redis überhaupt keine Capabilities. fsGroup lässt Kubernetes die Volumes des Pods dieser Gruppe geben, sodass das (ungenutzte, aber vorhandene) Datenverzeichnis beschreibbar ist.
wordpress.yml: drei Container und ein Init-Container
Der WordPress-Pod ist das größte Template der Rolle. Sein Init-Container hat zwei Aufgaben: den Besitzer des Web-Roots setzen und WP-CLI herunterladen, das Kommandozeilenwerkzeug, mit dem blog_wordpress WordPress steuert. Beide Aufgaben tragen eine Lektion:
- WP-CLI lebt außerhalb des Web-Roots. Früher lag es in
/var/www/html– und alles dort ist über HTTP erreichbar. nginx verweigerte nur.php-Dateien, nicht.phar, also konnte jeder ein 7-MB-Werkzeug vom Blog herunterladen. Jetzt kommt es in ein emptyDir (ein Arbeits-Volume, das so lange lebt wie der Pod), schreibgeschützt in den PHP-Container eingehängt. - Festgelegt und mit Prüfsumme. Der Download kommt mit fester Version von GitHub und wird gegen eine SHA-512 aus
versions.ymlgeprüft. Das busybox-wgetim Init-Container prüft keine TLS-Zertifikate, und WP-CLI läuft später als root – ein ungeprüfter Download wäre Codeausführung aus der Ferne, die nur darauf wartet zu passieren. Passt die Prüfsumme nicht, scheitert der Init-Container und der Pod startet nie. sleep 5vor dem Download. Die erste ausgehende Verbindung eines brandneuen Pods kann scheitern, weil Calico seine Netzwerkregeln noch nicht eingerichtet hat. Das verursachte einen echten Ausfall: Bei einem Neustart hing der Download, der alte Pod war schon weg (eine Replik, Recreate), und die Seite antwortete mit 502, bis der Init-Container von Hand neu gestartet wurde. Fünf Sekunden Warten sind billiger.
Dann die drei Container – PHP-FPM, der FPM-Exporter und nginx –, jeder mit demselben Härtungsmuster: alle Capabilities entzogen und nur die nötigen zurück, mit einem Kommentar pro Capability („der FPM-Master startet als root und forkt Worker als www-data“ braucht SETUID/SETGID), keine Rechteausweitung, das Standard-seccomp-Profil, ein schreibgeschütztes Root-Dateisystem mit kleinen beschreibbaren Volumes für genau die Pfade, die sie brauchen. Der Exporter läuft als Benutzer „nobody“ und braucht gar keine Capabilities.
Die eigene Konfiguration von WordPress kommt über eine Umgebungsvariable herein, die das Image versteht: WORDPRESS_CONFIG_EXTRA. Sie setzt die öffentliche URL, die Redis-Verbindung, die Cache-Lebensdauer, den Seiten-Cache – und DISALLOW_FILE_EDIT, das den Plugin- und Theme-Editor aus dem Admin-Bereich entfernt. Ohne das ist ein gestohlenes Admin-Passwort nur einen Klick davon entfernt, beliebiges PHP auf dem Server auszuführen.
Zuletzt die Probes, drei Arten aus gutem Grund:
| Probe | Frage | Folge |
|---|---|---|
| startupProbe | Ist er schon gestartet? | Hält die anderen beiden bis zu fünf Minuten zurück, damit ein langsamer Start nicht für ein Hängen gehalten wird |
| readinessProbe | Kann er jetzt Anfragen annehmen? | Wenn nicht, schickt der Service keine Anfragen mehr an diesen Pod |
| livenessProbe | Lebt er noch? | Wenn nicht, startet Kubernetes den Container neu |
Bei nginx prüfen die Probes bewusst den TCP-Port, nicht eine HTTP-Seite: Eine HTTP-Prüfung ginge bis zu PHP durch und könnte nginx für tot erklären, wenn nur PHP ein Problem hat.
ingress.yml: Master, Minion und ein Platzhalter
Das Routing des Blogs besteht aus drei Ingress-Objekten. Das ist keine Wahl, sondern die Art, wie der Controller von F5 mehrere Ingresses für einen Hostnamen behandelt: Ein Master hält das TLS-Zertifikat und serverweite Einstellungen, Minions halten die Pfade. Der temporäre Challenge-Ingress von cert-manager (Teil 2) kommt als weiterer Minion dazu. Hier trägt der Master die Weiterleitung auf HTTPS (außer für den ACME-Challenge-Pfad), ein paar harte Sperren und die Ratenbegrenzung auf wp-login.php – in einer eigenen Location, was einmal dazu führte, dass die Login-Seite ohne HSTS-Header ausgeliefert wurde, weil die Header-Regeln im Minion stehen. Ein Minion leitet alles an WordPress weiter und fügt die Sicherheits-Header hinzu; ein zweiter leitet /grafana/ an Grafana auf dem Host. Grafana bekam einen eigenen Minion, weil es WebSocket-Header braucht, die WordPress nicht bekommen soll, und weil sonst zwei widersprüchliche Frame-Header verschickt worden wären. Ein vierter Ingress leitet die nackte Domain ohne „www“ auf die vollständige weiter.
Nach dem Apply kümmert sich ein kleines Stück Logik um das allererste Deployment auf einem neuen Server:
- name: Ingress | Check whether TLS secrets already exist
kubernetes.core.k8s_info:
kind: Secret
namespace: wordpress
name: "{{ item.secret }}"
loop:
- { secret: wordpress-tls, host: "{{ blog_hostname }}" }
- { secret: apt-upgrade-me-apex-tls, host: "{{ blog_hostname | regex_replace('^www\\.', '') }}" }
register: wp_tls_secret_check
no_log: true
- name: Ingress | Seed self-signed placeholder for any TLS secret cert-manager hasn't issued yet
ansible.builtin.shell: |
openssl req -x509 -nodes -newkey rsa:2048 -days 7 ... -subj "/CN={{ item.item.host }}"
k3s kubectl create secret tls {{ item.item.secret }} ...
loop: "{{ wp_tls_secret_check.results }}"
when: item.resources | length == 0Für einen Hostnamen, dessen Zertifikat noch nicht existiert, lehnt dieser Controller den TLS-Handshake ab – die Seite sieht tot aus, bis Let’s Encrypt geantwortet hat. Also fragt die Rolle für jedes erwartete Zertifikats-Secret, ob es existiert, und legt sonst einen selbstsignierten Platzhalter mit sieben Tagen Laufzeit an; cert-manager überschreibt ihn, sobald das echte Zertifikat da ist. Die Schleife zeigt ein Muster, das man kennen sollte: Der zweite Task läuft über die Ergebnisse des ersten. Jedes Ergebnis enthält das ursprüngliche Schleifenelement als item.item (Hostname und Secret-Name) und die Antwort als item.resources (leere Liste = nicht gefunden). Der Filter regex_replace leitet die nackte Domain aus dem www-Namen ab. Und no_log steht da, weil k8s_info die Daten eines Secrets zurückgibt – private Schlüssel.
cronjob.yml: zwei geplante Jobs
Der erste CronJob führt alle fünf Minuten wp-cron.php aus, mit concurrencyPolicy: Forbid (nie zwei gleichzeitig). Er hatte zwei erzählenswerte Fehler. Seine Pods trugen früher das Label app: wordpress – dasselbe Label, nach dem der WordPress-Service auswählt. Für ihre zehn Sekunden Lebenszeit gehörten die Cron-Pods also zum Service, und der Ingress schickte echte Besucher an einen Pod ohne Webserver: ab und zu ein 502, alle fünf Minuten. Jetzt heißen sie wordpress-cron, und die NetworkPolicies erlauben beide Labels. Der zweite Fehler: Dem Cron-Container fehlte der Block WORDPRESS_CONFIG_EXTRA, er wusste also nicht, wo Redis war, versuchte 127.0.0.1, und jeder geplante Lauf starb an „Error establishing a Redis connection“ – still, weil WordPress in diesem Fall mit 0 endet. Der Block ist jetzt in beiden Templates identisch, mit einem Kommentar, dass das so bleiben muss, und der Container wartet ebenfalls fünf Sekunden auf Calico, wie der Init-Container oben.
Der zweite CronJob ist das nächtliche Update. Die Cron-Pods hängen das Web-Root schreibgeschützt ein, also gaben die eigenen Hintergrund-Updates von WordPress still auf – jedes Plugin-Update wartete auf einen Klick im Admin-Bereich. Statt die Cron-Pods beschreibbar zu machen, erledigt das ein eigener nächtlicher Job richtig: Ein Init-Container lädt WP-CLI herunter (wieder festgelegt und mit Prüfsumme), ein zweiter schreibt einen Datenbank-Dump in ein Backup-Verzeichnis und behält 14 Tage, und nur wenn der Dump gelungen ist, führt der Hauptcontainer Core-, Plugin-, Theme- und Übersetzungs-Updates als www-data aus und prüft danach die Startseite. Das Update-Playbook weiter unten verwendet genau diesen Job wieder.
exporters.yml, networkpolicy.yml, image_policy.yml
Die Exporter lauschen in ihren Pods. Prometheus läuft auf dem Host, also machen zwei Services vom Typ NodePort sie auf festen Ports des Servers erreichbar – und die Firewall aus Teil 5 verwirft diese Ports für alles außer lokalen Verbindungen.
Die NetworkPolicies beginnen mit einem Bedrohungsmodell im Kommentar, für mich der beste Teil der Datei. PHP-FPM verarbeitet nicht vertrauenswürdige Eingaben – Anfragen, Uploads, Plugin-Code – und ist mit Abstand der wahrscheinlichste Ort, an dem ein Angreifer Code ausführen kann. Es braucht aber auch berechtigterweise die Datenbank und Redis. Ziel ist also nicht, es abzuschneiden, sondern (1) sicherzustellen, dass nur die WordPress-Pods MariaDB und Redis erreichen, und (2) zu begrenzen, was ein kompromittierter WordPress-Pod erreichen kann. Die Regeln erlauben genau: den Ingress (über die Adresse des Hosts, weil der Controller im Host-Netz läuft), DNS, MariaDB, Redis, den Exporter-Port – und ausgehendes HTTP/HTTPS ins Internet. Der Kommentar ist bei Letzterem ehrlich: Updates kommen von wordpress.org und GitHub hinter wechselnden CDN-Adressen, und normale NetworkPolicies können keine Domainnamen prüfen. Was sie verhindern, ist Bewegung innerhalb des Clusters.
Die Image-Richtlinie ist eine ValidatingAdmissionPolicy: eine Regel, die der API-Server prüft, bevor er einen Pod annimmt. Ihre Bedingung ist in CEL geschrieben, einer kleinen Ausdruckssprache:
validations:
- expression: >
object.spec.containers.all(c, c.image.split(':')[0] in variables.allowedRepos)
&& (!has(object.spec.initContainers) ||
object.spec.initContainers.all(c, c.image.split(':')[0] in variables.allowedRepos))„Das Image jedes Containers, ohne Tag, steht in der Liste erlaubter Repositorys – und dasselbe für Init-Container, falls es welche gibt.“ Auf das Repository statt auf den Tag zu prüfen heißt, dass ein Versionssprung hier nie eine Änderung braucht; blockiert wird ein anderes Image. Ein Eintrag in der Liste hat eine Geschichte: Der Challenge-Solver von cert-manager startet seinen Pod im Namespace des Blogs, nicht in seinem eigenen. Als das Zertifikat für die nackte Domain dazukam, wurde der Solver abgelehnt, und es konnte kein Zertifikat ausgestellt werden, bis sein Image erlaubt war.
| Baustein | Wo | Was man mitnimmt |
|---|---|---|
assert mit fail_msg | main.yml | Früh scheitern, mit Anleitung |
import_tasks in Abhängigkeitsreihenfolge + Warten auf den Rollout | main.yml | Die Rolle endet mit einer laufenden Anwendung |
| Besitzer als Zahlen | dirs.yml | Mit den Containern übereinstimmen, sonst streiten sie bei jedem Lauf |
k8s ohne apply | namespace_secrets.yml | Ein paar Felder in ein bestehendes Objekt mergen |
lookup(...) | hash('sha256') | WordPress-Template | Pods neu ausrollen, wenn sich ihre Konfiguration ändert |
argv, eine zählende SQL-Abfrage, when: ... != '4' | mariadb.yml | Idempotente Änderungen ohne Modul |
runAsUser / fsGroup | Redis-Template | Als richtiger Benutzer starten statt zu wechseln |
Schleife über results, item.item | ingress.yml | Auf die Antworten einer vorherigen Schleife reagieren |
| CEL in einer ValidatingAdmissionPolicy | Image-Richtlinie | Regeln, die der API-Server durchsetzt |
🔧 blog_wordpress: eine laufende Website von außen konfigurieren
WordPress läuft jetzt, ist aber leer oder – bei jedem späteren Lauf – womöglich abgewichen. Diese Rolle konfiguriert es. Es gibt kein Ansible-Modul für WordPress; alles läuft über WP-CLI im Pod:
k3s kubectl exec deployment/wordpress --namespace wordpress --container wordpress-fpm --
php /usr/local/lib/wp-cli/wp-cli.phar <command> --path=/var/www/html ...Damit ist die ganze Rolle eine lange Anwendung des Musters „erst fragen, dann handeln“ aus Teil 2. Die Rolle sagt das in ihrem Kopf und nennt die Reihenfolge: Installation, ein Härtungs-Plugin, Plugins, Erscheinungsbild, der KI-Hinweis und drei kleine Hilfs-Plugins, Einstellungen, Updates.
root oder www-data?
kubectl exec läuft im Container als root. Für eine PHP-Anwendung ist das ein Problem: Dateien, die root anlegt, kann PHP-FPM nicht ändern, weil es als www-data läuft. Die Rolle folgt einer Regel, die oben in main.yml steht: Befehle, die Code installieren oder aktualisieren, laufen als root mit --allow-root (WP-CLI verweigert root sonst) und werden immer von einem chown zurück auf www-data gefolgt. Reine Abfragen und alles, was ohne späteres chown schreibt, laufen als www-data über su -s /bin/sh www-data -c "...". Die Regel gibt es, weil der umgekehrte Weg einmal ein Cache-Verzeichnis im Besitz von root hinterließ und das Cache-Plugin nicht mehr hineinschreiben konnte.
install.yml: nur einmal
wp core is-installed endet mit 0, wenn die WordPress-Tabellen existieren, und mit 1, wenn nicht. Mit failed_when: false registriert, wird der Exit-Code zu einer Entscheidung: when: wp_installed.rc != 0 führt wp core install auf einer frischen Datenbank aus und danach nie wieder. Der Installations-Task hat no_log: true, weil das Admin-Passwort Teil seiner Befehlszeile ist. Der Titel der Website geht durch den Filter quote, der ihn sicher für die Shell verpackt.
security_muplugin.yml: eine Datei, die sich nur ändert, wenn sie sich ändert
Ein Must-Use-Plugin ist eine PHP-Datei in wp-content/mu-plugins/. WordPress lädt jede Datei dort bei jeder Anfrage, vor den normalen Plugins, und sie lässt sich im Admin-Bereich nicht deaktivieren – der richtige Ort für Regeln, die immer gelten müssen. Dieses hier versteckt Versionsnummern, entfernt Discovery-Links und sperrt für anonyme Besucher den REST-Endpunkt, der Benutzernamen auflistet.
Interessant ist, wie es geschrieben wird, denn dasselbe Muster gilt für jede PHP-Datei, die die Rolle ausrollt:
- name: WordPress | Deploy security hardening mu-plugin
ansible.builtin.command:
argv:
- k3s
- kubectl
- exec
- deployment/wordpress
- ...
- sh
- -c
- |
F=/var/www/html/wp-content/mu-plugins/security-hardening.php
cat > "$F.new" << 'PHPEOF'
<?php
...
PHPEOF
if [ -f "$F" ] && [ "$(md5sum < "$F.new")" = "$(md5sum < "$F")" ]; then
rm -f "$F.new"
else
mv "$F.new" "$F" && chown www-data:www-data "$F" && echo CHANGED
fi
register: wp_mu_hardening
changed_when: "'CHANGED' in wp_mu_hardening.stdout"
notify: Restart wordpress deployment (OPcache picks up changed PHP files)- Die Datei wird als
security-hardening.php.newgeschrieben – ein Name, den WordPress nicht lädt, weil er nicht auf.phpendet. - Das Heredoc in Anführungszeichen (
<< 'PHPEOF') hält den PHP-Code wörtlich: Die Shell expandiert kein$.argvsorgt dafür, dass auch Ansible ihn nicht anfasst. - Hat die neue Datei dieselbe Prüfsumme wie die bestehende, wird sie verworfen. Sonst ersetzt sie die alte, und das Skript gibt
CHANGEDaus. changed_whensucht nach diesem Wort. Nur dann ist der Task „changed“ – und nur dann wird der Handler benachrichtigt.
Der Handler startet WordPress neu, weil PHP kompilierten Code im OPcache hält und Dateien nur einmal pro Minute neu prüft; ein Neustart macht eine geänderte Datei sofort wirksam. Vor diesem Muster wurde die Datei einfach mit changed_when: true überschrieben – und jeder Playbook-Lauf startete WordPress neu, jedes Mal mit dreißig bis sechzig Sekunden 502. Die Handler-Datei enthält einen einzigen Eintrag, den vier Tasks benachrichtigen, und läuft höchstens einmal am Ende des Plays.
plugins.yml: block, always und eine Listendifferenz
Die Plugin-Liste steht im Inventory des Hosts (blog_wordpress_plugins). Die Rolle liest die installierten Plugins einmal als JSON und berechnet den Rest:
- name: WordPress | Install and activate missing plugins
ansible.builtin.command: >
... wp-cli.phar plugin install {{ item }} --activate --allow-root
loop: "{{ blog_wordpress_plugins | default([]) | difference(_wp_plugins_installed) }}"
register: wp_plugin_result
until: wp_plugin_result.rc == 0
retries: 3
delay: 10
vars:
_wp_plugins_installed: "{{ wp_plugin_state.stdout | from_json | map(attribute='name') | list }}"
- name: WordPress | Activate installed but inactive plugins
...
when: _wp_plugins_inactive | length > 0
vars:
_wp_plugins_inactive: >-
{{ wp_plugin_state.stdout | from_json
| selectattr('name', 'in', blog_wordpress_plugins | default([]))
| selectattr('status', 'equalto', 'inactive')
| map(attribute='name') | list }}from_json macht aus der Ausgabe von WP-CLI eine Liste von Dictionaries. difference ist Listensubtraktion: gewünschte Plugins minus installierte – nur die werden installiert. selectattr filtert eine Liste von Dictionaries nach einem Feld: von allen installierten Plugins die gewünschten behalten, davon die inaktiven. Vorher installierte jeder Lauf jedes Plugin mit --force neu, meldete alle als geändert – und konnte still eine neuere Version ziehen, ohne dass es jemand merkte. Neue Versionen kommen jetzt nur noch aus dem Update-Schritt.
until mit retries wirkt pro Schleifenelement: Läuft ein Download in ein Zeitlimit (das von WP-CLI liegt fest bei zehn Sekunden), wird nur dieses Plugin erneut versucht. Und all das steckt in einem block mit einem always-Abschnitt:
- name: WordPress | Install plugins and fix ownership
block:
- ...read state, install, activate, Redis...
always:
- name: WordPress | Fix wp-content ownership after plugin installation
ansible.builtin.command: >
k3s kubectl exec ... -- chown -R www-data:www-data /var/www/html/wp-contentTasks unter always laufen auch dann, wenn ein Task im Block scheitert. Warum das zählt: WP-CLI als root schreibt Plugin-Dateien als root. Einmal lief ein Download auf halbem Weg in ein Zeitlimit, das Play hielt an, und das chown lief nie – ein Backup-Plugin konnte danach seinen eigenen Speicherordner nicht anlegen, bis es jemand von Hand reparierte. Jetzt werden die Besitzrechte wiederhergestellt, egal was darüber passiert.
Das Redis-Plugin bekommt zwei eigene Tasks, weil wp redis enable eine Drop-in-Datei ablegt, die das Plugin einschaltet. Sein changed_when ist ein schönes Beispiel dafür, die Ausgabe eines Werkzeugs genau zu lesen: WP-CLI gibt „Activating …“ auch für ein Plugin aus, das schon aktiv ist, gefolgt von „already active“ auf stderr. Nur ein Lauf, der nicht sowohl „already installed“ als auch „already active“ meldet, zählt also als Änderung.
appearance.yml, ai_disclosure.yml, settings.yml
Drei kleinere Dateien, jede mit einer Idee:
- Erscheinungsbild führt kleine PHP-Schnipsel über
wp evalaus, um das eigene CSS und das Menü zu setzen, beides in der Datenbank gespeichert. Der Kommentar gibt eine Einschränkung ehrlich zu: Das CSS wird bei jedem Lauf geschrieben, aber nur seine erste Anlage zählt als „changed“ – ein geänderter CSS-Block wird angewendet, die Zusammenfassung zeigt aber „ok“. - Der KI-Hinweis legt die Offenlegungsseite und ihren Link von der Über-mich-Seite an – erst verglichen, nur bei einem Unterschied geschrieben, sodass eine Bearbeitung von Hand im Admin-Bereich zurückgesetzt wird – und rollt drei weitere Must-Use-Plugins mit demselben Muster aus
.newund Prüfsumme aus: den Hinweis im Footer, Performance-Hinweise (Vorladen der Schriften und ein sofort geladenes erstes Bild, nachdem ein mobiler Lighthouse-Lauf beide Probleme gezeigt hatte) und die Zeile „Auch verfügbar in“ für die Übersetzungen. - Einstellungen schließt die öffentliche Selbstregistrierung (sie wurde von Spam-Bots abgegrast: achtzehn Fake-Konten bei einer Aufräumaktion) und schaltet das von Besuchern ausgelöste Cron von WordPress ab, weil der CronJob diese Aufgabe übernimmt. Zwei Einstellungen, zwei Wege zur Idempotenz:
wp option updatesagt „Value updated“ nur, wenn sich wirklich etwas geändert hat, also liestchanged_whendas; die Cron-Konstante wird erst gelesen und nur geschrieben, wenn sie nicht schon wahr ist.
updates.yml: Updates, Aufräumen und ein ehrlicher Neustart
Die letzte Datei aktualisiert bei jedem Lauf Core, Plugins, Themes und Übersetzungen, in einem weiteren block/always-Paar mit derselben Besitzer-Korrektur. Zwei Techniken machen das möglich:
failed_when, das die Ausgabe liest.wp plugin update --allendet mit 1, wenn es nichts zu aktualisieren gibt – der Normalfall. Also scheitert der Task nur, wenn der Exit-Code nicht 0 ist und die Ausgabe nicht „No plugins updated“ sagt. Dasselbe für Themes und für das Löschen von Plugins, die schon weg sind.- Zwei Momentaufnahmen. Zu Beginn von
plugins.ymlhält die Rolle alle Core-, Plugin- und Theme-Versionen fest; hier hält sie sie noch einmal fest. WordPress wird nur neu gestartet, wenn etwas ein Update gemeldet hat oder sich die beiden Momentaufnahmen unterscheiden. Ein Lauf ohne Updates verursacht keinen Ausfall.
Zwischen den Update-Schritten löscht die Rolle auch ungenutzte Plugins und Themes, denn ein deaktiviertes Plugin liegt weiter auf der Platte und ist weiter erreichbar – eine Schwachstelle darin lässt sich weiter ausnutzen. Inaktive Themes werden berechnet, nicht aufgelistet: theme list --status=inactive kann nie das aktive Theme zurückgeben, das eigene Theme der Website ist also strukturell sicher, und ein Standard-Theme bleibt als Rückfallebene von WordPress erhalten. Das Löschen läuft absichtlich nach dem Core-Update: Ein Core-Update kann die mitgelieferten Standard-Themes neu installieren.
Eine Einschränkung steht im Kopf der Rolle: command-Tasks werden im Check-Modus übersprungen. Ein Probelauf kann hier deshalb nur die lesenden Abfragen ausführen; was WP-CLI ändern würde, lässt sich nicht vorab zeigen.
📊 blog_visitor_stats: Leser zählen ohne Tracker
Die Idee hat einen eigenen Beitrag: kein Tracking-Skript, keine Cookies, kein Dritter – Besucher aus den Ingress-Logs zählen, auf dem Server, und nur die Zahlen behalten. K3s hält Container-Logs nur ein paar Tage, also braucht es eine nächtliche Momentaufnahme, um einen Trend über Monate zu sehen. Die Rolle ist kurz, nutzt aber mehrere Features, die sonst nirgends vorkommen:
copy src: "{{ playbook_dir }}/scripts/analytics/visitor-stats.py"– eine Datei von außerhalb der Rolle.playbook_dirist das Verzeichnis des laufenden Playbooks; ein einfachessrc: namewürde imfiles/der Rolle suchen. Das Skript liegt inscripts/, weil es auch ein Werkzeug zum Ausführen von Hand ist.- Vier systemd-Units aus Templates, in einer
loop, mitnotifyan einen Handler, der systemd neu lädt – undmeta: flush_handlersdirekt danach, weil die Timer mit den neuen Units starten müssen, nicht erst am Ende des Plays. - Der tägliche Dienst läuft mit den Sandbox-Optionen von systemd: das ganze Dateisystem schreibgeschützt außer dem eigenen Datenverzeichnis, keine Home-Verzeichnisse, ein privates
/tmp, keine Rechteausweitung. Ein Statistik-Skript, das Logs liest, braucht nicht mehr. - Ein einmaliges Nachfüllen: Die Logs, die noch auf der Platte liegen, decken die letzten paar Tage ab, also erfasst die Rolle sie einmal, mit einer
loopüber 1 bis 5 und einem in Jinja berechneten Datum –'%Y-%m-%d' | strftime(epoch - item * 86400), „heute minus N Tage“. Ein{% for %}in der Befehlszeile fügt für jedes auszulassende Netz ein--exclude-Argument hinzu (meine eigenen Verbindungen, die die Zahlen eines kleinen Blogs sonst dominieren würden).creates:auf eine Markierungsdatei lässt das ganze Nachfüllen genau einmal laufen.
✅ blog_verify und common_verify: Hat es geklappt?
Teil 1 hat die Idee beschrieben: alle Ergebnisse sammeln, bewerten, berichten und erst dann scheitern. So ist es gebaut. blog_verify hat drei Dateien: main.yml setzt die Farbcodes für den Bericht und bindet die anderen beiden ein, checks.yml führt die Prüfungen aus, report.yml bewertet sie.
Ein Teil der Prüfungen steht gar nicht in dieser Rolle. common_verify ist eine Bibliotheksrolle: Sie taucht in keinem Playbook auf und hat keine tasks/main.yml, nur drei Task-Dateien – Host-Prüfungen, Kubernetes-Prüfungen, HTTP-Prüfungen. Beide Verify-Rollen binden sie ein:
- name: Verify | Shared host checks (common_verify)
ansible.builtin.import_role:
name: common_verify
tasks_from: hosttasks_from wählt eine andere Datei als main.yml. Die Host-Datei prüft nftables (Dienst, Tabelle, Standard-Drop-Policy, Sperrliste), fail2ban, K3s, auditd, SELinux und TCP BBR; die Kubernetes-Datei prüft die Pods von Ingress und cert-manager und sucht überall nach Pods in einem Fehlerzustand; die HTTP-Datei prüft die Website von außen, ihre Header und einen gesperrten Pfad. Der Blog fügt eigene hinzu: die Login-Seite erreichbar, xmlrpc.php und readme.html gesperrt, keine Benutzernamen über die REST-API, OPcache an, MariaDB vom WordPress-Pod aus erreichbar. Mehrere davon sind genau die Dinge, die ich einmal zu knacken versucht habe.
Jeder Prüf-Task hat dieselben vier Zeilen: register: _v_something, failed_when: false, changed_when: false, check_mode: false. Nichts scheitert, nichts ändert sich, alles läuft auch im Probelauf. report.yml berechnet dann pro Prüfung ein wahr/falsch mit set_fact, zählt sie, gibt eine farbige Tabelle aus und verschickt sie per Mail – vor dem abschließenden assert, damit die Mail auch dann rausgeht, wenn etwas Kritisches kaputt ist. Nur das assert kann das Play scheitern lassen. Ein optionaler letzter Schritt führt einen Selbsttest des Monit-Wachhunds aus, der echte Alarm-Mails verschickt – standardmäßig aus, pro Lauf eingeschaltet mit -e monit_selftest=true.
🔄 blog-update.yml: das Routine-Update
Das volle Playbook kann WordPress aktualisieren, fasst aber den ganzen Server an. Das Update-Playbook fasst nur die Anwendung an, und zwar genauso wie der nächtliche Job – es startet diesen Job. Die Strategie dahinter ist älter als dieser Code; das tut der Code heute:
- Wachen. Ohne
--limitverweigern. Verweigern, wenn WordPress keinen verfügbaren Pod hat – eine Seite zu aktualisieren, die schon down ist, würde zwei Probleme vermischen. - Versionen vorher, für den Bericht und die Entscheidung über den Neustart.
- Den Update-Job jetzt ausführen.
kubectl create job --from=cronjob/wordpress-autoupdatestartet eine einmalige Kopie des nächtlichen Jobs: Datenbank-Dump, dann die Updates. Das Playbook fragt dann den Status des Jobs mituntilab, stattkubectl wait --for=condition=completezu nutzen – Letzteres würde bis zu seinem Zeitlimit herumsitzen, wenn der Job scheitert, statt fertig zu werden. Das Job-Log wird ausgegeben. Der ganze Schritt wird im Check-Modus übersprungen. - Neu gebaute Images.
common_image_refreshführt ein kleines Python-Skript auf dem Server aus (mit dem Ansible-Modulscript, das es kopiert, ausführt und wieder entfernt). Für jedes Deployment zieht es den Image-Tag erneut und vergleicht den Digest mit dem, mit dem der laufende Pod gestartet ist. Docker Hub baut unveränderte Tags für Sicherheitskorrekturen im Basis-Image neu, aber K3s zieht nie von selbst neu. Das Skript gibt JSON zurück: welche Deployments einen veralteten Build betreiben. - Nur neu starten, was sich geändert hat, in fester Reihenfolge: Datenbank zuerst, WordPress zuletzt – WordPress verbindet sich mit einer neu gestarteten Datenbank wieder, aber ein neu gestartetes WordPress vor einer Datenbank, die gerade herunterfährt, würde Fehler ausliefern. Der Schleifenausdruck hält diese Reihenfolge ein und hängt alles an, was nicht in der Liste steht.
- Prüfung der Startseite mit dem Modul
uri: HTTP 200, oder der Lauf scheitert.
All das steckt in einem block. Scheitert etwas, zeigt der rescue-Abschnitt Pods und letzte Events, den neuesten Datenbank-Dump des Update-Jobs (den Wiederherstellungspunkt) und den Zustand im Moment des Fehlers – und hält dann an. Kein automatisches Zurückrollen: Eine Wiederherstellung ist eine Entscheidung, kein Reflex.
🛠️ Im Betrieb: der Blog im Alltag
Alles oben passiert automatisch. Dieser Abschnitt ist das, was man von Hand tut – die Befehle, die Routine und was zu tun ist, wenn etwas nicht stimmt. Alle Befehle laufen als root auf dem Server; k3s kubectl ist der Kubernetes-Client, der mit K3s kommt.
Nachsehen
# Are all pods running? (mariadb, redis, wordpress; cron pods come and go)
k3s kubectl get pods -n wordpress
# Did the scheduled jobs succeed? (wordpress-cron every 5 min, wordpress-autoupdate nightly)
k3s kubectl get jobs -n wordpress
# Logs of a container (add --previous after a crash)
k3s kubectl logs deployment/wordpress -c wordpress-fpm -n wordpress --tail=50
# Is the certificate valid, and when is it renewed?
k3s kubectl get certificate -n wordpress \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,RENEWAL:.status.renewalTimeMit WordPress sprechen
WP-CLI liegt im Pod. Führe es als www-data aus, nie als root, außer du installierst Code und korrigierst danach den Besitzer – Dateien im Besitz von root sind hier der häufigste Weg, Plugins und Caches kaputtzumachen:
k3s kubectl exec -n wordpress deployment/wordpress -c wordpress-fpm -- \
su -s /bin/sh www-data -c 'php /usr/local/lib/wp-cli/wp-cli.phar --path=/var/www/html plugin list'Die Routine
| Wann | Was | Wie |
|---|---|---|
| Jede Nacht | Plugin-, Theme-, Übersetzungs- und kleinere Core-Updates, vorher ein Datenbank-Dump | automatisch (Update-CronJob); prüfen mit kubectl get jobs |
| Wenn du es jetzt willst | Dasselbe, plus neu gebaute Container-Images | ansible-playbook blog-update.yml --limit <host> – zuerst mit --check ausführen |
Wenn sich versions.yml ändert | Neue Image-Tags (WordPress, MariaDB, nginx, Redis) | ansible-playbook blog.yml --limit <host> --tags blog_k3s_deploy, nach einem Probelauf |
| Nach jeder Änderung am Repository | Alles, geprüft | das volle Playbook; scripts/check-drift.sh vorher und nachher |
| Nach jedem Lauf | Die Verify-Mail lesen | alles grün, oder die Namen der gescheiterten Prüfungen |
Wenn etwas schiefgeht
- 502 Bad Gateway: nginx läuft, PHP-FPM antwortet nicht. Schau in die FPM-Logs (oben); ein Neustart ist
k3s kubectl rollout restart deployment/wordpress -n wordpress– etwa eine halbe Minute Ausfall wegen der Recreate-Strategie. - Ein Pod startet nicht:
k3s kubectl describe pod -l app=wordpress -n wordpresszeigt unten die Events. Die üblichen Ursachen sind eine Datenbank, die nicht läuft, ein Passwort im Secret, das nicht zum Datenbankbenutzer passt (Playbook noch einmal laufen lassen), oder falsche Besitzer auf den Host-Verzeichnissen (uid 33 für das Web-Root, 999 für die Datenbank). - Ein Plugin-Update hat die Seite kaputtgemacht: Der nächtliche Job hat vorher die Datenbank gedumpt, ins Update-Backup-Verzeichnis auf dem Host (14 Tage). Spiel den Dump zurück, wenn das Update die Datenbank angefasst hat, und leg das Plugin auf die letzte gute Version fest:
wp plugin install <slug> --version=<x> --force. Die rescue-Ausgabe des Update-Playbooks nennt den neuesten Dump. - Der ganze Server ist weg: einen neuen mit dem vollen Playbook aufsetzen, dann Dateien und Datenbank mit dem Wiederherstellungsskript aus dem externen Backup zurückholen, dann das Playbook noch einmal laufen lassen. Das Backup wird jeden Tag von außen abgeholt – Dateien mit rsync, die Datenbank als geprüfter Dump, 30 Tage versioniert –, und der Wachhund schlägt Alarm, wenn das letzte erfolgreiche Backup älter als drei Tage ist. Das Betriebshandbuch im Repository enthält jeden Schritt.
🧭 Wie es weitergeht
Teil 4 ist Nextcloud: dieselben Bausteine, aber mit Datenbank und Redis auf dem Host, einer Office-Suite daneben, einem Push-Dienst und einer Konfigurationsrolle, die jeden Wert mit „lesen, vergleichen, setzen“ setzt. Wer nur einen Blog betreibt, kann ihn überspringen und direkt zu Teil 5 gehen, den Sicherheitsrollen – SSH, die Firewall, Härtung, SELinux und die nächtlichen Scanner.


