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:

1
2
docker exec --user=33 $(docker ps -q -f name=nextcloud_app | head -1) \
  /var/www/html/occ maintenance:mode --on

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:

1
2
3
4
5
PGUSER=$(docker exec $(docker ps -q -f name=myapp_db) \
  cat /run/secrets/myapp_db_user)

docker exec $(docker ps -q -f name=myapp_db) pg_dumpall -U "$PGUSER" \
  > /srv/myapp/db-backup-$(date +%Y%m%d-%H%M%S).sql

Kurz verifizieren, dass der Dump vollständig ist:

1
2
3
4
head -3 /srv/myapp/db-backup-*.sql
# → -- PostgreSQL database cluster dump
tail -3 /srv/myapp/db-backup-*.sql
# → -- PostgreSQL database cluster dump complete

Schritt 3: Alle Services stoppen

Erst die Applikation, dann die Datenbank:

1
2
docker service scale myapp_app=0 myapp_web=0 # ... alle anderen
docker service scale myapp_db=0

Schritt 4: Altes Datenverzeichnis sichern, leeres anlegen

Das alte Datenverzeichnis bleibt als Backup erhalten, bis alles läuft:

1
2
mv /srv/myapp/db /srv/myapp/db-pg14-backup
mkdir /srv/myapp/db

Schritt 5: Image aktualisieren

In der docker-stack.yml das Image auf die neue Version setzen:

1
image: postgres:18-alpine

Bei PG18 zusätzlich PGDATA explizit in die Umgebungsvariablen aufnehmen – warum das nötig ist, erkläre ich weiter unten beim Nextcloud-Erfahrungsbericht.

1
2
environment:
  PGDATA: /var/lib/postgresql/data

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:

1
2
3
4
5
6
docker service update \
  --image postgres:18-alpine \
  --env-add PGDATA=/var/lib/postgresql/data \
  --update-failure-action continue \
  --force \
  myapp_db

Warten, bis der Container als (healthy) gemeldet wird:

1
docker ps --filter "name=myapp_db" --format "{{.Status}}"

Schritt 7: Dump einspielen

1
2
3
4
5
PGUSER=$(docker exec $(docker ps -q -f name=myapp_db) \
  cat /run/secrets/myapp_db_user)

docker exec -i $(docker ps -q -f name=myapp_db) psql -U "$PGUSER" \
  < /srv/myapp/db-backup-*.sql

Zwei Fehlermeldungen sind dabei normal und harmlos:

1
2
ERROR: role "myuser" already exists
ERROR: database "mydb" already exists

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

1
docker stack deploy -c docker-stack.yml myapp

Bei Nextcloud danach den Wartungsmodus wieder ausschalten und ein paar Datenbankprüfungen laufen lassen:

1
2
3
4
5
docker exec --user=33 $(docker ps -q -f name=nextcloud_app | head -1) \
  /var/www/html/occ maintenance:mode --off

docker exec --user=33 $(docker ps -q -f name=nextcloud_app | head -1) \
  /var/www/html/occ db:add-missing-indices

Schritt 9: Aufräumen

Erst wenn alles stabil läuft, das Backup-Verzeichnis und die Dump-Datei löschen:

1
2
rm -rf /srv/myapp/db-pg14-backup
rm /srv/myapp/db-backup-*.sql

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:

1
find /srv/nextcloud/db -mindepth 1 -delete

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:

1
2
3
Error: The suggested container configuration for 18+ is to place a single
mount at /var/lib/postgresql which will then place PostgreSQL data in a
subdirectory.

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:

1
2
environment:
  PGDATA: /var/lib/postgresql/data

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:

  • --force bei docker service update erzwingt einen neuen Deployment-Zyklus und setzt den Fehlerzähler zurück.
  • --update-failure-action continue verhindert, dass Swarm das Image beim nächsten Fehler automatisch zurückrollt. Die Standard-Einstellung rollback ist 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:

1
2
3
4
5
6
docker service update \
  --image postgres:18-alpine \
  --env-add PGDATA=/var/lib/postgresql/data \
  --update-failure-action continue \
  --force \
  nextcloud_db

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:

1
2
collector failed  name=stat_bgwriter
err="pq: column \"checkpoints_timed\" does not exist"

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:

1
2
# postgres_exporter.yml
auth_modules: {}

Diese Datei als Read-only-Volume in den Container mounten und beim Start referenzieren:

1
2
3
volumes:
  - ./postgres_exporter.yml:/postgres_exporter.yml:ro
command: ["--config.file=/postgres_exporter.yml"]

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:

1
docker service update --image postgres:18-alpine --replicas 1 synapse_synapse-db

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:

  1. Einmal docker stack deploy ausführen, damit die Env-Vars in die Service-Spec übernommen werden. Danach kann man weiterhin mit docker service update --force arbeiten.
  2. Die Variable explizit beim Update mitgeben:
1
2
3
4
5
6
7
docker service update \
  --image postgres:18-alpine \
  --env-add PGDATA=/var/lib/postgresql/data \
  --update-failure-action continue \
  --force \
  --replicas 1 \
  synapse_synapse-db

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:

1
2
3
4
5
6
docker exec $(docker ps -q -f name=synapse_synapse-db) \
  psql -U synapse -d postgres -c "DROP DATABASE synapse;"

docker exec -i $(docker ps -q -f name=synapse_synapse-db) \
  psql -U synapse -d postgres \
  < /srv/synapse/synapse/pg14-dumpall-*.sql

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:

1
test: ["CMD-SHELL", "wget -qO- http://localhost:8008/health || exit 1"]

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.

1
test: ["CMD-SHELL", "curl -fsSo /dev/null http://localhost:8008/health || exit 1"]

Eine kleine Erinnerung daran, dass Healthchecks regelmäßig getestet werden sollten – sonst sind sie Schein­sicherheit.


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:

  1. Snapshot.

  2. App-Service auf 0 skalieren.

  3. pg_dumpall -U forgejo (1,9 MB Dump).

  4. DB-Service auf 0 skalieren, Datenverzeichnis nach db-pg16-backup verschieben, leeres db anlegen.

  5. docker-stack.yml: Image auf postgres:18-alpine ändern, PGDATA ergänzen, committen.

  6. DB-Service mit explizitem --env-add PGDATA=… starten:

    1
    2
    3
    4
    5
    6
    7
    
    docker service update \
      --image postgres:18-alpine \
      --env-add PGDATA=/var/lib/postgresql/data \
      --update-failure-action continue \
      --force \
      --replicas 1 \
      forgejo_forgejo-db
    
  7. Automatisch erstellte forgejo-Datenbank droppen, Dump gegen postgres einspielen.

  8. docker stack deploy – die App kommt automatisch wieder hoch.

  9. https://git.bugtrack.eu/api/healthz antwortet mit 200, 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:

  1. docker service scale ändert nicht das Image – immer zuerst update --image oder stack deploy, dann hochskalieren.
  2. PG18 will explizit PGDATA: /var/lib/postgresql/data, sonst startet es nicht auf einem Volume, das auf den alten Pfad gemountet ist.
  3. --update-failure-action continue und --force sind nötig, sobald max_attempts einmal getriggert wurde.
  4. Der postgres_exporter muss 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:

  1. docker service update --image übernimmt keine neuen Env-Vars aus der YAML. Entweder vorher einmal docker stack deploy, oder beim Update explizit --env-add mitgeben.
  2. PG18 legt während initdb automatisch eine Datenbank mit dem Namen aus POSTGRES_USER an. Vor dem Restore droppen und das Dump gegen postgres einspielen – 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.