1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS】ALB + ECS on Fargate の Terraform テンプレート (ECS Exec, Cognito, VPC Endpoint つき)

Posted at

全部詰め込んだ サンプルコード を作成しました。とても長いので、要点だけを解説していきます。
これまで作ってきた VPCパブリックホストゾーン を利用しています。
バグや改善点などがあれば、お気軽に教えてください。

工夫

  • アプリ側とインフラ側のコードを完全に分離する。これによって、インフラの担当とアプリの担当で切り分けが簡単になる。
  • docker の二段階ビルドをして、docker image を軽量にする。
  • Cognito では初期ユーザーを作成できるようにする。それ以降も、ユーザー部分を変えれば、差分検出によってユーザーが追加変更される。
  • ECS Exec を可能にすることで、デバッグが容易になる。
  • terraform apply 時に、アプリをビルドしプッシュする自動化。
  • VPC Endpoint で、ネットワークを閉域にする。

アプリ側のコードの解説

Dockerfile の他段階ビルド

FROM rust:1.89 AS builder
WORKDIR /build
COPY . .
RUN cargo build --release

FROM rust:1.89
WORKDIR /app
COPY --from=builder /build/target/release/app .
RUN chmod +x ./app
CMD ["./app"]

docker のビルドを 1 つ目で行い、そこからビルドされたアーティファクトだけをとりだしています。2 つ目のイメージもとを rust:1.89 にする必要がなかったかもしれません。cargo あたりはいらない気がしています。

Terraform 側のコードの解説

分量が多いので、基本的なところよりも応用的なところの解説をしていきます。基本的なところの解説は需要があればするかもしれないです。

ALB -> の通信で 443 ポートを全開放する

resource "aws_vpc_security_group_egress_rule" "alb_https" {
  security_group_id = aws_security_group.alb.id
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  # TODO!: limit to cognito security group, but this is impossible.
  cidr_ipv4 = "0.0.0.0/0"
}

Cognito の authenticate は、以下の流れで通信しています。

  1. リクエスト元 → ALB にアクセス
  2. ALB → Cognito Hosted UI にリダイレクト(認証要求)
  3. ユーザーが Cognito で認証を行う
  4. Cognito → リクエスト元に OAuth トークンをリダイレクトで返す(ブラウザにセット)
  5. リクエスト元 → トークン付きで ALB に再リクエスト
  6. ALB がトークンを検証し、ECS にリクエストを転送
  7. ECS → ALB → リクエスト元 にレスポンスを返す

つまり、途中で ALB -> Cognito があるので、その分の egress rule が必要です。この部分をうまく扱う方法がわからず、毎回 0.0.0.0/0 としています。
いい方法ないですかね。

http リクエストのリダイレクト

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.this.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type = "redirect"
    redirect {
      port        = 443
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

HTTP で ALB にリクエストが来た時に、HTTPS にリダイレクトをしています。これによって、HTTPS の認証フローを HTTP リクエスト時にも強要できます。

リスナールール

resource "aws_lb_listener_rule" "http" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100
  dynamic "action" {
    for_each = var.need_authenticate ? [0] : []
    content {
      type = "authenticate-cognito"
      authenticate_cognito {
        scope               = "openid"
        user_pool_arn       = aws_cognito_user_pool.this[0].arn
        user_pool_client_id = aws_cognito_user_pool_client.this[0].id
        user_pool_domain    = aws_cognito_user_pool_domain.this[0].domain
      }
    }
  }
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.http.arn
  }
  condition {
    path_pattern {
      values = ["/*"]
    }
  }
}

変数に応じて、 authenticate-cognito をつけるかどうかを動的に決めています。
実は、ALB -> ECS (target group) の通信は、HTTP です。名前にもそれが反映されています。

cognito user pool domain

resource "aws_cognito_user_pool_domain" "this" {
  count = var.need_authenticate ? 1 : 0

  domain          = "auth.${var.sub_domain}"
  certificate_arn = aws_acm_certificate.cognito[0].arn
  user_pool_id    = aws_cognito_user_pool.this[0].id
  depends_on      = [aws_route53_record.alb, aws_acm_certificate_validation.cognito]
}

依存関係に aws_route53_record.alb が必要です。 auth.<ドメイン> の形になっているので、 <ドメイン> の A レコードがないとuser pool domain が作成できません。

ECS タスク定義

