28. Juli 2026 · 14 Min. Lesezeit
Mastodon 4.6 auf Kubernetes installieren
Vollständige Anleitung für eine eigene Instanz: Postgres 18 über einen Operator statt als Deployment, Valkey 9 mit Persistenz, Sidekiq mit Autoscaler, verschlüsselte Secrets und Backups mit Point-in-Time-Recovery. Zweiter Anlauf, diesmal mit den Fehlern aus dem ersten.
Im September 2025 habe ich schon einmal aufgeschrieben, wie man Mastodon auf Kubernetes installiert. Die Anleitung läuft seither auf meiner eigenen Instanz, und ich stehe zu fast allem darin. Ein Teil war trotzdem falsch, nämlich der wichtigste: Postgres als Deployment mit einem PVC daneben.
Das hier ist die vollständige Anleitung neu geschrieben. Du musst die alte nicht kennen, hier steht alles von der leeren Namespace bis zum geprüften Backup. Was sich geändert hat, erkläre ich jeweils dort, wo die Entscheidung fällt.
Wie beim letzten Mal: kein Helm, alles von Hand, weil ich verstehen will, was läuft. Ich benutze GitOps mit ArgoCD, aber alle Dateien funktionieren genauso mit kubectl apply.
Mein Setup
Kubernetes 1.33, drei Nodes mit je 4 GB RAM, 2 Kernen und 20 GB SSD, dazu ein Load Balancer. Nginx Ingress, cert-manager für die Zertifikate, kubeseal für Secrets. Kein Elasticsearch, das lohnt sich auf dieser Grösse nicht.
Die Versionen, die ich beim Schreiben benutze:
| Komponente | Version | Anmerkung |
|---|---|---|
| Mastodon | v4.6.4 | verlangt Postgres 14+, Redis 7+ |
| PostgreSQL | 18.4 | über CloudNativePG |
| CloudNativePG | 1.30.0 | der Operator |
| Valkey | 9.1.1 | Redis-Fork, protokollkompatibel |
| Sealed Secrets | v0.38.4 | Controller und CLI |
| cert-manager | v1.21.0 | für Let's Encrypt |
Valkey statt Redis ist eine bewusste Entscheidung. Mastodon dokumentiert Redis ab 7.0, und Valkey ist ein protokollkompatibler Fork davon. Die Umgebungsvariablen heissen weiterhin REDIS_*, es ändert sich nur das Image.
Werkzeuge
Für die Secrets brauchst du kubeseal lokal. Unter macOS:
brew install kubeseal
Unter Linux:
export KUBESEAL_VERSION="0.38.4"
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"
tar -xvzf kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
Prüfen mit kubeseal --version.
Namespace und Controller
kubectl create namespace mastodon
Den Sealed-Secrets-Controller, falls noch nicht vorhanden:
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.38.4/controller.yaml
Und den Postgres-Operator. Das --server-side ist hier nicht optional, die CRDs sind zu gross für die klassische Annotation:
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml
kubectl -n cnpg-system rollout status deployment/cnpg-controller-manager
Die Datenbank
Hier ist der Teil, den ich 2025 falsch gemacht habe. Damals standen da ein Deployment mit replicas: 1, ein PVC und ein pg_dump-CronJob um vier Uhr morgens. Das läuft monatelang gut und hat drei Probleme, die alle erst auffallen, wenn es gerade nicht passt.
Ein Deployment rollt standardmässig mit RollingUpdate aus, startet also den neuen Pod, bevor der alte weg ist. Auf einem ReadWriteOnce-Volume bleibt der Rollout dann hängen, bis jemand eingreift. Kubernetes schickt zum Beenden ausserdem SIGTERM, was Postgres als "smart shutdown" liest: warten, bis alle Clients von selbst gehen. Sidekiq geht nicht, also kommt nach 30 Sekunden SIGKILL und beim nächsten Start eine Crash-Recovery. Und ein nächtlicher Dump heisst, dass ein Fehler um 14 Uhr dich einen halben Tag Föderation kostet.
Der Operator nimmt einem das alles ab. Zuerst die Zugangsdaten für den Backup-Speicher:
kubectl create secret generic mastodon-backup-creds \
--namespace mastodon \
--from-literal=ACCESS_KEY_ID=dein_key \
--from-literal=ACCESS_SECRET_KEY=dein_secret \
--dry-run=client -o yaml > backup-creds.yaml
kubeseal --controller-namespace kube-system \
--controller-name sealed-secrets-controller \
--format yaml < backup-creds.yaml > sealed-backup-creds.yaml
kubectl apply -f sealed-backup-creds.yaml && rm backup-creds.yaml
Dann der Cluster:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: mastodon-db
namespace: mastodon
spec:
instances: 2
imageName: ghcr.io/cloudnative-pg/postgresql:18.4
# Auf drei kleinen Nodes ist "preferred" der Unterschied zwischen
# "läuft" und "Pending, weil die Regel nicht erfüllbar ist"
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: preferred
storage:
size: 20Gi
walStorage:
size: 10Gi
postgresql:
parameters:
shared_buffers: "512MB"
effective_cache_size: "1536MB"
max_connections: "200"
work_mem: "8MB"
maintenance_work_mem: "128MB"
bootstrap:
initdb:
database: mastodon_production
owner: mastodon
postInitApplicationSQL:
- CREATE EXTENSION IF NOT EXISTS pg_trgm;
backup:
barmanObjectStore:
destinationPath: s3://dein-bucket/mastodon
endpointURL: https://dein-s3-endpunkt
s3Credentials:
accessKeyId:
name: mastodon-backup-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: mastodon-backup-creds
key: ACCESS_SECRET_KEY
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "1Gi"
Vier Dinge daran sind erklärungsbedürftig.
walStorage als eigenes Volume trennt WAL und Daten. Läuft das Datenvolume voll, während das WAL noch Platz hat, sind die Chancen auf einen sauberen Ausgang deutlich besser.
postInitApplicationSQL legt pg_trgm an, das Mastodon für die Suche braucht. Der Applikationsbenutzer ist bei CNPG absichtlich kein Superuser, und seit Postgres 13 ist pg_trgm als trusted markiert, darf also vom Eigentümer der Datenbank selbst erzeugt werden. Welche Extensions deine Mastodon-Version sonst noch will, steht in ihrer db/schema.rb, und dort schaue ich vor dem Aufsetzen nach statt es auf die Migration ankommen zu lassen.
Der backup-Block macht kontinuierliches WAL-Archiving. Nicht ein Stand pro Nacht, sondern jeder Zeitpunkt seit dem letzten Basis-Backup. Eine Anmerkung zur Version, weil sich das gerade bewegt: Seit CNPG 1.26 ist diese eingebaute barmanObjectStore-Form zugunsten des Barman-Cloud-Plugins abgekündigt. Sie ist weiterhin der Standard und funktioniert, aber wenn du das hier später liest, prüf vorher in der Dokumentation, ob du gleich das Plugin nehmen solltest.
Und instances: 2 ist eine Entscheidung, keine Vorgabe. Auf drei Nodes mit je 4 GB kostet die zweite Instanz Speicher, den Sidekiq gern hätte. Mit instances: 1 bekommst du weiterhin Backups, Point-in-Time-Recovery, kontrollierte Updates und deklarative Konfiguration, nur eben keine automatische Übernahme bei einem Node-Ausfall. Für eine persönliche Instanz ist das ein legitimer Tausch, und ich halte es für keine Schande, das so zu fahren.
Dazu der geplante Basis-Backup:
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: mastodon-db-daily
namespace: mastodon
spec:
# Sechs Felder, nicht fünf. Das erste sind Sekunden.
schedule: "0 0 3 * * *"
backupOwnerReference: self
cluster:
name: mastodon-db
Der Cron-Ausdruck hat mich beim ersten Versuch erwischt. CNPG benutzt eine Go-Bibliothek mit Sekundenfeld, ein aus einem normalen CronJob kopierter Fünf-Felder-Ausdruck bedeutet hier etwas anderes als gedacht.
Der Operator legt jetzt drei Services an: mastodon-db-rw zeigt immer auf die aktuelle Primary, mastodon-db-ro auf die Replicas, mastodon-db-r auf alle. Genau das kann ein handgeschriebener Service mit festem Selector nicht, weil er nach einem Failover auf den falschen Pod zeigen würde.
Das Passwort für den Applikationsbenutzer erzeugt der Operator selbst:
kubectl -n mastodon get secret mastodon-db-app \
-o jsonpath='{.data.password}' | base64 -d
Valkey
Valkey hält bei Mastodon den Cache und die Sidekiq-Warteschlangen. Wer die verliert, verliert offene Jobs: ausgehende Zustellungen, Push-Nachrichten, Medienverarbeitung. Deshalb ein StatefulSet mit eigenem Volume und nicht, wie bei mir 2025, ein Deployment mit angehängtem PVC.
apiVersion: v1
kind: Service
metadata:
name: valkey
namespace: mastodon
spec:
clusterIP: None
selector:
app: valkey
ports:
- port: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: valkey
namespace: mastodon
spec:
serviceName: valkey
replicas: 1
selector:
matchLabels:
app: valkey
template:
metadata:
labels:
app: valkey
spec:
terminationGracePeriodSeconds: 60
containers:
- name: valkey
image: valkey/valkey:9.1-alpine
args: ["--appendonly", "yes", "--appendfsync", "everysec"]
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
memory: "512Mi"
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
Die 60 Sekunden Grace Period sind kein Zufallswert. Valkey soll beim Beenden seine Daten wegschreiben können, und die Standardvorgabe von 30 Sekunden ist dafür knapp.
Secrets
Diese Werte werden einmal erzeugt und danach nie geändert. SECRET_KEY_BASE verschlüsselt Sessions, OTP_SECRET hängt an der Zwei-Faktor-Anmeldung, die VAPID-Schlüssel an den Push-Benachrichtigungen. Wer sie später austauscht, wirft alle Anmeldungen, alle 2FA-Einrichtungen und alle Push-Abos weg.
# Active-Record-Schlüssel
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 \
bin/rails db:encryption:init
# SECRET_KEY_BASE und OTP_SECRET, zweimal ausführen
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 bundle exec rake secret
# VAPID-Schlüsselpaar
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 \
bundle exec rake mastodon:webpush:generate_vapid_key
Daraus wird das Secret. DB_HOST zeigt auf den -rw-Service des Operators:
apiVersion: v1
kind: Secret
metadata:
name: mastodon-env
namespace: mastodon
type: Opaque
stringData:
LOCAL_DOMAIN: "deine-domain.ch"
WEB_DOMAIN: "deine-domain.ch"
SINGLE_USER_MODE: "true"
RAILS_ENV: "production"
SECRET_KEY_BASE: "..."
OTP_SECRET: "..."
VAPID_PRIVATE_KEY: "..."
VAPID_PUBLIC_KEY: "..."
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "..."
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "..."
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "..."
DB_HOST: "mastodon-db-rw"
DB_NAME: "mastodon_production"
DB_USER: "mastodon"
DB_PASS: "vom_operator_erzeugt"
REDIS_HOST: "valkey"
REDIS_PORT: "6379"
CACHE_REDIS_URL: "redis://valkey:6379/1"
RAILS_CACHE_STORE: "redis_cache_store"
SMTP_SERVER: "smtp.example.ch"
SMTP_PORT: "587"
SMTP_LOGIN: "mail@deine-domain.ch"
SMTP_PASSWORD: "..."
SMTP_FROM_ADDRESS: "mail@deine-domain.ch"
S3_ENABLED: "true"
S3_BUCKET: "dein-bucket"
S3_REGION: "..."
S3_ENDPOINT: "https://dein-s3-endpunkt"
S3_FORCE_PATH_STYLE: "true"
S3_ALIAS_HOST: "cdn.deine-domain.ch"
AWS_ACCESS_KEY_ID: "..."
AWS_SECRET_ACCESS_KEY: "..."
Verschlüsseln und die Klartextdatei löschen:
kubeseal --controller-namespace kube-system \
--controller-name sealed-secrets-controller \
--format yaml < mastodon-env.yaml > sealed-mastodon-env.yaml
kubectl apply -f sealed-mastodon-env.yaml
rm mastodon-env.yaml
Migrationen
Vor dem ersten Start muss das Schema angelegt werden. Als Job, nicht als Init-Container, damit es nicht bei jedem Pod-Neustart erneut läuft:
apiVersion: batch/v1
kind: Job
metadata:
name: mastodon-db-migrate
namespace: mastodon
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: ghcr.io/mastodon/mastodon:v4.6.4
command: ["bundle", "exec", "rails", "db:migrate"]
envFrom:
- secretRef:
name: mastodon-env
Denselben Job brauchst du nach jedem Upgrade wieder. Vorher lohnt der Blick in die Release Notes, ob die Migration vor oder nach dem Rollout laufen soll.
Web, Streaming und Sidekiq
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-web
namespace: mastodon
spec:
replicas: 1
selector:
matchLabels:
app: mastodon-web
template:
metadata:
labels:
app: mastodon-web
spec:
containers:
- name: web
image: ghcr.io/mastodon/mastodon:v4.6.4
command: ["bundle", "exec", "puma", "-C", "config/puma.rb"]
envFrom:
- secretRef:
name: mastodon-env
env:
- name: WEB_CONCURRENCY
value: "1"
- name: MAX_THREADS
value: "3"
- name: DB_POOL
value: "5"
- name: MALLOC_ARENA_MAX
value: "2"
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 20
periodSeconds: 10
resources:
requests:
cpu: "300m"
memory: "600Mi"
limits:
memory: "1.2Gi"
Das PVC für /mastodon/public/system aus meiner alten Anleitung ist hier weg. Mit S3_ENABLED: true liegen die Medien im Objektspeicher, das lokale Verzeichnis wird nicht gebraucht, und damit fällt auch der Init-Container weg, der früher die Berechtigungen zurechtgebogen hat.
Die Readiness Probe fehlte 2025 komplett. Ohne sie schickt der Ingress Anfragen an einen Pod, in dem Puma noch startet.
Streaming ist ein eigener Prozess mit eigenem Image:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-streaming
namespace: mastodon
spec:
replicas: 1
selector:
matchLabels:
app: mastodon-streaming
template:
metadata:
labels:
app: mastodon-streaming
spec:
containers:
- name: streaming
image: ghcr.io/mastodon/mastodon-streaming:v4.6.4
envFrom:
- secretRef:
name: mastodon-env
ports:
- containerPort: 4000
readinessProbe:
httpGet:
path: /api/v1/streaming/health
port: 4000
initialDelaySeconds: 10
resources:
requests:
cpu: "100m"
memory: "200Mi"
limits:
memory: "400Mi"
Sidekiq verarbeitet die Föderation. Die Grace Period ist hier wichtiger als bei den anderen Diensten, weil laufende Jobs sonst mitten in der Zustellung abgeschnitten werden:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-sidekiq
namespace: mastodon
spec:
replicas: 1
selector:
matchLabels:
app: mastodon-sidekiq
template:
metadata:
labels:
app: mastodon-sidekiq
spec:
terminationGracePeriodSeconds: 120
containers:
- name: sidekiq
image: ghcr.io/mastodon/mastodon:v4.6.4
command:
- bundle
- exec
- sidekiq
- -c
- "5"
- -q
- default,8
- -q
- push,6
- -q
- ingress,4
- -q
- mailers,2
- -q
- pull,1
- -q
- scheduler,1
envFrom:
- secretRef:
name: mastodon-env
env:
- name: DB_POOL
value: "5"
- name: MALLOC_ARENA_MAX
value: "2"
resources:
requests:
cpu: "500m"
memory: "300Mi"
limits:
memory: "600Mi"
Dazu der Autoscaler, damit unter Last mehrere kleine Pods laufen statt eines grossen:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mastodon-sidekiq
namespace: mastodon
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mastodon-sidekiq
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80
Ehrlich zur Skalierung nach CPU: Sidekiq wartet bei der Föderation viel auf fremde Server, ist also oft nicht CPU-gebunden. Die Warteschlangenlänge wäre das bessere Signal, kostet aber einen Adapter für benutzerdefinierte Metriken. Für eine kleine Instanz reicht CPU, man sollte nur wissen, dass es ein Näherungswert ist.
Ein Wort zu DB_POOL: Der Wert gilt pro Prozess. Ein Web-Pod mit fünf, drei Sidekiq-Pods mit je fünf, dazu Verbindungen für Migrationen, das summiert sich schneller als gedacht. Deshalb steht oben max_connections: 200 statt der Vorgabe von 100. Wenn du später PgBouncer davorsetzt, kommt eine Zeile dazu, die man leicht übersieht: Im Transaction-Pooling-Modus muss PREPARED_STATEMENTS auf false, sonst kollidieren Rails' vorbereitete Anweisungen mit geteilten Verbindungen.
Services und Ingress
apiVersion: v1
kind: Service
metadata:
name: mastodon-web
namespace: mastodon
spec:
selector:
app: mastodon-web
ports:
- port: 80
targetPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: mastodon-streaming
namespace: mastodon
spec:
selector:
app: mastodon-streaming
ports:
- port: 4000
targetPort: 4000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mastodon
namespace: mastodon
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
tls:
- hosts:
- deine-domain.ch
secretName: deine-domain-tls
rules:
- host: deine-domain.ch
http:
paths:
- path: /api/v1/streaming
pathType: Prefix
backend:
service:
name: mastodon-streaming
port:
number: 4000
- path: /
pathType: Prefix
backend:
service:
name: mastodon-web
port:
number: 80
Zwei Details: Der Streaming-Pfad steht vor /, sonst greift die allgemeinere Regel zuerst. Und proxy-body-size gehört hoch, weil der Standardwert von nginx für Medien-Uploads zu klein ist.
Medien aufräumen
Ohne Aufräumen wächst der Objektspeicher unbegrenzt, weil jede geföderierte Datei zwischengespeichert wird.
apiVersion: batch/v1
kind: CronJob
metadata:
name: mastodon-media-cleanup
namespace: mastodon
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: cleanup
image: ghcr.io/mastodon/mastodon:v4.6.4
envFrom:
- secretRef:
name: mastodon-env
command:
- /bin/bash
- -c
- |
bin/tootctl media remove --days=7
bin/tootctl media remove-orphans
bin/tootctl preview_cards remove --days=7
bin/tootctl statuses remove --days=14
bin/tootctl accounts prune
concurrencyPolicy: Forbid fehlte in meiner alten Fassung. Wenn ein Durchlauf länger als 24 Stunden braucht, startet sonst der nächste daneben, und zwei parallele Aufräumjobs auf derselben Datenbank sind keine gute Idee.
Das Backup, das nicht mehr weh tut
2025 stand an dieser Stelle die längste und unangenehmste Sektion der ganzen Anleitung. Ich hatte ein eigenes Docker-Image gebaut: Ubuntu mit AWS CLI, rclone und dem Postgres-Client, dazu ein Bash-Skript mit Fehlerprüfung nach jedem Schritt und ein Registry-Secret, damit der Cluster das Image ziehen darf. Es funktionierte, und es waren sehr viele bewegliche Teile für eine Aufgabe, die eigentlich Standard ist.
Der ScheduledBackup von weiter oben ersetzt das vollständig. Die Datenbank landet über WAL-Archiving laufend im Objektspeicher, das Basis-Backup läuft nachts, die Aufbewahrung regelt retentionPolicy.
Die Medien brauchen kein eigenes Backup mehr, wenn sie ohnehin in S3 liegen. Was sie brauchen, ist Versionierung oder Replikation beim Anbieter, und das ist eine Einstellung am Bucket statt eines nächtlichen rclone sync.
Kontrollieren kannst du den Stand so:
kubectl -n mastodon get backups
kubectl -n mastodon get cluster mastodon-db \
-o jsonpath='{.status.firstRecoverabilityPoint}'
Und der Teil, den ich vorher gar nicht hatte: Zurückspielen auf einen Zeitpunkt. Das erzeugt einen neuen Cluster, der alte läuft unangetastet weiter, du kannst vergleichen und dann umschalten:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: mastodon-db-restore
namespace: mastodon
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:18.4
storage:
size: 20Gi
bootstrap:
recovery:
source: mastodon-db
recoveryTarget:
targetTime: "2026-07-27 14:00:00+02"
externalClusters:
- name: mastodon-db
barmanObjectStore:
destinationPath: s3://dein-bucket/mastodon
endpointURL: https://dein-s3-endpunkt
s3Credentials:
accessKeyId:
name: mastodon-backup-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: mastodon-backup-creds
key: ACCESS_SECRET_KEY
Bitte einmal machen
Spiel diesen Restore einmal durch, bevor du ihn brauchst. Ein Backup, dessen Wiederherstellung man nie geübt hat, ist eine Vermutung mit Dateigrösse.
Wenn du von der alten Anleitung kommst
Falls bei dir noch Postgres als Deployment läuft: CNPG kann beim Anlegen direkt aus einer bestehenden Datenbank importieren, statt dass du mit pg_dump und pg_restore hantierst.
spec:
bootstrap:
initdb:
import:
type: microservice
databases:
- mastodon_production
source:
externalCluster: alte-instanz
externalClusters:
- name: alte-instanz
connectionParameters:
host: postgres
user: mastodon
dbname: mastodon_production
password:
name: mastodon-env
key: DB_PASS
Der Ablauf: Cluster mit import anlegen und Mastodon noch nicht anfassen. Zeilenzahlen der grossen Tabellen gegen die alte Datenbank halten. Web, Streaming und Sidekiq auf null skalieren, ab hier ist echte Downtime. Import wiederholen, damit die Lücke zu ist. DB_HOST im Secret auf mastodon-db-rw umbiegen, hochskalieren, Logs beobachten.
Das alte Deployment und sein PVC lässt du danach eine Woche stehen. Nicht löschen. Der Rückweg muss existieren, bis du sicher bist, dass du ihn nicht brauchst.
Was ich gegenüber 2025 geändert habe
| Damals | Jetzt | Warum |
|---|---|---|
| Postgres als Deployment | CloudNativePG | Kein sauberer Rollout, kein Failover, kein PITR |
pg_dump per CronJob | WAL-Archiving | RPO von 24 Stunden auf Minuten |
| Eigenes Backup-Image | ScheduledBackup | Ein Image, ein Skript, ein Registry-Secret weniger |
| Redis als Deployment | Valkey als StatefulSet | Sidekiq-Warteschlangen überleben Neustarts |
| Keine Probes | Readiness überall | Kein Verkehr auf startende Pods |
tootsuite/mastodon | ghcr.io/mastodon/... | Offizielle Registry, konsistente Tags |
| PVC für Medien | Nur S3 | Ein Volume und ein Init-Container weniger |
Was ich nicht geändert habe: Sealed Secrets, den Verzicht auf Helm, kein Elasticsearch auf kleinen Nodes, den Autoscaler für Sidekiq und das nächtliche Aufräumen der Medien. Das war schon damals richtig.
Und eine Warnung, die zur Ehrlichkeit gehört: Zwei Postgres-Instanzen schützen dich vor einem toten Node, nicht vor einem DELETE ohne WHERE. Das repliziert sauber auf beide. Hochverfügbarkeit und Backup lösen zwei verschiedene Probleme, und nur eines davon lässt sich nachts um drei mit einem Kaffee reparieren.
Wenn du das nachbaust und irgendwo hängst, schreib mir. Beim letzten Mal kamen die besten Korrekturen von Leuten, die einfach ausprobiert haben, was ich aufgeschrieben hatte.