목차
22 장

인프라 골격 — FastAPI/Django를 ECS Fargate에 배포

컨테이너 이미지를 ECR에 올리고, Task Definition을 짜고, ALB 뒤의 ECS Fargate Service로 띄우는 흐름. 작은 블로그 API를 운영 환경에 처음 올리는 한 챕터입니다.

여기서부터가 이 책의 4부, “콘솔에서 코드로"입니다. 1~3부에서 계정과 IAM, EC2와 VPC, S3와 RDS, ALB와 CloudFront, 그리고 ECS / Lambda / 메시징 / Secrets까지 도구를 하나씩 손에 익혔습니다. 이제부터는 그 도구들을 하나의 시스템으로 묶습니다. 흩어져 있던 콘솔 작업을 코드로 옮기고, 작은 백엔드를 운영 가능한 형태까지 끌어올리는 실전 챕터들입니다.

본 챕터가 가정하는 애플리케이션은 FastAPI 또는 장고 DRF로 만든 블로그 API (Post + Comment + User)입니다. 이번 챕터는 그 컨테이너를 ECS Fargate 위에 처음 올리는 인프라 골격을 세웁니다. Route 53으로 받은 도메인이 ALB를 거쳐 두 AZ의 Fargate Task로 흐르고, 그 뒤에 RDS Postgres가 놓이는 구조입니다. RDS 연동은 분량이 커서 23장 RDS 연동과 마이그레이션 운영에서 따로 다루고, 본 챕터는 DB를 제외한 모든 구성 요소를 한 번에 셋업합니다.

큰 그림 #

이번 챕터에서 만들 인프라는 다음과 같습니다.

블로그 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도메인 → ALB12장 Route 53
ALBTLS 종단, 라우팅, 헬스체크13장 ALB / NLB와 ACM
ACMTLS 인증서 발급 / 갱신13장 ALB / NLB와 ACM
ECR이미지 저장16장 ECR
ECS Fargate컨테이너 실행 (서버리스)15장 ECS Fargate
RDSDB11장 RDS, 23장
VPC + Subnet네트워크 분리8장 EC2와 VPC
Secrets ManagerDB 비밀번호20장 Secrets / Parameter Store, 23장

블로그 API 컨테이너 — 한 줄 약속 #

이 책이 가정하는 컨테이너는 다음 모양의 산출물입니다.

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 연결이 정상이면 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를 부여). 운영 모양은 25장 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에 이미지 올리기 #

16장 ECR에서 이미 다뤘지만 빠르게 다시 정리합니다.

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

scanOnPush=true는 이미지 푸시 시 자동으로 취약점을 스캔합니다(26장 모니터링에서 결과를 확인합니다).

빌드와 푸시 #

빌드 → 태그 → 푸시
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가 자동 발행 (24장)
<git-sha>-prod환경별 별칭

latest는 개발자 편의용입니다. 운영 Task Definition은 항상 git SHA 또는 semver로 고정합니다. 그래야 “어떤 코드가 돌고 있는지"가 한 의문 없이 확인됩니다.

3) Task Definition — 컨테이너의 신상명세 #

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가 정해진 조합만 허용 (예: 256/512, 512/1024, 1024/2048)
executionRoleArnECS agent가 ECR pull / Logs / Secrets 접근에 쓸 역할
taskRoleArn컨테이너 코드가 쓸 IAM 역할 — boto3가 이것으로 sign
awslogs로그가 자동으로 CloudWatch로 (26장)
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 번호로 하며, 24장 CI/CD에서 다룹니다.

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

13장 ALB / NLB와 ACM에서 만든 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 규칙은 13장을 참조합니다. HTTPS 443 → forward → tg-blog-api, HTTP 80 → 443 redirect의 구성입니다.

5) ECS Service — 컨테이너를 지키는 관리자 #

Task Definition이 직원의 직무 기술서라면, Service는 그 직원을 항상 일하게 만드는 관리자입니다. 항상 desired count 만큼의 task를 띄우고, 죽으면 새로 만들고, 배포 시 점진적으로 교체합니다.

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 회 연속 실패하면 자동 롤백 (24장에서 자세히)
maximumPercent=200배포 중 최대 task 수 (200% = 기존 + 새 동시)
minimumHealthyPercent=100배포 중 최소 healthy 비율 (100% = 다운타임 0)

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

자동 스케일링 #

Service가 떴다고 자동 확장이 켜지지는 않습니다. 별도로 설정합니다.

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%) 두고 트래픽 패턴을 보면서 조정합니다.

Terraform 동행 — 같은 골격을 코드로 #

위에서는 흐름을 이해하기 위해 콘솔과 CLI로 만들었습니다. 하지만 4부의 약속은 모든 인프라를 코드로 두는 것입니다(25장 Terraform 입문). 같은 SG · Target Group · Task Definition · Service를 Terraform으로 옮기면 다음과 같습니다. (VPC · ALB · ACM은 25장 · 13장의 모듈을 재사용한다고 가정합니다.)

