R2 和 Cloudflare 集成,分步迁移 Discourse

这是一份真实迁移的复盘/运行手册。我略过了 Discourse 的常规准备工作(官方文档已有涵盖),重点介绍关键的配置切换、Cloudflare R2 的陷阱、至关重要的 Rails/Rake 单行命令、遇到的问题以及如何在下一次实现低风险迁移。


目标最终状态

  • Discourse 运行在新主机上(Docker,单个 app 容器)。
  • 上传文件和前端资源托管在 Cloudflare R2 上:
    • 存储桶 discourse-uploads(公开)
    • 存储桶 discourse-backups(私有)
  • R2 自定义域名:https://files.example.com(在 R2 → 自定义域名 中创建,而非手动配置跨账户 CNAME)。

0) 真正有效的数据库备份(每日夜间备份 切换时备份)

夜间备份用于灾难恢复。最后的即时备份用于迁移切换。两者都要保留。

0.1 策略

  • 夜间:仅数据库备份(.sql.gz,不含上传文件)→ 本地验证上传至 R2。保留至少 7 份副本(或使用 R2 生命周期管理)。
  • 切换:在 DNS 切换前立即进行另一次仅数据库备份,并将其恢复到新主机,以最小化内容缺口。

0.2 创建仅数据库备份并验证

在容器内:

# 可选但推荐:在快照期间减少写入
discourse enable_readonly

# 通过管理 UI 触发仅数据库备份(取消勾选“包含上传文件”)
# 或通过 CLI:
discourse backup

# 验证生成的文件
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

深度验证(最佳实践):恢复到临时数据库并统计行数:

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,生成的都是仅数据库.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) 旧主机:准备并复制(已验证的)仅数据库备份

发布维护公告 → 启用只读模式:

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。在 DNS 指向此处之前,请保持 SSL 模板处于注释状态。最小 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) 恢复仅数据库转储(通过 psql 导入 .sql.gz

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

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

# 确保数据库干净
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(私有)。使用账户 API 令牌进行初始化,该令牌需限定于这两个存储桶并拥有管理员读写权限(以便执行 PutBucketCors),成功后再轮换为对象读写权限

自定义域名:在与 DNS 区域相同的 Cloudflare 账户下的 R2 → 自定义域名 中添加 files.example.com(避免 1014 跨账户 CNAME 错误)。

CORS 配置在 discourse-uploads 上:

[
  {
    "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 个帖子未重新映射……”,请参阅第 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 令牌 → 对象读写权限;如果 SMTP 应用密码曾出现在日志中,也请轮换)。


10) 下次操作(演练手册)—— 优先采用 R2 路径

  1. 旧 → 新(仅数据库):只读 → 备份 → 通过 psql 恢复 .sql.gz
  2. DNS 切换前配置 R2:存储桶、令牌(管理员读写 → 后续对象读写)、自定义域名、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) 临时数据库行数统计
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 — 最小化前端代理(可选)

可以在前面部署一个小型反向代理虚拟机来终止 TLS 并通过 HTTPS 转发到源站。请将 IP 替换为你自己的。

上游配置/etc/nginx/conf.d/upstream.conf

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

站点配置/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;
        # 可选验证:
        # 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 个赞