콘텐츠로 이동

Deployment

Umbra 의 배포 파이프라인. bot/api/worker 3개 Go 프로세스는 Fly.io (asia-northeast nrt 리전), web 은 Vercel, DNS 는 Cloudflare. 마이그레이션은 Atlas, secrets 는 Fly secrets 로 관리한다.

Why this guide exists

운영 단계에서 "어떻게 배포하나, 어디에 secret 이 있나, 롤백은 어떻게 하나" 가 모호하면 incident 시 대응 시간이 길어진다. 이 문서는 배포 토폴로지와 절차를 명확히 한다.

Deployment topology

flowchart TB
    subgraph DNS["Cloudflare DNS"]
        direction LR
        D1["umbra.ink"]
        D2["app.umbra.ink"]
        D3["join.umbra.ink"]
        D4["api.umbra.ink"]
        D5["status.umbra.ink
(Phase 2)"] end subgraph Edge["Edge / Static"] Vercel["Vercel
umbra-web
(React SPA)"] end subgraph Fly["Fly.io — nrt region"] Bot["umbra-bot
Discord Gateway
1 instance"] API["umbra-api
HTTP server
2+ instances"] Worker["umbra-worker
asynq + Temporal + Outbox
1 instance"] end subgraph Data["Managed data services"] direction LR Neon["Neon
PostgreSQL"] Redis["Upstash
Redis"] Temporal["Temporal
Server"] end D1 --> Vercel D2 --> Vercel D3 --> Vercel D4 --> API Bot --> Neon Bot --> Redis API --> Neon API --> Redis Worker --> Neon Worker --> Redis Worker --> Temporal style DNS fill:#fef3c7,stroke:#a16207,color:#000 style Edge fill:#dbeafe,stroke:#1e40af,color:#000 style Fly fill:#e9d5ff,stroke:#6b21a8,color:#000 style Data fill:#d1fae5,stroke:#065f46,color:#000

프로세스별 배포 단위

  • umbra-bot (apps/bot) — Discord Gateway 연결. 단일 인스턴스 (Discord 가 sharding 미요구 규모).
  • umbra-api (apps/api) — HTTP server. 2+ 인스턴스 + Fly Proxy load balance.
  • umbra-worker (apps/worker) — asynq + Temporal worker + Outbox poller. 단일 인스턴스 (poller 순서 보장).
  • umbra-web (apps/web) — Vercel static + serverless functions.

Region

  • Fly.io: nrt (Tokyo) — 한국 사용자에 가장 가까움
  • Vercel: edge global, 자동
  • Neon: 한국 (Seoul) 또는 일본 region
  • Upstash: 일본 region

Repository → Deployment mapping

apps/bot/Dockerfile       → fly.io app: umbra-bot
apps/bot/fly.toml         → 설정

apps/api/Dockerfile       → fly.io app: umbra-api
apps/api/fly.toml

apps/worker/Dockerfile    → fly.io app: umbra-worker
apps/worker/fly.toml

apps/web/                 → Vercel project: umbra-web
                            (vercel.json 또는 자동 감지)

Dockerfile

공통 Go base + multi-stage:

# deployments/fly/Dockerfile.base
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG APP_PATH
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ${APP_PATH}/cmd

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /out/app /app
ENV TZ=Asia/Seoul
USER nobody
ENTRYPOINT ["/app"]

각 process 는 build arg 로 binary 선택:

# apps/bot/Dockerfile
FROM umbra-base AS final
ENV APP_PATH=./apps/bot

또는 shared script 호출:

# apps/bot/Dockerfile
ARG APP_PATH=./apps/bot
# inherit deployments/fly/Dockerfile.base

(상세 빌드 스크립트는 첫 배포 시 finalize)

Fly.io configuration

apps/api/fly.toml

app = "umbra-api"
primary_region = "nrt"

[build]
  dockerfile = "Dockerfile"

[env]
  PORT = "8080"
  APP_BASE_URL = "https://umbra.ink"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 2

  [[http_service.checks]]
    interval = "10s"
    timeout = "5s"
    grace_period = "10s"
    method = "GET"
    path = "/healthz"

[[vm]]
  size = "shared-cpu-1x"
  memory = "512mb"

apps/bot/fly.toml

app = "umbra-bot"
primary_region = "nrt"

[env]
  PORT = "8081"  # internal API for signal forwarding

# Bot 은 outbound only (Discord Gateway 연결)
# HTTP service 없음, internal 메트릭 endpoint 만

[[services]]
  internal_port = 8081
  protocol = "tcp"

  [[services.tcp_checks]]
    interval = "30s"
    timeout = "5s"

[[vm]]
  size = "shared-cpu-1x"
  memory = "512mb"
  count = 1  # 단일 인스턴스

apps/worker/fly.toml

app = "umbra-worker"
primary_region = "nrt"

[env]
  PORT = "8082"

[[services]]
  internal_port = 8082
  protocol = "tcp"

  [[services.tcp_checks]]
    interval = "30s"
    timeout = "5s"

[[vm]]
  size = "shared-cpu-2x"  # Temporal worker + asynq + outbox poller
  memory = "1gb"
  count = 1  # 단일 인스턴스 (poller 순서 보장)

Secrets management

Fly.io secrets

모든 production secret 은 Fly secrets 로:

# 한 번 설정 (배포 시 환경변수로 자동 주입)
fly secrets set DATABASE_URL="postgres://..." -a umbra-api
fly secrets set REDIS_URL="rediss://..." -a umbra-api
fly secrets set DISCORD_BOT_TOKEN="..." -a umbra-bot
fly secrets set TOSS_SECRET_KEY="live_sk_..." -a umbra-api
fly secrets set TOSS_WEBHOOK_SECRET="..." -a umbra-api
fly secrets set BILLING_KEY_ENCRYPTION_KEY="$(openssl rand -hex 32)" -a umbra-api
fly secrets set BILLING_KEY_ENCRYPTION_KEY="$(...)" -a umbra-worker  # 동일 값
fly secrets set SESSION_SECRET="..." -a umbra-api
fly secrets set TEMPORAL_HOST="..." -a umbra-worker

공유 secret 동기화

BILLING_KEY_ENCRYPTION_KEY 같이 여러 app 이 같은 값을 써야 하는 경우 수동 동기화 (실수 주의). Phase 2 에서 1Password 또는 Doppler 도입 검토.

Secret rotation

  • Discord bot token — Developer Portal 에서 회전 → fly secrets set
  • Toss secret key — Toss 대시보드 회전 → fly secrets set
  • BILLING_KEY_ENCRYPTION_KEY회전 불가 (기존 데이터 복호화 못 함). MVP 는 고정. Phase 2 에서 dual-key 도입.
  • SESSION_SECRET — 회전 시 모든 사용자 재로그인 필요

절대 금지

  • Secret 을 git 에 commit
  • Secret 을 fly.toml 의 [env] 에 작성
  • Production secret 을 로컬 .env 에 복사
  • Slack/이메일로 secret 전달 (1Password 사용)

CI/CD pipeline

GitHub Actions workflow

# .github/workflows/deploy-api.yml
name: Deploy API
on:
  push:
    branches: [main]
    paths:
      - 'apps/api/**'
      - 'engine/**'
      - 'platform/**'
      - 'go.mod'
      - 'go.sum'
      - 'db/**'
      - '.github/workflows/deploy-api.yml'

jobs:
  test:
    uses: ./.github/workflows/ci.yml

  migrate:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ariga/setup-atlas@v0
      - run: |
          atlas migrate apply \
            --env prod \
            --url "${{ secrets.PROD_DATABASE_URL }}"

  deploy:
    needs: migrate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only --config apps/api/fly.toml
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

Pipeline stages

  1. Lint — golangci-lint, biome
  2. Test — unit + integration (with Postgres/Redis services)
  3. Migrate — Atlas 로 prod DB schema 적용
  4. Deploy — Fly.io rolling deploy

순서가 중요: 마이그레이션이 먼저 (backwards-compatible 원칙으로 인해 새 코드 배포 전 schema 가 새 컬럼 갖고 있어야 함).

Per-app workflow

각 process 가 독립 workflow:

  • deploy-bot.ymlapps/bot/**, engine/**, platform/**
  • deploy-api.ymlapps/api/**, engine/**, platform/**
  • deploy-worker.ymlapps/worker/**, engine/**, platform/**

engine/, platform/ 변경 시 세 workflow 모두 trigger → 동시 배포.

Vercel (web)

Vercel 이 GitHub 연동 자동 처리:

  • main 브랜치 push → production 배포
  • PR → preview 배포 (PR 별 unique URL)

apps/web/vercel.json:

{
  "buildCommand": "bun run build",
  "outputDirectory": "dist",
  "framework": "vite",
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Migration strategy

Backwards-compatible 원칙

새 코드 배포 전에 schema 가 새 컬럼/테이블 가져야 함:

  1. PR-1: 컬럼 추가 (nullable 또는 DEFAULT)
  2. PR-2: 새 컬럼 사용 코드 배포
  3. PR-3: 기존 컬럼 사용 중단 (있다면)
  4. PR-4: 기존 컬럼 삭제 (다음 release cycle)

Atlas apply

# Workflow 자동 실행
atlas migrate apply --env prod --url "$PROD_DATABASE_URL"

수동 확인 필요한 변경 (DROP COLUMN 등) 은 별도 PR + 운영자 승인.

상세는 data/migration-strategy.md 참조.

Rollback

Code rollback

Option 1: Fly rollback

fly releases -a umbra-api
fly releases rollback v123 -a umbra-api

장점: 즉시. 단점: schema 이미 변경됐으면 호환 안 될 수 있음.

Option 2: Forward fix

새 PR 로 문제 코드 revert → 정상 deploy.

장점: schema 와 code 일관. 단점: 시간 소요.

권장

  • Hot bug (사용자 영향 큼): Fly rollback + 즉시 forward fix PR
  • Minor bug: Forward fix only

Schema rollback

Atlas 는 down migration 자동 생성 안 함. 두 경로:

  1. Forward fix (안전) — 새 마이그레이션으로 복구
  2. Neon PITR (위험) — Point-in-time recovery, 데이터 손실 위험

Production schema rollback 은 반드시 팀 논의 후.

Web rollback

# Vercel 대시보드에서 이전 deployment promote
# 또는
vercel rollback {deployment-url}

Health checks

Liveness vs readiness

  • /healthz — liveness (프로세스 살아있음). DB 안 봄.
  • /readyz — readiness (트래픽 받을 준비). DB / Redis 연결 확인.
e.GET("/healthz", func(c echo.Context) error {
    return c.JSON(200, map[string]string{"status": "ok"})
})

e.GET("/readyz", func(c echo.Context) error {
    if err := pool.Ping(c.Request().Context()); err != nil {
        return c.JSON(503, map[string]string{"status": "db_unavailable"})
    }
    if err := redisClient.Ping(c.Request().Context()).Err(); err != nil {
        return c.JSON(503, map[string]string{"status": "redis_unavailable"})
    }
    return c.JSON(200, map[string]string{"status": "ready"})
})

Fly.io 의 http_service.checks 는 /healthz 사용 (간단). Readiness 는 별도 모니터링.

Bot / Worker (no HTTP)

내부 metric endpoint (포트 8081, 8082) 에 healthz expose:

mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(200)
    w.Write([]byte("ok"))
})
go http.ListenAndServe(":8081", mux)

Observability

Metrics

OpenTelemetry → Grafana Cloud:

  • HTTP request duration / count / error rate
  • DB query latency
  • Redis ops
  • Temporal workflow execution time
  • asynq queue depth
  • Outbox unpublished count

Logs

  • 구조화된 slog
  • Fly.io 가 자동 stdout 수집 → Grafana Loki (or Fly logs)
  • 검색: request_id, guild_id, subscription_id

Alerts

Phase 2 에서 본격 도입. MVP 는 간단:

  • Critical — outbox dead letter 발생, payment_attempts.failed 비율 급증, healthcheck fail
  • Warning — DB connection pool 90%+ 사용, slow query 증가
  • Info — 일일 스냅샷 / archive cron 결과

알림 채널: Discord 운영 채널 webhook (Phase 1) → Slack/PagerDuty (Phase 2).

Deployment cadence

MVP 단계

  • main merge 시 자동 배포
  • Hot fix 외에는 weekday 작업 시간 (9-18시 KST) 권장
  • 결제 관련 변경은 사용자 트래픽 적은 시간 (새벽) 권장

Phase 2

  • Staging environment 추가 (별도 Neon branch + Fly app)
  • 수동 promote 게이트 검토
  • Canary deploy (Fly machine 단위)

Disaster recovery

Backup

  • Neon — PITR 자동 (7일 retention free tier)
  • Outbox — 30일 retention
  • Audit — 영구 보관

Recovery scenario

Scenario Recovery
Bot crash Fly auto-restart
API instance crash Load balancer 가 healthy instance 로
Worker crash Temporal 이 다른 worker 에 재할당 (단일 인스턴스라 잠시 중단)
DB primary failure Neon 자동 failover
Region failure (nrt) Phase 2 — multi-region 검토
데이터 손상 Neon PITR + outbox replay
Discord bot token leak 즉시 회전 + 모든 가입 사용자에게 안내
Toss key leak 즉시 회전 + 결제 일시 중단 + Toss 대시보드 모니터링

Incident playbook

  • docs/runbooks/ (Phase 2 작성 예정) — 시나리오별 단계
  • 운영 채널: Discord (현재) → PagerDuty (Phase 2)

Cost monitoring

Fly.io

  • bot: ~$5/mo (shared-cpu-1x, 1 instance)
  • api: ~$10/mo (shared-cpu-1x × 2)
  • worker: ~$15/mo (shared-cpu-2x, 1 instance)

Vercel

  • web: free tier 또는 $20/mo (Hobby vs Pro)

Neon

  • $19/mo (Launch tier) 또는 $69/mo (Scale tier)

Upstash Redis

  • ~$10/mo (Pay-as-you-go)

Total

  • MVP: ~$60-100/mo
  • Discord bot 만큼 작아도 운영 가능, 트래픽 증가 시 elastic

월별 모니터링: Fly dashboard, Vercel usage, Neon usage page.

Domain & DNS

Cloudflare 설정

Subdomain Type Target
umbra.ink CNAME Vercel (umbra-web.vercel.app)
app.umbra.ink CNAME Vercel
join.umbra.ink CNAME Vercel
api.umbra.ink CNAME Fly.io (umbra-api.fly.dev)
_acme-challenge.* TXT Let's Encrypt verification

Cloudflare 가 SSL 자동, Vercel/Fly 도 자체 SSL 보유.

TLS

  • 모든 트래픽 HTTPS (force_https = true in fly.toml)
  • HSTS header (Phase 2)

Pre-launch checklist

런칭 전 확인:

  • 모든 secret Fly 에 등록
  • BILLING_KEY_ENCRYPTION_KEY 안전하게 보관 (1Password 등)
  • Toss live key 적용 (test → live)
  • Discord bot token live 환경 적용
  • DNS 모든 subdomain 정상 동작
  • HTTPS 강제
  • Health check 정상
  • 첫 배포 후 smoke test (실제 길드 invite, OAuth 로그인)
  • 로그 / metric 수집 확인
  • 운영 채널 alert 설정
  • Backup (Neon) 활성 확인

Do / Don't

Do

  • ✅ Backwards-compatible migration
  • ✅ Migration → code deploy 순서
  • ✅ Secret 은 Fly secrets 로만
  • ✅ 변경마다 ChangeLog (git history) 명확히
  • ✅ Health check endpoint 분리 (liveness vs readiness)
  • ✅ 한 PR 한 변경 (큰 PR 지양)

Don't

  • ❌ Secret 을 git, env file, Slack 으로 전달
  • ❌ Schema 수동 변경 (Atlas 우회)
  • ❌ Production DB 직접 쿼리 수정 (마이그레이션으로만)
  • ❌ Force-push to main
  • ❌ Worker 수평 확장 (poller 순서 깨짐 — 보호 장치 추가 후만)
  • ❌ Encryption key 회전 (MVP 는 고정)

See also

  • getting-started.md
  • project-structure.md
  • database-conventions.md
  • testing-strategy.md
  • ../data/migration-strategy.md
  • ../adr/0010-deploy-fly-vercel.md
  • ../adr/0004-database-neon-postgres.md
  • ../adr/0017-process-separation-bot-api-worker.md