🏭

ECSとCodeBuildでNext.jsならコレだけ読んで!

2024/06/30に公開

はじめに

悲しいとき〜!Next.jsを選定したのにVercelを使えないとき〜!

今回のプロジェクトではNext.jsを用いてフルスタックなウェブサイトをリプレイスしました。

リプレイスということもあり、出来るだけ新しくて流行が継続しやすいそうな技術の使用が求められました。
私はよく馴染んでいる技術として、Next.jsをTypeScriptで書くことにしました。

このプロジェクトには、インフラをAWSに統一するという制約がありました。
スケーラブルなコンテナ技術としてECSをよく使用するので今回もECSでの運用を選択しました。
製品環境ではもっぱらVercelにデプロイを丸投げしている私にはチャレンジングだったので記録します。

免責

それぞれAWSのサービスやその使い方などには触れません。

前提

2024年6月29日現在
Next.js v13
React v18

やっていき

Terraform

何はともあれ環境構築ですね。Terrformを使う必要はないですが、弊社ではこちらでIaCで統一しています。

基本的なネットワークのリソース
####################################################
# VPC
####################################################
resource "aws_vpc" "vpc" {
  cidr_block           = "${local.config.vpc.cidr_block_network_prefix}.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = {
    Name = "${local.config.env_name}-vpc"
  }
}

####################################################
# Public Subnets
####################################################
resource "aws_subnet" "public_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  map_public_ip_on_launch = true

  availability_zone = "${var.region}a"
  cidr_block        = "${local.config.vpc.cidr_block_network_prefix}.40.0/24"
  tags = {
    Name = "${local.config.env_name}-public-subnet-1a"
  }
}

resource "aws_subnet" "public_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  map_public_ip_on_launch = true

  availability_zone = "${var.region}c"
  cidr_block        = "${local.config.vpc.cidr_block_network_prefix}.50.0/24"
  tags = {
    Name = "${local.config.env_name}-public-subnet-1c"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id
  tags = {
    Name = "${local.config.env_name}-igw"
  }
}

resource "aws_route_table" "public_route_table" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "${local.config.env_name}-public-rtb"
  }
}

resource "aws_route_table_association" "public_route_table_association_1a" {
  subnet_id      = aws_subnet.public_subnet_1a.id
  route_table_id = aws_route_table.public_route_table.id
}

resource "aws_route_table_association" "public_route_table_association_1c" {
  subnet_id      = aws_subnet.public_subnet_1c.id
  route_table_id = aws_route_table.public_route_table.id
}

####################################################
# Private Subnets
####################################################
resource "aws_subnet" "private_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  map_public_ip_on_launch = false # That means this subnet is private.

  availability_zone = "${var.region}a"
  cidr_block        = "${local.config.vpc.cidr_block_network_prefix}.20.0/24"
  tags = {
    Name = "${local.config.env_name}-private-subnet-1a"
  }
}

resource "aws_subnet" "private_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  map_public_ip_on_launch = false # That means this subnet is private.

  availability_zone = "${var.region}c"
  cidr_block        = "${local.config.vpc.cidr_block_network_prefix}.30.0/24"
  tags = {
    Name = "${local.config.env_name}-private-subnet-1c"
  }
}

resource "aws_route_table" "private_route_table" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.private_subnet.id
  }

  tags = {
    Name = "${local.config.env_name}-private-rtb"
  }
}

resource "aws_route_table_association" "private_route_table_association_1a" {
  subnet_id      = aws_subnet.private_subnet_1a.id
  route_table_id = aws_route_table.private_route_table.id
}

resource "aws_route_table_association" "private_route_table_association_1c" {
  subnet_id      = aws_subnet.private_subnet_1c.id
  route_table_id = aws_route_table.private_route_table.id
}

####################################################
# Elastic IP
####################################################
data "aws_eip" "private_subnet" {
  tags = {
    Name = "${local.app_name}-private-subnet"
  }
}

