실제 마이그레이션에 대한 사후 분석(post-mortem) 및 런북(runbook)입니다. 공식 문서에 이미 다루고 있는 일반적인 Discourse 사전 준비 과정은 생략합니다. 정확한 설정 전환, Cloudflare R2의 주의사항, 중요한 rails/rake 원라이너, 실패한 부분, 그리고 다음에 같은 작업을 낮은 리스크로 수행하는 방법에 초점을 맞춥니다.
목표 최종 상태
- Discourse가 새 호스트에서 실행됩니다 (Docker, 단일
app컨테이너). - 업로드 및 프론트엔드 자원은 Cloudflare R2에 저장됩니다:
- 버킷
discourse-uploads(공개) - 버킷
discourse-backups(비공개)
- 버킷
- R2 커스텀 도메인:
https://files.example.com(R2 → Custom domains에서 생성, 수동 크로스-계정 CNAME이 아님).
0) 실제로 작동하는 DB 백업 (야간 및 전환 시)
야간 백업은 재해 복구를 위한 것입니다. 마지막 순간의 백업은 마이그레이션 전환을 위한 것입니다. 둘 다 유지해야 합니다.
0.1 정책
- 야간: DB 전용 백업 (
.sql.gz, 업로드 없음) → 로컬에서 검증 → R2에 업로드. 최소 7개 복사본 유지 (또는 R2 라이프사이클 사용). - 전환: DNS 전환 직전에 DB 전용 백업을 한 번 더 생성하고, 이를 새 호스트에 복원하여 콘텐츠 격차를 최소화합니다.
0.2 DB 전용 백업 생성 및 검증
컨테이너 내부에서:
# 선택 사항이지만 좋은 방법: 스냅샷 생성 중 쓰기 작업 감소
discourse enable_readonly
# Admin UI에서 DB 전용 백업 트리거 ("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
심층 검증 (최선): 임시 DB에 복원하고 행 수를 확인합니다:
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~4GB는 정상)
Admin 야간 백업과 수동 pg_dump 모두 DB 전용 .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) 구 호스트: (검증된) DB 전용 백업 준비 및 복사
유지보수 공지 → 읽기 전용 활성화:
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) DB 전용 덤프 복원 (.sql.gz via psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# 깨끗한 DB 보장
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 (비공개) 생성. 두 버킷에 대해 Admin Read & Write 권한이 있는 Account API Token으로 부트스트랩(PutBucketCors 작동 보장), 성공 후 Object Read & Write로 회전.
커스텀 도메인: DNS 존과 같은 Cloudflare 계정에서 R2 → Custom domains에 files.example.com 추가 (1014 크로스-계정 CNAME 오류 방지).
discourse-uploads의 CORS:
[
{
"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을 새 프론트(또는 오리진) 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
부트스트랩(버킷 수준 CORS 작업)에는 Admin RW 토큰을 사용하고, 성공 후 Object RW로 회전하세요.
8) 검증
컨테이너 내부
# 이제 CDN을 사용하는 URL
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/...아래에 표시됨.
백업
- Admin → Backups → 하나 생성; 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 토큰 → Object RW; 로그에 노출된 경우 SMTP 앱 비밀번호).
10) 다음번을 위한 (플레이북) — R2 우선 경로
- 구 → 새 (DB 전용): 읽기 전용 → 백업 →
psql로.sql.gz복원. - DNS 이전 R2 연결: 버킷, 토큰 (Admin RW → 이후 Object RW), 커스텀 도메인, CORS.
env+hooks: 체크섬 플래그 +s3:upload_assets; 재빌드.- DNS 전환을 새 호스트로.
- 업로드를 R2로 마이그레이션.
- 잔여 항목 수정 (표적 재베이킹/재매핑) → 마이그레이션 빠른 재실행.
- Sidekiq가 백그라운드 재베이킹 완료 (또는
posts:rebake_uncooked_posts). - R2로의 백업 검증.
- 권한 강화 및 비밀키 회전.
- 쿨링오프 기간 후 로컬 업로드 정리.
부록 A — “업로드 전 검증” 야간 작업 (위조 크론)
LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)
# 1) gzip 무결성
gzip -t "$LATEST" || exit 1
# 2) 임시 DB 행 수
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를 종료하고 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 헤더 예상