Migrazione di Discourse passo-passo con integrazione R2 e Cloudflare

Questo è un post-mortem/runbook di una migrazione reale. Ometto le preparazioni comuni di Discourse (i documenti ufficiali le coprono). Mi concentro sugli switch esatti, sulle insidie di Cloudflare R2, sui comandi one-liner di rails/rake che contavano, su cosa è andato storto e su come rendere lo stesso passaggio a basso rischio la prossima volta.


Stato finale obiettivo

  • Discourse gira sul nuovo host (Docker, singolo container app).
  • Upload e asset front-end risiedono su Cloudflare R2:
    • Bucket discourse-uploads (pubblico)
    • Bucket discourse-backups (privato)
  • Dominio personalizzato R2: https://files.example.com (creato in R2 → Custom domains, non tramite un CNAME manuale cross-account).

0) Backup DB che funzionano davvero (notturni e al momento del cutover)

I backup notturni servono per il disaster recovery. Un backup dell’ultimo minuto serve per il cutover della migrazione. Mantienili entrambi.

0.1 Politica

  • Notturno: backup solo DB (.sql.gz, senza upload) → verifica localecarica su R2. Mantieni ≥7 copie (o usa il ciclo di vita di R2).
  • Cutover: subito prima dello switch DNS, crea un altro backup solo DB e ripristinalo sul nuovo host per minimizzare il divario di contenuti.

0.2 Crea un backup solo DB e verifica

All’interno del container:

# Opzionale ma utile: ridurre le scritture durante lo snapshot
discourse enable_readonly

# Attiva un backup solo DB dall'interfaccia di amministrazione (deseleziona "with uploads")
# o da CLI:
discourse backup

# Verifica l'artefatto
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

Verifica approfondita (migliore): ripristina su un DB temporaneo e conta le righe:

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

Se il test gzip o il ripristino temporaneo falliscono, non caricare quel file su R2: correggi e rifai il backup.

0.3 Carica su R2 solo dopo il superamento dei test

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

0.4 Perché le dimensioni differiscono (1–4 GB è normale)

Sia il backup notturno di Admin che il pg_dump manuale producono file .sql.gz solo DB. Le differenze di dimensione sono solitamente dovute alle tabelle incluse e alla compressione, non a “post mancanti”. Se vuoi vedere cosa c’è dentro:

# Quali tabelle hanno dati nel dump?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head

# Approssimazione rapida del conteggio delle righe per le tabelle chiave
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

Se quei conteggi corrispondono alle aspettative, il backup contiene tutti i post/argomenti indipendentemente dalla dimensione del file.


1) Host vecchio: prepara e copia il backup (verificato) solo DB

Annuncia la manutenzione → attiva sola lettura:

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

Copia il .sql.gz verificato sul nuovo host:

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/

Se desideri un divario di contenuti quasi nullo, ripeti questo passaggio subito prima del cutover DNS.


2) Bootstrap del nuovo host

Installa 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

Crea containers/app.yml con valori di produzione. Tieni i template SSL commentati finché il DNS non punta qui. Set minimo di 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"

  # Knob di checksum R2 (prevenire conflitti)
  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

Pubblica gli asset su R2 durante la ricostruzione:

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

Avvia il container (per ora solo HTTP):

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

3) Ripristina il dump solo DB (.sql.gz via psql)

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

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

# assicurati di un DB pulito
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;"

# importa il 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

Se stai ancora trasportando upload locali pre-R2, puoi sincronizzarli una volta come rete di sicurezza; li migreremo su R2 dopo.


4) Knob R2 che contavano

Bucket e token: crea discourse-uploads (pubblico) e discourse-backups (privato). Inizializza con un Account API Token con ambito limitato a quei due bucket con Admin Read & Write (così PutBucketCors funziona), poi ruota a Object Read & Write dopo il successo.

Dominio personalizzato: aggiungi files.example.com in R2 → Custom domains sotto lo stesso account Cloudflare della tua zona DNS (evita errori CNAME cross-account 1014).

CORS su discourse-uploads:

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

Ricostruisci in modo che CSS/JS/font vengano pubblicati su R2:

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

5) Migrazione una tantum degli upload storici su 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

Se ricevi “X posts not remapped…”, vedi §7.2 per correzioni mirate.


6) Switch del dominio di produzione

Imposta in app.yml:

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

DNS: punta forum.example.com al nuovo front (o origin) IP, abilita i template SSL, poi:

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

Sanity check:

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

Vedere HTTP/2 403 per gli anonimi di solito significa login_required — non un’interruzione.


7) Cose che si sono rotte davvero (e relative correzioni)

7.1 Conflitto di checksum R2

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

Correzione (mantieni permanentemente):

AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

7.2 “X posts are not remapped to new S3 upload URL”

Motivo: alcuni HTML cooked puntano ancora a /uploads/<db>/original/....

Rebake mirato:

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

O rimappa un prefisso statico poi rebaka i post toccati:

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

Esegui di nuovo la migrazione per confermare la pulizia:

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 Task “mancanti”

Esegui sempre con 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

Stampa le impostazioni S3 effettive:

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

Usa un token Admin RW per il bootstrap (operazioni CORS a livello di bucket), poi ruota a Object RW.


8) Verifica

All’interno del container

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

# Riferimenti cooked rimanenti agli upload locali (dovrebbero tendere a 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'

Browser

  • La scheda Rete mostra asset da files.example.com.
  • Gli argomenti vecchi mostrano immagini sotto https://files.example.com/original/....

Backup

  • Admin → Backups → crea uno; conferma che un nuovo oggetto appaia in discourse-backups su R2.

9) Pulizia

Quando i riferimenti cooked sono essenzialmente 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

# dopo alcuni giorni stabili
rm -rf /var/discourse/shared/standalone/uploads.bak

Ruota i segreti (token R2 → Object RW; password app SMTP se ha mai toccato i log).


10) La prossima volta (playbook) — percorso R2-first

  1. Vecchio → Nuovo (solo DB): sola lettura → backup → ripristino .sql.gz via psql.
  2. Collega R2 prima del DNS: bucket, token (Admin RW → poi Object RW), dominio personalizzato, CORS.
  3. env + hooks: flag di checksum + s3:upload_assets; ricostruisci.
  4. Cutover DNS sul nuovo host.
  5. Migra gli upload su R2.
  6. Correggi i ritardatari (rebake/remap mirati) → riesegui rapidamente la migrazione.
  7. Sidekiq termina i rebake in background (o posts:rebake_uncooked_posts).
  8. Backup su R2 verificati.
  9. Irrigidimento permessi e rotazione segreti.
  10. Pulisci gli upload locali dopo un periodo di raffreddamento.

Appendice A — “verifica prima del caricamento” notturno (pseudo-cron)

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

# 1) integrità gzip
gzip -t "$LATEST" || exit 1

# 2) conteggi righe DB temporaneo
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) solo allora carica su R2
aws s3 cp "$LATEST" s3://discourse-backups/

Appendice B — Proxy front-end minimale (opzionale)

Una piccola VM proxy inverso davanti può terminare TLS e inoltrare all’origin via HTTPS. Sostituisci gli IP con i tuoi.

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

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

Sito: /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;
        # verifica opzionale:
        # 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;
    }
}

Abilita e ricarica:

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

Controllo rapido:

curl -I https://forum.example.com   # ci si aspetta HTTP/2 200/302 e header X-Relay
6 Mi Piace