Migration Discourse étape par étape avec intégration R2 et Cloudflare

Voici un post-mortem / runbook d’une migration réelle. Je passe les préparatifs Discourse courants (la documentation officielle les couvre). Je me concentre sur les basculements exacts, les pièges de Cloudflare R2, les commandes rails/rake en une ligne qui comptaient, ce qui a échoué, et comment reproduire ce déplacement à faible risque la prochaine fois.


État final cible

  • Discourse s’exécute sur le nouvel hôte (Docker, conteneur app unique).
  • Les uploads et les assets front-end résident sur Cloudflare R2 :
    • Bucket discourse-uploads (public)
    • Bucket discourse-backups (privé)
  • Domaine personnalisé R2 : https://files.example.com (créé dans R2 → Domaines personnalisés, pas un CNAME inter-compte manuel).

0) Sauvegardes de base de données qui fonctionnent vraiment (quotidiennes et lors du basculement)

Les sauvegardes quotidiennes servent à la reprise après sinistre. Une dernière sauvegarde immédiate sert au basculement de migration. Conservez les deux.

0.1 Politique

  • Quotidienne : sauvegarde DB-only (.sql.gz, sans uploads) → vérification localeenvoi vers R2. Conservez ≥7 copies (ou utilisez le cycle de vie R2).
  • Basculement : juste avant le basculement DNS, effectuez une autre sauvegarde DB-only et restaurez-la sur le nouvel hôte pour minimiser l’écart de contenu.

0.2 Créer une sauvegarde DB-only et la vérifier

Dans le conteneur :

# Optionnel mais recommandé : réduire les écritures pendant la prise d'instantané
discourse enable_readonly

# Déclencher une sauvegarde DB-only depuis l'interface d'administration (décocher "avec uploads")
# ou en CLI :
discourse backup

# Vérifier l'artefact
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

Vérification approfondie (meilleure) : restaurer dans une base de données temporaire et compter les lignes :

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

Si le test gzip ou la restauration temporaire échoue, ne téléversez pas ce fichier sur R2 — corrigez et refaites la sauvegarde.

0.3 Envoyer vers R2 uniquement après réussite

aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
  s3://discourse-backups/

0.4 Pourquoi les tailles diffèrent (1–4 Go est normal)

Les sauvegardes quotidiennes Admin et le pg_dump manuel produisent tous deux des fichiers DB-only .sql.gz. Les différences de taille proviennent généralement des tables incluses et de la compression, et non de “posts manquants”. Si vous voulez voir ce qu’il y a dedans :

# Quelles tables contiennent des données dans le dump ?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head

# Approximation rapide du nombre de lignes pour les tables clés
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

Si ces comptes correspondent aux attentes, la sauvegarde contient tous les posts/sujets quelle que soit la taille du fichier.


1) Ancien hôte : préparer et copier la sauvegarde (vérifiée) DB-only

Annoncez la maintenance → activez le mode lecture seule :

cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit

Copiez le .sql.gz vérifié vers le nouvel hôte :

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/

Si vous voulez un écart de contenu presque nul, répétez cette étape juste avant le basculement DNS.


2) Amorçage du nouvel hôte

Installer Docker + discourse_docker :

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

Créez containers/app.yml avec des valeurs de production. Gardez les modèles SSL commentés jusqu’à ce que le DNS pointe ici. Jeu minimum env :

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"

  # Réglages de checksum R2 (éviter les conflits)
  AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
  AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

  # SMTP / Email Let's Encrypt
  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

Publier les assets sur R2 pendant la reconstruction :

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

Démarrer le conteneur (HTTP uniquement pour l’instant) :

cd /var/discourse && ./launcher rebuild app

3) Restaurer le dump DB-only (.sql.gz via psql)

cd /var/discourse && ./launcher enter app

sv stop unicorn || true; sv stop sidekiq || true

# s'assurer d'une base de données propre
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;"

# importer le dump
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

Si vous conservez encore des uploads locaux avant R2, vous pouvez les rsync une fois comme filet de sécurité ; nous les migrerons vers R2 ensuite.


4) Réglages R2 qui comptaient

Buckets & token : créer discourse-uploads (public) et discourse-backups (privé). Amorcer avec un Token API de compte étendu à ces deux buckets avec Lecture et Écriture Admin (pour que PutBucketCors fonctionne), puis basculer vers Lecture et Écriture Objet après réussite.

Domaine personnalisé : ajouter files.example.com dans R2 → Domaines personnalisés sous le même compte Cloudflare que votre zone DNS (évite les erreurs de CNAME inter-compte 1014).

CORS sur discourse-uploads :

[
  {
    "AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
    "AllowedMethods": ["GET","HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["*"],
    "MaxAgeSeconds": 86400
  }
]

Reconstruire pour que CSS/JS/polices soient publiés sur R2 :

cd /var/discourse && ./launcher rebuild app

5) Migration en une fois des uploads historiques vers 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

Si vous obtenez « X posts non remappés… », consultez §7.2 pour des correctifs ciblés.


