AWS実践 #1 インフラの骨格 — FastAPI/Django を ECS Fargate にデプロイ

読了 9分

基礎 7 編 でアカウント / リージョン / IAM / コスト / CLI / セキュリティ / ログを、中級 7 編 で EC2 / VPC / S3 / RDS / DNS / ALB / CloudFront を、上級 7 編 で ECS / Lambda / メッセージング / Secrets / Step Functions を、それぞれ一つずつ押さえてきました。21 編の道具箱が集まったところで — いよいよ 本物のバックエンドを 1 つのプロジェクトとして 乗せます。

このシリーズが扱うのは、FastAPI 実戦 または Django 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"]

中核となる約束は 3 つ:

  1. ポート 8000 で listen する
  2. /health が 200 を返す (DB 依存のない軽いチェック)
  3. /ready が DB 接続 OK なら 200、ダメなら 503 — ALB / ECS がトラフィックルーティングを判断する材料

Django なら gunicorn -w 4 myproject.wsgi で同じ約束を満たせます。

1) VPC とサブネット — ネットワークの骨格 #

ECS / RDS / ALB はすべて VPC の中 に住みます。VPC がなければ 1 行も走らせられません。幸い新しいアカウントには default VPC がリージョンごとに用意されているので、立ち上げを急ぐときはそれを使っても OK。本番は自前 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

3 つのサブネット:

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 2 つ #

SG の 2 つ
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 — イメージ push 時に自動で脆弱性スキャン (#5 モニタリング で結果を確認)。

ビルドと push #

ビルド → タグ → push
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) push
docker push $REPO:v1
docker push $REPO:latest

Apple Silicon (M1/M2/M3) の Mac でそのまま docker build すると arm64 イメージができてしまい、Fargate (x86_64 標準) では起動しません。--platform=linux/amd64 を必ず明示。Fargate は ARM もサポートしますが別途設定が必要。

イメージタグ戦略 #

タグ意味
latest最新 — 本番では 使わない (ロールバック不能)
v1, v2, ...人が読めるバージョン
<git-sha>追跡用 — CI が自動発行 (#3)
<git-sha>-prod環境別エイリアス

latest は開発者の便宜のため。本番の Task Definition は常に git SHA か semver で固定 します — そうすれば「いまどのコードが動いているか」が一切の疑問なく確認できます。

3) Task Definition — コンテナの「住民票」 #

ECS で最も重要な部分。イメージ + CPU/メモリ + 環境変数 + ポート + ログ設定 が 1 つの 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 へ (#5)
healthCheckコンテナ自身のヘルスチェック (Dockerfile のものとは別)

IAM ロール 2 つの違いがよく混乱される #

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:1blog-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 リダイレクト。

5) ECS Service — コンテナの「会社」 #

Task Definition が従業員の職務記述書なら、Service は会社 です — 常に desired count 分の task を維持し、タスクがダウンすれば自動で立て直し、デプロイ時には少しずつ入れ替えます。

ECS Cluster を作る (1 回だけ)
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 1 つの障害に耐える
assignPublicIp=ENABLEDprivate subnet + NAT がないとき (簡易セット)。本番は NAT 推奨
health-check-grace-periodService が task を起動した直後 ALB ヘルスチェックの待ち時間 (アプリのブート時間)
deploymentCircuitBreaker新デプロイが N 回連続で失敗したら自動ロールバック (#3 で詳細)
maximumPercent=200デプロイ中の最大 task 数 (200% = 旧 + 新が同時に存在)
minimumHealthyPercent=100デプロイ中の最低 healthy 比率 (100% = ダウンタイム 0)

この 2 つの % が ローリングアップデート の形を決めます。

オートスケーリング #

Service を立てただけでは自動スケールは入りません。別途:

Auto Scaling ターゲット登録
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) 初回デプロイの検証 #

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

リクエストを 1 本投げてログに 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 のサブネットまでルーティングできるか (同一 VPC)

3) awsvpc networkMode の ENI 上限 #

Fargate task は ENI (Elastic Network Interface) を 1 個ずつ消費します。AZ / サブネットの IP が枯れると新しい task を立てられません。CIDR を狭くしすぎないこと (上の例 /24 = 256 IP)。

4) Public IP なしで ECR pull 失敗 #

Private subnet に task を立てているのに NAT Gateway も VPC Endpoint もないと、ECR / Secrets Manager / CloudWatch へのトラフィックが詰まって起動が失敗します。

解決 3 通り:

  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 2 つで 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 Scalingapplication-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