R2 및 Cloudflare 통합을 활용한 단계별 Discourse 마이그레이션

실제 마이그레이션에 대한 사후 분석(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 domainsfiles.example.com 추가 (1014 크로스-계정 CNAME 오류 방지).

discourse-uploadsCORS:

[
  {
    "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 우선 경로

  1. 구 → 새 (DB 전용): 읽기 전용 → 백업 → psql.sql.gz 복원.
  2. DNS 이전 R2 연결: 버킷, 토큰 (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 — “업로드 전 검증” 야간 작업 (위조 크론)

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 헤더 예상
6개의 좋아요