这是一份真实迁移的复盘/运行手册。我略过了 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 路径
- 旧 → 新(仅数据库):只读 → 备份 → 通过
psql恢复.sql.gz。 - DNS 切换前配置 R2:存储桶、令牌(管理员读写 → 后续对象读写)、自定义域名、CORS。
env+hooks:校验和标志 +s3:upload_assets;重建。- DNS 切换到新主机。
- 迁移上传文件到 R2。
- 修复遗留问题(针对性重烘焙/重映射)→ 快速重新运行迁移。
- Sidekiq 完成后台重烘焙(或
posts:rebake_uncooked_posts)。 - 验证备份到 R2。
- 权限加固和密钥轮换。
- 冷却期后清理本地上传文件。
附录 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 头