Пошаговая миграция Discourse с интеграцией R2 и Cloudflare

Это постмортем/руководство по реальной миграции. Я пропускаю общую подготовку Discourse (официальная документация это покрывает). Я сосредоточусь на конкретных переключателях, подводных камнях Cloudflare R2, однострочных командах rails/rake, которые имели значение, что пошло не так и как сделать такой же переход с низким риском в следующий раз.


Целевое конечное состояние

  • Discourse работает на новом хосте (Docker, один контейнер app).
  • Загрузки и фронтенд-активы размещены на Cloudflare R2:
    • Бакет discourse-uploads (публичный)
    • Бакет discourse-backups (приватный)
  • Пользовательский домен R2: https://files.example.com (создан в разделе R2 → Custom domains, а не через ручную CNAME-запись между аккаунтами).

0) Резервные копии БД, которые действительно работают (ежедневные и при переключении)

Ежедневные резервные копии предназначены для аварийного восстановления. Резервная копия в последний момент нужна для переключения при миграции. Сохраняйте обе.

0.1 Политика

  • Ежедневно: резервная копия только БД (.sql.gz, без загрузок) → проверка локальнозагрузка в R2. Храните ≥7 копий (или используйте жизненный цикл R2).
  • При переключении: непосредственно перед сменой DNS создайте еще одну резервную копию только БД и восстановите её на новом хосте, чтобы минимизировать разрыв в контенте.

0.2 Создание резервной копии только БД и проверка

Внутри контейнера:

# Опционально, но желательно: уменьшить запись во время создания снимка
discourse enable_readonly

# Запуск резервного копирования только БД через админ-интерфейс (снимите галочку "with uploads")
# или через 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 ГБ — это нормально)

И ежедневные резервные копии из админки, и ручная команда 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 с производственными значениями. Шаблон 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 / Email 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) Восстановление дампа только БД (.sql.gz через psql)

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 (приватный). Инициализируйте с помощью Account API Token с правами на эти два бакета Admin Read & Write (чтобы работала PutBucketCors), затем после успеха замените его на Object Read & Write.

Пользовательский домен: добавьте files.example.com в разделе R2 → Custom domains в том же аккаунте Cloudflare, что и ваша DNS-зона (избегайте ошибок CNAME между аккаунтами 1014).

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 posts not remapped…», см. §7.2 для точечных исправлений.


6) Переключение производственного домена

Установите в app.yml:

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

DNS: укажите forum.example.com на новый фронтенд (или origin) IP, включите шаблоны SSL, затем:

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

Проверка:

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 posts are not remapped to new S3 upload URL»

Причина: некоторые cooked HTML-теги все еще ссылаются на /uploads/<db>/original/....

Точечная пересборка (rebake):

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 + env:

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'

В браузере

  • Вкладка Network показывает активы с files.example.com.
  • Старые темы показывают изображения под https://files.example.com/original/....

Резервные копии

  • Admin → Backups → создайте одну; убедитесь, что новый объект появился в discourse-backups на R2.

9) Очистка

Когда cooked-ссылки практически исчезнут:

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-first

  1. Старый → Новый (только БД): режим только для чтения → резервная копия → восстановление .sql.gz через psql.
  2. Подключите R2 до DNS: бакеты, токен (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) подсчет строк во временной БД
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 и пересылать запросы на origin по 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;
        # опциональная проверка:
        # 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 лайков