Multisite 与 Cloudflare R2 对象

在 Discourse 多站点安装中仅将一个站点用于 Cloudflare R2

如果你运行的是 多站点(multisite)Discourse 安装,并且只想让 其中一个 站点使用 Cloudflare R2(用于上传、备份以及可选的静态资源),标准的 为上传配置 S3 兼容对象存储提供商 指南并没有完全涵盖多站点的特殊情况。这篇文章将详细讲解实际发生的情况、哪些设置是站点级别的、哪些是集群级别的,以及我在过程中遇到的一些棘手问题,这些问题耗费了我不少时间才弄清楚。

核心概念:GlobalSetting 与 SiteSetting

此设置中的所有问题都归结为一个区别:

  • app.yml 环境变量DISCOURSE_*)会变成 GlobalSetting —— 它们在容器启动时从进程环境中读取一次,并由集群中的 每个站点 共享。RAILS_DB 对它们没有影响。
  • 管理界面(Admin UI)中的字段 是普通的 SiteSetting —— 它们按站点存储在每个站点自己的数据库中,真正限定于该特定站点。

如果存在某个 GlobalSetting,它会静默地覆盖并在管理界面中隐藏对应的 SiteSetting 字段。这意味着:你在 app.yml 中设置的任何内容都适用于每个站点,没有例外,也没有 RAILS_DB 的变通方法。

第一部分 — 上传和备份(真正的站点级别,简单)

这部分完全符合预期。enable_s3_uploadss3_upload_bucketbackup_locations3_backup_bucket 以及凭据字段都是普通的站点设置。请 仅通过 Admin → Settings → 搜索 “S3” 进行配置,登录到你想使用 R2 的那个特定站点,并保持 app.yml 不变。集群中的其他站点将继续在本地存储。

R2 的示例值:

Enable S3 uploads = true
Enable direct S3 uploads = true
S3 access key ID / secret access key = <你的 R2 令牌>
S3 region = auto
S3 upload bucket = <存储桶名称>
S3 endpoint = https://<account-id>.r2.cloudflarestorage.com
S3 CDN URL = https://uploads.yourdomain.com
S3 use ACLs = false   (R2 使用存储桶级别的权限,而不是对象 ACL)
S3 backup bucket = <备份存储桶名称>
Backup location = S3

直接在 Cloudflare 仪表板中设置存储桶的 CORS 策略(R2 不需要 Discourse 的 CORS rake 任务):

