はじめに
FastAPIのアプリをAWSで本番運用するにあたってECSを使った構成を作った。
Lambdaも試したが(前の記事参照)、常時トラフィックがあるAPIにはECSのほうが向いている。ECSの設定項目が多くて最初は何がなんだかわからなかったので、ECRへのイメージプッシュからFargateでのタスク起動まで一連の流れを整理した。
全体構成
[GitHub]
↓ git push
[GitHub Actions]
↓ docker build
[ECR(コンテナレジストリ)]
↓ イメージをデプロイ
[ECS(Fargate)]
├── タスク定義(コンテナの設定)
└── サービス(タスクの起動管理)
↑
ALB(ロードバランサー)
↑
Internet
使うAWSサービス
ECR — Dockerイメージの保存場所
ECS — コンテナの実行環境
Fargate — ECSの起動タイプ(サーバーレスコンテナ)
ALB — HTTPSの終端とロードバランシング
VPC — ネットワーク(プライベートサブネットにECSを置く)
IAM — ECSタスクの権限管理
CloudWatch — ログ収集
Secrets Manager — 環境変数の機密情報管理
FastAPIアプリのDockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY requirements.txt .
RUN uv pip install --no-cache --system -r requirements.txt
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages \
/usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY src/ ./src/
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
RUN useradd --create-home --no-log-init appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app",
"--host", "0.0.0.0",
"--port", "8000",
"--workers", "2",
"--log-level", "info"]
ECR — Dockerイメージの保存
リポジトリの作成
# ECRリポジトリを作成
aws ecr create-repository \
--repository-name fastapi-app \
--region ap-northeast-1 \
--image-scanning-configuration scanOnPush=true \
--encryption-configuration encryptionType=AES256
scanOnPush=trueでプッシュ時に脆弱性スキャンが自動で走る。セキュリティリスクを早期発見できる。
イメージのビルドとプッシュ
# ECRにログイン
aws ecr get-login-password --region ap-northeast-1 | \
docker login --username AWS --password-stdin \
123456789012.dkr.ecr.ap-northeast-1.amazonaws.com
# ビルド
docker build -t fastapi-app .
# タグ付け
docker tag fastapi-app:latest \
123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/fastapi-app:latest
# プッシュ
docker push \
123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/fastapi-app:latest
IAMロールの作成
ECSタスクに必要なIAMロールを作成する。
タスク実行ロール(ECSがECRからイメージを取得するために必要)
// trust-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
# タスク実行ロールを作成
aws iam create-role \
--role-name ecsTaskExecutionRole \
--assume-role-policy-document file://trust-policy.json
# AWS管理ポリシーをアタッチ
aws iam attach-role-policy \
--role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
# Secrets Managerへのアクセスを追加
aws iam attach-role-policy \
--role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/SecretsManagerReadWrite
タスクロール(コンテナ内のアプリが使う権限)
// task-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::my-app-bucket/*"
},
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:*"
}
]
}
aws iam create-role \
--role-name ecsTaskRole \
--assume-role-policy-document file://trust-policy.json
aws iam put-role-policy \
--role-name ecsTaskRole \
--policy-name ecsTaskPolicy \
--policy-document file://task-policy.json
Secrets Managerにシークレットを保存
# データベース接続情報を保存
aws secretsmanager create-secret \
--name production/database \
--region ap-northeast-1 \
--secret-string '{
"DATABASE_URL": "postgresql://user:pass@rds-endpoint:5432/mydb"
}'
# APIキーを保存
aws secretsmanager create-secret \
--name production/api-keys \
--region ap-northeast-1 \
--secret-string '{
"SECRET_KEY": "your-secret-key",
"OPENAI_API_KEY": "sk-..."
}'
タスク定義
// task-definition.json
{
"family": "fastapi-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "fastapi",
"image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/fastapi-app:latest",
"portMappings": [
{
"containerPort": 8000,
"protocol": "tcp"
}
],
"environment": [
{"name": "APP_ENV", "value": "production"},
{"name": "LOG_LEVEL", "value": "info"}
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:production/database:DATABASE_URL::"
},
{
"name": "SECRET_KEY",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:production/api-keys:SECRET_KEY::"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/fastapi-app",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs",
"awslogs-create-group": "true"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
},
"essential": true
}
]
}
# タスク定義を登録
aws ecs register-task-definition \
--cli-input-json file://task-definition.json
ECSクラスターとサービスの作成
# クラスターを作成
aws ecs create-cluster \
--cluster-name production \
--capacity-providers FARGATE FARGATE_SPOT \
--default-capacity-provider-strategy \
capacityProvider=FARGATE,weight=1,base=1
// service.json
{
"cluster": "production",
"serviceName": "fastapi-service",
"taskDefinition": "fastapi-app:1",
"desiredCount": 2,
"launchType": "FARGATE",
"networkConfiguration": {
"awsvpcConfiguration": {
"subnets": [
"subnet-private-1a",
"subnet-private-1c"
],
"securityGroups": ["sg-ecs-fastapi"],
"assignPublicIp": "DISABLED"
}
},
"loadBalancers": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/fastapi-tg/xxx",
"containerName": "fastapi",
"containerPort": 8000
}
],
"deploymentConfiguration": {
"maximumPercent": 200,
"minimumHealthyPercent": 100,
"deploymentCircuitBreaker": {
"enable": true,
"rollback": true
}
},
"enableExecuteCommand": true
}
# サービスを作成
aws ecs create-service \
--cli-input-json file://service.json
deploymentCircuitBreakerを有効にすると、デプロイが失敗したときに自動でロールバックする。
enableExecuteCommand: trueでコンテナにexecコマンドでSSH的に接続できる。デバッグに便利。
ALBの設定
# ターゲットグループの作成
aws elbv2 create-target-group \
--name fastapi-tg \
--protocol HTTP \
--port 8000 \
--vpc-id vpc-xxx \
--target-type ip \
--health-check-path /health \
--health-check-interval-seconds 30 \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3
# ALBの作成
aws elbv2 create-load-balancer \
--name fastapi-alb \
--subnets subnet-public-1a subnet-public-1c \
--security-groups sg-alb \
--scheme internet-facing \
--type application
# HTTPSリスナーの追加
aws elbv2 create-listener \
--load-balancer-arn arn:aws:elasticloadbalancing:...:loadbalancer/app/fastapi-alb/xxx \
--protocol HTTPS \
--port 443 \
--certificates CertificateArn=arn:aws:acm:ap-northeast-1:123456789012:certificate/xxx \
--default-actions Type=forward,TargetGroupArn=arn:aws:...:targetgroup/fastapi-tg/xxx
# HTTPをHTTPSにリダイレクト
aws elbv2 create-listener \
--load-balancer-arn arn:aws:elasticloadbalancing:...:loadbalancer/app/fastapi-alb/xxx \
--protocol HTTP \
--port 80 \
--default-actions \
Type=redirect,RedirectConfig='{Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'
FastAPIのヘルスチェックエンドポイント
# src/main.py
from fastapi import FastAPI
from sqlalchemy import text
from database import engine
app = FastAPI()
@app.get("/health")
async def health_check():
"""ALBのヘルスチェック用エンドポイント"""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_status = "ok"
except Exception as e:
db_status = f"error: {str(e)}"
status = "ok" if db_status == "ok" else "degraded"
return {
"status": status,
"db": db_status,
"version": "1.0.0",
}
オートスケーリングの設定
# オートスケーリングターゲットの登録
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/production/fastapi-service \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 \
--max-capacity 10
# CPU使用率に基づくスケーリングポリシー
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/production/fastapi-service \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu-scaling \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 70.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleInCooldown": 60,
"ScaleOutCooldown": 30
}'
CPU使用率が70%を超えるとタスク数を増やし、下がると減らす。
GitHub ActionsでCI/CDを構築
# .github/workflows/deploy.yml
name: Deploy to ECS
on:
push:
branches: [main]
env:
AWS_REGION: ap-northeast-1
ECR_REPOSITORY: fastapi-app
ECS_CLUSTER: production
ECS_SERVICE: fastapi-service
CONTAINER_NAME: fastapi
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install uv
uv pip install --system -r requirements.txt
uv pip install --system pytest pytest-cov
- name: Run tests
run: pytest --cov=src tests/
deploy:
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC認証に必要
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials(OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Download task definition
run: |
aws ecs describe-task-definition \
--task-definition fastapi-app \
--query taskDefinition \
> task-definition.json
- name: Update image in task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
- name: Notify success
if: success()
run: |
echo "デプロイ完了: ${{ github.sha }}"
wait-for-service-stability: trueでデプロイが完了するまでActions が待つ。デプロイ失敗時もGitHub上で確認できる。
OIDC認証(アクセスキーを使わない方法)
// IAMロールの信頼ポリシー
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
}
}
}
]
}
GitHub ActionsにAWSのアクセスキーを渡さずにOIDCでロールを引き受ける方法。シークレットにアクセスキーを保存しなくていいのでセキュリティが向上する。
デバッグ — コンテナへの接続
# ECS Execでコンテナに接続(SSM Agent経由)
aws ecs execute-command \
--cluster production \
--task タスクID \
--container fastapi \
--command "/bin/bash" \
--interactive
# ログをリアルタイムで確認
aws logs tail /ecs/fastapi-app --follow
# 特定の時間帯のログを確認
aws logs get-log-events \
--log-group-name /ecs/fastapi-app \
--log-stream-name ecs/fastapi/タスクID \
--start-time $(date -d "1 hour ago" +%s000)
よくあるトラブルシューティング
# サービスの状態を確認
aws ecs describe-services \
--cluster production \
--services fastapi-service \
--query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount,Events:events[0:3]}'
# タスクが起動しない場合 — タスクのログを確認
aws ecs list-tasks \
--cluster production \
--service-name fastapi-service \
--desired-status STOPPED
aws ecs describe-tasks \
--cluster production \
--tasks タスクID \
--query 'tasks[0].stoppedReason'
# デプロイの状態を確認
aws ecs describe-services \
--cluster production \
--services fastapi-service \
--query 'services[0].deployments'
まとめ
- ECRにDockerイメージを保存してECSから参照する
- FargateはサーバーレスコンテナでEC2の管理不要
- タスク実行ロール(ECRからのイメージ取得)とタスクロール(アプリの権限)は分ける
- Secrets ManagerのシークレットをタスクDefinitionで環境変数に注入する
- deploymentCircuitBreakerを有効にして失敗時に自動ロールバック
- GitHub ActionsのOIDC認証でアクセスキーを排除できる
- ECS ExecとCloudWatch Logsでデバッグする
最初はAWS CLIのコマンドの多さに圧倒されたが、一度構築してしまえばあとはGitHubにプッシュするだけでデプロイが走る。構築のコストが高い分、運用が楽になる。