AWS 실전 #2 RDS 연동과 마이그레이션 운영

8 분 소요

#1에서 ECS Fargate 위에 블로그 API를 띄웠지만 DB가 메모리 안에 있었습니다. 이번 글은 그 부분을 RDS Postgres Multi-AZ로 옮기고, 마이그레이션을 운영 트래픽에 영향을 주지 않고 운영하는 방법을 정리합니다.

중급 #4 RDS가 콘솔에서 만든 첫 RDS였다면, 이번엔 운영 패턴, 즉 VPC 분리, Secrets 주입, 마이그레이션 패턴까지 한 번에.

큰 그림: 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 시 승격

핵심 원칙 셋:

  1. DB는 인터넷에서 안 보인다. Private subnet 또는 격리된 DB Subnet Group
  2. 비밀번호는 코드에 없다. Secrets Manager → 작업 정의의 secrets 필드
  3. 마이그레이션은 배포 단계로 분리. 컨테이너 시작 시 migrate 금지

1) DB Subnet Group 만들기 #

RDS는 DB Subnet Group 안에서만 살 수 있습니다. 최소 2개의 AZ에 걸친 서브넷이 필요합니다 (Multi-AZ를 켜든 안 켜든).

DB Subnet Group
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의 역할
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를 갱신할 일이 없습니다.

DB 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_FARGATE

3) Secrets Manager로 비밀번호 만들기 #

고급 #6의 패턴 그대로. 두 가지 방식:

방식설명
수동 생성강한 랜덤을 직접 만들고 RDS에 동일하게 입력
RDS가 Secrets Manager와 자동 연동RDS 콘솔에서 “Manage in Secrets Manager” 체크 → 비밀번호 자동 생성 + 자동 회전

운영에는 자동 연동을 권장합니다. 수동 방식은 학습용입니다.

수동: Secrets 생성
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 인스턴스 만들기 #

RDS Postgres Multi-AZ
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.smallARM 기반, x86 (t3)보다 ~20% 저렴, 작은 워크로드 시작점
gp3최신 SSD, IOPS / 처리량 분리 가능
--multi-azStandby 자동. 페일오버 60~120초
--publicly-accessible false인터넷에서 접근 불가. 꼭 false
--backup-retention-period 7자동 백업 7일 보관 (PITR가능)
--deletion-protection실수 삭제 방지. CLI에서도 별도 옵션 필요
--enable-performance-insights쿼리 단위 성능 분석 (7일 무료)
--storage-encryptedKMS로 디스크 암호화. 반드시 켜기

페일오버의 모양 #

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로 주입 #

비밀번호를 환경 변수에 평문으로 두지 않고, **작업 정의의 secrets**로 ARN을 가리킵니다.

