インフラの骨格 — 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 または Django 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"]核となる約束は3つです。
- ポート 8000 で待ち受けます。
/healthが 200 を返します(DB 依存のない軽いチェック)。/readyは DB 接続が正常なら 200、そうでなければ 503 を返します。ALB と ECS はこの応答でトラフィックのルーティングを決めます。
Django なら 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サブネットは3種類です。
| 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) の Mac でそのまま
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: 15sTarget 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 へ向かうトラフィックがふさがれて起動が失敗します。解決策は3つです。
- 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 の3種類のサブネット)とどの点が違うかを §「推奨構造」 を根拠に整理し、その運用構造をコードに移す 第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 マイグレーションパターンまで整理します。