★★★ 上級

10. ECS / ECR / ALB

コンテナを Fargate(サーバ管理レス)で動かす標準構成。ECR(イメージ置き場)→ ECS(実行)→ ALB(外向きエンドポイント) の 3 点セット。

登場人物

用語役割
ECRDocker イメージの置き場(プライベート)
ECS Cluster論理的な「実行環境のグループ」
Task Definition「どのイメージを、どの CPU/Memory で、どの IAM で動かすか」の設計図
ECS Service「Task Definition を N 個常時起動しておく」マネージャ
FargateEC2 不要のサーバレス実行モード
ALB外からの HTTP/HTTPS を受けて Task に振り分ける
Target GroupALB の振り分け先プール(ECS Service が登録)

構成図

Internet → ALB → ECS Service (Fargate × 3) → ECR
ALB が public subnet で HTTPS を受け、Target Group 経由で private subnet の Fargate タスクへ振り分ける。各タスクはイメージを ECR から pull。

ECR(コンテナレジストリ)

# ECR リポジトリ(Docker イメージのプライベート置き場)
resource "aws_ecr_repository" "app" {
  name                 = "myapp"
  # IMMUTABLE = 同じタグでの上書き push を禁止(バージョン管理事故防止)
  image_tag_mutability = "IMMUTABLE"   # 同じタグの再 push 禁止(推奨)

  # push 時に自動で脆弱性スキャン(基本版は無料)
  image_scanning_configuration {
    scan_on_push = true                 # 脆弱性スキャン
  }

  encryption_configuration {
    encryption_type = "AES256"          # KMS にする場合は KMS + kms_key
  }
}

# 古いイメージを自動削除(ストレージコスト管理)
resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  # ライフサイクルルール:30 個より多いイメージを古い順に削除
  policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Keep last 30 images"
      selection = {
        tagStatus   = "any"                  # タグ有無問わず対象
        countType   = "imageCountMoreThan"
        countNumber = 30
      }
      action = { type = "expire" }
    }]
  })
}

ECS Cluster

# ECS Cluster:論理的なグループ。Service / Task はこの中で動く
resource "aws_ecs_cluster" "main" {
  name = "main"

  setting {
    name  = "containerInsights"
    value = "enabled"   # CloudWatch のメトリクス強化(コンテナ単位の詳細メトリクス)
  }
}

# Fargate Capacity Provider を有効化
# FARGATE_SPOT も入れておくと、開発環境などで weight 指定でコスト最適化できる
resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name       = aws_ecs_cluster.main.name
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]

  # Service 側で明示しなかった時のデフォルト戦略
  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 100
  }
}

Task Definition

2 つのロールに注意:

# 2 つのロールで共通の Trust Policy(ECS タスクサービス向け)
data "aws_iam_policy_document" "ecs_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

# 実行ロール:ECR からの pull、CloudWatch Logs への書き込み等
# AWS マネージドポリシーで一発(自前の権限は task ロール側に)
resource "aws_iam_role" "task_execution" {
  name               = "ecs-task-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

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"
}

# アプリ自身のロール(コンテナ内コードが使う)
# S3/DynamoDB アクセス等はこちらに権限を足す
resource "aws_iam_role" "task" {
  name               = "ecs-task-app"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

# コンテナの stdout/stderr が流れるロググループ
resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/myapp"
  retention_in_days = 30
}

# Task Definition: 「どのコンテナをどう動かすか」の設計図(imutable のリビジョン管理)
resource "aws_ecs_task_definition" "app" {
  family                   = "myapp"             # リビジョンをグルーピングする名前
  requires_compatibilities = ["FARGATE"]         # 実行プラットフォーム
  network_mode             = "awsvpc"            # Fargate は awsvpc 必須
  cpu                      = "512"     # 0.5 vCPU(256/512/1024/2048/4096 の選択肢)
  memory                   = "1024"    # 1 GiB(CPU と組み合わせの制約あり)

  execution_role_arn = aws_iam_role.task_execution.arn   # プラットフォーム側
  task_role_arn      = aws_iam_role.task.arn             # アプリ側

  # コンテナ定義は JSON 文字列(HCL ではなく JSON)。jsonencode で生成
  container_definitions = jsonencode([{
    name      = "app"
    image     = "${aws_ecr_repository.app.repository_url}:latest"
    essential = true                              # このコンテナが止まったらタスク全体停止

    # コンテナのポート(ALB のターゲットがここを指す)
    portMappings = [{
      containerPort = 8080
      protocol      = "tcp"
    }]

    # 環境変数(秘匿情報は secrets で参照)
    environment = [
      { name = "LOG_LEVEL", value = "info" },
    ]

    # ログ出力先(CloudWatch Logs)
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.app.name
        awslogs-region        = data.aws_region.current.name
        awslogs-stream-prefix = "app"             # ログストリーム名のプレフィックス
      }
    }
  }])
}

