ECSとCodeBuildでNext.jsならコレだけ読んで!
はじめに
悲しいとき〜!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ファイルで設定します。この内容の指定方法は以下のように複数あります。
- AWS CodeBuildに参照させるソースディレクトリのルートにbuildspec.ymlを配置して自動で参照させるという方法。
- AWS console (今回であればTerraform)に直接YAML形式で書き込む方法。
- 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