Dies ist eine Post-Mortem-Analyse/Runbook einer echten Migration. Ich lasse die üblichen Discourse-Vorbereitungen aus (die offiziellen Dokumentationen decken diese ab). Ich konzentriere mich auf die genauen Switches, Cloudflare-R2-Fallen, die wichtigen Rails/Rake-Einzeiler, was schiefgelaufen ist und wie man den gleichen Schritt beim nächsten Mal mit geringem Risiko durchführt.
Zielzustand
- Discourse läuft auf dem neuen Host (Docker, einzelner
app-Container). - Uploads und Frontend-Assets liegen auf Cloudflare R2:
- Bucket
discourse-uploads(öffentlich) - Bucket
discourse-backups(privat)
- Bucket
- R2-Custom-Domain:
https://files.example.com(erstellt in R2 → Custom domains, nicht als manuelles Cross-Account-CNAME).
0) DB-Backups, die wirklich funktionieren (nächtlich und beim Umstieg)
Nächtliche Backups dienen der Katastrophenwiederherstellung. Ein Backup kurz vor dem Umstieg dient dem Migration-Cutover. Behalten Sie beides bei.
0.1 Richtlinie
- Nächtlich: Nur DB-Backup (
.sql.gz, keine Uploads) → lokal verifizieren → auf R2 hochladen. Behalten Sie ≥7 Kopien bei (oder nutzen Sie R2-Lifecycle). - Cutover: Kurz vor dem DNS-Wechsel ein weiteres Nur-DB-Backup erstellen und dieses auf den neuen Host wiederherstellen, um die Content-Lücke zu minimieren.
0.2 Ein Nur-DB-Backup erstellen und verifizieren
Im Container:
# Optional, aber empfehlenswert: Schreibzugriffe während des Snapshots reduzieren
discourse enable_readonly
# Ein Nur-DB-Backup über die Admin-Oberfläche auslösen ("mit Uploads" deaktivieren)
# oder per CLI:
discourse backup
# Das Artefakt verifizieren
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz
Tiefe Verifizierung (am besten): Wiederherstellung auf eine temporäre DB und Zählen der Zeilen:
cd /var/discourse && ./launcher enter app
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) from posts where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
Wenn der Gzip-Test oder die temporäre Wiederherstellung fehlschlägt, laden Sie diese Datei nicht auf R2 hoch – beheben Sie den Fehler und erstellen Sie ein neues Backup.
0.3 Erst nach erfolgreicher Prüfung auf R2 hochladen
aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
s3://discourse-backups/
0.4 Warum die Größen abweichen (1–4 GB ist normal)
Sowohl das nächtliche Admin-Backup als auch manuelle pg_dump-Befehle erzeugen Nur-DB .sql.gz-Dateien. Größenunterschiede ergeben sich meist aus den enthaltenen Tabellen und der Komprimierung, nicht aus „fehlenden Beiträgen". Wenn Sie sehen möchten, was enthalten ist:
# Welche Tabellen enthalten Daten im Dump?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head
# Schnelle Zeilenzahl-Näherung für wichtige Tabellen
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.posts /{c=1;next}/^\\\./{c=0} c' | wc -l
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.topics /{c=1;next}/^\\\./{c=0} c' | wc -l
Wenn diese Zahlen den Erwartungen entsprechen, enthält das Backup alle Beiträge/Themen, unabhängig von der Dateigröße.
1) Alter Host: Vorbereiten und Kopieren des (verifizierten) Nur-DB-Backups
Wartung ankündigen → Lesezugriff aktivieren:
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit
Das verifizierte .sql.gz auf den neuen Host kopieren:
rsync -avP -e "ssh -o StrictHostKeyChecking=no" \
root@OLD:/var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
/var/discourse/shared/standalone/backups/default/
Wenn Sie eine nahezu null Content-Lücke wünschen, wiederholen Sie diesen Schritt kurz vor dem DNS-Cutover.
2) Bootstrap des neuen Hosts
Docker + discourse_docker installieren:
apt-get update && apt-get install -y git curl tzdata
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
git clone https://github.com/discourse/discourse_docker /var/discourse
Erstellen Sie containers/app.yml mit Produktionswerten. Halten Sie SSL-Vorlagen auskommentiert, bis der DNS auf diesen zeigt. Minimales env-Set:
env:
DISCOURSE_HOSTNAME: forum.example.com
# R2 / S3
DISCOURSE_USE_S3: "true"
DISCOURSE_S3_REGION: "auto"
DISCOURSE_S3_ENDPOINT: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
DISCOURSE_S3_FORCE_PATH_STYLE: "true"
DISCOURSE_S3_BUCKET: "discourse-uploads"
DISCOURSE_S3_BACKUP_BUCKET: "discourse-backups"
DISCOURSE_S3_ACCESS_KEY_ID: "<R2_KEY>"
DISCOURSE_S3_SECRET_ACCESS_KEY: "<R2_SECRET>"
DISCOURSE_S3_CDN_URL: "https://files.example.com"
DISCOURSE_BACKUP_LOCATION: "s3"
# R2-Checksummen-Einstellungen (Konflikte verhindern)
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
# SMTP / Let's Encrypt E-Mail
DISCOURSE_SMTP_ADDRESS: smtp.gmail.com
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: you@example.com
DISCOURSE_SMTP_PASSWORD: "<app-password>"
DISCOURSE_SMTP_DOMAIN: example.com
DISCOURSE_NOTIFICATION_EMAIL: you@example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
Assets während des Neubaus auf R2 veröffentlichen:
hooks:
after_assets_precompile:
- exec:
cd: $home
cmd:
- sudo -E -u discourse bundle exec rake s3:upload_assets
- sudo -E -u discourse bundle exec rake s3:expire_missing_assets
Container hochfahren (vorläufig nur HTTP):
cd /var/discourse && ./launcher rebuild app
3) Wiederherstellen des Nur-DB-Dumps (.sql.gz via psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# Sicherstellen einer sauberen DB
sudo -E -u postgres psql -c "REVOKE CONNECT ON DATABASE discourse FROM public;"
sudo -E -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='discourse';"
sudo -E -u postgres psql -c "DROP DATABASE IF EXISTS discourse;"
sudo -E -u postgres psql -c "CREATE DATABASE discourse WITH OWNER discourse TEMPLATE template0 ENCODING 'UTF8';"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS citext;"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS hstore;"
# Dump importieren
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql discourse
sv start unicorn
[ -d /etc/service/sidekiq ] && sv start sidekiq || true
exit
Wenn Sie noch lokale Uploads vor R2 mitführen, können Sie diese einmalig als Sicherheitsnetz per rsync kopieren; wir migrieren sie im nächsten Schritt auf R2.
4) R2-Einstellungen, die wichtig waren
Buckets & Token: Erstellen Sie discourse-uploads (öffentlich) und discourse-backups (privat). Bootstrappen Sie mit einem Account-API-Token, der auf diese beiden Buckets mit Admin Read & Write beschränkt ist (damit PutBucketCors funktioniert), und rotieren Sie nach Erfolg auf Object Read & Write.
Custom Domain: Fügen Sie files.example.com in R2 → Custom domains hinzu, und zwar unter dem selben Cloudflare-Konto wie Ihre DNS-Zone (vermeidet 1014 Cross-Account-CNAME-Fehler).
CORS auf discourse-uploads:
[
{
"AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
Neubau durchführen, damit CSS/JS/Fonts auf R2 veröffentlicht werden:
cd /var/discourse && ./launcher rebuild app
5) Einmalige Migration historischer Uploads auf R2
cd /var/discourse && ./launcher enter app
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
Wenn Sie „X Beiträge nicht neu zugeordnet…" erhalten, siehe §7.2 für gezielte Fixes.
6) Produktionsdomain umstellen
In app.yml setzen:
DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
DNS: forum.example.com auf die neue Front (oder Origin)-IP zeigen lassen, SSL-Vorlagen aktivieren, dann:
cd /var/discourse && ./launcher rebuild app
Gesundheitscheck:
curl -I https://forum.example.com
./launcher logs app | tail -n 200
Eine HTTP/2 403 für anonyme Benutzer bedeutet meist login_required – kein Ausfall.
7) Dinge, die tatsächlich schiefgelaufen sind (und Fixes)
7.1 R2-Checksummen-Konflikt
Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.
Fix (dauerhaft beibehalten):
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
7.2 „X Beiträge sind nicht auf neue S3-Upload-URL remappt"
Grund: Einige cooked-HTML-Elemente zeigen immer noch auf /uploads/<db>/original/....
Gezieltes Rebacken:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r '
db = RailsMultisite::ConnectionManagement.current_db
ids = Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").pluck(:id)
ids.each { |pid| Post.find(pid).rebake! }
puts "rebaked=#{ids.size}"
'
Oder ein statisches Präfix remappen und betroffene Beiträge neu backen:
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"
Die Migration erneut ausführen, um Sauberkeit zu bestätigen:
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
7.3 Tasks „fehlen"
Immer mit Bundler + Env ausführen:
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T s3
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T uploads
Effektive S3-Einstellungen ausgeben:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts({ use_s3: ENV["DISCOURSE_USE_S3"], bucket: ENV["DISCOURSE_S3_BUCKET"], endpoint: ENV["DISCOURSE_S3_ENDPOINT"], cdn: ENV["DISCOURSE_S3_CDN_URL"] })'
7.4 s3:upload_assets AccessDenied
Verwenden Sie ein Admin RW-Token für den Bootstrap (Bucket-Level-CORS-Operationen), und rotieren Sie danach auf Object RW.
8) Verifizierung
Im Container
# URLs verwenden jetzt das CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'
# Verbleibende cooked-Referenzen auf lokale Uploads (sollten gegen 0 gehen)
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'db=RailsMultisite::ConnectionManagement.current_db; puts Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").count'
Browser
- Der Network-Tab zeigt Assets von
files.example.com. - Alte Themen zeigen Bilder unter
https://files.example.com/original/....
Backups
- Admin → Backups → eines erstellen; bestätigen Sie, dass ein neues Objekt in
discourse-backupsauf R2 erscheint.
9) Bereinigung
Wenn cooked-Referenzen im Wesentlichen 0 sind:
mv /var/discourse/shared/standalone/uploads /var/discourse/shared/standalone/uploads.bak
mkdir -p /var/discourse/shared/standalone/uploads
chown -R 1000:1000 /var/discourse/shared/standalone/uploads
# nach einigen stabilen Tagen
rm -rf /var/discourse/shared/standalone/uploads.bak
Geheimnisse rotieren (R2-Token → Object RW; SMTP-App-Passwort, falls es jemals in Logs landete).
10) Beim nächsten Mal (Playbook) — R2-first-Pfad
- Alt → Neu (Nur DB): Lesezugriff → Backup → Wiederherstellung
.sql.gzviapsql. - R2 vor DNS verdrahten: Buckets, Token (Admin RW → später Object RW), Custom Domain, CORS.
env+hooks: Checksummen-Flags +s3:upload_assets; Neubau.- DNS-Cutover auf den neuen Host.
- Uploads auf R2 migrieren.
- Nachzügler beheben (gezieltes Rebacken/Remappen) → schnelles erneutes Ausführen der Migration.
- Sidekiq beendet Hintergrund-Rebakes (oder
posts:rebake_uncooked_posts). - Backups auf R2 verifiziert.
- Berechtigungen härten und Geheimnisse rotieren.
- Lokale Uploads nach einer Auszeit bereinigen.