ALB(負荷分散)

# ALB 用 SG(インターネットからの 443 を受ける)
resource "aws_security_group" "alb" {
  name_prefix = "alb-"
  vpc_id      = aws_vpc.main.id

  lifecycle { create_before_destroy = true }
}

# 0.0.0.0/0 から 443 を許可(HTTPS は ALB で終端)
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
  security_group_id = aws_security_group.alb.id
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

# ALB 本体(インターネット向け)
resource "aws_lb" "main" {
  name               = "main"
  internal           = false                            # false = インターネット向け
  load_balancer_type = "application"                    # ALB(HTTP/HTTPS)
  security_groups    = [aws_security_group.alb.id]
  subnets            = [for s in aws_subnet.public : s.id]   # public subnet に配置

  enable_deletion_protection = true                     # 誤削除防止
}

# Target Group:ALB の振り分け先プール(ECS Service がここに自動登録)
resource "aws_lb_target_group" "app" {
  name        = "app"
  port        = 8080                # コンテナのポート
  protocol    = "HTTP"              # ALB→Task 間は HTTP(内部通信)
  vpc_id      = aws_vpc.main.id
  target_type = "ip"   # Fargate は ip ターゲット必須

  # ヘルスチェック設定
  health_check {
    path                = "/health"
    healthy_threshold   = 2     # 2 回成功で正常判定
    unhealthy_threshold = 3     # 3 回失敗で異常判定
    interval            = 30    # チェック間隔(秒)
    timeout             = 5
    matcher             = "200" # 200 を正常とみなす("200-299" も可)
  }
}

# HTTPS リスナー:ALB の入り口
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate.app.arn       # ACM 証明書
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"  # 安全な TLS ポリシー

  # 既定のアクション:上の Target Group に転送
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

ECS Service

# Task 用 SG(ALB からのみ 8080 を受ける)
resource "aws_security_group" "task" {
  name_prefix = "task-"
  vpc_id      = aws_vpc.main.id
  lifecycle { create_before_destroy = true }
}

# 「ALB の SG からだけ 8080 を許可」のルール(CIDR より安全)
resource "aws_vpc_security_group_ingress_rule" "task_from_alb" {
  security_group_id            = aws_security_group.task.id
  referenced_security_group_id = aws_security_group.alb.id   # SG 参照
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

# ECS Service:「Task Definition を desired_count 個常時起動」を維持
resource "aws_ecs_service" "app" {
  name            = "app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  # ネットワーク設定(awsvpc モードで必須)
  network_configuration {
    subnets          = [for s in aws_subnet.private : s.id]   # private subnet で実行
    security_groups  = [aws_security_group.task.id]
    assign_public_ip = false        # private 配置なので不要
  }

  # ALB との連携:Task 起動時に Target Group に自動登録
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"        # container_definitions の name と一致
    container_port   = 8080
  }

  # デプロイ戦略(ローリング)
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200   # ローリング更新(一時的に倍まで)

  # ECS deploy circuit breaker(自動ロールバック)
  # デプロイ失敗を検知したら直前の Task Definition に自動戻し
  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  # latest タグを使う場合、image 変更で再起動できないので task_definition の更新で再起動を促す
  lifecycle {
    ignore_changes = [desired_count]   # オートスケーリングが管理する場合
  }
}
CI/CD との連携 実運用では、image を latest ではなく git SHA や semver タグ で push し、Task Definition の image を Terraform 変数 var.image_tag で受け取る。CI(GitHub Actions)で terraform apply -var image_tag=$GITHUB_SHA がデプロイの正体。