Это постмортем/руководство по реальной миграции. Я пропускаю общую подготовку 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
- Старый → Новый (только БД): режим только для чтения → резервная копия → восстановление
.sql.gzчерезpsql. - Подключите R2 до DNS: бакеты, токен (Admin RW → позже Object RW), пользовательский домен, 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 — Минимальный фронтенд-прокси (опционально)
Небольшая 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