R2とCloudflare統合による段階的なDiscourse移行

これは実際の移行に関するポストモーテム/ランブックです。一般的な Discourse の準備(公式ドキュメントでカバーされている部分)は省略します。ここでは、重要な切り替えポイント、Cloudflare R2 の落とし穴、rails/rake のワンライナー、失敗した点、そして次回同様の移行を低リスクで行うための方法に焦点を当てます。


目標とする最終状態

  • Discourse は新しいホスト上で動作する(Docker、単一の app コンテナ)。
  • アップロードおよびフロントエンドアセットは Cloudflare R2 に存在する:
    • バケット discourse-uploads(公開)
    • バケット discourse-backups(非公開)
  • R2 カスタムドメイン:https://files.example.comR2 → Custom domains で作成。手動のクロスアカウント CNAME ではない)。

0) 実際に機能する DB バックアップ(毎夜および切り替え時)

毎夜のバックアップは災害復旧用です。最後の瞬間のバックアップは移行切り替え用です。両方を保持してください。

0.1 ポリシー

  • 毎夜:DB のみのバックアップ(.sql.gz、アップロード含まず)→ ローカルで検証R2 にアップロード。7 以上のコピーを保持(または R2 のライフサイクルを使用)。
  • 切り替え時:DNS 切り替えの直前に、もう一度DB のみのバックアップを作成し、それを新しいホストに復元してコンテンツのギャップを最小限に抑える。

0.2 DB のみのバックアップを作成して検証

コンテナ内:

# オプションだが推奨:スナップショット中の書き込みを減らす
discourse enable_readonly

# 管理画面から DB のみのバックアップをトリガー(「アップロードを含む」のチェックを外す)
# または CLI:
discourse backup

# アーティファクトを検証
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

詳細な検証(推奨): 一時的な DB に復元して行数をカウント:

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

gzip テストまたは一時的な復元が失敗した場合は、そのファイルを R2 にアップロードしないでください。修正して再バックアップしてください。

0.3 合格後にのみ R2 にプッシュ

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

0.4 サイズが異なる理由(1–4 GB は正常)

管理画面の毎夜バックアップと手動の pg_dump の両方はDB のみ.sql.gz を生成します。サイズの違いは通常、含まれるテーブルと圧縮によるものであり、「投稿の欠落」によるものではありません。中身を確認したい場合は:

# ダンプに含まれるテーブルはどれか?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head

# 主要テーブルの行数の簡易近似
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

これらのカウントが期待値と一致する場合、ファイルサイズに関わらずバックアップにはすべての投稿/トピックが含まれています。


1) 旧ホスト:準備と(検証済みの)DB のみのバックアップのコピー

メンテナンスを告知 → 読み取り専用モードを有効化:

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

検証済みの .sql.gz を新しいホストにコピー:

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/

コンテンツのギャップをほぼゼロにする場合は、DNS 切り替えの直前にこの手順を繰り返してください。


2) 新ホストのブートストラップ

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

containers/app.yml に本番環境用の値を作成する。SSL テンプレートは DNS がここを指すまでコメントアウトしたままにする。最小限の 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"

  # R2 チェックサム調整(競合防止)
  AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
  AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

  # SMTP / 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

ビルド中にアセットを R2 に公開:

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

コンテナを起動(当面は HTTP のみ):

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

3) DB のみのダンプの復元(.sql.gz を psql で)

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

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

# クリーンな 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;"

# ダンプをインポート
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

R2 移行前にローカルアップロードを引き継いでいる場合は、安全策として一度 rsync できます。次にそれらを R2 に移行します。


4) 重要な R2 設定

バケットとトークン: discourse-uploads(公開)と discourse-backups(非公開)を作成。Account API Token を使用して、これら 2 つのバケットにスコープを限定し、Admin Read & WritePutBucketCors が機能するため)でブートストラップし、成功後に Object Read & Write にローテートする。