[
  {
    "AllowedOrigins": ["https://your-site.tld"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

第二部分 — 迁移现有的本地上传

rake uploads:migrate_to_s3 从环境变量读取 S3 配置 —— 它没有任何回退到站点设置的机制,无论你在管理界面中配置了什么。这是该任务的一个实际缺陷,而不是配置错误。请内联传递凭据以进行一次性运行,而不是修改 app.yml

./launcher enter app

RAILS_DB=default \
DISCOURSE_S3_REGION=auto \
DISCOURSE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
DISCOURSE_S3_BUCKET=<bucket name> \
DISCOURSE_S3_ACCESS_KEY_ID=<key> \
DISCOURSE_S3_SECRET_ACCESS_KEY=<secret> \
rake uploads:migrate_to_s3

这些环境变量仅存在于该 shell 进程中 —— 一旦你退出,它们就不会持久化。

较新 AWS SDK 版本上的校验和错误

如果你遇到以下错误:

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

这是最近的 aws-sdk-core 版本(默认发送 CRC32 校验和)与 R2 之间已知的不兼容性问题。通过在相同命令中添加两个环境变量来修复:

export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required

大部分成功运行后遗留的“未迁移”记录

如果任务完成时显示类似 1 of 1291 uploads are not migrated 的信息,不要惊慌 —— 其他所有内容已经迁移,并且数据库中的 URL 已经重写。在 rails c 中找到那个漏网之鱼:

base_url = File.join(SiteSetting.Upload.s3_base_url, "original/")
Upload.by_users.where("url NOT LIKE '#{base_url}%'").pluck(:id, :url, :original_filename)

在我的案例中,这是一个杂散的 Upload 记录(一个备份日志 zip 文件),它使用了 R2 的原始端点式 URL,而不是检查所期望的 CDN URL 格式 —— 这是一个误报,而不是真正的失败。如果它不是有意义的内容,请修复 URL 或删除该记录。

第三部分 — 静态资源(JS/CSS)—— 真正集群级别的部分

这是“仅针对一个站点”的目标遇到硬性障碍的地方。编译后的资源在 整个多站点集群中共享 —— 只有一个编译后的 JS/CSS 包,而不是每个站点一个。资源是从 R2 还是本地提供,是在 Rails 启动时通过 GlobalSetting.use_s3? 一次性决定的 —— 没有针对每个站点的覆盖选项。

如果你想将资源卸载到 R2,你必须将 连接详情(而不是启用上传的标志)放入 app.yml

env:
  DISCOURSE_USE_S3: true
  DISCOURSE_S3_REGION: auto
  DISCOURSE_S3_ENDPOINT: https://<account-id>.r2.cloudflarestorage.com
  DISCOURSE_S3_ACCESS_KEY_ID: "xxx"
  DISCOURSE_S3_SECRET_ACCESS_KEY: "xxx"
  DISCOURSE_S3_BUCKET: <bucket name>
  DISCOURSE_S3_CDN_URL: https://uploads.yourdomain.com
  AWS_REQUEST_CHECKSUM_CALCULATION: when_required
  AWS_RESPONSE_CHECKSUM_VALIDATION: when_required

hooks:
  after_assets_precompile:
    - exec:
        cd: $home
        cmd:
          - sudo -E -H -u discourse bundle exec rake s3:upload_assets
          - sudo -E -H -u discourse bundle exec rake s3:expire_missing_assets

注意事项:

  • 不要设置 DISCOURSE_CDN_URL 只设置 DISCOURSE_S3_CDN_URL。如果同时设置两者,并且你的主域名通过 Cloudflare 代理,根据主 S3 指南本身的警告,会导致重定向循环。
  • 使用 bundle exec rake,而不是 bundle rake(容易打错)—— 并使用 sudo -E -H -u discourse-Hdiscourse 用户正确设置 HOME;没有它,Bundler 每次运行都会回退到临时目录)。
  • 资源卸载会影响两个站点。 你的第二个站点的 <script>/<link> 标签也将开始解析到 R2 CDN URL,因为它们是同一个编译后的包。确保你的存储桶 CORS AllowedOrigins 包含每个站点的域名。
  • 不会 强制你的第二个站点的实际上传使用 S3 —— enable_s3_uploads 仍然是真正的站点级别设置,独立于资源提供的 GlobalSetting。在重建后,在该站点的数据库中通过 rails c 使用 SiteSetting.Upload.enable_s3_uploads 进行验证。

USE_DB_S3_CONFIG — 它实际做什么(以及不做什么)

你会在一些社区设置(例如 Bitnami 的 chart)中看到引用 USE_DB_S3_CONFIG=true,作为让 s3:upload_assets 从站点设置而不是环境变量读取凭据的方法。它对 上传任务本身 有效 —— 但它 不会 翻转 GlobalSetting.use_s3?,而后者才是实际控制资源 URL 在渲染时是否重写为 CDN 的标志。因此,你可以使用 USE_DB_S3_CONFIG 成功将文件推送到 R2,但仍然看到你的站点在本地提供资源,因为页面渲染检查从未看到“S3 已启用”。如果你希望资源实际上从 R2 提供,你需要在 app.yml 中设置真正的 DISCOURSE_USE_S3: true + 连接环境变量,而不仅仅是 DB 配置变通方法。

第四部分 — 仍然不会在 R2 上的内容,以及原因

即使钩子正常工作,s3:upload_assets 也只上传 Rails.application.assets.load_path 中的内容 —— 即 Rails 的 Sprockets 清单。有三类内容是在该管道 之外 生成的,永远不会出现在此列表中,因此无论怎样它们都保留在本地磁盘上:

  • 主题 CSS —— 由 Discourse 的 Stylesheet::Manager 根据每个主题/颜色方案动态编译,而不是通过 Sprockets。
  • theme-javascripts —— 由 ThemeJavascriptCompiler 编译的每个主题的 JS。
  • extra-locale / 本地化 JS 文件 —— 由 JsLocaleHelper 生成。

这不是配置问题 —— 这些从一开始就不是 Sprockets 资源,因此没有环境变量可以拉取它们。实际上这意味着:核心 Ember/vendor JS 包 → 成功卸载到 R2;主题 CSS/JS 和本地化 → 保留在本地,由应用直接提供。这是一种正常、工作状态,而不是损坏状态。

总结:实际应该在哪里放置什么

内容 位置 范围
enable_s3_uploads, s3_upload_bucket, backup_location, s3_backup_bucket Admin UI,按站点 站点级别
凭据 + DISCOURSE_S3_REGION/ENDPOINT/BUCKET/CDN_URL + DISCOURSE_USE_S3 app.yml仅当 你想要资源 CDN 卸载时 集群级别(不可避免)
after_assets_precompile 钩子 app.yml 集群级别
AWS_REQUEST_CHECKSUM_CALCULATION / AWS_RESPONSE_CHECKSUM_VALIDATION app.yml 集群级别(无害的 SDK 行为标志)
存储桶上的 CORS Cloudflare 仪表板 如果资源是共享的,必须包含每个站点的域名

如果你不需要资源 CDN 卸载,请完全跳过第三部分 —— 你可以运行一个完全正常、真正站点级别的 R2 设置(仅上传 + 备份),而无需触碰 app.yml

1 个赞