PostgreSQL Major-Version-Upgrade in Docker Swarm
Wer selbst gehostete Dienste betreibt, kommt irgendwann an den Punkt, an dem eine
Datenbank ein Major-Version-Upgrade braucht. Bei PostgreSQL ist das kein trivialer
Vorgang: Das Dateiformat des Datenverzeichnisses ist zwischen Hauptversionen
inkompatibel. PostgreSQL 18 verweigert schlicht den Start, wenn es ein PG14-Datenverzeichnis
vorfindet. Ein einfacher Image-Tausch in der docker-compose.yml reicht nicht.
Der einzige unterstützte Weg ist Dump und Restore: Daten exportieren, frisches Cluster mit der neuen Version initialisieren, Daten importieren. In der Theorie einfach, in der Praxis hat Docker Swarm ein paar Eigenheiten, die einen erwischen können.
Dieser Artikel beschreibt zunächst das generelle Vorgehen, danach den konkreten Erfahrungsbericht vom Upgrade der Nextcloud-Datenbank von PG14 auf PG18 – inklusive der Überraschungen, die dabei aufgetaucht sind.
Mein Setup
Der Stack läuft auf einem einzelnen Rootserver mit Docker Swarm. Jeder Dienst ist als
eigener Swarm-Stack organisiert, die Konfiguration liegt in einer docker-stack.yml.
Sensitive Werte wie Datenbankpasswörter werden als Docker Secrets injiziert –
das sind verschlüsselte Werte, die Swarm zur Laufzeit als Dateien unter
/run/secrets/<name> in den Container einhängt. Datenverzeichnisse werden als
Bind-Mounts von einem Host-Verzeichnis in den Container gemountet, also zum Beispiel
/srv/nextcloud/db → /var/lib/postgresql/data.
Generelles Vorgehen
Vor dem Start: Snapshot
Bevor irgendetwas angefasst wird, nehme ich einen Server-Snapshot. Bei einem fehlgeschlagenen Datenbankupgrade will man einen sauberen Ausgangspunkt haben, zu dem man zurückkehren kann.
Schritt 1: Applikation in den Wartungsmodus
Die Applikation sollte keine Schreibzugriffe mehr machen, solange die Datenbank nicht läuft. Bei Nextcloud gibt es dafür einen eingebauten Wartungsmodus:
|
|
Bei anderen Diensten (z.B. Forgejo) reicht es, den App-Service auf 0 Replikate zu skalieren, bevor die Datenbank angefasst wird.
Schritt 2: Datenbank dumpen
PostgreSQL bringt pg_dumpall mit – das exportiert alle Datenbanken, Rollen und
Konfiguration in eine einzige SQL-Datei. Wichtig: In Setups mit Docker Secrets
existiert der Standardbenutzer postgres oft nicht. Der tatsächliche Superuser
steht im Secret:
|
|
Kurz verifizieren, dass der Dump vollständig ist:
|
|
Schritt 3: Alle Services stoppen
Erst die Applikation, dann die Datenbank:
|
|
Schritt 4: Altes Datenverzeichnis sichern, leeres anlegen
Das alte Datenverzeichnis bleibt als Backup erhalten, bis alles läuft:
|
|
Schritt 5: Image aktualisieren
In der docker-stack.yml das Image auf die neue Version setzen:
|
|
Bei PG18 zusätzlich PGDATA explizit in die Umgebungsvariablen aufnehmen –
warum das nötig ist, erkläre ich weiter unten beim Nextcloud-Erfahrungsbericht.
|
|
Schritt 6: Neue Datenbankversion initialisieren
Den Service auf das neue Image aktualisieren und starten. Die Flags
--update-failure-action continue und --force sind wichtig – auch dazu
gleich mehr:
|
|
Warten, bis der Container als (healthy) gemeldet wird:
|
|
Schritt 7: Dump einspielen
|
|
Zwei Fehlermeldungen sind dabei normal und harmlos:
|
|
PostgreSQL legt diese Objekte beim Initialisieren des frischen Clusters bereits
aus den Umgebungsvariablen an. Jeder andere ERROR sollte vor dem nächsten
Schritt untersucht werden.
Schritt 8: Stack neu deployen und Wartungsmodus deaktivieren
|
|
Bei Nextcloud danach den Wartungsmodus wieder ausschalten und ein paar Datenbankprüfungen laufen lassen:
|
|
Schritt 9: Aufräumen
Erst wenn alles stabil läuft, das Backup-Verzeichnis und die Dump-Datei löschen:
|
|
Nextcloud: PG14 → PG18
So viel zur Theorie. In der Praxis lief das Upgrade der Nextcloud-Datenbank größtenteils nach Plan – mit vier Ausnahmen, die jeweils etwas Zeit gekostet haben.
Die Ausgangslage
Der Nextcloud-Stack lief auf postgres:14-alpine. Ziel war PG18, das Nextcloud 33
mittlerweile offiziell empfiehlt. Der Server hatte einen frischen Snapshot,
genug freien Speicher – also los.
Wartungsmodus aktiviert, Dump erstellt (365 MB, Integrität geprüft), alle Services
auf 0 skaliert. Datenverzeichnis nach /srv/nextcloud/db-pg14-backup verschoben,
leeres /srv/nextcloud/db angelegt, Image in der docker-stack.yml auf
postgres:18-alpine geändert. Dann fingen die Überraschungen an.
Problem 1: docker service scale ändert nicht das Image
Erster Instinkt nach dem Image-Update in der YAML: den DB-Service mit
docker service scale nextcloud_db=1 wieder hochfahren.
Das war falsch.
docker service scale passt nur die Replikat-Anzahl an – es verwendet die
bestehende Service-Spezifikation, also noch das alte Image. Das Ergebnis:
PG14 startete, sah ein leeres Verzeichnis, und initialisierte es als PG14-Cluster.
Als ich danach docker service update --image postgres:18-alpine ausführte,
fand PG18 ein frisch initialisiertes PG14-Verzeichnis vor – und weigerte sich zu starten.
Ich musste den Service erneut auf 0 skalieren und das Verzeichnis wieder leeren.
Dabei noch eine kleine Falle: rm -rf /srv/nextcloud/db/* schlug auf dem Bind-Mount
stillschweigend fehl – der Glob * wurde vom Swarm-Kontext nicht korrekt expandiert.
Zuverlässig funktioniert:
|
|
Die Lektion: In Docker Swarm zuerst docker service update --image (oder
docker stack deploy) ausführen, um das Image zu wechseln – und erst dann
die Replikate hochsetzen. Nie umgekehrt.
Problem 2: PG18 verweigert den Start wegen eines geänderten Standardpfads
Mit leerem Verzeichnis und dem korrekten docker service update --image-Befehl
rollte Swarm das neue Image aus – und rollte es sofort automatisch zurück.
Die Service-Logs erklärten warum:
|
|
Das PostgreSQL-Docker-Image hat in Version 18 seinen Standard-PGDATA-Pfad
geändert. Frühere Versionen speicherten Daten direkt unter
/var/lib/postgresql/data – PG18 erwartet den Mount-Point nun eine Ebene höher
bei /var/lib/postgresql und legt das Datenverzeichnis selbst als Unterordner an.
Da das Volume weiterhin auf /var/lib/postgresql/data gemountet ist (und das
aus gutem Grund so bleibt), muss man PG18 explizit mitteilen, dass es den alten
Pfad verwenden soll:
|
|
Mit dieser Umgebungsvariable in der Service-Definition startete PG18 problemlos und initialisierte das leere Verzeichnis korrekt.
Problem 3: Erschöpfte Neustartversuche blockieren weitere Starts
Nach den ersten zwei fehlgeschlagenen PG18-Starts hatte Docker Swarm die
konfigurierte max_attempts-Grenze der Restart Policy erreicht und versuchte
schlicht nicht mehr, den Service zu starten – auch nicht nach einer Korrektur.
Zwei Flags lösen das Problem:
--forcebeidocker service updateerzwingt einen neuen Deployment-Zyklus und setzt den Fehlerzähler zurück.--update-failure-action continueverhindert, dass Swarm das Image beim nächsten Fehler automatisch zurückrollt. Die Standard-Einstellungrollbackist im Normalbetrieb sinnvoll, beim Upgrade aber hinderlich – jeder Fehlversuch würde das Image sofort wieder auf die alte Version zurücksetzen.
Das vollständige Kommando für den Upgrade-Schritt sieht damit so aus:
|
|
Danach initialisierte PG18 das leere Verzeichnis ohne Probleme, der Dump ließ sich einspielen, und der Stack lief wieder.
Problem 4: Der Prometheus-Exporter kannte PG17+ nicht mehr
Nach dem Neustart des gesamten Stacks tauchten in den Logs des
postgres_exporter-Containers – der Metriken für Prometheus sammelt –
alle 15 Sekunden Fehlermeldungen auf:
|
|
Die Ursache: PostgreSQL 17 hat die Checkpoint-Statistiken aus der internen
View pg_stat_bgwriter herausgelöst und in eine neue View namens
pg_stat_checkpointer verschoben. Der damals verwendete
postgres_exporter in Version 0.15.0 fragte noch die alte Spalte ab
und schlug damit auf jedem Cluster ab PG17 fehl.
Das Update auf postgres_exporter v0.17.0 behebt das. Beim Wechsel auf
die neue Version gibt es zwei weitere kleine Anpassungen:
Auto-Discovery ist deprecated. Die bisher gesetzte Umgebungsvariable
PG_EXPORTER_AUTO_DISCOVER_DATABASES=true ist in v0.17.0 als veraltet markiert.
Da die Datenbankverbindung ohnehin direkt über DATA_SOURCE_URI auf die
gewünschte Datenbank zeigt, wird Auto-Discovery nicht gebraucht – die Variable
fliegt einfach raus.
Neue Konfigurationsdatei erwartet. v0.17.0 sucht beim Start nach einer
postgres_exporter.yml für benutzerdefinierte Collector-Abfragen und loggt
eine Warnung, wenn sie fehlt. Da keine benutzerdefinierten Abfragen nötig sind,
reicht eine minimale leere Konfigurationsdatei:
|
|
Diese Datei als Read-only-Volume in den Container mounten und beim Start referenzieren:
|
|
Danach: keine Fehlermeldungen mehr, saubere Metriken.
Synapse: PG14.1 → PG18 (vier Datenbanken)
Sechs Tage nach dem Nextcloud-Upgrade kam der Synapse-Stack dran. Hier wird es
interessanter, weil der Stack vier separate PostgreSQL-Instanzen hat: eine für
Synapse selbst und je eine pro Mautrix-Bridge (Signal, Telegram, WhatsApp).
Alle vier liefen auf einem ungewöhnlich alten Pin: postgres:14.1 (nicht
Alpine, exakte Version).
Das Vorgehen aus dem Nextcloud-Upgrade ließ sich grundsätzlich übernehmen, aber mit ein paar Anpassungen:
- Stoppen in der richtigen Reihenfolge: zuerst alle Bridges, baibot und signald, dann Synapse selbst, dann die vier Datenbanken.
- Alle vier DBs einzeln dumpen, jede mit ihrem eigenen Superuser-Secret.
- Datenverzeichnisse einzeln verschieben.
- Jede DB einzeln upgraden und das Dump einspielen.
Dabei bin ich in zwei neue Probleme gelaufen, die beim Nextcloud-Upgrade nicht aufgetaucht waren – plus ein hübscher Folgefund.
Problem 5: docker service update --image übernimmt keine Env-Vars aus der YAML
Nach dem Update der docker-stack.yml mit dem neuen Image und der
PGDATA-Variablen schien der naheliegende Schritt:
|
|
PG18 startete – und beendete sich sofort wieder mit dem PGDATA-Konflikt aus Problem 2. Aber die Umgebungsvariable stand doch in der YAML?
Die Auflösung: docker service update --image X ändert nur das, was man
explizit übergibt. Was in der docker-stack.yml steht, wird bei diesem
Kommando nicht übernommen – das passiert nur bei docker stack deploy.
Solange PGDATA noch nicht in der laufenden Service-Spezifikation steht,
kennt der Container die Variable nicht.
Zwei Lösungen funktionieren:
- Einmal
docker stack deployausführen, damit die Env-Vars in die Service-Spec übernommen werden. Danach kann man weiterhin mitdocker service update --forcearbeiten. - Die Variable explizit beim Update mitgeben:
|
|
Letzteres ist sauberer, weil es genau einen Service betrifft.
docker stack deploy würde nebenbei alle App-Services im Stack auf ihren
Soll-Replikat-Wert zurücksetzen – und damit hochfahren, obwohl die Daten noch
nicht wiederhergestellt sind.
Problem 6: PG18 legt automatisch eine Datenbank mit dem Namen des Users an
Mit korrektem PGDATA startete PG18 sauber und initialisierte das frische
Datenverzeichnis. Anschließend ließ ich den Dump per psql einspielen – und
stellte erst beim späteren Spot-Check fest, dass in der Tabelle
current_state_events Zeilen fehlten, obwohl der Restore keinen offensichtlichen
Fehler gemeldet hatte.
Das PostgreSQL-Docker-Image legt während initdb automatisch eine Datenbank an,
die nach dem Wert von POSTGRES_USER benannt ist – im Synapse-Fall also
synapse. Der von pg_dumpall erzeugte Dump enthält selbst ein
CREATE DATABASE synapse. Dieses Statement scheitert mit already exists – was
harmlos aussieht. Nicht harmlos: anschließend lädt der Restore die Tabellen in
die bereits existierende, leere synapse-Datenbank. CREATE TABLE scheitert
dort jeweils mit „already exists" – PG erstellt sie nicht. Spätere
COPY-Statements landen entweder auf einer Tabelle ohne Trigger oder, schlimmer,
auf einer mit einem Trigger, der eine andere, noch leere Tabelle referenziert.
Das Ergebnis ist COPY 0 und Datenverlust ohne sichtbaren Fehler.
Die Lösung ist einfach, wenn man weiß, was passiert: vor dem Restore die
automatisch angelegte Datenbank explizit droppen und das Dump dann gegen
postgres einspielen, nicht gegen synapse:
|
|
Der einzig erwartete Fehler ist jetzt ERROR: role "synapse" already exists –
auch die Rolle wird beim initdb aus den Env-Vars angelegt, das ist harmlos,
weil das Dump anschließend nur noch ALTER ROLE ausführt.
Mit dieser Korrektur landeten beim Synapse-Restore 835.286 Events, 72.237
current_state_events und ~67M Zeilen in state_groups_state – diesmal
vollständig.
Bei den drei Mautrix-Bridges war der Effekt dasselbe, nur dass die automatisch
erzeugte Datenbank dort jeweils mautrix hieß. Gleiches Vorgehen, gleiches
Ergebnis: erst droppen, dann gegen postgres einspielen.
Bonusfund: Der Synapse-Healthcheck war kaputt
Beim ersten Hochfahren nach dem DB-Upgrade kam der synapse-Service nicht zur
Ruhe – Endlosschleife aus Start, Healthcheck-Fehler, automatischem Rollback,
nächster Start. Der Healthcheck in der Stack-Datei:
|
|
Die Container-Logs zeigten /bin/sh: wget: not found. Das offizielle
matrixdotorg/synapse:v1.147.1-Image enthält schlicht kein wget, nur curl
und python3. Der Healthcheck war seit irgendeinem Image-Update kaputt – aber
weil PG14 stabil lief und Synapse selbst nie restartet wurde, fiel es nicht auf.
Erst die DB-Migration hat die Wiederanläufe getriggert, in denen der Fehler
offensichtlich wurde.
Fix: wget durch curl ersetzen.
|
|
Eine kleine Erinnerung daran, dass Healthchecks regelmäßig getestet werden sollten – sonst sind sie Scheinsicherheit.
Forgejo: PG16 → PG18
Mit den drei zusätzlichen Lektionen aus dem Synapse-Upgrade lief der
Forgejo-Stack erfreulich unspektakulär. Nur eine Datenbank, klein (76 MB), und
der Superuser ist hier ausnahmsweise als simpler Env-Var
POSTGRES_USER: forgejo gesetzt – kein Secret-Lookup nötig.
Der ganze Vorgang dauerte rund zwei Minuten:
-
Snapshot.
-
App-Service auf 0 skalieren.
-
pg_dumpall -U forgejo(1,9 MB Dump). -
DB-Service auf 0 skalieren, Datenverzeichnis nach
db-pg16-backupverschieben, leeresdbanlegen. -
docker-stack.yml: Image aufpostgres:18-alpineändern,PGDATAergänzen, committen. -
DB-Service mit explizitem
--env-add PGDATA=…starten:1 2 3 4 5 6 7docker service update \ --image postgres:18-alpine \ --env-add PGDATA=/var/lib/postgresql/data \ --update-failure-action continue \ --force \ --replicas 1 \ forgejo_forgejo-db -
Automatisch erstellte
forgejo-Datenbank droppen, Dump gegenpostgreseinspielen. -
docker stack deploy– die App kommt automatisch wieder hoch. -
https://git.bugtrack.eu/api/healthzantwortet mit200, Logs sauber.
Keine neuen Überraschungen. Die Lektionen aus dem Synapse-Upgrade waren auf Forgejo direkt übertragbar.
Fazit
Nach drei produktiven Upgrades (Nextcloud, Synapse, Forgejo) ergibt sich ein vollständigeres Bild der Stolpersteine bei PostgreSQL-Major-Upgrades unter Docker Swarm:
Aus dem Nextcloud-Upgrade:
docker service scaleändert nicht das Image – immer zuerstupdate --imageoderstack deploy, dann hochskalieren.- PG18 will explizit
PGDATA: /var/lib/postgresql/data, sonst startet es nicht auf einem Volume, das auf den alten Pfad gemountet ist. --update-failure-action continueund--forcesind nötig, sobaldmax_attemptseinmal getriggert wurde.- Der
postgres_exportermuss beim Sprung über PG17 auf v0.17.0 angehoben werden – die alte Version fragt eine Spalte ab, die es ab PG17 nicht mehr gibt.
Aus dem Synapse-Upgrade:
docker service update --imageübernimmt keine neuen Env-Vars aus der YAML. Entweder vorher einmaldocker stack deploy, oder beim Update explizit--env-addmitgeben.- PG18 legt während
initdbautomatisch eine Datenbank mit dem Namen ausPOSTGRES_USERan. Vor dem Restore droppen und das Dump gegenpostgreseinspielen – sonst lädt der Restore still unvollständig.
Und vom Forgejo-Upgrade:
Wenn man die ersten sechs Punkte kennt, ist eine kleine Datenbank in zwei Minuten umgezogen. Es lohnt sich, beim ersten kniffligen Stack die Lektionen direkt im Runbook festzuhalten – beim nächsten Stack zahlt sich das aus.
Damit ist der gesamte Stack auf PostgreSQL 18. Bis zum nächsten Major-Upgrade in ein paar Jahren.