AWS 실전 #1 인프라 골격: FastAPI/Django를 ECS Fargate에 배포

8 분 소요

기초 7 편에서 계정 / 리전 / IAM / 비용 / CLI / 보안 / 로그를, 중급 7 편에서 EC2 / VPC / S3 / RDS / DNS / ALB / CloudFront를, 고급 7 편에서 ECS / Lambda / 메시징 / Secrets / Step Functions를 차례로 다뤘습니다. 21 편의 도구를 모았고, 이제 진짜 백엔드를 하나의 프로젝트로 올립니다.

이 시리즈는 FastAPI 실전 또는 장고 DRF 시리즈에서 만든 블로그 API (Post + Comment + User)를 기반으로, 6 편을 거쳐 운영 가능한 형태까지 끌어올립니다.

큰 그림 #

이번 글에서 만들 인프라:

블로그 API의 구조
                      Internet
                  ┌──────────────┐
                  │  Route 53    │   blog.example.com
                  └──────┬───────┘
                ┌────────────────┐
                │      ALB       │   :443 → :8000
                │   (HTTPS, ACM) │
                └────────┬───────┘
              ┌──────────┴──────────┐
              ▼                     ▼
        ┌───────────┐         ┌───────────┐
        │  AZ-a     │         │   AZ-c    │
        │ Fargate   │         │  Fargate  │
        │  Task #1  │         │  Task #2  │
        │  (Blog)   │         │  (Blog)   │
        └─────┬─────┘         └─────┬─────┘
              │                     │
              └──────────┬──────────┘
                  ┌──────────────┐
                  │  RDS Postgres│   (Multi-AZ, Private)
                  └──────────────┘

구성별 정리:

구성 요소역할출처
Route 53도메인 → ALB중급 #5
ALBTLS 종단, 라우팅, 헬스체크중급 #6
ACMTLS 인증서 발급/갱신중급 #6
ECR이미지 저장고급 #2
ECS Fargate컨테이너 실행 (서버리스)고급 #1
RDSDB중급 #4, #2
VPC + Subnet네트워크 분리중급 #1
Secrets ManagerDB 비밀번호고급 #6, #2

이번 글은 DB를 제외한 모든 구성 요소를 한 번에 셋업합니다. RDS는 #2에서 별도로 다룹니다.

도메인: 블로그 API 컨테이너 한 줄 요약 #

이 시리즈가 가정하는 컨테이너는 FastAPI 실전 #6 또는 DRF #6의 산출물로, 다음 모양입니다.

Dockerfile (FastAPI 기준)
FROM python:3.14-slim AS base
WORKDIR /app

ENV PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 curl && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app /app/app

EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
    CMD curl -fsS http://127.0.0.1:8000/health || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

핵심 약속 셋:

  1. 포트 8000에서 듣기
  2. /health가 200을 반환 (DB의존 없는 가벼운 체크)
  3. /ready가 DB 연결 OK 면 200, 아니면 503. ALB / ECS가 트래픽 라우팅을 결정

장고라면 gunicorn -w 4 myproject.wsgi로 같은 약속을 만들면 됩니다.

1) VPC와 서브넷: 네트워크 골격 #

ECS / RDS / ALB는 모두 VPC 안에 삽니다. VPC가 없으면 한 줄도 못 띄웁니다. 다행히 새 계정엔 default VPC가 리전마다 있어서, 빠르게 시작할 땐 그걸 써도 됩니다. 운영은 직접 만든 VPC를 권장합니다.

권장 구조 #

VPC 10.0.0.0/16
Public Subnet  (10.0.0.0/24,   AZ-a)  ← ALB, NAT GW
Public Subnet  (10.0.1.0/24,   AZ-c)  ← ALB, NAT GW
Private Subnet (10.0.10.0/24,  AZ-a)  ← Fargate Task
Private Subnet (10.0.11.0/24,  AZ-c)  ← Fargate Task
DB Subnet      (10.0.20.0/24,  AZ-a)  ← RDS
DB Subnet      (10.0.21.0/24,  AZ-c)  ← RDS

세 가지 구성 요소:

Subnet트래픽 방향누가 사는가
Public인터넷 ↔ALB, NAT Gateway
Private인터넷 X (NAT 통해 outbound만)Fargate, EC2
DB인터넷 X, Fargate만 접근RDS

