
🌐 Auch auf: English · Français · Español
Heute habe ich eine produktive Nextcloud von einer gemütlichen nativen Bare-Metal-Installation auf einen glänzenden K3s/Container-Stack umgezogen. Auf dem Papier: Backup ziehen, zurückspielen, DNS umstellen, fertig. In der Praxis: eine Reihe kleiner Landminen, jede unsichtbar, bis man drauftritt. Hier ist das Kriegstagebuch. 🪖
Schritt 1: die Daten schaufeln 🚚
Die Schwerarbeit war ein rsync von ~360 GB Nutzerdaten plus einem ~330 MB großen SQL-Dump von der Backup-Kiste auf den neuen Node. Weil ich schon tagsüber vorsynchronisiert hatte, musste der letzte Lauf nur noch das Delta übertragen – am Ende war das Ganze eine schnelle inkrementelle Kopie. Eine Lektion, so alt wie die Zeit: erst vorbefüllen, dann ist der Sync beim Umschalten winzig.
Schritt 2: der Major-Version-Spießrutenlauf 🎮
Das Backup war Nextcloud 32.x. Der Ziel-Stack läuft mit 34.x. Nextcloud hat eine eiserne Regel: Man darf keine Major-Version überspringen. Also geht es zu Fuß, Schritt für Schritt:
32.0.14 → 33.0.9 → 34.0.4 (occ upgrade at every step)Der Container-Haken: Der Code liegt in einem persistenten Volume, und Nextcloud weigert sich, älteren Code gegen eine neuere Datenversion laufen zu lassen („downgrade not supported“). Eine v32-Datenbank auf einen Pod zurückspielen, der noch v34-Code hatte = sofortige Verweigerung. Die Lösung ist herrlich hacky: das Image auf die passende Version pinnen, die version.php im Code-Volume so anstupsen, dass der Entrypoint den richtigen Code neu ausrollt, und jedes occ upgrade durchlaufen lassen (die Readiness-Probe blockiert, bis die DB-Migration fertig ist – Geduld gefragt).
Die unsichtbaren Landminen 💣
1. Der Antivirus-Socket des Grauens
Nach dem Restore warf die Dateien-App direkt nach dem Login einen nackten „Internal Server Error“ – und das Log blieb leer, weil der Absturz unterhalb von Nextclouds eigenem Logger passierte. Ursache: Die zurückgespielte Konfiguration hatte files_antivirus im socket-Modus wieder aktiviert, mit Verweis auf einen ClamAV-Daemon-Socket, den es auf der alten nativen Kiste gibt, im Container aber nicht. Lösung: die App deaktivieren (dieser Stack scannt stattdessen nachts im Batch). Diagnostiziert nur durch Hinschauen, nicht durch irgendeine Logzeile. Hinterhältig.
2. Let’s-Encrypt-Knast 🔒
Das DNS zeigte noch nicht auf die neue Kiste, also war cert-manager tagelang daran gescheitert, Zertifikate auszustellen. Als ich das DNS endlich umstellte, kamen die Zertifikate immer noch nicht. Warum? Nach genug Fehlschlägen parkt cert-manager das Certificate in einem exponentiellen Back-off, der im Status des Objekts gespeichert ist – nächster Versuch in ~24 Stunden. Controller neu starten? Ignoriert. Secret löschen? Ignoriert. Was tatsächlich funktioniert: das Certificate-Objekt löschen und den ingress-shim es frisch neu anlegen lassen, ohne Fehlerhistorie → sofortige Ausstellung.
Bonusfalle: Die HTTP-01-Prüfung von Let’s Encrypt bevorzugt IPv6. Wer nur den A-Record umzieht und den AAAA vergisst, dessen Validierung landet weiter beim alten Host und scheitert, während alles umgestellt „aussieht“. Beide umziehen. Immer beide.
Das Hauptereignis: die Checkbox, die gelogen hat ☑️
Die hier hat am meisten Kopfkratzen gekostet. Collabora (Nextcloud Office) weigerte sich, Dokumente zu öffnen: „Nextcloud Office konnte nicht geladen werden.“ Derweil war der Office-Verbindungstest im Admin-Bereich beruhigend grün: „Collabora Online server is reachable.“ Serverseitig war alles in Ordnung – Discovery 200, Capabilities 200, gültige Zertifikate, korrekte WOPI-URL, WebSocket-Header vorhanden, identisch mit einem bekannt guten Referenz-Host.
Der Unterschied entpuppte sich als eine einzige Einstellung, die vom alten Server mitgekommen war: „Zertifikatsprüfung deaktivieren (unsicher)“ war angehakt (disable_certificate_verification=yes in richdocuments). Auf der alten Kiste ergab das Sinn – selbstsignierte Zertifikate. Aber jetzt, mit gültigen Let’s-Encrypt-Zertifikaten, zerlegt dieser „unsichere“ Modus die WOPI-Dokumentsitzung in richdocuments 11.x – während der Admin-Verbindungstest seelenruhig grün bleibt, weil dieser Test einen anderen Codepfad nimmt als der eigentliche Dokument-Handshake.
Warum ich glaube, dass das der Übeltäter war? Weil genau das die Lösung war: Haken raus (Key löschen), neu versuchen, und die Dokumente gingen sofort auf. Meine Theorie: Der unsichere Pfad ohne Prüfung und der WOPI-Token-Austausch werden sich nicht mehr einig, sobald echtes TLS im Spiel ist, und der Fehler zeigt sich erst in der Editor-Sitzung – der Klassiker „der Health-Check ist grün, aber das Feature ist tot“, der Sysadmins demütig hält. Zwei weitere Prod-Überbleibsel (public_wopi_url, doc_format) flogen gleich mit raus, passend zur minimalen Konfiguration des Referenz-Hosts.
Die falsche Fährte: „Warum sind so viele Apps deaktiviert?“ 🎣
Nach dem Upgrade stand ein ganzer Haufen Apps auf deaktiviert (Dashboard, Kommentare, externer Speicher, …), und ich stellte mich auf einen Abgleich-Marathon ein. Dann habe ich den aktuellen App-Bestand gegen den Zustand des alten Servers gedifft (aus dem DB-Dump vor dem Upgrade extrahiert). Plot-Twist: Auf der alten Kiste waren sie ebenfalls deaktiviert. Die neue Instanz entsprach also längst der Produktion. Das einzige echte Opfer war metadata, für das es schlicht keine mit Nextcloud 34 kompatible Version gibt. Manchmal ist der gruselige Diff einfach … die Wahrheit.
Nativ vs. Container: der ehrliche Teil ⚖️
Die neue Instanz fühlt sich einen Tick langsamer an als die alte native. RAM und CPU langweilen sich (reichlich Luft), und die serverseitigen Antwortzeiten liegen bei 50–210 ms – kerngesund. Der Unterschied ist architekturbedingt: Container bringen zusätzliche Hops mit (Ingress-Proxy, Overlay-Netzwerk, PHP-FPM↔nginx über das Pod-Netzwerk, DB über die Grenze Pod/Host). Pro Request sind das Millisekunden; über die vielen Requests, die eine Nextcloud-Seite abfeuert, summiert es sich zu „etwas weniger flott“. Das ist die Steuer für Reproduzierbarkeit, Isolation und schmerzfreie Upgrades – und für eine kleine Organisation ein Tausch, der sich lohnt.
Der große Datenbank-Frühjahrsputz 🗃️
Wer eine Datenbank von einem alten Server zurückspielt, schleppt dessen gesamte Geschichte mit. Der Filecache-Index trug die Dateipfade des alten Servers unter abgelösten Storage-IDs mit sich herum – dazu ein längst entferntes OnlyOffice-Paket, dessen 276k Indexzeilen die App überlebt hatten. Ergebnis: eine oc_filecache-Tabelle mit 1.053.373 Zeilen, davon ~93 % Ballast, und eine 1 GB große Datenbank.
Die Aufräumaktion, sorgfältig mit Backup und Kontrollwerten (echte Nutzerdateien und Nutzerzahl dürfen sich nicht ändern): die 7 verwaisten OnlyOffice-Tabellen droppen, die beiden veralteten Storages (der alte native Pfad und ein früheres Container-Mapping) löschen, nachdem geprüft war, dass sie null echte Dateien enthielten – nur regenerierbare App-Daten –, dann OPTIMIZE, um die freigewordenen Seiten ans Dateisystem zurückzugeben. Ergebnis:
oc_filecache rows: 1,053,373 → 72,580
database size: 1 GB → 282 MB (-72%)
real user files: 56,183 → 56,183 (unchanged ✔)
users: 151 → 151 (unchanged ✔)Und auf der Platte: 27 GB verwaiste Vorschaubilder, deren Index gerade gelöscht worden war. Previews sind ein reiner, regenerierbarer Cache – den verwaisten Baum zu löschen holt den Platz zurück, und Nextcloud baut die Thumbnails bei Bedarf neu. (Die Lehre aus einer früheren Generalprobe: verwaiste Previews niemals neu indexieren – das schaufelt nur ~500k tote Zeilen zurück in genau die Tabelle, die man gerade geputzt hat. Stattdessen löschen.)
Bonus-Gremlin: die leeren Netzwerkgraphen 📉
node_exporter meldete fröhlich CPU, RAM und Platte an Grafana – aber jedes Network Traffic-Panel blieb stur leer. Die Metrik node_network_receive_bytes_total war schlicht nicht da. Der Hinweis steckte in node_scrape_collector_success{collector="netdev"} 0 und dieser Logzeile:
netdev: "couldn't get netstats: socket: address family not supported by protocol"Moderne node_exporter-Versionen lesen die NIC-Statistiken über einen Netlink-Socket – und die systemd-Härtung des Dienstes hatte RestrictAddressFamilies=AF_INET AF_INET6, was AF_NETLINK still und leise verbietet. Der Collector scheitert lautlos, die Metrik taucht nie auf, der Graph bleibt leer. AF_NETLINK in die Allowlist, reload, restart – und die Traffic-Kurven erwachen zum Leben. (Bonus-Déjà-vu: Genau dasselbe fehlende AF_NETLINK hatte früher in diesem Projekt schon die Härtung von MariaDB erwischt. Die Sandbox gibt Sicherheit und nimmt Netlink.)
Der Maschine beim Denken zusehen 📊

