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:

KomponenteVersionAnmerkung
Mastodonv4.6.4verlangt Postgres 14+, Redis 7+
PostgreSQL18.4über CloudNativePG
CloudNativePG1.30.0der Operator
Valkey9.1.1Redis-Fork, protokollkompatibel
Sealed Secretsv0.38.4Controller und CLI
cert-managerv1.21.0fü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

DamalsJetztWarum
Postgres als DeploymentCloudNativePGKein sauberer Rollout, kein Failover, kein PITR
pg_dump per CronJobWAL-ArchivingRPO von 24 Stunden auf Minuten
Eigenes Backup-ImageScheduledBackupEin Image, ein Skript, ein Registry-Secret weniger
Redis als DeploymentValkey als StatefulSetSidekiq-Warteschlangen überleben Neustarts
Keine ProbesReadiness überallKein Verkehr auf startende Pods
tootsuite/mastodonghcr.io/mastodon/...Offizielle Registry, konsistente Tags
PVC für MedienNur S3Ein 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.

Mastodon