이 글에선 default VPC의 public subnet만 사용해서 빠르게 띄웁니다 (Fargate task에 public IP 부여). 운영 모양은 #4 Terraform에서 코드로.

Security Group 두 개 #

SG 두 개
sg-alb       80, 443 ← 0.0.0.0/0
             (인터넷이 ALB에)

sg-fargate   8000   ← sg-alb
             (ALB만 Fargate에)

중요한 패턴. SG는 다른 SG를 source로 받을 수 있습니다. IP 대역이 아니라 “이 SG가 붙은 리소스만” 을 의미합니다. ALB의 IP가 바뀌어도 규칙이 자동 따라옵니다.

2) ECR에 이미지 올리기 #

고급 #2 ECR에서 이미 다뤘지만 빠르게 다시.

ECR 리포지터리 만들기
aws ecr create-repository \
  --repository-name blog-api \
  --image-scanning-configuration scanOnPush=true \
  --region ap-northeast-2

scanOnPush=true. 이미지 푸시 시 자동 취약점 스캔 (#5 모니터링에서 결과 확인).

빌드와 푸시 #

빌드 → 태그 → 푸시
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=ap-northeast-2
REPO=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/blog-api

# 1) 로그인
aws ecr get-login-password --region $REGION | \
  docker login --username AWS --password-stdin $REPO

# 2) 빌드 (linux/amd64 : Fargate 표준 아키텍처)
docker build --platform=linux/amd64 -t blog-api:v1 .

# 3) 태그
docker tag blog-api:v1 $REPO:v1
docker tag blog-api:v1 $REPO:latest

# 4) 푸시
docker push $REPO:v1
docker push $REPO:latest

Apple Silicon (M1/M2/M3) 맥에서 그냥 docker build로 만들면 arm64이미지가 생기고, Fargate (x86_64 표준)에선 안 돕니다. --platform=linux/amd64를 항상 명시. Fargate가 ARM도 지원하지만 별도 설정이 필요합니다.

이미지 태그 전략 #

태그의미
latest최신. 운영에서 쓰지 마세요 (롤백 불가)
v1, v2, ...사람이 읽는 버전
<git-sha>추적. CI가 자동 발행 (#3)
<git-sha>-prod환경별 별칭

latest는 개발자 편의용입니다. 운영 작업 정의는 항상 git SHA 또는 semver로 고정합니다. 그래야 “어떤 코드가 돌고 있는지"를 한 번에 확인할 수 있습니다.

3) 작업 정의: 컨테이너의 “주민등록” #

ECS의 가장 중요한 구성 요소입니다. 이미지 + CPU/메모리 + 환경 변수 + 포트 + 로그 설정이 한 JSON에 묶입니다.

task-definition.json
{
  "family": "blog-api",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789012:role/blog-api-task-role",
  "containerDefinitions": [
    {
      "name": "api",
      "image": "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/blog-api:v1",
      "portMappings": [
        { "containerPort": 8000, "protocol": "tcp" }
      ],
      "essential": true,
      "environment": [
        { "name": "ENVIRONMENT", "value": "production" },
        { "name": "LOG_LEVEL", "value": "info" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/blog-api",
          "awslogs-region": "ap-northeast-2",
          "awslogs-stream-prefix": "api",
          "awslogs-create-group": "true"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health || exit 1"],
        "interval": 10,
        "timeout": 3,
        "retries": 3,
        "startPeriod": 30
      }
    }
  ]
}

핵심 항목:

의미
cpu / memoryFargate가 정해진 조합만 허용 (e.g. 256/512, 512/1024, 1024/2048)
executionRoleArnECS agent가 ECR pull / Logs / Secrets 접근에 쓸 역할
taskRoleArn컨테이너 코드가 쓸 IAM 역할. boto3가 이걸로 sign
awslogs로그가 자동으로 CloudWatch로 (#5)
healthCheck컨테이너 자체 헬스체크 (Dockerfile와 별개)

두 IAM 역할의 차이가 자주 헷갈림 #

executionRoleArntaskRoleArn
누가 쓰는가ECS agent (시작 단계)컨테이너 안 코드 (실행 중)
권한ECR pull, CloudWatch 쓰기, Secrets 읽기S3 접근, RDS, SQS 등. 앱 로직

executionRoleArn 누락 → 이미지 pull 실패. taskRoleArn 누락 → boto3가 NoCredentialsError.

등록 #

Task Definition 등록
aws ecs register-task-definition \
  --cli-input-json file://task-definition.json \
  --region ap-northeast-2

등록할 때마다 revision 번호 (blog-api:1, blog-api:2, …)가 올라갑니다. 롤백은 이전 revision 번호로 #3에서.

4) ALB + Target Group: 트래픽을 받는 쪽 #

중급 #6에서 만든 ALB 패턴 그대로. 핵심:

ALB → Target Group → Fargate
ALB:443  (HTTPS, ACM 인증서)
Listener: 443 → forward → tg-blog-api
Target Group: tg-blog-api
  - Protocol: HTTP / 8000
  - Target type: ip   ← Fargate는 무조건 ip
  - Health check: GET /health
  - Healthy threshold: 2
  - Interval: 15s

Target type은 반드시 ip. Fargate task는 매번 IP가 바뀌어 instance 모드가 안 됩니다.

Target Group 만들기
aws elbv2 create-target-group \
  --name tg-blog-api \
  --protocol HTTP --port 8000 \
  --vpc-id $VPC_ID \
  --target-type ip \
  --health-check-path /health \
  --healthy-threshold-count 2 \
  --health-check-interval-seconds 15

ALB Listener 규칙은 중급 #6 참조. HTTPS 443 → forward → tg-blog-api, HTTP 80 → 443 redirect.

5) ECS 서비스: 컨테이너의 “회사” #

서비스는 항상 desired count만큼의 작업을 유지하고, 작업이 죽으면 새로 만들고, 배포 시 점진 교체합니다.

ECS Cluster 만들기 (한 번)
aws ecs create-cluster --cluster-name blog-cluster
ECS Service 만들기
aws ecs create-service \
  --cluster blog-cluster \
  --service-name blog-api \
  --task-definition blog-api:1 \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={
      subnets=[subnet-aaa, subnet-bbb],
      securityGroups=[sg-fargate],
      assignPublicIp=ENABLED
    }" \
  --load-balancers "targetGroupArn=$TG_ARN,containerName=api,containerPort=8000" \
  --health-check-grace-period-seconds 60 \
  --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true},maximumPercent=200,minimumHealthyPercent=100"

핵심 옵션 정리:

옵션의미
desired-count 2최소 2개. Multi-AZ 배포로 한 AZ 장애 견딤
assignPublicIp=ENABLEDprivate subnet + NAT가 없을 때 사용 (간이 셋업). 운영은 NAT 권장
health-check-grace-periodService가 task를 띄운 직후 ALB 헬스체크를 기다리는 유예 (앱 부팅 시간)
deploymentCircuitBreaker새 배포가 N 회 연속 실패하면 자동 롤백 (#3에서 자세히)
maximumPercent=200배포 중 최대 task 수 (200% = 기존 + 새 동시)
minimumHealthyPercent=100배포 중 최소 healthy 비율 (100% = 다운타임 0)

이 두 % 값이 롤링 업데이트의 모양을 결정합니다.

자동 스케일링 #

서비스가 떴다고 자동 확장이 켜지지는 않습니다. 별도로:

Auto Scaling Target 등록
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/blog-cluster/blog-api \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 --max-capacity 10
CPU 기반 정책
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/blog-cluster/blog-api \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name cpu-target \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
      "TargetValue": 60.0,
      "PredefinedMetricSpecification": {"PredefinedMetricType": "ECSServiceAverageCPUUtilization"},
      "ScaleOutCooldown": 30,
      "ScaleInCooldown": 120
    }'

CPU 평균 60% 를 기준으로 자동 scale out / in. 운영에서 처음엔 보수적으로 (40~60%) 두고 트래픽 패턴 보면서 조정.

6) 첫 배포 검증 #