Zwei verwandte Aufräumschritte rundeten das Ganze ab. Zuerst habe ich die 27 GB verwaisten Vorschaubilder gelöscht (ihr Index war mit dem DB-Putz oben schon weg) – Previews sind ein reiner Cache, also kein Schaden. Statt dann jeden frühen Tester die „Thumbnail wird erzeugt, bitte warten“-Steuer zahlen zu lassen, habe ich mit occ preview:generate-all eine komplette Neugenerierung angestoßen. Der Job ackert sich die ganze Nacht durch ~35.000 Bilder, PDFs und Videos – das Dashboard oben zeigt ihn auf frischer Tat, und der Scanner unten ist derselbe Lauf, der Previews Datei für Datei neu baut.

Was mich zu einem kleinen Geständnis bringt: So ein Dashboard hatte ich vorher nie. CPU, Load, Arbeitsspeicher und – jetzt, wo der Netlink-Gremlin erledigt ist – den Netzwerkdurchsatz in Echtzeit zu sehen, ist seltsam befriedigend. Ich bin ehrlich gespannt, wie das im Alltag aussieht: Bekomme ich schöne, saubere Spitzen, wenn jemand ein komplettes Fotoshooting hochlädt oder ein Team eine ganze Galerie herunterzieht? Ich weiß es noch nicht – und das ist der halbe Spaß. Frag mich in einer Woche noch mal, wenn ich lange genug auf diese Graphen gestarrt habe, um Meinungen zu Perzentilen zu haben, von denen ich nicht wusste, dass ich sie habe. 😄
Nachtrag: Probes, die einen hängenden Pod wirklich neu starten 🩺
Einen Tag später habe ich etwas repariert, das mich die ganze Zeit gewurmt hatte. Die Pods hatten Readiness-Probes – Kubernetes wusste also, wann es keinen Traffic schicken soll –, aber ihr Liveness-Check war das zahnlose kill -0 1, das erst merkt, wenn PID 1 schon gestorben ist (und dann ist der Container ohnehin weg). Ein Prozess, der noch läuft, aber hängt – FPM steckt fest, nimmt auf seinem Socket nichts mehr an –, würde einfach kaputt herumstehen, bis es ein Mensch merkt. Bei einem Pod mit nur einer Replika ist das genau der Fall, den man automatisch behandelt haben will.
Die Lösung ist ein ordentliches Drei-Probe-Setup pro Container: eine startupProbe, die Liveness und Readiness zurückhält, bis die App wirklich lauscht (bis zu ~10 Minuten Schonfrist, damit ein langsames occ upgrade oder ein Preview-Schub beim Booten nie eine Restart-Schleife auslöst), eine readinessProbe, die den Traffic steuert, und eine knackige livenessProbe – ein echter tcpSocket-Connect, kein PID-Check –, die den Container erst neu startet, wenn er wirklich nicht mehr antwortet. Das Startup-Gate ist das tragende Teil: Ohne es würde eine Liveness-Probe, die streng genug ist, um ein Hängen zu erwischen, auch einen Container hinrichten, der nur langsam bootet. Der Blog läuft auf demselben Stack, also bekam er der Einheitlichkeit halber die identische Behandlung.
Nachtrag: Tuning auf das Blech 🎛️
Als sich der Staub gelegt hatte, begann der spaßige Teil: die neuen Grafana-Dashboards beobachten und nach dem tunen, was sie tatsächlich zeigten, statt nach dem, was ich geraten hatte. Die Kiste hatte weit mehr Reserven als angenommen – 8 Kerne, 23 GB RAM, ~16 GB frei im Leerlauf –, also hörte ich auf zu knausern. Collabora ging von 4 auf 6 vorgeforkte Kindprozesse, seine Limits von 3 auf 6 GB und von 2 auf 4 Kerne, was den gelegentlichen Kaltstart-Schluckauf glättet, wenn jemand nach einer Ruhepause das erste Dokument öffnet. Nebenbei landete noch etwas Feinschliff: schreibgeschützte Root-Dateisysteme für die Sidecars, ein vernünftigeres Log-Level (fatal-only versteckt genau die Warnungen, die man sehen will), und node_exporter hat gelernt, nicht mehr drei Dutzend virtuelle Container-Interfaces zu melden, damit der Netzwerkgraph die eine NIC zeigt, auf die es ankommt. Alles liegt im selben Ansible-Repo – reproduzierbar, versioniert und langweilig im allerbesten Sinne.
Den schwarzen Hut aufsetzen 🕵️
Bevor ich es für fertig erklärte, tat ich, was jeder paranoide Admin tun sollte: Ich habe meine eigene Instanz von außen angegriffen. Keine Portscans – abgesehen von den Web-Ports und einem SSH-Port nur mit Schlüssel verwirft die Firewall alles stillschweigend, da gibt es wenig zu finden –, nur die Requests, die ein echter Gelegenheitsangreifer auf eine frische Nextcloud abfeuert, auf der Suche nach einer weichen Stelle. Kurzfassung: Es gab keine.
Jeder sensible Pfad kam mit einem trockenen 403 zurück – config/, data/, .htaccess, .git/, 3rdparty/, das volle Programm. WebDAV und remote.php verlangen eine Anmeldung (401), bevor sie irgendetwas sagen. Der Server-Header ist ein lakonisches nginx ohne Version, kein X-Powered-By, kein PHP-Fingerabdruck, den man gegen eine CVE-Liste halten könnte. TLS ist 1.3 mit einem gültigen Let’s-Encrypt-Zertifikat; 1.0 und 1.1 werden rundweg abgelehnt. Der komplette Satz an Security-Headern ist da – HSTS mit preload, eine Nonce-basierte CSP, nosniff, Frame- und Referrer-Policies, eine dicht gemachte Permissions-Policy und __Host--Cookies. Nextclouds eigene Code-Integritätsprüfung meldet null veränderte Dateien.
Der Teil, der mir am wichtigsten ist: Die Brute-Force-Drosselung ist aktiv, und – weil die Reverse-Proxy-Kette die echte Client-IP korrekt weiterreicht – drosselt sie den Angreifer, nicht meinen eigenen Ingress. Genau dieser Fehler legt bei vielen Setups hinter Proxys das Rate-Limiting still und leise lahm, also lohnt es sich, das zu prüfen, statt es anzunehmen. Nichts davon ist exotisch; es sind vor allem die langweiligen, korrekten Defaults plus ein paar bewusste Handgriffe. Und genau darum geht es – das Entmutigendste, was ein Angreifer finden kann, ist eine Wand ohne Fugen. 🧱
Tag drei: lose Enden verknüpfen 🧹
Der Preview-Marathon von oben war im Morgengrauen von Tag drei endlich fertig, nach weit über einem Tag, in dem er sich durch die ganze Bibliothek gekaut hatte. Da nun nichts mehr in den Dateiindex schrieb, konnte das aufgeschobene Aufräumen laufen – und ein ordentlicher End-to-End-Check danach förderte zwei Gremlins zutage, die sich direkt vor unserer Nase versteckt hatten.
Die „leeren“ Tabellen, die es nicht waren
Laut meinen eigenen Notizen waren die übrig gebliebenen Tabellen längst verschwundener Apps (Talk, Polls, Collectives, Maps) leer und bereit zum Droppen. Bevor ich irgendetwas droppte, habe ich trotzdem die Zeilen gezählt. Sie waren nicht leer: ein paar Dutzend Wiki-Seiten, einige Umfragen mit Stimmen, mehrere Dutzend Chaträume. Gerade sieht sie niemand, weil die Apps nicht installiert sind – aber installiert man eine App neu, greift sie sofort wieder auf ihre alten Tabellen zu. Also bleiben sie. Was gehen durfte: die letzten Reste von OnlyOffice auf der Platte (erst archiviert, dann gelöscht), plus ein komplettes OPTIMIZE, das die Fragmentierung in 17 Sekunden von 59 MB auf 2 MB drückte. Lektion: Vertrau den Zeilenzahlen, nicht deiner eigenen Zusammenfassung von gestern.
Push statt Poll: notify_push 📡
Die Sync-Clients von Nextcloud fragen den Server normalerweise alle ~30 Sekunden: „Gibt’s was Neues?“ Die App notify_push (Client Push) dreht das um – ein kleiner Rust-Daemon hält einen WebSocket offen, und der Server kündigt Änderungen in dem Moment an, in dem sie passieren. (Mit Benachrichtigungen auf dem Handy hat das nichts zu tun; die laufen über Nextclouds Push-Proxy und haben die ganze Zeit funktioniert.) Auf einer nativen Kiste sind das drei Teile: die App, ein systemd-Dienst und eine location /push/ im Webserver. Auf K3s sind es dieselben drei Teile in anderen Kleidern: die App, ein eigenes Deployment und eine Ingress-Route.
Die Ansible-Seite hatte ich am Vortag gebaut, ausgeschaltet. Beim Review vor dem Umlegen des Schalters fanden sich vier Bugs, die das schlimmstmögliche Ergebnis produziert hätten – einen grünen Playbook-Lauf und ein totes Feature, weil jeder Schritt so eingestellt war, dass er Fehler toleriert. occ lief als root (Nextcloud weigert sich). Das Binary wurde per Glob ausgewählt – und alphabetisch kommt aarch64 vor x86_64. Der Container lief als root mit allen Capabilities gedroppt, was sicher klingt, bis man merkt, dass root ohne CAP_DAC_OVERRIDE keine 0640-Datei lesen kann, die www-data gehört – etwa die config.php. Und der Ingress reichte /push/… unverändert durch, während der Daemon /ws an seiner Wurzel erwartet.
Selbst mit allen vier Fixes kam der erste Selbsttest mit einem 404 zurück – gerendert von Nextcloud. Die Route existierte; nginx hat sie nur nie ausgewählt. Die Nextcloud-Routen sind eine Catch-all-Regex, location ~ "^/", und in nginx schlägt eine Regex ein einfaches Präfix. Also musste auch die Push-Route eine Regex werden – und dann verlor sie immer noch, weil der Ingress-Controller Regex-Locations in alphabetischer Reihenfolge des Ingress-Namens ausgibt und nginx den ersten Treffer nimmt. notify-push-routes sortiert hinter nextcloud-routes; umbenannt in nextcloud-push-routes („p“ vor „r“) gewinnt sie. Nicht mein stolzester Fix, aber ein dokumentierter. Alle sechs Selbsttests wurden grün, und die erste Browsersitzung wechselte auf einen WebSocket (HTTP 101) und empfing ihre ersten Datei-Events. Ehrliches Fazit: Für eine Handvoll Desktop-Clients ist das nett zu haben – Änderungen kommen in Sekunden statt in bis zu einer halben Minute an –, aber kein Gamechanger.
Gremlin Nr. 1: die Härtung, die die Datenbank abgeschossen hat
Während ein Playbook-Lauf unterwegs war, antwortete Nextcloud kurz mit 500. MariaDB war gestoppt worden – sauber, kein Absturz. Das Journal erzählte die Geschichte: Zwei Sekunden vorher hatte die OS-Härtungsrolle das Paket rsync als „auf diesem Host ungenutzt“ entfernt. Auf dem Blog-Host stimmte das, dort war es geprüft worden. Auf dem Nextcloud-Host aber braucht das Paket MariaDB-server rsync – sein Galera-Cluster-Werkzeug bringt ein rsync-basiertes State-Transfer-Skript mit, sogar auf einem einzelnen Node, der nie einem Cluster beitreten wird. Also entfernte dnf pflichtbewusst MariaDB gleich mit, was den Dienst stoppt; eine spätere Rolle installierte beides wieder, und eine weitere startete die Datenbank erneut. Unterm Strich: zwei bis drei Minuten Ausfall bei jedem einzelnen Playbook-Lauf, unsichtbar, außer man schaute zufällig im richtigen Moment hin. Die Lösung ist ein einziger Guard: rsync nur entfernen, wenn rpm -e --test rsync sagt, dass nichts davon abhängt. „Auf Host A geprüft“ heißt nicht „wahr auf Host B“.
Gremlin Nr. 2: die Alarme, die nie auslösen konnten
Die Grafana-Alarmregeln – Platte, Speicher, CPU, Node down – sahen in der Oberfläche völlig in Ordnung aus. Im Log scheiterte jede einzelne bei jeder Auswertung mit condition must not be empty: Der Provisioning-Code hatte Grafana nie gesagt, welche Abfrage über den Alarm entscheidet. Und weil die Regeln Auswertungsfehler als „OK“ behandelten, wurde nie etwas rot. Ein fehlendes Feld, "condition": "B", und jetzt werten sie korrekt aus. Ein Alarm, der nicht auslösen kann, sieht genauso aus wie ein gesundes System – die gefährlichste Sorte Grün, die es gibt. (Einfache Mail-Alarme über Monit gab es die ganze Zeit, die Kiste flog also nicht komplett blind.)
Gremlin Nr. 3: die Portscan-Sperre, die keine war
Eine frühere Version genau dieses Beitrags behauptete, ein Portscan „bringt dir sofort eine fail2ban-Auszeit ein“. Tat er nicht. fail2ban reagiert nur auf Logzeilen, und die Firewall loggt verworfene Pakete mit absichtlich winziger Rate, also verschwand ein Scan einfach in der Default-Drop-Policy. Harmlos, aber gesperrt wurde eben auch niemand. Die Firewall hatte sogar ein Paar Sets mit dem Etikett „port-scanner blocklist“, und nichts, was mit Scans zu tun hatte, füllte sie je. Die Lösung lebt jetzt im Kernel: Die letzte Regel vor dem finalen Drop zählt Verbindungsversuche pro Quelle, und sie sieht ausschließlich Pakete an geschlossene Ports, normaler Traffic zählt also nie mit. Eine Quelle, die weiter anklopft, wird eine Weile auf allen Ports verworfen. Keine Logs, kein Daemon, kein Parsen.
Beim Ausrollen tauchte noch eine Falle auf. Der Standard-nftables-Dienst lädt mit flush ruleset neu, und auf dem Nextcloud-Host halten kube-proxy, flannel und Calico ihre Regeln ebenfalls in nftables. Jede Firewall-Änderung hatte also auch das Kubernetes-eigene Networking weggewischt, für bis zu anderthalb Minuten, bis es sich neu synchronisiert hatte. Jetzt ersetzt das Regelwerk nur noch seine eigene Tabelle, in einem atomaren Schritt.
Tag vier bis sechs: Logs lesen wie ein Detektiv 🔎
Als sich der Staub gelegt hatte, wechselte der Job von „zum Laufen bringen“ zu „im Auge behalten“. Ein paar Tage lang war die Routine einfach: nextcloud.log, die Container-Logs und das Ingress-Log lesen und bei jeder Zeile, die seltsam aussah, so lange warum fragen, bis es eine Antwort gab. Eine Voraussetzung machte das überhaupt erst möglich: Das Log-Level war von 4 (nur fatal) auf 2 (Warnungen) angehoben worden. Auf Level 4 wäre nichts von dem Folgenden aufgetaucht. Ein stilles Log ist nicht immer ein gesunder Server. Manchmal ist es nur ein stummgeschalteter.
Gremlin Nr. 4: der Jail, der eine tote Datei bewachte
fail2ban lief, die nginx-Jails waren „aktiv“, und die Bannliste war leer. Tagelang. Ruhige Woche oder kaputter Jail? Der Ingress-Controller schreibt sein Access-Log in eine Datei, deren Name auf einen Zähler endet (…/0.log, 1.log, …), und jeder Container-Neustart beginnt eine neue. fail2ban expandiert den logpath-Glob nur beim Start des Jails. Nach einem Reboot kommt fail2ban vor dem Ingress-Container hoch, beißt sich an der alten Datei fest und verfolgt dann treu ein Log, in das niemand mehr schreibt, auf beiden Hosts. Die Lösung ist ein kleiner systemd-Timer, der bemerkt, wenn sich die Menge der Ingress-Logdateien ändert (und einmal nach jedem Boot), und nur die nginx-Jails neu lädt. Ein Sicherheitswerkzeug, das „alles ruhig“ meldet, weil es auf die falsche Datei schaut, ist schlimmer als keins, denn du glaubst, du wärst abgesichert.
Gremlin Nr. 5: das Besitzer-Pingpong
Bei jedem Playbook-Lauf lieferte Nextclouds status.php kurz einen Fehler, und das Datenverzeichnis fiel durch seinen eigenen Schreibbarkeitstest, nur um sich später im selben Lauf selbst zu heilen. Schuld waren zwei Rollen, die sich uneinig waren. Die SELinux-Rolle legte die hostPath-Verzeichnisse als root:root an, und die Deploy-Rolle setzte sie auf 33:33 (www-data). Jeder Lauf ließ den Besitzer hin- und herkippen, und bis die nächste Rolle ihn zurücksetzte, stand Nextcloud vor einem Datenverzeichnis, in das es nicht schreiben durfte. Jetzt setzen beide Rollen denselben Besitzer und Modus, und das Pingpong ist vorbei. Zwei Rollen, die jede für sich etwas „Richtiges“ tun, können sich trotzdem zu einem Bug addieren.
Gremlin Nr. 6: Office-Vorschauen und die 1-MB-Mauer
Kleine Word- und Excel-Dateien bekamen hübsche Thumbnails in der Dateiliste, größere nicht. Kein Fehler in der Oberfläche, nur das generische Icon. Das Ingress-Log hatte die Antwort: 413 Request Entity Too Large bei jedem convert-to-Aufruf an Collabora. Nextcloud rendert Office-Vorschauen, indem es das Dokument zu Collabora hochlädt, und das Standardlimit von nginx für den Request-Body liegt bei 1 MB. Die Nextcloud-Route hatte ein großzügiges Limit, die Collabora-Route nie eins bekommen. Eine Annotation (client-max-body-size: 2g) hat es behoben, und die betroffenen Vorschauen lassen sich einfach neu erzeugen. Die wenigen verbliebenen convert-to-Fehler sind Dokumente, die Collabora wirklich nicht öffnen kann – ein Dateiproblem, kein Konfigurationsproblem.
Gremlin Nr. 7: das 108-Megapixel-Foto
Manche Fotos blieben ohne Thumbnail, und die App bekam für ihre Vorschauen ständig ein 404. Alle stammten von einem einzigen Handy, mit 108 Megapixeln und 12.032 Pixeln Breite. Ein Bild dieser Größe zu dekodieren braucht rund 430 MB RAM, und Nextclouds preview_max_memory steht standardmäßig auf 256 MB. Darüber versucht Nextcloud es gar nicht erst. Es überspringt die Vorschau stillschweigend. Auf 512 angehoben (PHPs eigenes Limit liegt weit darüber), und die Fotos bekamen endlich ihre Thumbnails.
Bei der Gelegenheit bekamen auch iPhone-HEIC-Fotos Vorschauen. Das ist eine Zeile in enabledPreviewProviders, mit einem Haken: Dieser Key ersetzt die eingebaute Standardliste, statt sie zu erweitern, also müssen die Defaults (PNG, JPEG, GIF, Text, OpenDocument …) wiederholt werden, sonst hat man HEIC-Vorschauen und keine für JPEG. Was jetzt noch ohne Vorschau ist, sind Daten, keine Konfiguration: ein paar Dutzend 400-Byte-Reste eines jahrealten fehlgeschlagenen Uploads, einige leere Dateien und eine Handvoll PNGs über 50 MB.
Gremlin Nr. 8: die Konfiguration, die nie ankam
Beim Überprüfen des vorigen Fixes nach einem Playbook-Lauf kam etwas Schlimmeres zum Vorschein. Die neuen Werte standen in der Kubernetes-ConfigMap, aber die custom.config.php im laufenden Pod war einen Tag alt. Die Datei ist per subPath eingehängt, und ein subPath-Mount wird einmal beim Containerstart kopiert und nie aktualisiert. Normale ConfigMap-Volumes aktualisiert Kubernetes live, subPath-Mounts nicht. Jede Konfigurationsänderung im Repo wurde also erst beim nächsten zufälligen Pod-Neustart wirksam. (Der Preview-Fix funktionierte in der Zwischenzeit nur, weil er zusätzlich per occ gesetzt worden war.)
Die Standardlösung ist eine checksum/config-Annotation am Pod-Template: ein SHA-256 der gerenderten Konfigurations-Templates. Ändert sich die Konfiguration, ändert sich der Hash, und Kubernetes rollt den Pod neu aus. Bleibt die Konfiguration gleich, passiert nichts. Der Blog bekam dieselbe Behandlung für seine nginx- und PHP-Konfiguration. Lektion: „Das Playbook lief grün“ und „die Einstellung ist aktiv“ sind zwei verschiedene Aussagen, also prüf die Datei im Container.
So sieht ein gesundes Log aus 🩺
Nach alldem ist die tägliche Durchsicht langweilig geworden, und genau das ist das Ziel. Ein typischer Tag hat jetzt eine Handvoll Zeilen in nextcloud.log: eine Vorschau, die sich nicht öffnen ließ, eine PHP-Notice. Der Rest ist Rauschen, das man auswendig kennen sollte, damit es einen nicht jeden Morgen auf die Jagd schickt:
- Collabora „Broken pipe“ / „Connection reset“, rund 60 pro Stunde, sogar um 3 Uhr nachts: Die Gegenseite hat die Verbindung geschlossen, bevor Collabora mit dem Schreiben fertig war. Harmlos.
- nginx „access forbidden“ auf
/data/.ncdataund/.env: Ersteres ist Nextclouds eigener Sicherheitscheck, der prüft, dass das Datenverzeichnis nicht aus dem Web erreichbar ist, Letzteres ist ein Bot. Beides absichtlich blockiert. - „config differs from the latest version of this image“ bei jedem Pod-Start: Die Rolle verwaltet diese Dateien selbst (Redis mit Passwort zum Beispiel), sie sollen also abweichen.
- notify_push „Self test failed“ direkt nach einem Reboot: Der TLS-Check scheitert einmal mit einem Zertifikatsfehler (höchstwahrscheinlich liefert der Ingress ein paar Sekunden lang noch sein Standardzertifikat aus, bevor das echte geladen ist). Eine Minute später ist der Selbsttest 6/6 grün.
Der Sinn dieser Liste sind nicht die Einträge selbst. Wenn du weißt, wie harmlos aussieht, sticht die eine Zeile, die es nicht ist, sofort heraus. Das ist der ganze Trick beim Log-Monitoring. Keine Magie, sondern die Baseline kennen.
Fazit ✅
Sie ist live. Zertifikate gültig, Office-Bearbeitung funktioniert, Daten intakt, und die Nutzer haben nichts gemerkt. Die Migration selbst waren die leichten 20 %; die anderen 80 % waren eine Schnitzeljagd nach Einstellungen und Verhaltensweisen, die nur unter genau den neuen Bedingungen aus der Reihe tanzen. Wie immer: Der Server war nie das Problem – die Annahmen waren es. 😄
Tag drei hat dazu eine Fußnote geliefert. Die fiesesten Bugs waren nicht die, die abgestürzt sind – es waren die, die grün geblieben sind: eine Checkbox, ein fehlertolerantes Playbook, eine Alarmregel, die nicht auslösen konnte. Wenn mir diese Migration eine Gewohnheit beigebracht hat, dann die: die Sache selbst prüfen, nicht das, was über sie berichtet. 🔍
Noch etwas: Das war nicht mein einziges Nextcloud-Rodeo. Ehrenamtlich betreibe ich noch eine zweite produktive Instanz – kleiner, mit weniger Daten und weniger registrierten Nutzern –, und alles, was ich hier gelernt habe, hat sich als richtig nützlich erwiesen. Die ist also als Nächstes dran: Ich ziehe sie in den nächsten zwei Wochen auf denselben K3s-Stack um und trete beim zweiten Mal hoffentlich auf deutlich weniger dieser Landminen. 🤞
Der komplette Ansible-Stack hinter alldem – die WordPress- und Nextcloud-Rollen, der Ingress, cert-manager, das Monitoring und jeder oben erwähnte Härtungstrick – liegt auf GitHub, ohne Secrets und Host-Details: github.com/aptupgrademe/www_k3s. Diese Migration ist der Meilenstein v3.0.0, du kannst also genau den Stand durchstöbern, den dieser Beitrag beschreibt, statt dessen, was HEAD heute zufällig ist.




