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 선택:
또는 shared script 호출:
(상세 빌드 스크립트는 첫 배포 시 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¶
- Lint — golangci-lint, biome
- Test — unit + integration (with Postgres/Redis services)
- Migrate — Atlas 로 prod DB schema 적용
- Deploy — Fly.io rolling deploy
순서가 중요: 마이그레이션이 먼저 (backwards-compatible 원칙으로 인해 새 코드 배포 전 schema 가 새 컬럼 갖고 있어야 함).
Per-app workflow¶
각 process 가 독립 workflow:
deploy-bot.yml—apps/bot/**,engine/**,platform/**deploy-api.yml—apps/api/**,engine/**,platform/**deploy-worker.yml—apps/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 가 새 컬럼/테이블 가져야 함:
- PR-1: 컬럼 추가 (nullable 또는 DEFAULT)
- PR-2: 새 컬럼 사용 코드 배포
- PR-3: 기존 컬럼 사용 중단 (있다면)
- PR-4: 기존 컬럼 삭제 (다음 release cycle)
Atlas apply¶
수동 확인 필요한 변경 (DROP COLUMN 등) 은 별도 PR + 운영자 승인.
상세는 data/migration-strategy.md 참조.
Rollback¶
Code rollback¶
Option 1: Fly rollback¶
장점: 즉시. 단점: 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 자동 생성 안 함. 두 경로:
- Forward fix (안전) — 새 마이그레이션으로 복구
- Neon PITR (위험) — Point-in-time recovery, 데이터 손실 위험
Production schema rollback 은 반드시 팀 논의 후.
Web rollback¶
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 = truein 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.mdproject-structure.mddatabase-conventions.mdtesting-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