カスタムドメイン: R2 → Custom domainsfiles.example.com を追加する。DNS ゾーンと同じ Cloudflare アカウント下にあること(1014 クロスアカウント CNAME エラーを回避)。

discourse-uploads 上の CORS:

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

CSS/JS/フォントを R2 に公開するために再ビルド:

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

5) 過去のアップロードの 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

「X 件の投稿が新しい S3 アップロード URL にマッピングされていない」というエラーが出る場合は、§7.2 を参照してターゲットを絞った修正を行ってください。


6) 本番ドメインの切り替え

app.yml に設定:

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

DNS: forum.example.com を新しいフロント(またはオリジン)IP に指し、SSL テンプレートを有効化してから:

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

Sanity チェック:

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

匿名ユーザーに対して HTTP/2 403 が表示されるのは、通常 login_required が原因であり、ダウンタイムではありません。


7) 実際に発生した問題(と修正)

7.1 R2 チェックサム競合

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

修正(恒久的に保持):

AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

7.2 「X 件の投稿が新しい S3 アップロード URL にマッピングされていない」

理由: 一部の cooked HTML が /uploads/<db>/original/... を指したままになっている。

ターゲットを絞ったリベイク:

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

または静的プレフィックスをマッピングしてから、影響を受けた投稿をリベイク:

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

移行を再実行してクリーンであることを確認:

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 タスクが「見つからない」

常に bundler と環境変数と共に実行:

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

有効な S3 設定を表示:

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

ブートストラップにはAdmin RW トークンを使用(バケットレベルの CORS 操作のため)、成功後に Object RW にローテートする。


8) 検証

コンテナ内

# URL が現在 CDN を使用しているか確認
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'

# ローカルアップロードへの残りの cooked 参照(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'

ブラウザ

  • ネットワークタブでアセットが files.example.com から読み込まれていることを確認。
  • 古いトピックで画像が https://files.example.com/original/... 下に表示される。

バックアップ

  • 管理画面 → バックアップ → 作成;R2 上の discourse-backups に新しいオブジェクトが表示されることを確認。

9) クリーンアップ

cooked 参照が実質的に 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

# 数日間安定した後
rm -rf /var/discourse/shared/standalone/uploads.bak

シークレットをローテート(R2 トークン → Object RW;ログに記録された場合は SMTP アプリパスワード)。


10) 次回のために(プレイブック) — R2 ファースト経路

  1. 旧 → 新(DB のみ): 読み取り専用 → バックアップ → psql を介して .sql.gz を復元。
  2. DNS 以前に R2 を接続: バケット、トークン(Admin RW → 後に Object RW)、カスタムドメイン、CORS。
  3. env + hooks: チェックサムフラグ + s3:upload_assets;再ビルド。
  4. DNS 切り替え を新しいホストへ。
  5. アップロードを R2 に移行
  6. 取り残されたものを修正(ターゲットを絞ったリベイク/マッピング)→ 移行の再実行。
  7. Sidekiq がバックグラウンドでのリベイクを完了(または posts:rebake_uncooked_posts)。
  8. R2 へのバックアップ が検証済み。
  9. 権限の強化 とシークレットのローテート。
  10. クールダウン期間後、ローカルアップロードをクリーンアップ。

付録 A — アップロード前の検証を含む毎夜のスクリプト(疑似 cron)

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

# 1) gzip 整合性
gzip -t "$LATEST" || exit 1

# 2) 一時的な DB の行数カウント
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) その後にのみ R2 にアップロード
aws s3 cp "$LATEST" s3://discourse-backups/

付録 B — 最小限のフロントプロキシ(オプション)

前面に小さなリバースプロキシ VM を配置し、TLS を終端して HTTPS でオリジンへ転送できます。IP はご自身のものに置き換えてください。

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;
        # optional verification:
        # 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;
    }
}

有効化してリロード:

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

クイックチェック:

curl -I https://forum.example.com   # HTTP/2 200/302 および X-Relay ヘッダーを期待
「いいね!」 6