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

**URL:** https://meta.discourse.org/t/step-by-step-discourse-migration-with-r2-and-cloudflare-integration/382491
**Category:** Sysadmins
**Tags:** migrations, s3, cloudflare
**Created:** [9월 13, 2025, 1:31오전 UTC](https://meta.discourse.org/t/step-by-step-discourse-migration-with-r2-and-cloudflare-integration/382491 "2025-09-13T01:31:47Z")
**Posts on this page:** 1
**Showing post:** 1

<div class="post-metadata">

### Author: ![hel\_Sinki](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/hel_sinki/32/525916_2.png) [@hel\_Sinki](https://meta.discourse.org/u/hel_Sinki)
#### Post date: [9월 13, 2025, 1:31오전 UTC](https://meta.discourse.org/t/step-by-step-discourse-migration-with-r2-and-cloudflare-integration/382491/1 "2025-09-13T01:31:47Z")

</div>

실제 마이그레이션에 대한 사후 분석(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 전용 백업 생성 및 검증

컨테이너 내부에서:

```bash
# 선택 사항이지만 좋은 방법: 스냅샷 생성 중 쓰기 작업 감소
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** 에 복원하고 행 수를 확인합니다:

```bash
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에 푸시

```bash
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`를 생성합니다. 크기 차이는 주로 포함된 테이블과 압축에서 비롯되며, “누락된 게시물” 때문은 아닙니다. 내부 내용을 확인하려면:

```bash
# 덤프에 데이터가 있는 테이블은?
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 전용 백업 준비 및 복사

유지보수 공지 → 읽기 전용 활성화:

```bash
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit

```

검증된 `.sql.gz`를 새 호스트로 복사:

```bash
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 설치:

```bash
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` 설정:

```yaml
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에 자산 게시:

```yaml
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 전용):

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

```

* * *

## 3) DB 전용 덤프 복원 (`.sql.gz` via psql)

```bash
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** :

```json
[
  {
    "AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
    "AllowedMethods": ["GET","HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["*"],
    "MaxAgeSeconds": 86400
  }
]

```

CSS/JS/폰트가 R2에 게시되도록 재빌드:

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

```

* * *

## 5) 과거 업로드의 R2 일회성 마이그레이션

```bash
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`에 설정:

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

```

**DNS** : `forum.example.com`을 새 프론트(또는 오리진) IP로 가리키고, SSL 템플릿을 활성화한 후:

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

```

확인:

```bash
curl -I https://forum.example.com
./launcher logs app | tail -n 200

```

익명 사용자에게 `HTTP/2 403`이 표시되는 것은 일반적으로 장애가 아니라 `login_required`를 의미합니다.

* * *

## 7) 실제로 깨진 것 (및 수정 방법)

### 7.1 R2 체크섬 충돌

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

```

**수정** (영구 유지):

```yaml
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):**

```bash
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}"
'

```

또는 정적 접두사를 재매핑한 후 수정된 게시물을 재베이킹:

```bash
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"

```

깨끗함을 확인하기 위해 마이그레이션 재실행:

```bash
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와 함께 실행:

```bash
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 설정 출력:

```bash
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) 검증

**컨테이너 내부**

```bash
# 이제 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이 되면:

```bash
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 — “업로드 전 검증” 야간 작업 (위조 크론)

```bash
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`

```nginx
upstream origin_forum {
    server <ORIGIN_IP>:443;
    keepalive 64;
}

```

**Site:** `/etc/nginx/sites-available/forum.conf`

```nginx
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;
    }
}

```

활성화 및 재로드:

```bash
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

```

**빠른 확인:**

```bash
curl -I https://forum.example.com # HTTP/2 200/302 및 X-Relay 헤더 예상

```

---

_[View the full topic](https://meta.discourse.org/t/step-by-step-discourse-migration-with-r2-and-cloudflare-integration/382491)._