6) Basculement du domaine de production

Définir dans app.yml :

DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com

DNS : pointer forum.example.com vers la nouvelle façade (ou l’origine), activer les modèles SSL, puis :

cd /var/discourse && ./launcher rebuild app

Vérification de bon sens :

curl -I https://forum.example.com
./launcher logs app | tail -n 200

Voir HTTP/2 403 pour les anonymes signifie généralement login_required — pas une panne.


7) Choses qui ont vraiment planté (et correctifs)

7.1 Conflit de checksum R2

Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.

Correctif (garder en permanence) :

AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

7.2 « X posts ne sont pas remappés vers la nouvelle URL d’upload S3 »

Raison : certains HTML cooked pointent encore vers /uploads/<db>/original/....

Rebake ciblé :

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}"
'

Ou remapper un préfixe statique puis rebaker les posts touchés :

sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"

Relancer la migration pour confirmer la propreté :

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 Tâches « manquantes »

Toujours exécuter avec bundler + env :

sudo -E -u discourse RAILS_ENV=production bundle exec rake -T s3
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T uploads

Imprimer les paramètres S3 effectifs :

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

Utiliser un token Admin RW pour l’amorçage (opérations CORS au niveau du bucket), puis basculer vers Objet RW.


8) Vérification

Dans le conteneur

# URL utilisant désormais le CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'

# Références cooked restantes vers les uploads locaux (devrait tendre vers 0)
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'

Navigateur

  • L’onglet Réseau montre les assets provenant de files.example.com.
  • Les anciens sujets affichent les images sous https://files.example.com/original/....

Sauvegardes

  • Admin → Sauvegardes → en créer une ; confirmer qu’un nouvel objet apparaît dans discourse-backups sur R2.

9) Nettoyage

Lorsque les références cooked sont essentiellement à 0 :

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

# après quelques jours stables
rm -rf /var/discourse/shared/standalone/uploads.bak

Faire tourner les secrets (token R2 → Objet RW ; mot de passe d’application SMTP s’il a jamais touché les logs).


10) La prochaine fois (playbook) — chemin R2-first

  1. Ancien → Nouveau (DB-only) : lecture seule → sauvegarde → restauration .sql.gz via psql.
  2. Câbler R2 avant DNS : buckets, token (Admin RW → plus tard Objet RW), domaine personnalisé, CORS.
  3. env + hooks : drapeaux de checksum + s3:upload_assets ; reconstruire.
  4. Basculement DNS vers le nouvel hôte.
  5. Migrer les uploads vers R2.
  6. Corriger les retardataires (rebake/remap ciblé) → réexécution rapide de la migration.
  7. Sidekiq termine les rebakes en arrière-plan (ou posts:rebake_uncooked_posts).
  8. Sauvegardes vers R2 vérifiées.
  9. Durcissement des permissions et rotation des secrets.
  10. Nettoyer les uploads locaux après une période de latence.

Annexe A — « vérifier-avant-téléversement » quotidien (pseudo-cron)

LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)

# 1) intégrité gzip
gzip -t "$LATEST" || exit 1

# 2) comptes de lignes de base de données temporaire
cd /var/discourse && ./launcher enter app <<'EOS'
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/$(basename '"$LATEST"') | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) as topics from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) as posts  from posts  where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
EOS

# 3) seulement alors envoyer vers R2
aws s3 cp "$LATEST" s3://discourse-backups/

Annexe B — Proxy frontal minimal (optionnel)

Une petite VM de proxy inverse à l’avant peut terminer TLS et transférer vers l’origine sur HTTPS. Remplacez les IPs par les vôtres.

Upstream : /etc/nginx/conf.d/upstream.conf

upstream origin_forum {
    server <ORIGIN_IP>:443;
    keepalive 64;
}

Site : /etc/nginx/sites-available/forum.conf

server {
    listen 80;
    listen [::]:80;
    server_name forum.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name forum.example.com;

    ssl_certificate     /etc/letsencrypt/live/forum.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/forum.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_timeout 1d;

    client_max_body_size 100m;
    add_header Strict-Transport-Security "max-age=31536000" always;

    location / {
        proxy_pass https://origin_forum;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host forum.example.com;
        proxy_ssl_server_name on;
        proxy_ssl_name forum.example.com;
        # vérification optionnelle :
        # proxy_ssl_verify on;
        # proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;

        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        proxy_buffering off;
        proxy_read_timeout 360s;
        proxy_send_timeout 360s;
        proxy_connect_timeout 60s;

        add_header X-Relay relay-min always;
    }

    location /message-bus/ {
        proxy_pass https://origin_forum;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host forum.example.com;
        proxy_ssl_server_name on;
        proxy_ssl_name forum.example.com;
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Activer et recharger :

ln -sf /etc/nginx/sites-available/forum.conf /etc/nginx/sites-enabled/forum.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx

Vérification rapide :

curl -I https://forum.example.com   # attendre HTTP/2 200/302 et l'en-tête X-Relay
6 « J'aime »