Mutisite e objetos do Cloudflare R2

Usando Cloudflare R2 em apenas um site em uma instalação multisite do Discourse

Se você executa uma instalação multisite do Discourse e deseja colocar apenas um dos seus sites no Cloudflare R2 (uploads, backups e, opcionalmente, assets estáticos), o guia padrão Configure an S3 compatible object storage provider for uploads não cobre completamente a peculiaridade do multisite. Este post detalha o que realmente acontece, o que permanece por site versus em todo o cluster, e algumas armadilhas que encontrei ao longo do caminho e que custaram tempo real para serem resolvidas.

O ponto central a entender: GlobalSetting vs SiteSetting

Tudo neste setup se resume a uma distinção:

  • Variáveis de ambiente do app.yml (DISCOURSE_*) tornam-se GlobalSettings — lidas uma vez na inicialização do container, a partir do ambiente do processo, compartilhadas por todos os sites no cluster. RAILS_DB não tem efeito sobre elas.
  • Campos da interface de administração (Admin UI) são SiteSettings comuns — armazenados por site no banco de dados próprio de cada site, genuinamente limitados a aquele único site.

Se uma GlobalSetting existir para algo, ela silenciosamente sobrescreve e oculta o campo correspondente de SiteSetting na interface de administração. Isso significa: o que você colocar no app.yml se aplica a todos os sites, sem exceções, sem contorno via RAILS_DB.

Parte 1 — Uploads e backups (genuinamente por site, fácil)

Esta parte funciona exatamente como você esperaria. enable_s3_uploads, s3_upload_bucket, backup_location, s3_backup_bucket e os campos de credenciais são todos configurações de site comuns. Configure-os apenas através de Admin → Settings → pesquise “S3”, logado no site específico que você deseja no R2, e deixe o app.yml intacto. Outros sites no cluster continuam armazenando localmente.

Valores de exemplo para R2:

Enable S3 uploads = true
Enable direct S3 uploads = true
S3 access key ID / secret access key = <seu token R2>
S3 region = auto
S3 upload bucket = <nome do bucket>
S3 endpoint = https://<account-id>.r2.cloudflarestorage.com
S3 CDN URL = https://uploads.yourdomain.com
S3 use ACLs = false   (R2 usa permissões em nível de bucket, não ACLs de objeto)
S3 backup bucket = <nome do bucket de backup>
Backup location = S3

Defina a política CORS do seu bucket diretamente no painel do Cloudflare (R2 não precisa da tarefa rake de CORS do Discourse):

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

Parte 2 — Migrando uploads locais existentes

rake uploads:migrate_to_s3 apenas lê a configuração do S3 das variáveis de ambiente — não tem nenhum recurso de fallback para configurações de site, independentemente do que esteja configurado na interface de administração. Esta é uma lacuna real na tarefa, não um erro de configuração. Passe as credenciais inline para uma execução única em vez de tocar no app.yml:

./launcher enter app

RAILS_DB=default \
DISCOURSE_S3_REGION=auto \
DISCOURSE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
DISCOURSE_S3_BUCKET=<nome do bucket> \
DISCOURSE_S3_ACCESS_KEY_ID=<chave> \
DISCOURSE_S3_SECRET_ACCESS_KEY=<segredo> \
rake uploads:migrate_to_s3

Essas variáveis de ambiente existem apenas para aquele processo de shell — nada persiste após você sair.

Erro de checksum em versões mais novas do AWS SDK

Se você encontrar:

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

esta é uma incompatibilidade conhecida entre versões recentes do aws-sdk-core (que por padrão enviam um checksum CRC32) e o R2. Corrija adicionando duas variáveis de ambiente adicionais ao mesmo comando:

export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required

Registros “não migrados” restantes após uma execução majoritariamente bem-sucedida

Se a tarefa terminar com algo como 1 of 1291 uploads are not migrated, não entre em pânico — tudo o mais já foi migrado e as URLs do banco de dados já foram reescritas. Encontre o atrasado no 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)

No meu caso, foi um registro Upload avulso (um zip de log de backup) usando a URL de endpoint bruto do R2 em vez do formato de URL do CDN que a verificação espera — um falso positivo, não uma falha real. Corrija a URL ou exclua o registro se não for conteúdo significativo.

Parte 3 — Assets estáticos (JS/CSS) — a parte que é genuinamente em todo o cluster

