★★★ 上級

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] ──HTTPS──→ [ALB (public subnet)]
                            │
                            ↓ Target Group
                     ┌─────────────────────┐
                     │ ECS Service (count=3)│
                     │   Task A (Fargate)  │
                     │   Task B (Fargate)  │ ← private subnet
                     │   Task C (Fargate)  │
                     └─────────────────────┘
                            │ pull image
                            ↓
                       [ECR Repo]

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

resource "aws_ecr_repository" "app" {
  name                 = "myapp"
  image_tag_mutability = "IMMUTABLE"   # 同じタグの再 push 禁止(推奨)

  image_scanning_configuration {
    scan_on_push = true                 # 脆弱性スキャン
  }

  encryption_configuration {
    encryption_type = "AES256"
  }
}

# 古いイメージを自動削除
resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Keep last 30 images"
      selection = {
        tagStatus   = "any"
        countType   = "imageCountMoreThan"
        countNumber = 30
      }
      action = { type = "expire" }
    }]
  })
}

ECS Cluster

resource "aws_ecs_cluster" "main" {
  name = "main"

  setting {
    name  = "containerInsights"
    value = "enabled"   # CloudWatch のメトリクス強化
  }
}

# Fargate Capacity Provider を有効化
resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name       = aws_ecs_cluster.main.name
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]

  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 100
  }
}

Task Definition

2 つのロールに注意:

data "aws_iam_policy_document" "ecs_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

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

# アプリが使うロール(必要に応じて権限を足す)
resource "aws_iam_role" "task" {
  name               = "ecs-task-app"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/myapp"
  retention_in_days = 30
}

resource "aws_ecs_task_definition" "app" {
  family                   = "myapp"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "512"     # 0.5 vCPU
  memory                   = "1024"    # 1 GiB

  execution_role_arn = aws_iam_role.task_execution.arn
  task_role_arn      = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name      = "app"
    image     = "${aws_ecr_repository.app.repository_url}:latest"
    essential = true

    portMappings = [{
      containerPort = 8080
      protocol      = "tcp"
    }]

    environment = [
      { name = "LOG_LEVEL", value = "info" },
    ]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.app.name
        awslogs-region        = data.aws_region.current.name
        awslogs-stream-prefix = "app"
      }
    }
  }])
}

ALB(負荷分散)

resource "aws_security_group" "alb" {
  name_prefix = "alb-"
  vpc_id      = aws_vpc.main.id

  lifecycle { create_before_destroy = true }
}

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

resource "aws_lb" "main" {
  name               = "main"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [for s in aws_subnet.public : s.id]

  enable_deletion_protection = true
}

resource "aws_lb_target_group" "app" {
  name        = "app"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"   # Fargate は ip ターゲット必須

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
    matcher             = "200"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate.app.arn
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

ECS Service

resource "aws_security_group" "task" {
  name_prefix = "task-"
  vpc_id      = aws_vpc.main.id
  lifecycle { create_before_destroy = true }
}

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
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

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"

  network_configuration {
    subnets          = [for s in aws_subnet.private : s.id]
    security_groups  = [aws_security_group.task.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }

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

  # ECS deploy circuit breaker(自動ロールバック)
  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 がデプロイの正体。