####################################################
# NAT Gateway
####################################################
resource "aws_nat_gateway" "private_subnet" {
  allocation_id = data.aws_eip.private_subnet.id
  subnet_id     = aws_subnet.public_subnet_1a.id

  tags = {
    Name = "${local.config.env_name}-nat-gateway-for-private-subnet"
  }
}
ECS関連のリソース
####################################################
# Elastic Load Balancing
####################################################
resource "aws_security_group" "nlb_security_group" {
  name   = "${local.config.env_name}-sg-nlb"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port   = 80
    protocol    = "tcp"
    to_port     = 80
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    protocol    = "tcp"
    to_port     = 443
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_lb" "ecs_nlb" {
  name               = "${local.app_name}-ecs-nlb"
  load_balancer_type = "network"
  security_groups = [
    aws_security_group.nlb_security_group.id
  ]
  subnet_mapping {
    subnet_id = aws_subnet.public_subnet_1a.id
  }

  subnet_mapping {
    subnet_id = aws_subnet.public_subnet_1c.id
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.ecs_nlb.arn
  port              = 443
  protocol          = "TLS"
  ssl_policy        = "ELBSecurityPolicy-2015-05"
  certificate_arn   = aws_acm_certificate.cert.arn
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

####################################################
# ECS IAM Role
####################################################
data "aws_iam_policy_document" "ecs_task_assume_policy" {
  version = "2012-10-17"
  statement {
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
  }
}

resource "aws_iam_role" "ecs_task_execution_role" {
  name               = "ecs_task_execution_role_${local.app_name}"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_policy.json
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
    "arn:aws:iam::aws:policy/AmazonSESFullAccess",
    # SSMはコンテナにログインしてコマンド実行できるように設定
    "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
  ]
}

####################################################
# ECR data source
####################################################
data "aws_ecr_repository" "ecr" {
  name = "${local.app_name}-ecr"
}

resource "aws_ecr_lifecycle_policy" "ecr" {
  repository = data.aws_ecr_repository.ecr.name

  policy = jsonencode(
    {
      "rules" : [
        {
          "rulePriority" : 1,
          "description" : "Keep only designated number of untagged images, expire all others",
          "selection" : {
            "tagStatus" : "untagged",
            "countType" : "imageCountMoreThan",
            "countNumber" : local.config.ecs.number_of_images,
          },
          "action" : {
            "type" : "expire"
          }
        }
      ]
    }
  )
}

####################################################
# ECS Task Container Log Groups
####################################################
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${local.app_name}-ecs"
  retention_in_days = local.config.ecs.cloudwatch_log_group_retention_in_days
}

####################################################
# ECS Task Definition
####################################################
locals {
  task_container_name = "${local.app_name}-app-container"
}

resource "aws_ecs_task_definition" "app" {
  family                   = "${local.app_name}-app-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = local.config.ecs.cpu
  memory                   = local.config.ecs.memory
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn            = aws_iam_role.ecs_task_execution_role.arn
  container_definitions = jsonencode([
    {
      name  = local.task_container_name
      image = "${data.aws_ecr_repository.ecr.repository_url}:${var.ecs_image_tag}"
      portMappings = [{
        containerPort = 3000
        hostPort      = 3000
        protocol      = "tcp"
      }]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-region : "ap-northeast-1"
          awslogs-group : aws_cloudwatch_log_group.ecs.name
          awslogs-stream-prefix : "ecs"
        }
      }
    }
  ])
}

####################################################
# ECS Cluster
####################################################
resource "aws_ecs_cluster" "app" {
  name = "${local.app_name}-ecs-cluster"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

####################################################
# ECS Cluster Service
####################################################
resource "aws_security_group" "ecs_security_group" {
  name   = "${local.config.env_name}-sg-ecs"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port   = 3000
    protocol    = "tcp"
    to_port     = 3000
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_ecs_service" "app" {
  name                               = "${local.app_name}-app-service"
  cluster                            = aws_ecs_cluster.app.id
  platform_version                   = "LATEST"
  task_definition                    = aws_ecs_task_definition.app.arn
  desired_count                      = local.config.ecs.task_desired_count
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
  propagate_tags                     = "SERVICE"
  enable_execute_command             = true # コンテナへログインできるようにする
  health_check_grace_period_seconds  = 60
  wait_for_steady_state              = local.config.ecs.wait_for_steady_state

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
  network_configuration {
    assign_public_ip = true
    subnets = [
      aws_subnet.public_subnet_1a.id,
      aws_subnet.public_subnet_1c.id,
    ]
    security_groups = [
      aws_security_group.ecs_security_group.id,
    ]
  }
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = local.task_container_name
    container_port   = 3000
  }

  capacity_provider_strategy {
    capacity_provider = "FARGATE_SPOT"
    base              = 99
    weight            = 99
  }
  capacity_provider_strategy {
    capacity_provider = "FARGATE"
    base              = 0
    weight            = 1
  }
  depends_on = [aws_ecs_task_definition.app]
}

resource "aws_lb_target_group" "app" {
  name                              = "${local.app_name}-service-app-tg"
  vpc_id                            = aws_vpc.vpc.id
  target_type                       = "ip"
  port                              = 3000
  protocol                          = "TCP"
  deregistration_delay              = 60
  load_balancing_cross_zone_enabled = true
  health_check { path = "/healthz/" }
}