resource "aws_ecs_task_definition" "this" {
  family                   = "${var.prefix}-task-def"
  execution_role_arn       = aws_iam_role.ecs_task_exec.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
  cpu                      = 512
  memory                   = 1024
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  container_definitions = jsonencode([
    {
      name   = "${var.prefix}-ecs-container"
      image  = "${aws_ecr_repository.app.repository_url}:${data.aws_ecr_image.app.image_tag}@${data.aws_ecr_image.app.image_digest}"
      cpu    = 512
      memory = 1024
      portMappings = [
        {
          protocol      = "tcp"
          containerPort = var.app_port
          hostPort      = var.app_port
        }
      ]
      linuxParameters = {
        initProcessEnabled = true
      }
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-create-group  = "true"
          awslogs-group         = "${var.prefix}/ecs"
          awslogs-region        = var.aws_region
          awslogs-stream-prefix = "app"
        }
      }
    }
  ])
  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = "X86_64"
  }
  tags = local.tags
}

タスク実行ロールとタスクロールは両方とも必要です。前者は、タスクの起動時に ECR などとからイメージをとってくる必要があります。後者は、タスク自体の操作に必要なポリシーをつける必要があります。
また、linuxParameters として initProcessEnabled を有効にする必要があります。これは特に、ECS Exec を有効化する時に必要です。なぜ必要かがあまりわかっていないです。なんでですか??

タスクロール


resource "aws_iam_role_policy" "ecs_task" {
  name = "${var.prefix}-ecs-task-policy"
  role = aws_iam_role.ecs_task.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "logs:DescribeLogGroups",
          "logs:CreateLogStream",
          "logs:DescribeLogStrams",
          "logs:PutLogEvents",
        ]
        Effect   = "Allow"
        Resource = "*"
      },
      {
        Action = [
          "ssmmessages:CreateControlChannel",
          "ssmmessages:CreateDataChannel",
          "ssmmessages:OpenControlChannel",
          "ssmmessages:OpenDataChannel",
        ]
        Effect   = "Allow"
        Resource = "*"
      }
    ]
  })
}

タスクからログを流すために、ログ関連のポリシーが入っています。
後者は、ECS Exec のために必要です。仕組みとしては、ssmMessage が中継元として使われる感じなので、それに関するポリシーが必要になるみたいです。

docker イメージのプッシュの自動化

resource "null_resource" "push_app_image" {
  triggers = {
    app_code_change = data.archive_file.app.output_base64sha256
    script_hash     = sha1(file("${path.module}/push_image.sh"))
  }
  provisioner "local-exec" {
    command = <<EOT
      chmod +x ${path.module}/push_image.sh
      ${path.module}/push_image.sh ${aws_ecr_repository.app.repository_url} ${var.aws_region} ${path.module}
    EOT
  }
  depends_on = [aws_ecr_repository.app]
}

トリガーとして、実行するスクリプトとアプリケーション側のコードを設定してあります。これによって、再デプロイが正しく行われます。command の内容を変更したときに、再実行してくれませんでした。仕方なく、trigger に入れました。

VPC Endpoint

resource "aws_vpc_endpoint" "s3" {
  count = var.use_nat_gateway ? 0 : 1

  vpc_id            = module.vpc.vpc_id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  route_table_ids   = [module.vpc.private_route_table_id]
  vpc_endpoint_type = "Gateway"
  tags              = local.tags
}

resource "aws_vpc_endpoint" "interface" {
  for_each = var.use_nat_gateway ? toset([]) : toset(
    concat(
      [
        "ecr.api",
        "ecr.dkr",
        "logs",
        "sts",
      ],
      var.enable_execute_command ? [
        "ssm",
        "ssmmessages",
        "ec2messages",
      ] : []
    )
  )

  vpc_id              = module.vpc.vpc_id
  service_name        = "com.amazonaws.${var.aws_region}.${each.value}"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = module.vpc.private_subnet_ids
  security_group_ids  = [aws_security_group.vpc_endpoint[0].id]
  private_dns_enabled = true
  tags                = local.tags
}

resource "aws_security_group" "vpc_endpoint" {
  count = var.use_nat_gateway ? 0 : 1

  name   = "${var.prefix}-vpc-endpoint-sg"
  vpc_id = module.vpc.vpc_id
  tags   = local.tags
}

このようになっています。S3 はゲートウェイ型で、それ以外はインタフェース型となっています。思ったよりたくさんのインタフェース型 Endpoint が必要でした。

ゲートウェイ型は、 route table への追加が必要ですが、そこも Terraform が自動で行ってくれました。
インタフェース型は、ENI を裏側で作成しているはずですがそれらの管理もとても簡単でした。ENI のためにセキュリティグループが必要でした。

これらは難しいと思って NAT Gateway を使っていましたが、とても便利です。インタフェース型で、どれが必要かを見極めるのが難しいです。そこだけです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?