
🌐 Auch auf: English · Français · Español
Jeden Montag schickt mir ein kleines Skript einen Versionsbericht: Welche gepinnten Versionen sind veraltet, welche Images wurden unter demselben Tag neu gebaut, welche Pakete warten auf den Hosts? Diesmal standen gleich mehrere Punkte darauf – darunter der Sprung von Nextcloud 34 auf 35 auf beiden Instanzen. Am Ende des Nachmittags war alles aktuell, kein Dienst kaputt und der Drift-Check zeigte changed=0.
Dieser Beitrag geht die Stationen der Reihe nach durch. Jede Station hat ihre eigene kleine Lektion – und eine davon war ein Fehler in meinem eigenen Werkzeug.
📬 Der Bericht informiert, er handelt nicht
Das Prüfskript läuft auf einem Server im Homelab und arbeitet nur lesend. Es vergleicht die Pins in versions.yml mit Docker Hub und GitHub, prüft, ob ein Image unter demselben Tag neu gebaut wurde, vergleicht die PHP-Version in den Pods mit php.net und zählt die ausstehenden Pakete je Host. Ändern darf es nichts – die Updates spiele ich bewusst selbst ein, einen Host nach dem anderen.
Diesmal meldete der Bericht: Nextcloud 35.0.2 verfügbar, Trivy 0.76.1 verfügbar, das WordPress-Image „neu gebaut“ und auf allen drei Hosts sechs Kernel-Pakete.
Wie der Bericht arbeitet: Das Skript ist ein Python-Programm auf HP3, einem Server in meinem Homelab, der auch die nächtlichen Sicherungen der drei Server abholt – also nicht auf den Servern selbst. Ein Cron-Eintrag startet es jeden Montag um 07:17, die Mail verschickt es über einen eigenen Mail-Zugang. In fünf Schritten:
- Es liest
versions.ymlaus der lokalen Kopie des Repositorys – dort steht jede gepinnte Version an einer einzigen Stelle. - Es fragt für jedes Image Docker Hub und für jedes Binary das letzte GitHub-Release ab und vergleicht mit dem Pin.
- Es meldet sich per SSH auf jedem Server an, liest mit
crictl imagesdie Images, die dort tatsächlich liegen, und vergleicht sie mit dem aktuellen Build unter demselben Tag. - Es fragt in den laufenden Pods die PHP-Version ab und vergleicht sie mit php.net.
- Es zählt mit
dnf check-updatedie ausstehenden Pakete je Host.
Am Ende stehen alle Befunde in einer Mail – plus die Befehle für die Update-Läufe in der richtigen Reihenfolge. Ausführen muss ich sie selbst.
So sah der Bericht zu diesem Update-Tag aus (geändert sind nur die Namen der beiden Nextcloud-Server und die Hostnamen in den Befehlen):
== Container images (pin vs Docker Hub) ==
nextcloud_image_fpm 34.0.5-fpm (newest overall 35.0.2-fpm)
nextcloud_image_nginx 1.30.6-alpine (newest overall 1.31.8-alpine)
nextcloud_image_collabora 26.04.5.2.1 ok
nextcloud_image_fpm_exporter 2.2.0 ok
blog_image_wordpress 7.1.4-php8.5-fpm ok
blog_image_nginx 1.30.6-alpine (newest overall 1.31.8-alpine)
blog_image_mariadb 10.11.19 (newest overall 13.0.2)
blog_image_redis 7.4.12-alpine (newest overall 8.10.3-alpine)
blog_image_mysqld_exporter v0.21.0 ok
blog_image_fpm_exporter 2.2.0 ok
== Binaries / charts (pin vs GitHub latest release) ==
k3s_version v1.37.1+k3s1 ok
helm_version v4.3.1 ok
helm_diff_version 3.15.15 ok
node_exporter_version 1.12.1 ok
prometheus_version 3.15.1 ok
mysqld_exporter_version 0.21.0 ok
loki_version 3.7.9 ok
alloy_version 1.21.3 ok
trivy_version 0.75.0 -> v0.76.1
blog_wp_cli_version 2.12.0 ok
cert_manager_chart_version 1.21.3 ok
== Same tag rebuilt upstream (image refresh needed) ==
blog wordpress:7.1.4-php8.5-fpm REBUILT upstream
== PHP in the pods ==
blog PHP 8.5.12
cloud1 PHP 8.5.12
cloud2 PHP 8.5.12
php.net 8.4: 8.4.27
php.net 8.5: 8.5.12
== Host packages (dnf check-update) ==
blog 6 pending incl. kernel, kernel-core, kernel-modules, kernel-tools ...
cloud1 6 pending incl. kernel, kernel-core, kernel-modules, kernel-tools ...
cloud2 6 pending incl. kernel, kernel-core, kernel-modules, kernel-tools ...
Update runs (one host at a time, blog first):
ansible-playbook blog-update.yml -l <blog host> --vault-password-file .vault_pass
ansible-playbook nextcloud-update.yml -l <nextcloud host> --vault-password-file .vault_pass (one at a time)
then per host: --tags common_rkhunter,common_aide_refresh
Pins (versions.yml) are changed by hand or via the Renovate PRs - this report only informs.📦 Zum Nachbauen: weekly-update-check.zip (Skript, Cron-Wrapper und Kurzanleitung, 4 KB). Die Hosts stehen als Platzhalter oben in check.py, die Pins liest es aus versions.yml meines öffentlichen Repositorys.
Was man mitnimmt: Ein Bericht, der nur informiert, kann ruhig gründlich sein. Die Entscheidung, was wann eingespielt wird, bleibt beim Menschen.
☁️ Nextcloud 34 → 35: erst prüfen, dann springen
Ein Major-Upgrade ist bei Nextcloud kein Selbstläufer. Versionen dürfen nicht übersprungen werden, und Apps aus dem App Store brauchen eine Version, die zur neuen Hauptversion passt. Vor dem Sprung habe ich deshalb geprüft:
- Gibt es das Image?
nextcloud:35.0.2-fpmlag auf Docker Hub bereit. - Passen die Apps? Die App-Store-API liefert pro Plattformversion die verfügbaren Apps. Alle neun Store-Apps je Instanz – unter anderem Collabora, Deck, notify_push und Groupfolders – hatten eine Version für 35.
- Passen die Anforderungen? Nextcloud 35 verlangt mindestens MariaDB 10.11 und unterstützt PHP 8.2 nicht mehr. Die Datenbank lief schon auf 10.11, PHP bringt das Container-Image selbst mit.
- Gibt es einen Rückweg? Zusätzlich zur nächtlichen Sicherung habe ich direkt vor dem Upgrade einen frischen Datenbank-Dump angelegt.
Den neuen Pin habe ich zuerst nur für eine Instanz gesetzt, in den Host-Variablen. Die zweite Instanz und der globale Standardwert blieben auf 34.0.5, bis die erste sauber lief. Erst danach wanderte 35.0.2 in versions.yml, und die beiden Host-Pins flogen wieder raus.
Was man mitnimmt: Ein Major-Upgrade beginnt nicht mit dem Upgrade, sondern mit drei Fragen: Gibt es alles, passt alles, komme ich zurück?
⚙️ Was beim Upgrade eigentlich passiert
Gestartet wird das Upgrade nicht von Ansible, sondern vom Nextcloud-Image selbst. Beim Start vergleicht das Startskript die installierte Version mit der im Image. Ist die im Image neuer, kopiert es den neuen Code und ruft occ upgrade auf – das passt die Datenbank an. Nextcloud steht dabei automatisch im Wartungsmodus.
Damit das sauber geht, sorgt das Playbook für zwei Dinge:
- Recreate statt Rolling Update: Bei einem neuen Image wird der alte Pod erst ganz beendet, dann startet der neue. Sonst würden alter und neuer Code gleichzeitig aus demselben Verzeichnis arbeiten.
- Eine geduldige Startprobe: PHP-FPM startet erst nach dem Upgrade. Die startupProbe gibt dafür bis zu zehn Minuten, damit Kubernetes einen langen Datenbankumbau nicht für einen Absturz hält.
Gedauert hat es dann 40 bzw. 47 Sekunden. Im Log standen allerdings Zeilen, die erst einmal erschrecken: „Disabled incompatible app: richdocuments“ – und so weiter für jede Store-App. Das ist normal: Nextcloud deaktiviert beim Major-Upgrade alle Apps, deren installierte Version nicht zur neuen Hauptversion passt. Der anschließende Lauf von nextcloud-update.yml holt die passenden Versionen, aktiviert sie wieder und startet den Pod neu, damit der OPcache keinen alten Bytecode mehr ausführt.
Danach waren alle Apps wieder da, die meisten in neuer Version (Collabora 11 → 12, Groupfolders 22 → 23). Neu dazugekommen ist die mitgelieferte App sharing für die einheitlichen Freigaben in Nextcloud 35.
Was man mitnimmt: Bei einem Major-Upgrade gehören zwei Schritte zusammen – erst der Kern, dann die Apps. Wer nach dem ersten aufhört, hat eine Nextcloud ohne Office.
🔍 Woran ich „fertig“ festmache
„Die Seite lädt“ reicht mir nicht als Abnahme. Nach jeder Instanz habe ich geprüft:
- das Nextcloud-Log: keine Fehler seit dem Upgrade, nur drei bekannte Warnungen zu absichtlich deaktivierten Apps
- die Webserver-Logs: keine 5xx-Antworten
- den Selbsttest von notify_push
- auf dem Host: keine SELinux-Meldungen, MariaDB und Redis in ihren eigenen Domänen, keine fehlgeschlagenen Units, alle Prometheus-Ziele erreichbar
- und zum Schluss den Drift-Check: ein Playbook-Lauf im Check-Modus, der nichts mehr ändern darf
Beide Instanzen meldeten changed=0. Erst dann gilt ein Upgrade bei mir als abgeschlossen: Der Server ist genau das, was der Code beschreibt.
Was man mitnimmt: „Läuft“ ist ein Gefühl, changed=0 ist ein Befund.
🧪 Der Drift-Check im Detail
Der Drift-Check ist ein zweites, ganz anderes Skript: scripts/check-drift.sh im öffentlichen Repository. Er läuft nicht nach Zeitplan, sondern von Hand auf dem Rechner, von dem aus ich Ansible starte – immer direkt nach einem echten Playbook-Lauf und nach jedem Upgrade.
Die Idee: Ansible beschreibt, wie ein Server aussehen soll. Lässt man dasselbe Playbook im Check-Modus noch einmal laufen, darf es nichts mehr zu tun geben. Das Skript macht genau das:
- Es ruft
ansible-playbook … --check --difffür einen Host auf. Im Check-Modus ändert Ansible nichts, es meldet nur, was es tun würde. - Die Ausgabe holt es sich als JSON und filtert daraus die Aufgaben, die „changed“ melden würden – mit ihrem Diff. Bei Kubernetes-Objekten blendet es die Verwaltungsfelder des Servers aus, damit nur echte Unterschiede übrig bleiben.
- Der Exit-Code sagt das Ergebnis:
0= kein Drift,1= Drift,2= der Prüflauf selbst ist fehlgeschlagen.
So sah das heute nach dem Upgrade aus:
$ scripts/check-drift.sh nextcloud-k3s.yml <host>
<host>: changed=0 failed=0
NO DRIFTUnd so sieht ein Treffer aus – hier ein früherer Fall, in dem eine Aufgabe bei jedem Lauf „changed“ meldete, obwohl sich nichts geändert hatte:
### would change: rkhunter | Build / refresh file-properties database
<host>: changed=1 failed=0
DRIFT: 1 task(s) would changeEin Treffer direkt nach einem echten Lauf bedeutet immer eines von zwei Dingen: Jemand hat am Server etwas von Hand geändert, oder eine Aufgabe im Code ist nicht idempotent. Beides will man wissen, bevor der nächste Lauf es unbemerkt „korrigiert“.
Was man mitnimmt: Der Drift-Check ist die Bauabnahme nach dem Plan. Er beweist nicht, dass alles funktioniert – aber dass der Server genau dem Code entspricht.
⏳ Zwei Wochen Wartezeit für Trivy
Trivy, mein nächtlicher Image-Scanner, hatte eine neue Version. Eingespielt habe ich sie trotzdem nicht. Im März 2026 wurde die Release-Pipeline von Trivy kompromittiert und hat einen manipulierten Build ausgeliefert. Seitdem gilt bei mir eine Regel: Trivy nur in Versionen, die mindestens zwei Wochen alt sind, und immer mit einer festen SHA-256-Prüfsumme aus der Checksum-Datei des Releases.
Version 0.76.1 erschien am 21. Oktober – erlaubt ist sie also erst ab dem 4. November. Die Prüfsumme habe ich vorab kontrolliert: Die heruntergeladene Datei passt zur offiziellen Checksum-Datei. Der Commit wartet einen Tag.
Was man mitnimmt: Bei Sicherheitswerkzeugen ist „neu“ nicht automatisch „sicherer“. Eine Wartefrist kostet wenig und hätte im März geholfen.
🧩 Der falsche Alarm: neuer Index, gleiches Image
Der Bericht meldete wordpress:7.1.4-php8.5-fpm als „upstream neu gebaut“. Das Update-Playbook hat das Image daraufhin gezogen – und keinen Pod neu gestartet. Im Log von containerd stand der Grund: Gelesen wurden nur rund 21 KB, die Image-ID blieb dieselbe.
Die Erklärung: Mein Skript hatte den Index-Digest verglichen. Ein Tag auf Docker Hub zeigt auf einen Index, der wiederum die Images für alle Plattformen auflistet. Ändert sich ein Image für eine andere Architektur oder nur eine Signatur-Beilage, bekommt der Index einen neuen Digest – das amd64-Image, das bei mir läuft, bleibt aber Byte für Byte gleich.
Die Korrektur war klein: Das Skript löst jetzt den Index auf, nimmt den Eintrag für linux/amd64 und vergleicht dessen Konfigurations-Digest mit der Image-ID auf dem Host. Das ist genau das, was der Container tatsächlich ausführt. Seitdem: „none – every pinned tag is current“.
Was man mitnimmt: Ein neuer Digest heißt nicht, dass sich das Image geändert hat. Vergleichen sollte man das, was wirklich läuft.
🐧 Warum Ansible bei mir keinen Kernel installiert
Blieben die Kernel-Pakete. Mein Paket-Playbook spielt alle Updates ein – außer Kernel. Das ist Absicht. Der SSH-Dienst ist bei mir mit systemd gehärtet, unter anderem mit ProtectKernelModules=yes. Jede SSH-Sitzung, auch die von Ansible, läuft dadurch in einem eigenen Mount-Namespace, in dem /usr/lib/modules leer und schreibgeschützt erscheint. Eine Kernel-Installation über SSH scheitert dort zwangsläufig.
Kernel kommen deshalb auf einem anderen Weg:
- dnf-automatic spielt nachts nur Sicherheitsupdates ein – als systemd-Dienst, außerhalb jeder SSH-Sitzung. Der neue Kernel war ein solches Sicherheitsupdate.
- Um 03:30 startet der Host neu, wenn
needs-restarting -rdas verlangt. - Um 04:00 zieht AIDE seine Prüfbasis nach, damit der neue Kernel am nächsten Tag kein Alarm ist.
Diesmal wollte ich nicht bis zur Nacht warten. Statt das Paket per SSH zu installieren, habe ich auf jedem Host den Dienst dnf-automatic-install.service von Hand gestartet – derselbe Weg wie nachts, nur früher. Danach Neustart, einen Host nach dem anderen. Jeder war nach 40 bis 50 Sekunden zurück, mit neuem Kernel, ohne SELinux-Meldung und mit allen Pods im Status Running.
Eine Folge hatte der frühe Zeitpunkt: Der tägliche AIDE-Check läuft um Mitternacht, vor dem Abgleich um 04:00. Ohne Gegenmaßnahme hätte er den neuen Kernel als Veränderung gemeldet. Deshalb gehört danach auf jeden Host der Refresh von AIDE und rkhunter.
Was man mitnimmt: Härtung hat Nebenwirkungen auf die eigene Automatisierung. Statt die Härtung aufzuweichen, gibt man dem Kernel-Update einen eigenen, passenden Weg.
🎯 Die Lehren
- Berichte informieren, Menschen entscheiden – automatisch prüfen, bewusst einspielen.
- Vor einem Major-Upgrade drei Fragen klären – gibt es alles, passt alles, komme ich zurück?
- Kern und Apps sind ein Upgrade – erst wenn die Apps wieder laufen, ist Nextcloud fertig.
- Das vergleichen, was wirklich läuft – nicht den Index, sondern das Image der eigenen Plattform.
- Fertig heißt
changed=0– der Drift-Check ist der Schlusspunkt jedes Update-Tags.