####################################################
# ECS AutoScaling
####################################################
resource "aws_appautoscaling_target" "app" {
  service_namespace  = "ecs"
  resource_id        = "service/${aws_ecs_cluster.app.name}/${aws_ecs_service.app.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  min_capacity       = local.config.ecs.autoscaling_min_capacity
  max_capacity       = local.config.ecs.autoscaling_max_capacity
}
resource "aws_appautoscaling_policy" "scale_out" {
  name               = "${local.app_name}-app-scale-out"
  policy_type        = "StepScaling"
  service_namespace  = aws_appautoscaling_target.app.service_namespace
  resource_id        = aws_appautoscaling_target.app.resource_id
  scalable_dimension = aws_appautoscaling_target.app.scalable_dimension

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 30
    metric_aggregation_type = "Average"

    step_adjustment {
      # scale-outのalarm状態ならスケールアウト
      metric_interval_lower_bound = 0
      scaling_adjustment          = 1
    }
  }
}
resource "aws_appautoscaling_policy" "scale_in" {
  name               = "${local.app_name}-app-scale-in"
  policy_type        = "StepScaling"
  service_namespace  = aws_appautoscaling_target.app.service_namespace
  resource_id        = aws_appautoscaling_target.app.resource_id
  scalable_dimension = aws_appautoscaling_target.app.scalable_dimension

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 30
    metric_aggregation_type = "Average"

    step_adjustment {
      # scale-inのalarm状態ならスケールイン
      metric_interval_upper_bound = 0
      scaling_adjustment          = -1
    }
  }
}

# scale-out及びscale-inを発生させるためのメトリクス設定
resource "aws_cloudwatch_metric_alarm" "fargate_cpu_high" {
  alarm_name          = "${aws_ecs_cluster.app.name}-cpu_utilization_high"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 2
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = 60
  statistic           = "Average"
  threshold           = 0.8

  dimensions = {
    ClusterName = aws_ecs_cluster.app.name
    ServiceName = aws_ecs_service.app.name
  }

  alarm_actions = [
    aws_appautoscaling_policy.scale_out.arn
  ]
}
resource "aws_cloudwatch_metric_alarm" "fargate_cpu_low" {
  alarm_name          = "${aws_ecs_cluster.app.name}-cpu_utilization_low"
  comparison_operator = "LessThanOrEqualToThreshold"
  evaluation_periods  = 2
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = 60
  statistic           = "Average"
  threshold           = 0.8

  dimensions = {
    ClusterName = aws_ecs_cluster.app.name
    ServiceName = aws_ecs_service.app.name
  }

  alarm_actions = [
    aws_appautoscaling_policy.scale_in.arn
  ]
}

Dockerfile

公式から提供されているexampleを利用しました。
アプリの設定もこちらに倣って、Next.jsをstandaloneモードでbuildしています。

ここまででECRにpushしたimageからcontainerが立ち上がる準備ができました。
yarn create next-appなどで作った適当なimageをpushして実験するとわかりますね。

さてここにローカルマシンでビルドしたimageがECRにプッシュされ、aws cliコマンドでECS serviceを更新するCI/CDを設定すれば終わりです。一件落着。

ではありませんでした。
このプロジェクトには一部ページでSSGを行っており、その際にRDSのDBに接続して情報を取得している処理があります。つまりビルドの時点でVPC内のリソースに通信する必要があり、ローカルではビルドできません。ローカルからビルドして踏み台サーバを用意して接続することもできるかもしれませんが、ビルドの時点とアプリが動作する時点のデータベースURLが変わってしまうので、運用が難しいです。
そこで、VPC内でアプリをビルドすることにしました。また、その際、RDSの配置されているprivate subnet内でビルドすることで、ビルドの環境を簡単に構築しました。

AWS CodeBuildというサービスを利用することで上記を実現しました。

やっていき (2回目)

Terraform

今回はBitbucketをバージョン管理ツールとしていたので、Bitbucketのあるbranchへのpushをトリガーにビルドが行われるよう設定しました。
AWS CodeBuildには、buildspecというその名の通りビルドの仕様を指定する機能があり、YAMLファイルで設定します。この内容の指定方法は以下のように複数あります。

  1. AWS CodeBuildに参照させるソースディレクトリのルートにbuildspec.ymlを配置して自動で参照させるという方法。
  2. AWS console (今回であればTerraform)に直接YAML形式で書き込む方法。
  3. S3などに置いておきURLを指定する方法。

