인프라 골격 — 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를 제외한 모든 구성 요소를 한 번에 셋업합니다.
큰 그림 #
이번 챕터에서 만들 인프라는 다음과 같습니다.
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 | 12장 Route 53 |
| ALB | TLS 종단, 라우팅, 헬스체크 | 13장 ALB / NLB와 ACM |
| ACM | TLS 인증서 발급 / 갱신 | 13장 ALB / NLB와 ACM |
| ECR | 이미지 저장 | 16장 ECR |
| ECS Fargate | 컨테이너 실행 (서버리스) | 15장 ECS Fargate |
| RDS | DB | 11장 RDS, 23장 |
| VPC + Subnet | 네트워크 분리 | 8장 EC2와 VPC |
| Secrets Manager | DB 비밀번호 | 20장 Secrets / Parameter Store, 23장 |
블로그 API 컨테이너 — 한 줄 약속 #
이 책이 가정하는 컨테이너는 다음 모양의 산출물입니다.
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"]핵심 약속은 셋입니다.
- 포트 8000에서 듣습니다.
/health가 200을 반환합니다(DB 의존이 없는 가벼운 체크)./ready가 DB 연결이 정상이면 200, 아니면 503을 반환합니다. ALB와 ECS가 이 응답으로 트래픽 라우팅을 결정합니다.
장고라면 gunicorn -w 4 myproject.wsgi로 같은 약속을 만들면 됩니다.
1) VPC와 서브넷 — 네트워크 골격 #
ECS, RDS, ALB는 모두 VPC 안에 삽니다. VPC가 없으면 한 줄도 띄울 수 없습니다. 다행히 새 계정에는 리전마다 default VPC가 있어서 빠르게 시작할 때는 그것을 써도 됩니다. 운영은 직접 만든 VPC가 권장됩니다.
권장 구조 #
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-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에서 이미 다뤘지만 빠르게 다시 정리합니다.
aws ecr create-repository \
--repository-name blog-api \
--image-scanning-configuration scanOnPush=true \
--region ap-northeast-2scanOnPush=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:latestApple 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에 묶입니다.
{
"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 / memory | Fargate가 정해진 조합만 허용 (예: 256/512, 512/1024, 1024/2048) |
executionRoleArn | ECS agent가 ECR pull / Logs / Secrets 접근에 쓸 역할 |
taskRoleArn | 컨테이너 코드가 쓸 IAM 역할 — boto3가 이것으로 sign |
awslogs | 로그가 자동으로 CloudWatch로 (26장) |
healthCheck | 컨테이너 자체 헬스체크 (Dockerfile과 별개) |
두 IAM 역할의 차이 #
두 역할을 자주 헷갈립니다.
executionRoleArn | taskRoleArn | |
|---|---|---|
| 누가 쓰는가 | ECS agent (시작 단계) | 컨테이너 안 코드 (실행 중) |
| 권한 | ECR pull, CloudWatch 쓰기, Secrets 읽기 | S3 접근, RDS, SQS 등 — 앱 로직 |
executionRoleArn을 누락하면 이미지 pull이 실패합니다. taskRoleArn을 누락하면 boto3가 NoCredentialsError를 냅니다.
등록 #
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: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 모드가 동작하지 않습니다.
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 15ALB Listener 규칙은 13장을 참조합니다. HTTPS 443 → forward → tg-blog-api, HTTP 80 → 443 redirect의 구성입니다.
5) ECS Service — 컨테이너를 지키는 관리자 #
Task Definition이 직원의 직무 기술서라면, Service는 그 직원을 항상 일하게 만드는 관리자입니다. 항상 desired count 만큼의 task를 띄우고, 죽으면 새로 만들고, 배포 시 점진적으로 교체합니다.
aws ecs create-cluster --cluster-name blog-clusteraws 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=ENABLED | private subnet + NAT가 없을 때 사용 (간이 셋업). 운영은 NAT 권장 |
health-check-grace-period | Service가 task를 띄운 직후 ALB 헬스체크를 기다리는 유예 (앱 부팅 시간) |
deploymentCircuitBreaker | 새 배포가 N 회 연속 실패하면 자동 롤백 (24장에서 자세히) |
maximumPercent=200 | 배포 중 최대 task 수 (200% = 기존 + 새 동시) |
minimumHealthyPercent=100 | 배포 중 최소 healthy 비율 (100% = 다운타임 0) |
이 두 % 값이 롤링 업데이트의 모양을 결정합니다.
자동 스케일링 #
Service가 떴다고 자동 확장이 켜지지는 않습니다. 별도로 설정합니다.
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 10aws 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장의 모듈을 재사용한다고 가정합니다.)
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 }
}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가 안정 상태에 들어갈 때까지 대기 #
aws ecs wait services-stable \
--cluster blog-cluster \
--services blog-api헬스체크 직접 확인 #
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"}로그 확인 #
aws logs tail /ecs/blog-api --follow --since 5m요청을 한 번 보내고 로그에 access log가 뜨면, 이 책의 4부에서 첫 도착점에 온 것입니다.
함정 — 첫 배포가 안 뜨는 5가지 원인 #
1) STOPPED 상태로 끝없이 재시작 #
ECS 콘솔의 Tasks 탭에서 STOPPED 행을 클릭해 “Stopped reason"을 확인합니다. 흔한 원인은 다음과 같습니다.
| 메시지 | 원인 |
|---|---|
CannotPullContainerError | ECR 권한 누락 → executionRole |
ResourceInitializationError: ... secret manager | Secrets ARN 오타 / 권한 |
Essential container ... exited | 컨테이너 자체가 죽음 → CloudWatch logs |
Task failed ELB health checks | ALB가 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로 가는 트래픽이 막혀 시작이 실패합니다. 해결책은 셋입니다.
- NAT Gateway 추가 (시간당 ~$0.045 + 데이터 전송)
- ECR / Logs / Secrets의 Interface VPC Endpoint 추가 (NAT보다 저렴)
- Public subnet +
assignPublicIp=ENABLED(학습용)
5) 배포 정체 — 새 task가 healthy가 안 됨 #
deploymentCircuitBreaker가 켜져 있으면 N 분 후 자동 롤백합니다. 꺼져 있으면 service가 영원히 IN_PROGRESS입니다. aws ecs describe-services로 deployments 배열을 확인합니다.
연습문제 #
- 본 챕터의 Task Definition에서
executionRoleArn과taskRoleArn이 각각 누구에게 쓰이는지 한 줄씩 적고, 둘 중 하나를 누락했을 때 나타나는 증상을 §“두 IAM 역할의 차이"를 근거로 연결해 보세요. 24장 CI/CD의iam:PassRole권한이 왜 필요한지 미리 떠올려 두면 좋습니다. - Fargate의 Target Group이
target-type ip여야 하는 이유를 한 단락으로 설명해 보세요. ALB 헬스체크가 실패할 때 점검할 5가지 포인트(§“ALB 헬스체크 실패”)도 보지 않고 적어 보세요. - 본 챕터에서는 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 마이그레이션 패턴까지 정리합니다.