서비스가 안정 상태에 들어갈 때까지 대기 #

안정 대기 (5~10 분)
aws ecs wait services-stable \
  --cluster blog-cluster \
  --services blog-api

헬스체크 직접 확인 #

ALB DNS로 직접 호출
ALB_DNS=$(aws elbv2 describe-load-balancers \
  --names blog-alb \
  --query 'LoadBalancers[0].DNSName' --output text)

curl -i https://$ALB_DNS/health
# HTTP/2 200
# {"status": "ok"}

로그 확인 #

CloudWatch Logs tail
aws logs tail /ecs/blog-api --follow --since 5m

요청 한 번 보내고 로그에 access log가 뜨면 이번 시리즈의 첫 도착점에 온 겁니다 🎉.

함정: 첫 배포가 안 뜨는 5가지 원인 #

1) STOPPED 상태로 끝없이 재시작 #

ECS 콘솔의 Tasks 탭에서 STOPPED 행 클릭 → “Stopped reason” 확인. 흔한 원인:

메시지원인
CannotPullContainerErrorECR 권한 누락 → executionRole
ResourceInitializationError: ... secret managerSecrets ARN 오타 / 권한
Essential container ... exited컨테이너 자체가 죽음 → CloudWatch logs
Task failed ELB health checksALB가 healthy 판정 못 함 → 다음 항목

