Files
project-infra/docs/examples/infra/db-and-migration.md
T

501 lines
15 KiB
Markdown

# db / migration 예시
모든 YAML은 `kubectl apply` 가능하다. 상세 Flyway Job 예시는
`docs/examples/infra/flyway.md` 참조.
---
## 좋은 예시 1: auth-server와 keycloak DB 경계 분리 (CNPG 2 cluster)
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg
namespace: data-prod
labels:
app.kubernetes.io/name: auth-pg
app.kubernetes.io/part-of: auth-platform
backup.platform.io/tier: gold
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
bootstrap:
initdb:
database: auth
owner: auth_app
secret: {name: auth-pg-app}
storage: {size: 50Gi, storageClass: fast-ssd-retain}
walStorage: {size: 20Gi, storageClass: fast-ssd-retain}
monitoring: {enablePodMonitor: true}
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {compression: gzip, maxParallel: 8}
data: {compression: gzip, immediateCheckpoint: true, jobs: 4}
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: keycloak-pg
namespace: data-prod
labels:
app.kubernetes.io/name: keycloak-pg
app.kubernetes.io/part-of: identity-platform
backup.platform.io/tier: gold
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
bootstrap:
initdb:
database: keycloak
owner: keycloak
secret: {name: keycloak-pg-app}
storage: {size: 30Gi, storageClass: fast-ssd-retain}
walStorage: {size: 10Gi, storageClass: fast-ssd-retain}
monitoring: {enablePodMonitor: true}
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/keycloak-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {compression: gzip, maxParallel: 8}
data: {compression: gzip, jobs: 4}
```
왜 좋은가:
- auth와 keycloak이 별도 CNPG cluster → 장애 / 업그레이드 영향 분리
- 각각 schema ownership이 분리되어 migration 파이프라인도 분리 가능
- 백업 destination path도 분리 → retention / 암호화 정책 독립
❌ 나쁜 예시 1: 하나의 cluster의 하나의 database에 두 서비스 schema
```yaml
# single CNPG cluster, database=shared
# auth-server uses schema "auth"
# keycloak uses schema "keycloak"
# one Flyway project manages both
```
문제:
- 서비스별 업그레이드 / restore 영향 격리 불가
- Flyway history가 서로 섞임
- 한 서비스가 lock을 오래 잡으면 다른 서비스가 멈춤
---
## 좋은 예시 2: migration을 Helm hook으로 app보다 먼저 실행
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
app.kubernetes.io/managed-by: Helm
annotations:
"helm.sh/hook": "pre-upgrade,pre-install"
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile: {type: RuntimeDefault}
containers:
- name: flyway
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
args: ["-X", "migrate"]
env:
- {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth"}
- {name: FLYWAY_USER, value: "auth_app"}
- {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"}
- {name: FLYWAY_SCHEMAS, value: "auth_server"}
- {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"}
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
- {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"}
- {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"}
- {name: FLYWAY_CLEAN_DISABLED, value: "true"}
- name: FLYWAY_PASSWORD
valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}}
resources:
requests: {cpu: "100m", memory: "256Mi"}
limits: {cpu: "1", memory: "1Gi"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: sql, mountPath: /flyway/sql, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: sql
configMap: {name: auth-flyway-sql}
- name: tmp
emptyDir: {}
```
왜 좋은가:
- Helm hook으로 app install/upgrade보다 **먼저** 실행 (`-10` weight)
- `before-hook-creation,hook-succeeded` 삭제 정책으로 이전 Job 깨끗이 정리
- `cleanDisabled=true` 명시 (실수로 `flyway clean` 방지)
- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800`
- digest pinning, restricted PSA
---
## 좋은 예시 3: Argo CD sync wave로 순서 지정
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
annotations:
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: flyway
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
args: ["-X", "migrate"]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.24.0
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
왜 좋은가:
- Argo CD는 sync-wave가 낮은 것부터 실행
- Helm hook과 혼용하지 않음 (한쪽만 사용)
❌ 나쁜 예시 2: Helm hook + Argo CD hook 혼용
```yaml
annotations:
"helm.sh/hook": "pre-upgrade"
"argocd.argoproj.io/sync-wave": "-1"
"argocd.argoproj.io/hook": Sync
```
문제:
- Argo CD가 Helm chart를 렌더링할 때 Helm hook을 일반 리소스로 취급해 sync 순서가 꼬임
- 실행이 중복되거나 누락됨
- 한 방식으로 통일할 것
---
## 좋은 예시 4: Expand → Migrate → Contract 3단계 릴리즈
### 배경
`users` 테이블의 `email` 컬럼 (NULL 허용)을 NOT NULL + 정규화된 `email_canonical` 컬럼으로 바꾸고 싶다.
### Release 1 — Expand
`V120__add_email_canonical_nullable.sql`:
```sql
-- flyway:executeInTransaction=false
ALTER TABLE users ADD COLUMN email_canonical text;
CREATE INDEX CONCURRENTLY idx_users_email_canonical ON users(email_canonical);
```
`V121__backfill_email_canonical.sql` (같은 릴리즈 또는 별도 배치 Job):
```sql
UPDATE users
SET email_canonical = lower(trim(email))
WHERE email_canonical IS NULL
AND email IS NOT NULL;
```
앱은 쓰기: `email` + `email_canonical` 둘 다 채움. 읽기: 여전히 `email`.
### Release 2 — Migrate
앱 읽기 경로를 `email_canonical`로 전환. 새 가입/수정은 `email_canonical`만 보장.
`V122__add_email_canonical_not_null.sql`:
```sql
-- 이 시점에는 모든 row에 email_canonical이 채워져 있어야 함
ALTER TABLE users ALTER COLUMN email_canonical SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_email_canonical_unique UNIQUE (email_canonical);
```
### Release 3 — Contract
앱이 `email` 컬럼을 더 이상 읽지/쓰지 않는 버전으로 완전히 롤아웃된 뒤.
`V130__drop_legacy_email_column.sql`:
```sql
ALTER TABLE users DROP COLUMN email;
```
왜 좋은가:
- 각 릴리즈가 N-1 ↔ N 동시 운영 가능
- `CREATE INDEX CONCURRENTLY``-- flyway:executeInTransaction=false`로 분리
- Contract는 backfill + 앱 전환이 모두 끝난 뒤 별도 릴리즈
❌ 나쁜 예시 3: 한 릴리즈에 expand + contract
```sql
-- V100__rename_email.sql
ALTER TABLE users RENAME COLUMN email TO email_old;
ALTER TABLE users ADD COLUMN email text NOT NULL DEFAULT '';
-- 앱이 어느 버전이든 장애 발생 가능
```
문제:
- rolling deploy 중간에 앱이 N-1 / N 모두 실행 → 컬럼 없음 / 이름 다름으로 에러
- rollback 시 DB 상태가 앞서가 있어 N-1 앱이 기동 안 됨
---
## 좋은 예시 5: PITR 복구 계획 (CNPG)
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg-restore
namespace: data-prod
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
storage: {size: 50Gi, storageClass: fast-ssd-retain}
walStorage: {size: 20Gi, storageClass: fast-ssd-retain}
bootstrap:
recovery:
source: auth-pg-source
recoveryTarget:
targetTime: "2026-04-16 09:45:00+00" # 잘못된 migration 직전
externalClusters:
- name: auth-pg-source
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {maxParallel: 8}
```
왜 좋은가:
- 운영 cluster는 건드리지 않고 `auth-pg-restore`로 복원
- `recoveryTarget.targetTime`을 분단위로 지정
- 복원 후 검증 → 운영 전환은 별도 runbook
---
## 좋은 예시 6: non-transactional DDL을 별도 migration 파일로
`V200__create_idx_users_last_login.sql`:
```sql
-- flyway:executeInTransaction=false
-- Long-running DDL. Run in low-traffic window.
-- Runtime estimate: ~15min on 50M rows.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
ON users(last_login_at);
```
왜 좋은가:
- `CREATE INDEX CONCURRENTLY`는 Postgres에서 트랜잭션 내 실행 불가
- Flyway 8.2+ `executeInTransaction=false` directive로 파일 단위 제어
- 주석에 runtime 추정치 / 영향 명시
❌ 나쁜 예시 4: 트랜잭션 내 CREATE INDEX CONCURRENTLY
```sql
-- V200__.sql (기본 트랜잭션 모드)
CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at);
-- → ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
```
문제:
- Flyway가 자동으로 트랜잭션을 열기 때문에 실패
- `-- flyway:executeInTransaction=false`가 필수
---
## 좋은 예시 7: 운영 절차 runbook snippet
```text
# auth-server DB schema change — 2026-04-16 02:00 UTC maintenance window
## Pre-check (T-1d)
1. Pending migration 검토: 로컬 `flyway info`
2. PR review + migration 영향 분석 문서 작성 (expand/migrate/contract 단계)
3. Backup 상태 확인:
kubectl -n data-prod get scheduledbackup auth-pg-daily
kubectl -n data-prod get backup -l cnpg.io/cluster=auth-pg --sort-by=.metadata.creationTimestamp
## T-5min
1. On-demand backup:
cat <<EOF | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: auth-pg-pre-$(date -u +%Y%m%dT%H%M%SZ)
namespace: data-prod
spec:
cluster: {name: auth-pg}
method: barmanObjectStore
EOF
2. Argo CD sync (dry-run):
argocd app diff auth-server-prod
## Apply
1. argocd app sync auth-server-prod
→ Flyway Job이 sync-wave -1로 먼저 실행
→ Deployment는 wave 0에서 롤아웃
2. Flyway Job 로그 확인:
kubectl -n auth-prod logs job/auth-flyway-migrate
3. Deployment rollout 확인:
kubectl -n auth-prod rollout status deploy/auth-server
## Post-check
1. flyway info (적용 결과)
2. 앱 스모크 테스트
3. DB 메트릭 (slow query, error rate)
4. Next PITR recovery point 확인
```
왜 좋은가:
- migration 직전 on-demand backup
- migration → app rollout 순서가 선언 (sync-wave)으로 보장됨
- 실패 시 PITR 복구 지점이 명확
---
## 나쁜 예시 5: 앱 시작 시 자동 migration
```yaml
# Spring Boot application.properties
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
# 앱이 기동될 때마다 Flyway migrate 수행
```
문제:
- replicas=3이면 3개 Pod가 동시에 migrate 시도 (Flyway advisory lock이 막아주지만 기동 latency 증가)
- app rollout 실패와 migration 실패가 섞임 — 원인 추적 어려움
- 신규 Pod가 기동되는 rolling restart 시에도 매번 validate 수행
---
## 나쁜 예시 6: pg_dump 하나만으로 운영 복구
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-dump-nightly
spec:
schedule: "0 3 * * *"
# ... pg_dumpall > /backup/dump.sql
```
문제:
- PITR 불가, RPO = 24h
- replication slot / extension / large object 누락
- 대규모 DB에서 restore 시간 폭증
- 같은 cluster 안 PVC에 저장하면 동시 소실
---
## 나쁜 예시 7: U__ undo migration 작성
```
flyway/
V120__add_column.sql
U120__drop_column.sql ← OSS Flyway는 실행 불가
```
문제:
- Flyway Community(OSS)는 undo 미지원 → `flyway undo`가 에러
- rollback 전략은 forward-only migration + PITR로 대체