この記事について
バックエンドAPIをAWS ECS Fargate上で稼働させる構成を題材に、ECSの構築から運用まで4回に分けて解説します。
- 基盤編(本記事): クラスタ、ネットワーク、タスク定義の分離設計、IAMロール
- デプロイ・可用性編: ECSサービス設定、Auto Scaling、ECS Exec、イメージ管理
- 監視・アラート編: CloudWatchアラーム、失敗通知、ログ監視
- バッチ・運用編: Step Functionsオーケストレーション、コスト管理、運用手順
全体のインフラ構成は、ALB配下にECS Fargateサービスを置き、RDS(PostgreSQL)とElastiCache(Redis、Spring Session用)をバックエンドに持つ構成です。
ステップ1: ECSクラスタを作る
最初に決めるのはクラスタの土台部分です。EC2キャパシティプロバイダーは使わず、Fargateのみで構成します。
resource "aws_ecs_cluster" "this" {
name = "${var.project}-${var.env}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_cluster_capacity_providers" "this" {
cluster_name = aws_ecs_cluster.this.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
}
}
Container Insightsを有効化しているのは、後述の監視編でCPU/メモリのタスク単位メトリクスを使うための前提になります。運用初期はFargate Spotを混在させない方が、デプロイ時の不確実性(スポット中断とデプロイサーキットブレーカーの相互作用)を減らせます。安定運用の実績ができてから、コスト最適化の一環でSpotを検討する順番が安全です。
ステップ2: ネットワーク(セキュリティグループ)を決める
コンテナをどこに置くかより先に、通信経路をどう絞るかを決めておきます。ALB→ECS→Redisの通信を、それぞれ一方向にのみ許可します。
resource "aws_security_group" "ecs" {
name = "${var.project}-${var.env}-ecs-sg"
vpc_id = var.vpc_id
ingress {
description = "Allow from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [var.alb_sg_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "redis" {
name = "${var.project}-${var.env}-redis-sg"
vpc_id = var.vpc_id
ingress {
description = "Allow from ECS tasks"
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
}
ポイントは、CIDRブロックではなくセキュリティグループIDを参照元に指定していることです。これにより、サブネットのIP体系が変わってもルールを書き換える必要がなく、許可対象を「ALB配下のリソースすべて」ではなく「ECSタスクのみ」に絞り込めます。
ステップ3: セッションストア(ElastiCache Redis)を用意する
ECSタスクを水平スケールさせる前提であれば、セッション情報を各タスクのメモリではなく共有ストアに持たせる必要があります。Spring SessionのバックエンドとしてRedisを使います。
resource "aws_elasticache_subnet_group" "this" {
name = "${var.project}-${var.env}-redis-subnet"
subnet_ids = var.private_subnet_ids
}
resource "aws_elasticache_cluster" "this" {
cluster_id = "${var.project}-${var.env}-redis"
engine = "redis"
node_type = "cache.t3.micro"
num_cache_nodes = 1
parameter_group_name = "default.redis7"
engine_version = "7.1"
port = 6379
subnet_group_name = aws_elasticache_subnet_group.this.name
security_group_ids = [aws_security_group.redis.id]
# メンテナンス・スナップショットは未指定だとAWS任意のタイミングで実行されるため、
# 業務影響が少ない時間帯を明示しておく
maintenance_window = "sun:19:00-sun:20:00" # UTC = 月曜04:00-05:00 JST
snapshot_window = "18:00-19:00" # UTC = 月曜03:00-04:00 JST
snapshot_retention_limit = 1
}
cache.t3.micro・単一ノード構成は開発環境向けのサイジングであり、本番運用では複数AZのレプリケーショングループへの切り替えが前提になります。snapshot_retention_limitとmaintenance_windowを明示しているのは、セッションストアのため障害時のデータ消失影響は限定的ですが、メンテナンスがいつ実行されるか分からない状態を避けるためです。
セッションのシリアライズ形式を変更するような変更は、ローリングデプロイ中に新旧バージョンが同じRedisを共有すると不整合を起こす可能性があります。この種の変更ではBlue/Greenデプロイに切り替え、旧環境のタスクが完全に入れ替わってから新環境へトラフィックを切り替える方式が安全です。
ステップ4: タスク定義を「用途ごと」に分離する
ここが設計の中心になります。1つのアプリケーションに対して、用途の異なる4つのタスク定義を用意します。
| タスク定義 | 起動契機 | 特徴 |
|---|---|---|
app |
ECSサービス(常駐) | ALB配下、ヘルスチェックあり |
flyway |
デプロイパイプラインからの一回実行 | マイグレーション専用、readonlyRootFilesystem |
db_debug |
手動実行(ECS Exec前提) | sleep infinityで維持、要自動停止(詳細は運用編) |
この分離の狙いは、タスクの生存期間と権限範囲を用途ごとに最小化することにあります。特にFlywayを常駐アプリのコンテナに同梱してエントリポイントで実行する方式は、マイグレーション失敗時の切り分けが難しく、「マイグレーション成功を確認してからサービスをデプロイする」という順序制御もしにくくなります。そのためFlywayは独立したECS one-offタスクとして実行し、終了コードでゲートします。
resource "aws_ecs_task_definition" "flyway" {
family = "${var.project}-${var.env}-flyway"
cpu = "512"
memory = "1024"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.task_execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([{
name = "flyway"
image = "${var.flyway_image_url}:${var.flyway_image_tag}"
readonlyRootFilesystem = true
mountPoints = [
{ sourceVolume = "tmp", containerPath = "/tmp", readOnly = false }
]
command = ["migrate"]
}])
volume {
name = "tmp"
}
}
readonlyRootFilesystem = trueはAWS Foundational Security Best Practices(ECS.5)への準拠で、ルートファイルシステムを読み取り専用にした上で、一時ファイル書き込みが必要な/tmpのみボリュームマウントで書き込み可能にしています。batchタスクにも同様の制約を適用します。
db_debugタスクはcommand = ["sleep", "infinity"]でコンテナを起動させ続け、ECS Execでシェル接続してから手動でpsqlを実行する運用を想定しています。ログ保持期間を他タスクの30日に対して7日に短縮しているのは、恒久稼働ではなく一時利用が前提のためです。この用途では起動しっぱなしによる課金が実運用上の落とし穴になりますが、対処は運用編で扱います。
ステップ5: IAMロールを「実行時権限」と「アプリ権限」に分ける
ECSタスクには2種類のIAMロールが関わり、責務が異なります。
-
タスク実行ロール(
task_execution): ECSエージェントがECRからイメージをプルし、CloudWatch Logsへログを送り、Secrets Managerから機密情報を取得するために使います。アプリケーションコードからは直接触れません。 -
タスクロール(
task): コンテナ内で稼働するアプリケーション自身が持つ権限です。
resource "aws_iam_role" "task_execution" {
name = "${var.project}-${var.env}-ecs-task-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "task_execution" {
role = aws_iam_role.task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "task_execution_secrets" {
name = "secrets-access"
role = aws_iam_role.task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
# Secrets Managerのシークレットは特定ARNまで絞り込める。
# "*"にすると意図しないシークレットまで読める範囲になるため、対象を明示する
Resource = [var.db_password_secret_arn]
}]
})
}
resource "aws_iam_role_policy" "task_execution_kms" {
name = "kms-decrypt"
role = aws_iam_role.task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["kms:Decrypt"]
# Secrets ManagerがAWS管理キー(aws/secretsmanager)で暗号化している場合、
# キーARNはリージョン+アカウントIDから一意に決まる
Resource = "arn:aws:kms:${var.aws_region}:${data.aws_caller_identity.current.account_id}:key/*"
Condition = {
StringEquals = {
"kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com"
}
}
}]
})
}
Resource = "*"をやめて、Secrets ManagerのシークレットARNとKMSキーへのアクセスをkms:ViaService条件付きで絞り込んでいます。CMK(カスタマー管理キー)に切り替える場合は、ResourceをそのキーARNに差し替えるだけで済む形にしてあります。
タスクロールには、ECS Exec(execute-command)でコンテナにシェル接続するための権限のみを付与します。
resource "aws_iam_role" "task" {
name = "${var.project}-${var.env}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "task_exec_ssm" {
name = "ecs-exec-ssmmessages"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}]
})
}
ssmmessages:*系のアクションはリソースレベル権限に対応していないため、ここはResource = "*"が仕様上の標準要件になります。Secrets Manager/KMSのように絞り込めるものと、SSM Session Managerのように絞り込めないものを区別して扱うのがポイントです。
ただし、このロール設定だけでは実はECS Execは動きません。ECSサービス側でenable_execute_commandを有効化し、クラスタ側でセッションログの出力先を設定する必要があります。この設定は次回のデプロイ・可用性編で扱います。
次回は、常駐するappタスクをECSサービスとして稼働させる部分を扱います。デプロイ時に可用性を落とさないためのサーキットブレーカー設定、Auto Scaling、ECS Execを実際に有効化する設定、そしてイメージタグの固定方針について解説します。