2) ALB 헬스체크 실패 #

가장 흔합니다. 점검 포인트:

  • 컨테이너 포트 (8000)가 Target Group 포트 (8000)와 일치하는가
  • /health 엔드포인트가 진짜 200을 반환하는가 (DB의존 X)
  • health-check-grace-period가 앱 부팅 시간 (FastAPI 5s, Django 20~40s)보다 긴가
  • Fargate Security Group inbound가 ALB SG만 허용하는가
  • ALB가 그 task의 subnet까지 라우팅 가능한가 (같은 VPC)

3) awsvpc networkMode의 ENI 한도 #

Fargate task는 ENI (Elastic Network Interface)를 한 개씩 잡아먹습니다. AZ / 서브넷의 IP가 부족하면 새 task를 못 띄웁니다. CIDR를 너무 좁게 잡지 말기 (위 예시 /24 = 256 IP).

4) Public IP 없이 ECR pull 실패 #

Private subnet에 task를 띄우는데 NAT Gateway도 VPC Endpoint도 없으면, ECR / Secrets Manager / CloudWatch로 가는 트래픽이 막혀 시작이 실패합니다.

해결 셋:

  1. NAT Gateway 추가 (시간당 ~$0.045 + 데이터 전송)
  2. ECR / Logs / Secrets의 Interface VPC Endpoint 추가 (NAT보다 저렴)
  3. Public subnet + assignPublicIp=ENABLED (학습용)

5) 배포 정체: 새 task가 healthy가 안 됨 #

deploymentCircuitBreaker가 켜져 있으면 N 분 후 자동 롤백. 꺼져 있으면 service가 영원히 IN_PROGRESS. aws ecs describe-services로 deployments 배열 확인.

정리 #

이번 글에서 잡은 것:

  • 큰 그림. Route 53 → ALB → Fargate (× 2 AZ) → RDS, Multi-AZ 운영의 표준
  • VPC 골격. public / private / db subnet의 역할, SG 두 개로 ALB ↔ Fargate
  • ECR. 이미지 빌드 시 --platform=linux/amd64, 태그는 git SHA 또는 semver, latest 운영 금지
  • Task Definition. Fargate CPU/메모리 조합, executionRole vs taskRole의 분리, awslogs로 자동 로깅
  • ALB Target Group. Fargate는 target-type ip, 헬스체크는 /health
  • ECS Service. desired count, deployment circuit breaker, maximum/minimum % 가 롤링 모양 결정
  • Auto Scaling. application-autoscaling으로 CPU/요청 수 기반 target tracking
  • 검증. services-stable wait, ALB DNS curl, CloudWatch Logs tail
  • 함정. STOPPED 원인 분석 / ALB 헬스체크 실패 5종 / ENI IP 부족 / NAT/Endpoint 누락 / 배포 정체

다음: RDS #

ALB 뒤로 트래픽이 들어오기 시작했지만, 우리 API는 아직 DB가 없어 메모리 안에서만 살고 있습니다.

#2 RDS 연동과 마이그레이션 운영에서는 VPC 안에 RDS Postgres Multi-AZ를 띄우고, Secrets Manager로 비밀번호를 주입하고, Alembic / Django migrations의 운영 패턴, 그리고 운영 트래픽에 영향을 주지 않는 blue/green 마이그레이션 패턴까지 정리하겠습니다.

X