ソース管理にしておくことでIaCを徹底したいので、1が良かったのですが、Bitbucketの場合、AWS CodeBuildが、指定したrepository URLからソースディレクトリのルートのbuildspec.ymlを見つけられませんでした。(GitHubではうまくいきます。)
そこで2を採用しました。AWSサービスを手動で管理する場合はこちらは3に劣るでしょうが、今回はTerraformを使用しているため、実質ソースコード管理になります。

他にもAWS CodeBuildでDockerを使用するためにはprivileged_modeを使用しなければならないなど、注意点はいくつかありますが、以下のソースコードで解消できます。

AWS CodeBuild関連のリソース
resource "aws_s3_bucket" "codebuild" {
  bucket = "kaitori-codebuild-${local.config.env_short_name}"
}

data "aws_iam_policy_document" "codebuild_assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["codebuild.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "codebuild" {
  name               = "codebuild-${local.config.env_short_name}"
  assume_role_policy = data.aws_iam_policy_document.codebuild_assume_role.json
}

data "aws_iam_policy_document" "codebuild" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
      "ecr:*",
      "s3:*",
      "ec2:*",
      "ecs:UpdateService",
    ]

    resources = ["*"]
  }

  statement {
    effect  = "Allow"
    actions = ["s3:*"]
    resources = [
      aws_s3_bucket.codebuild.arn,
      "${aws_s3_bucket.codebuild.arn}/*",
    ]
  }
}

resource "aws_iam_role_policy" "codebuild" {
  role   = aws_iam_role.codebuild.name
  policy = data.aws_iam_policy_document.codebuild.json
}

resource "aws_security_group" "codebuild_security_group" {
  name   = "${local.config.env_name}-sg-codebuild"
  vpc_id = aws_vpc.vpc.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_codebuild_project" "codebuild" {
  name          = "codebuild-project-${local.config.env_short_name}"
  build_timeout = 15
  service_role  = aws_iam_role.codebuild.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  cache {
    type     = "S3"
    location = aws_s3_bucket.codebuild.bucket
  }

  environment {
    compute_type                = "BUILD_GENERAL1_SMALL"
    image                       = "aws/codebuild/amazonlinux2-x86_64-standard:4.0"
    type                        = "LINUX_CONTAINER"
    image_pull_credentials_type = "CODEBUILD"
    privileged_mode             = true
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "log-group"
      stream_name = "log-stream"
    }

    s3_logs {
      status   = "ENABLED"
      location = "${aws_s3_bucket.codebuild.id}/build-log"
    }
  }

  source {
    type                = "BITBUCKET"
    location            = local.config.codebuild.bitbucket_repository_url
    git_clone_depth     = 1
    report_build_status = true
    buildspec = yamlencode({
      version = "0.2"

      phases = {
        install = {
          commands = [
            "nohup /usr/local/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 &",
            "timeout 15 sh -c \"until docker info; do echo .; sleep 1; done\"",
          ]
        }
        build = {
          commands = [
            "bash deploy.sh ${local.config.env_short_name}",
          ]
        }
      }
    })
  }

  source_version = local.config.codebuild.bitbucket_branch_name

  vpc_config {
    vpc_id = aws_vpc.vpc.id

    subnets = [
      aws_subnet.private_subnet_1a.id,
      aws_subnet.private_subnet_1c.id,
    ]

    security_group_ids = [
      aws_security_group.codebuild_security_group.id
    ]
  }
}


resource "aws_codebuild_source_credential" "bitbucket_kaitori" {
  auth_type   = "PERSONAL_ACCESS_TOKEN"
  server_type = "BITBUCKET"
  token       = local.config.codebuild.bitbucket_access_token
}

resource "aws_codebuild_webhook" "kaitori_build" {
  project_name = aws_codebuild_project.codebuild.name

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PUSH"
    }

    filter {
      type    = "HEAD_REF"
      pattern = local.config.codebuild.bitbucket_branch_name
    }
  }
}

最後にRDSのセキュリティグループにAWS CodeBuildのセキュリティグループからの通信を許可すれば、完成です。

aws_security_group "rds_security_group" {
  # skip details
  ingress {
    security_groups = [
      aws_security_group.ecs_security_group.id,
+       aws_security_group.codebuild_security_group.id,
    ]
    protocol  = "tcp"
    from_port = 5432
    to_port   = 5432
  }
}

まとめ

今回はAWS CodeBuildを用いてCI/CDをVPC内で構築しました。こちらの記事が読者の方のヒントになれば幸いです。
記載内容の間違いのご指摘や訂正案、より良い内容のご提案など、忌憚なきご意見を頂けますと幸いです。
今後、アプリの環境ごと再現性を高めながらデプロイメントの簡単化及び高頻度化を図ることで、質の高いアプリを量産していくため、積極的に社内の環境をDockerで均質化しております。

Discussion