RDS 연동과 마이그레이션 운영
VPC 안의 RDS Postgres Multi-AZ, Security Group 설계, Secrets Manager로 비밀번호 주입, Alembic / Django migrations의 운영 흐름, blue/green 호환 마이그레이션 패턴까지 정리합니다.
22장 인프라 골격에서 ECS Fargate 위에 블로그 API를 띄웠지만, DB가 메모리 안에 있었습니다. 본 챕터는 그 부분을 RDS Postgres Multi-AZ로 옮기고, 마이그레이션을 운영 트래픽을 죽이지 않고 굴리는 방법을 정리합니다.
11장 RDS가 콘솔에서 만든 첫 RDS 였다면, 본 챕터는 그 위에서 운영 패턴을 다룹니다. VPC 분리, Secrets 주입, 그리고 마이그레이션 패턴까지 한 번에 잡습니다. 4부의 두 번째 챕터로, 콘솔에서 손으로 만들던 DB를 운영 가능한 형태로 끌어올리는 단계입니다.
큰 그림 — DB를 어디에 두는가 #
Fargate Task (Private Subnet)
│
│ DATABASE_URL (Secrets Manager 에서)
▼
sg-rds ← 5432 ← sg-fargate
│
▼
RDS Postgres Multi-AZ (DB Subnet Group)
Primary (AZ-a)
Standby (AZ-c) ← Failover 시 승격핵심 원칙은 셋입니다.
- DB는 인터넷에서 안 보입니다. Private subnet 또는 격리된 DB Subnet Group에 둡니다.
- 비밀번호는 코드에 없습니다. Secrets Manager에서 Task Definition의
secrets필드로 주입합니다. - 마이그레이션은 배포 단계로 분리합니다. 컨테이너 시작 시
migrate를 돌리지 않습니다.
1) DB Subnet Group 만들기 #
RDS는 DB Subnet Group 안에서만 살 수 있습니다. Multi-AZ를 켜든 안 켜든 최소 2개의 AZ에 걸친 서브넷이 필요합니다.
aws rds create-db-subnet-group \
--db-subnet-group-name blog-db-subnets \
--db-subnet-group-description "Blog API DB subnets" \
--subnet-ids subnet-db-a subnet-db-c이 서브넷들은 인터넷 게이트웨이로 가는 라우트가 없는 구성이어야 합니다. 외부 접근은 ALB / Fargate를 통해서만 이루어집니다.
2) DB 전용 Security Group #
sg-rds inbound:
port 5432 ← sg-fargate ← Fargate task 만
port 5432 ← sg-bastion ← (선택) 운영자 점프 호스트
sg-rds outbound:
필요 없음 (default 에서 좁히기)여기서도 source를 IP 대역이 아니라 sg-fargate (다른 SG)로 둡니다. Fargate task가 아무리 늘어나도 자동으로 적용되며, CIDR 변경에 따라 SG를 갱신할 일이 없습니다.
aws ec2 create-security-group \
--group-name sg-rds \
--description "RDS allow from Fargate" \
--vpc-id $VPC_ID
aws ec2 authorize-security-group-ingress \
--group-id $SG_RDS \
--protocol tcp --port 5432 \
--source-group $SG_FARGATE3) Secrets Manager로 비밀번호 만들기 #
20장 Secrets / Parameter Store의 패턴 그대로입니다. 두 가지 방식이 있습니다.
| 방식 | 역할 |
|---|---|
| 수동 생성 | 강한 랜덤을 직접 만들고 RDS에 동일하게 입력 |
| RDS가 Secrets Manager와 자동 연동 | RDS 콘솔에서 “Manage in Secrets Manager” 체크 → 비밀번호 자동 생성 + 자동 회전 |
운영은 자동 연동이 권장됩니다. 수동은 학습용입니다.
aws secretsmanager create-secret \
--name blog-api/db \
--secret-string '{
"username": "blog_admin",
"password": "S3cr3t-r4nd0m-32-bytes-...",
"engine": "postgres",
"host": "PLACEHOLDER",
"port": 5432,
"dbname": "blogdb"
}'DB가 만들어진 뒤 host만 update 합니다.
4) RDS 인스턴스 만들기 #
aws rds create-db-instance \
--db-instance-identifier blog-db \
--db-instance-class db.t4g.small \
--engine postgres --engine-version 16.4 \
--allocated-storage 20 --storage-type gp3 \
--master-username blog_admin \
--master-user-password "S3cr3t-r4nd0m-32-bytes-..." \
--db-name blogdb \
--vpc-security-group-ids $SG_RDS \
--db-subnet-group-name blog-db-subnets \
--multi-az \
--publicly-accessible false \
--backup-retention-period 7 \
--deletion-protection \
--enable-performance-insights \
--storage-encrypted \
--auto-minor-version-upgrade운영 옵션을 정리하면 다음과 같습니다.
| 옵션 | 의미 |
|---|---|
db.t4g.small | ARM 기반, x86 (t3)보다 ~20% 저렴, 작은 워크로드 시작점 |
gp3 | 최신 SSD, IOPS / 처리량 분리 가능 |
--multi-az | Standby 자동 — 페일오버 60~120초 |
--publicly-accessible false | 인터넷에서 접근 불가. 꼭 false |
--backup-retention-period 7 | 자동 백업 7일 보관 (PITR 가능) |
--deletion-protection | 실수 삭제 방지. CLI에서도 별도 옵션 필요 |
--enable-performance-insights | 쿼리 단위 성능 분석 (7일 무료) |
--storage-encrypted | KMS로 디스크 암호화 — 반드시 켜기 |
페일오버의 모양 #
Multi-AZ가 어떻게 동작하는지 봅니다.
Primary (AZ-a) ─ 동기 복제 ─▶ Standby (AZ-c)
│ │
× 장애 │
▼ ▼
페일오버 트리거 Standby → Primary 승격
│
▼
DNS endpoint 가 새 Primary 로 자동 갱신 (60~120s)애플리케이션 입장에서는 endpoint hostname은 그대로입니다 (blog-db.xxxx.ap-northeast-2.rds.amazonaws.com). DNS TTL이 짧아서 (~5s) 페일오버 후 자연스럽게 새 Primary로 연결됩니다. 단, 연결 풀이 끊긴 연결을 검증 해야 합니다. pool에 좀비 커넥션이 남으면 일정 시간 504가 뜹니다.
5) Secrets를 Task로 주입 #
비밀번호를 환경 변수에 평문으로 두지 않고, **Task Definition의 secrets**로 ARN을 가리킵니다.
{
"containerDefinitions": [
{
"name": "api",
"image": "...",
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:blog-api/db-AbCdEf:url::"
}
],
"environment": [
{ "name": "ENVIRONMENT", "value": "production" }
]
}
]
}valueFrom의 형태는 다음과 같습니다.
arn:aws:secretsmanager:<region>:<account>:secret:<name>-<random>:<json-key>::JSON의 한 키만 뽑고 싶으면 :url::처럼 키를 명시합니다. 전체 JSON을 받으려면 키 부분을 비웁니다.
Secret 안의 모양 (권장) #
{
"url": "postgresql://blog_admin:PASSWORD@blog-db.xxxx.rds.amazonaws.com:5432/blogdb",
"username": "blog_admin",
"password": "PASSWORD",
"host": "blog-db.xxxx.rds.amazonaws.com",
"port": 5432,
"dbname": "blogdb"
}이미 조립된 url 키를 두면 앱이 한 줄로 받을 수 있어 편합니다.
IAM 권한 #
executionRole이 Secrets에 접근할 수 있어야 합니다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:blog-api/db-*"
}]
}누락하면 task가
ResourceInitializationError로 STOPPED 됩니다. 22장 인프라 골격의 함정에서 다룬 그 역할입니다.
6) 첫 연결 검증 #
psql "postgresql://blog_admin@blog-db.xxxx.ap-northeast-2.rds.amazonaws.com:5432/blogdb"
# 비밀번호 입력 → SELECT 1;VPC 안에서만 접근 가능하니, 점프 호스트 / CloudShell VPC 환경 / Session Manager port forwarding 중 하나가 필요합니다.
Session Manager port forward (권장) #
bastion 호스트 없이 ECS task를 통해 RDS로 가는 깨끗한 방법입니다.
aws ecs update-service \
--cluster blog-cluster --service blog-api \
--enable-execute-command --force-new-deployment
# task 안으로 들어가
TASK=$(aws ecs list-tasks --cluster blog-cluster --service-name blog-api \
--query 'taskArns[0]' --output text)
aws ecs execute-command --cluster blog-cluster --task $TASK \
--container api --interactive --command "/bin/sh"task 안에서 직접 psql을 쓰는 방식으로, 임시 디버깅에 강력합니다. SSM / iam:PassRole / ecs:ExecuteCommand 권한이 필요합니다.
7) 마이그레이션의 운영 #
대부분의 사고는 마이그레이션에서 납니다. 두 가지가 핵심입니다.
어디서 돌릴 것인가 #
A) 컨테이너 시작 시 자동 (CMD before uvicorn/gunicorn)
─ 간단. 작은 팀.
─ 위험: replica N 개가 동시 실행 → 락 / race
─ 위험: 실패 시 컨테이너 자체가 안 뜸 → 롤백 까다로움
B) 별도 ECS RunTask (deploy 단계)
─ 1 회 실행. 락/race 없음.
─ 결과 분리 추적.
─ 권장.
C) CodeDeploy lifecycle hook
─ blue/green 배포의 일부로
─ pre-traffic-shift 또는 before-allow-traffic운영 권장은 **B (RunTask)**입니다. CI가 새 이미지를 push → migration RunTask → 성공 시 Service update의 흐름입니다.
aws ecs run-task \
--cluster blog-cluster \
--task-definition blog-api-migrate:5 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[subnet-aaa],
securityGroups=[sg-fargate],
assignPublicIp=ENABLED
}" \
--count 1 \
--started-by "deploy-$(git rev-parse --short HEAD)"blog-api-migrate는 별도 Task Definition입니다. 같은 이미지지만 command가 다릅니다.
{
"family": "blog-api-migrate",
"containerDefinitions": [{
"name": "migrate",
"image": "...:v123",
"command": ["alembic", "upgrade", "head"],
"essential": true
}]
}장고라면 ["python", "manage.py", "migrate", "--noinput"]입니다.
Backward Compatible — Blue/Green 호환 #
배포 중 한 순간에는 이전 버전과 새 버전이 동시에 살아 있습니다 (rolling 배포의 정의). 이 두 버전이 같은 DB를 보고도 안 깨지려면, 마이그레이션이 양방향 호환 이어야 합니다.
| 위험 변경 | 안전한 흐름 (3단계) |
|---|---|
| 컬럼 삭제 | 1) 코드 사용 중지 배포 → 2) 컬럼 삭제 마이그레이션 → 3) 정리 |
| 컬럼 이름 변경 | 1) 새 컬럼 추가 + 두 컬럼 동기 쓰기 → 2) 새 컬럼만 읽도록 → 3) 옛 컬럼 삭제 |
| NOT NULL 추가 | 1) NULL 허용 + default 채우기 → 2) 코드가 항상 채우도록 → 3) NOT NULL 추가 |
| 큰 인덱스 | CREATE INDEX CONCURRENTLY (PG) — 락 없음. Alembic op.create_index() 옵션 |
원칙은 다음과 같습니다. 한 마이그레이션은 N 분 안에 끝나고, 그 마이그레이션을 적용한 DB는 이전 버전 코드도 새 버전 코드도 다 정상 동작해야 한다.
ALTER TABLE의 락 함정
#
PostgreSQL에서 흔히 만나는 함정입니다.
ALTER TABLE posts ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft';PG 11+ 에서는 DEFAULT가 instant (메타데이터만 변경)입니다. 하지만 일부 패턴은 여전히 전체 행 재작성 → 큰 테이블 락 → 운영 잠김으로 이어집니다.
안전한 방법은 단계를 쪼개는 것입니다.
-- 1) 컬럼만 추가 (NULL 허용)
ALTER TABLE posts ADD COLUMN status VARCHAR(20);
-- 2) 백필 (배치로)
UPDATE posts SET status = 'draft' WHERE status IS NULL AND id BETWEEN 1 AND 10000;
-- ... 반복
-- 3) NOT NULL + default
ALTER TABLE posts ALTER COLUMN status SET DEFAULT 'draft';
ALTER TABLE posts ALTER COLUMN status SET NOT NULL;8) 백업과 복구 #
RDS 자동 백업은 **PITR (Point-In-Time Recovery)**까지 가능합니다.
| 종류 | 역할 |
|---|---|
| 자동 백업 | retention 기간 (7~35일) 동안 매일 + WAL 5분 단위 |
| 수동 스냅샷 | 명시적 — retention 무관, 명시 삭제 전까지 보관 |
| PITR | 자동 백업 윈도 내 임의 시점으로 복원 (새 인스턴스로) |
aws rds create-db-snapshot \
--db-snapshot-identifier blog-db-pre-deploy-$(date +%Y%m%d-%H%M) \
--db-instance-identifier blog-db위험한 마이그레이션 직전에 한 번 찍어두면, 사고 시 새 인스턴스로 복원 후 DNS만 바꾸면 끝납니다. 재해 복구의 더 깊은 패턴은 30장 재해 복구·백업에서 다룹니다.
9) 연결 풀과 IAM 인증 #
운영 트래픽이 커지면 두 가지가 더 들어옵니다.
RDS Proxy #
각 task가 별도 풀을 들고 있으면 task × pool size = 동시 연결 수가 됩니다. PG는 연결 한 개당 메모리가 큽니다. 그대로 두면 DB가 죽습니다.
15장 ECS Fargate의 연장선에서 RDS Proxy가 역할합니다.
Fargate (10 task × pool 20) ──▶ RDS Proxy ──▶ RDS
총 200 연결 ▼
실제 RDS 연결: 20~50장점은 페일오버 시간 단축 (~30초로), 연결 폭주 방지, IAM 인증 옵션입니다. 단점은 시간당 비용 (~$0.015vCPU/h)입니다.
IAM 인증 #
비밀번호 대신 IAM 토큰으로 RDS에 접근합니다. Task role의 IAM 정책으로 통제합니다. 안전하지만 토큰 TTL (15분) 관리, 일부 ORM의 호환, TLS 강제가 따라와 운영 모양이 한 단계 복잡해집니다. 작은 시스템은 Secrets Manager로 충분합니다.
함정 — 운영에서 자주 깨지는 부분 #
1) password authentication failed — 회전 문제
#
Secrets Manager 자동 회전이 켜져 있는데 task가 이전 비밀번호로 캐시되어 있는 경우입니다. Task Definition의 secrets가 ARN을 매번 호출하지만, 앱이 시작 시 한 번 읽고 보관 하면 캐시가 안 갱신됩니다. 해결책은 다음과 같습니다.
- 앱 시작 시 한 번 + 연결 실패 시 한 번 더 읽기
- 회전 타이밍에 맞춰 task 재시작 (deployment)
2) RDS Free Tier가 끝났는데 운영 중 #
Free Tier는 12개월입니다. 끝나면 매월 ~$30이 자동 청구됩니다. 3장 비용 관리의 결제 알림으로 조기에 감지합니다.
3) 단일 AZ 운영 #
비용을 아끼려고 --multi-az를 빼면 AZ 장애 = DB 다운 = 모든 트래픽 다운입니다. 작은 운영도 Multi-AZ가 권장됩니다. 장애 1회의 손실과 매월 ~$30~50을 견줍니다.
4) deletion-protection 안 켜고 실수 삭제
#
콘솔에서 클릭 한 번에 RDS가 사라집니다. 반드시 deletion-protection을 켭니다. 끄려면 별도 update 호출이 필요하니 의식적인 행위가 됩니다.
5) terraform destroy가 RDS까지 지움
#
25장 Terraform 입문에서 다룰 함정입니다. terraform의 lifecycle 블록으로 보호합니다.
resource "aws_db_instance" "blog" {
# ...
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "blog-db-final-${formatdate("YYYYMMDD-hhmm", timestamp())}"
lifecycle {
prevent_destroy = true
}
}6) 마이그레이션 락이 풀리지 않음 #
PG의 ALTER TABLE은 lock_timeout 없이 무한 대기합니다. 운영 트래픽 중에 큰 락은 모든 쿼리를 대기시킵니다.
SET lock_timeout = '5s';
ALTER TABLE posts ADD COLUMN status TEXT;
-- 5s 안에 락 못 잡으면 에러로 빠짐Alembic이라면 마이그레이션 시작에 op.execute("SET lock_timeout = '5s'")를 둡니다.
Terraform 동행 — DB 골격을 코드로 #
위 CLI로 만든 DB Subnet Group · SG · RDS · Secrets를 Terraform으로 옮기면 다음과 같습니다. 비밀번호는 manage_master_user_password = true로 두어 RDS가 Secrets Manager에 직접 생성·회전 하게 하면, §3의 수동 시크릿 생성 단계가 사라지고 평문 비밀번호를 코드에서 만질 일이 없습니다(20장).
resource "aws_db_subnet_group" "main" {
name = "blog-db"
subnet_ids = var.db_subnet_ids # 2 AZ 의 DB 서브넷
}
resource "aws_security_group" "rds" {
name_prefix = "blog-rds-"
vpc_id = var.vpc_id
}
# Fargate SG 에서 온 5432 만 허용
resource "aws_security_group_rule" "rds_from_fargate" {
type = "ingress"
security_group_id = aws_security_group.rds.id
source_security_group_id = var.fargate_sg_id
from_port = 5432, to_port = 5432, protocol = "tcp"
}
resource "aws_db_instance" "main" {
identifier = "blog-db"
engine = "postgres"
engine_version = "17.2"
instance_class = "db.t4g.micro"
allocated_storage = 20
storage_encrypted = true # 사고 방지: 저장 암호화
multi_az = true # 사고 방지: AZ 장애 페일오버
deletion_protection = true # 사고 방지: 실수 삭제 차단
backup_retention_period = 14 # PITR 14일 (30장)
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
db_name = "blog"
username = "blog"
manage_master_user_password = true # → Secrets Manager 자동 생성·회전
}Task 정의에서는 RDS가 만든 시크릿 ARN을 secrets로 주입합니다(22장의 Task Definition에 이어집니다).
secrets = [{
name = "DB_SECRET"
valueFrom = aws_db_instance.main.master_user_secret[0].secret_arn
}]마이그레이션은 §7처럼 서비스가 아닌 일회성 Task (aws ecs run-task ... command=["alembic","upgrade","head"])로 돌립니다. 이 코드가 6부 캡스톤의 rds.tf로 그대로 이어집니다.
연습문제 #
- 본 챕터의 RDS 생성 명령에서
--storage-encrypted,--deletion-protection,--multi-az세 옵션이 각각 어떤 사고를 막는지 한 줄씩 적어 보세요. §“함정"의 항목 중 어느 것과 연결되는지도 표시해 두세요. - 컬럼에 NOT NULL을 추가하는 위험 변경을 안전하게 적용하는 3단계를 §“Backward Compatible"을 보지 않고 적어 보세요. 이 3단계가 22장에서 본 rolling 배포(이전·새 버전 공존)와 어떻게 맞물리는지 한 단락으로 설명해 보세요.
- 마이그레이션을 컨테이너 시작 시 자동(옵션 A)이 아니라 별도 RunTask(옵션 B)로 분리하는 이유를 §“어디서 돌릴 것인가"를 근거로 정리하세요. 24장 CI/CD에서 이 RunTask가 어떻게 자동화되는지 미리 떠올려 두면 좋습니다.
한 줄 요약: RDS는 인터넷에서 안 보이는 DB Subnet Group에 두고, sg-rds는 sg-fargate만 inbound로 받는다. 비밀번호는 Secrets Manager에서 Task Definition의
secrets로 주입하고, Multi-AZ / 암호화 / deletion-protection을 켠다. 마이그레이션은 컨테이너 시작이 아니라 별도 RunTask로 분리하고, 양방향 호환과 lock_timeout으로 운영 트래픽을 죽이지 않는다.
다음 챕터 #
이제 이미지 빌드 → ECR push → Service update → 마이그레이션의 흐름을 손으로 한 번 돌려봤습니다. 매번 손으로 할 수는 없습니다. 다음 24장 CI/CD — GitHub Actions + ECR + ECS에서는 이 흐름을 GitHub Actions OIDC로 자동화하고, 배포 실패 시 자동 롤백 (deployment circuit breaker), CodeDeploy의 blue/green 옵션까지 — 한 번의 git push로 배포가 끝나는 흐름을 만듭니다.