ecs.tf — 보안 그룹과 Target Group
resource "aws_security_group" "alb" {
  name_prefix = "blog-alb-"
  vpc_id      = var.vpc_id
  ingress { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
  egress  { from_port = 0,   to_port = 0,   protocol = "-1",  cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_security_group" "fargate" {
  name_prefix = "blog-fargate-"
  vpc_id      = var.vpc_id
  egress { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
}

# ALB SG 에서 온 8000 만 허용 — IP 가 아닌 SG 참조
resource "aws_security_group_rule" "fargate_from_alb" {
  type                     = "ingress"
  security_group_id        = aws_security_group.fargate.id
  source_security_group_id = aws_security_group.alb.id
  from_port = 8000, to_port = 8000, protocol = "tcp"
}

resource "aws_lb_target_group" "api" {
  name        = "tg-blog-api"
  port        = 8000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"                 # Fargate 는 반드시 ip
  health_check { path = "/health", healthy_threshold = 2, interval = 15 }
}
ecs.tf — Task Definition과 Service
resource "aws_ecs_task_definition" "api" {
  family                   = "blog-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.ecs_exec.arn   # ECR pull · Logs · Secrets
  task_role_arn            = aws_iam_role.app.arn        # 컨테이너 코드용

  container_definitions = jsonencode([{
    name         = "api"
    image        = "${aws_ecr_repository.api.repository_url}:${var.image_tag}"
    portMappings = [{ containerPort = 8000 }]
    essential    = true
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = "/ecs/blog-api"
        "awslogs-region"        = "ap-northeast-2"
        "awslogs-stream-prefix" = "api"
      }
    }
  }])
}

resource "aws_ecs_service" "api" {
  name                              = "blog-api"
  cluster                           = aws_ecs_cluster.main.id
  task_definition                   = aws_ecs_task_definition.api.arn
  desired_count                     = 2
  launch_type                       = "FARGATE"
  health_check_grace_period_seconds = 60

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.fargate.id]
  }
  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8000
  }
  deployment_circuit_breaker { enable = true, rollback = true }
}

image_tag를 변수로 빼면 24장 CI/CD에서 git SHA를 주입해 배포할 수 있습니다. 이 코드가 6부 캡스톤ecs-api.tf로 그대로 이어집니다. 본 챕터의 CLI 명령은 “무엇이 만들어지는지"를 눈으로 보기 위한 것이고, 운영에서는 이 Terraform을 정본으로 둡니다.

6) 첫 배포 검증 #

Service가 안정 상태에 들어갈 때까지 대기 #

안정 대기 (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가 뜨면, 이 책의 4부에서 첫 도착점에 온 것입니다.

함정 — 첫 배포가 안 뜨는 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 배열을 확인합니다.

연습문제 #

  1. 본 챕터의 Task Definition에서 executionRoleArntaskRoleArn이 각각 누구에게 쓰이는지 한 줄씩 적고, 둘 중 하나를 누락했을 때 나타나는 증상을 §“두 IAM 역할의 차이"를 근거로 연결해 보세요. 24장 CI/CDiam:PassRole 권한이 왜 필요한지 미리 떠올려 두면 좋습니다.
  2. Fargate의 Target Group이 target-type ip 여야 하는 이유를 한 단락으로 설명해 보세요. ALB 헬스체크가 실패할 때 점검할 5가지 포인트(§“ALB 헬스체크 실패”)도 보지 않고 적어 보세요.
  3. 본 챕터에서는 default VPC의 public subnet으로 빠르게 띄웠습니다. 운영 권장 구조(public / private / DB 세 종류 서브넷)와 어떤 점이 다른지 §“권장 구조"를 근거로 정리하고, 그 운영 구조를 코드로 옮기는 25장 Terraform 입문에서 어떤 변화가 필요할지 메모해 두세요.

한 줄 요약: ECS Fargate 첫 배포는 VPC 서브넷과 SG 두 개로 네트워크를 잡고, 이미지를 ECR에 올린 뒤 Task Definition에 묶고, ALB Target Group은 ip 타입으로 받아 Service가 desired count를 유지하는 흐름이다. executionRole과 taskRole은 역할이 다르고, 첫 배포 실패의 대부분은 ALB 헬스체크 실패와 IAM 권한 누락이다.

다음 챕터 #

ALB 뒤로 트래픽이 들어오기 시작했지만, 우리 API는 아직 DB가 없어 메모리 안에서만 살고 있습니다. 다음 23장 RDS 연동과 마이그레이션 운영에서는 VPC 안에 RDS Postgres Multi-AZ를 띄우고, Secrets Manager로 비밀번호를 주입하고, Alembic / Django migrations의 운영 패턴과 운영 트래픽을 죽이지 않는 blue/green 마이그레이션 패턴까지 정리합니다.

X