目次
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 または Django 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ドメイン → ALB第12章 Route 53
ALBTLS 終端、ルーティング、ヘルスチェック第13章 ALB / NLB と ACM
ACMTLS 証明書の発行 / 更新第13章 ALB / NLB と ACM
ECRイメージ保存第16章 ECR
ECS Fargateコンテナ実行(サーバーレス)第15章 ECS Fargate
RDSDB第11章 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"]

核となる約束は3つです。

  1. ポート 8000 で待ち受けます。
  2. /health が 200 を返します(DB 依存のない軽いチェック)。
  3. /ready は DB 接続が正常なら 200、そうでなければ 503 を返します。ALB と ECS はこの応答でトラフィックのルーティングを決めます。

Django なら 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

サブネットは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 二つの役割
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) の 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 にまとまります。

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 へ向かうトラフィックがふさがれて起動が失敗します。解決策は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 配列を確認します。

練習問題 #

  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 の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 マイグレーションパターンまで整理します。

X