É aqui que o objetivo de “apenas para um site” encontra um muro sólido. Os assets compilados são compartilhados em todo o cluster multisite — há um único bundle de JS/CSS compilado, não um por site. Se os assets são servidos do R2 ou localmente é decidido uma vez, na inicialização do Rails, via GlobalSetting.use_s3? — não há sobrescrita por site para isso.

Se você deseja descarregar os assets para o R2, você precisa colocar os detalhes de conexão (não a flag de habilitação de upload) no 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: <nome do bucket>
  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

Notas:

  • Não defina DISCOURSE_CDN_URL. Apenas DISCOURSE_S3_CDN_URL. Definir ambos, com seu domínio principal proxied através do Cloudflare, causa loops de redirecionamento, conforme o próprio aviso do guia principal de S3.
  • Use bundle exec rake, não bundle rake (erro de digitação fácil) — e use sudo -E -H -u discourse (o -H define o HOME corretamente para o usuário discourse; sem isso, o Bundler recorre a um diretório temporário a cada execução).
  • O descarregamento de assets afeta ambos os sites. As tags <script>/<link> do seu segundo site também começarão a resolver para a URL do CDN do R2, já que é o mesmo bundle compilado. Certifique-se de que AllowedOrigins do CORS do seu bucket inclua o domínio de cada site.
  • Isso não força os uploads reais do seu segundo site para o S3 — enable_s3_uploads permanece uma configuração genuinamente por site, independente da GlobalSetting de serviço de assets. Verifique com SiteSetting.Upload.enable_s3_uploads no rails c para o banco de dados desse site após a reconstrução.

USE_DB_S3_CONFIG — o que ele realmente faz (e não faz)

Você verá USE_DB_S3_CONFIG=true referenciado em alguns setups da comunidade (ex.: chart da Bitnami) como uma maneira de fazer s3:upload_assets ler credenciais das configurações de site em vez de variáveis de ambiente. Funciona para a tarefa de upload em si — mas não altera GlobalSetting.use_s3?, que é a flag que realmente controla se as URLs de assets são reescritas para o CDN no momento da renderização. Então, você pode empacotar arquivos para o R2 com sucesso usando USE_DB_S3_CONFIG e ainda ver seu site servindo assets localmente, porque a verificação de renderização da página nunca vê “S3 está habilitado”. Se você deseja que os assets sejam realmente servidos do R2, você precisa da real DISCOURSE_USE_S3: true + variáveis de ambiente de conexão no app.yml, não apenas o contorno de configuração do banco de dados.

Parte 4 — O que ainda não estará no R2, e por quê

Mesmo com o hook funcionando, s3:upload_assets apenas envia o que está em Rails.application.assets.load_path — o manifesto Sprockets do Rails. Três categorias são geradas fora desse pipeline e nunca aparecem nesta lista, então elas permanecem no disco local, independentemente do que for feito:

  • CSS de temas — compilado dinamicamente por tema/esquema de cores pelo Stylesheet::Manager do Discourse, não através do Sprockets.
  • theme-javascripts — JS compilado por tema do ThemeJavascriptCompiler.
  • extra-locale / arquivos JS de localização — gerados pelo JsLocaleHelper.

Isso não é um problema de configuração — estes nunca foram assets Sprockets para começar, então não há variável de ambiente que os inclua. Na prática, isso significa: bundle de JS core Ember/vendor → descarregado para o R2 com sucesso; CSS/JS de temas e localizações → permanecem locais, servidos diretamente pelo app. Este é um estado normal e funcional, não um quebrado.

Resumo: o que colocar onde

O quê Onde Escopo
enable_s3_uploads, s3_upload_bucket, backup_location, s3_backup_bucket Interface de Administração, por site Por site
Credenciais + DISCOURSE_S3_REGION/ENDPOINT/BUCKET/CDN_URL + DISCOURSE_USE_S3 app.yml, apenas se você deseja descarregamento de CDN de assets Em todo o cluster (inevitável)
Hook after_assets_precompile app.yml Em todo o cluster
AWS_REQUEST_CHECKSUM_CALCULATION / AWS_RESPONSE_CHECKSUM_VALIDATION app.yml Em todo o cluster (flag de comportamento do SDK inofensiva)
CORS no bucket Painel do Cloudflare Deve incluir o domínio de cada site se os assets forem compartilhados

Se você não precisar de descarregamento de CDN de assets, pule a Parte 3 inteiramente — você pode executar um setup R2 totalmente funcional e genuinamente por site (apenas uploads + backups) sem nunca tocar no app.yml.

1 Curtiu