task-definition.json (발췌)
{
  "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 안의 모양 (권장) #

blog-api/db secret JSON
{
  "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에 접근할 수 있어야 합니다.

executionRole 정책 추가
{
  "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. #1 함정에서 다룬 함정.

6) 첫 연결 검증 #

Bastion 또는 CloudShell에서
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 작업을 통해 RDS로 가는 깨끗한 방법:

Fargate Task에 enableExecuteCommand 켜기
aws ecs update-service \
  --cluster blog-cluster --service blog-api \
  --enable-execute-command --force-new-deployment

# 작업 안으로 들어가
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"

작업 안에서 직접 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 → 성공 시 서비스 갱신 흐름입니다.

마이그레이션 task 한 번 실행
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별도 작업 정의입니다. 같은 이미지지만 command가 다릅니다.

migrate task definition (발췌)
{
  "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 (메타데이터만 변경). 하지만 일부 패턴은 여전히 전체 행 재작성 → 큰 테이블 락 → 운영 잠김.

안전한 방식:

3 단계로 쪼개기
-- 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;

장고 고급 #3 쿼리 최적화의 N+1 / 인덱스 문제와 같은 결의 운영 의식입니다.

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만 바꾸면 끝.

9) 연결 풀과 IAM 인증 #

운영 트래픽이 커지면 두 가지가 더 들어옵니다.

RDS Proxy #

각 작업이 별도 풀을 들고 있으면 작업 × pool size = 동시 연결 수입니다. PG는 연결 한 개당 메모리가 큽니다. DB가 죽습니다.

고급 #1 ECS의 연장선에서 RDS Proxy가 나섭니다.

Proxy를 넣은 구성
Fargate (10 task × pool 20) ──▶ RDS Proxy ──▶ RDS
        총 200 연결                  ▼
                              실제 RDS 연결: 20~50

장점은 페일오버 시간 단축(~30초), 연결 폭주 방지, IAM 인증 옵션입니다. 단점은 시간당 비용($0.015/vCPU·h)입니다.

IAM 인증 #

비밀번호 대신 IAM 토큰으로 RDS에 접근합니다. 작업 역할의 IAM 정책으로 통제합니다. 안전하지만 토큰 TTL(15분) 관리 / 일부 ORM 호환 / TLS 강제로 운영 모양이 한 단계 복잡해집니다. 작은 시스템은 Secrets Manager로 충분합니다.

함정: 운영에서 자주 깨지는 부분 #

1) password authentication failed: 회전 문제 #

Secrets Manager 자동 회전이 켜져 있는데 작업이 이전 비밀번호로 캐시되어 있는 경우입니다. 작업 정의의 secrets가 ARN을 매번 호출하지만, 앱이 시작 시 한 번 읽고 보관하면 캐시가 갱신되지 않습니다.

해결:

  • 앱 시작 시 한 번 + 연결 실패 시 한 번 더 읽기
  • 회전 타이밍에 맞춰 task 재시작 (deployment)

2) RDS Free Tier가 끝났는데 운영 중 #

Free Tier는 12개월. 끝나면 매월 ~$30이 자동 청구. 기초 #3 비용 알림으로 조기 감지.

3) 단일 AZ 운영 #

비용 아끼려고 --multi-az 빼면 AZ 장애 = DB 다운 = 모든 트래픽 다운. 작은 운영도 Multi-AZ 권장. 장애 1회의 손실 vs 매월 ~$30~50.

4) deletion-protection 안 켜고 실수 삭제 #

콘솔에서 클릭 한 번에 RDS가 사라집니다. 반드시 deletion-protection 켜기. 끄려면 별도 update 호출 → 의식적인 행위.

5) terraform destroy가 RDS까지 지움 #

#4 IaC에서 다룰 내용. terraform의 lifecycle 블록으로 보호.

Terraform 보호
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'")를 마이그레이션 시작에.

정리 #

이번 글에서 잡은 것:

  • DB의 역할. Private DB Subnet Group, sg-rds에 sg-fargate만 inbound
  • Secrets Manager. 비밀번호 평문 X, 작업 정의의 secrets로 ARN 주입, executionRole 권한
  • RDS 운영 옵션. Multi-AZ, gp3, deletion-protection, storage-encrypted, performance-insights, auto-minor-upgrade
  • 페일오버. 60~120초, endpoint 그대로, 연결 풀 검증 필요
  • Session Manager. bastion 없이 task에서 psql, enableExecuteCommand
  • 마이그레이션. 컨테이너 시작이 아니라 별도 RunTask, blue/green 호환, ALTER TABLE 락 분할
  • 백업. 자동 PITR + 위험 배포 전 수동 스냅샷
  • 연결 풀. 큰 트래픽엔 RDS Proxy, 작은 시스템은 Secrets로 충분
  • 함정. 비밀번호 회전 캐시, Free Tier만료, 단일 AZ, deletion-protection 누락, 마이그레이션 무한 락

다음: CI/CD #

이제 이미지 빌드 → ECR push → 서비스 갱신 → 마이그레이션의 흐름을 손으로 한 번 돌려봤습니다. 매번 손으로 할 수는 없습니다.

#3 CI/CD: GitHub Actions + ECR + ECS에서는 이 흐름을 GitHub Actions OIDC로 자동화하고, 배포 실패 시 자동 롤백 (deployment circuit breaker), CodeDeploy의 blue/green 옵션까지, 한 번의 git push로 배포가 끝나는 흐름을 만듭니다.

X