Next.jsをAWS App Runnerで動かす

Next.jsをホスティングするとなると、Vercel一択と思いきや、データベースとの連携だったり、他のリソースとの通信云々を考えるとどうしてもAWS上にホスティングしたくなる時がある
でも、Fargateとかだと大げさだし、LambdaでNext.jsを動かそうとすると、以下のような面倒さがある
- Lambda Web Adapterの設定周りの設定
- アクセス制御のためにAPI Gatewayを準備
- コールドスタートが入ってしまう
そこで、このあたりを諸々解消してくれるApp Runnerを使って立ち上げる
App Runnerのざっくり特徴
- ECRのリポジトリを指定すればコンテナを立ち上げてくれる
- GitHubと連携してビルドもしてくれたりするが、こちらはビルド時間に応じた課金がある
- オートスケールとかの設定も簡単
- WAFと統合し、アクセス制御ができる
- VPC Connectorにより、VPC内のリソースにアクセスできる
- コンテナインスタンスのメモリを常にプロビジョニングしているらしいので、コールドスタートはほぼない
- ちなみに課金はこのメモリの容量に応じて発生する模様(アクセスがない場合)
- アクセスがあればvCPUあたりの課金がプラスで発生する
- ちなみに課金はこのメモリの容量に応じて発生する模様(アクセスがない場合)

プロジェクト作成
pnpm create next-app next-app-runner

Dockerfileの準備
Next.jsの公式サンプルから拝借しました
Next.jsのビルドをstandaloneに設定
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

Terraformの初期設定
Terraform用のディレクトリを作り、そこで作業していく
mkdir terraform
cd terraform
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.19.0"
}
}
required_version = "~> 1.0"
}
# AWS
locals {
aws_region = "ap-northeast-1"
}
locals {
repo_name = "next-app-runner"
}
provider "aws" {
region = local.aws_region
default_tags {
tags = {
Terraform = local.repo_name
Environment = terraform.workspace
}
}
}
module "terraform_state_s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 3.0"
bucket = "tf-state-${local.repo_name}"
attach_deny_insecure_transport_policy = true
attach_require_latest_tls_policy = true
acl = "private"
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
versioning = {
status = true
mfa_delete = false
}
server_side_encryption_configuration = {
rule = {
apply_server_side_encryption_by_default = {
sse_algorithm = "AES256"
}
}
}
}
terraform init
terraform plan
terraform apply

上記で、一旦terraformのステートを管理するバックエンドを作成しておく
その後、backend.tf
に以下のコードを追加し、ステートをS3に同期しておく
...
terraform {
backend "s3" {
bucket = "tf-state-next-app-runner"
key = "terraform.tfstate"
}
}
terraform init
上記でローカルステートをリモートに同期するか聞かれるのでyes
を選択しておく

ECRを作成する
resource "aws_ecr_repository" "next_app_runner" {
name = local.repo_name
}
resource "aws_ecr_lifecycle_policy" "next_app_runner" {
repository = aws_ecr_repository.next_app_runner.name
policy = <<EOF
{
"rules": [
{
"rulePriority": 10,
"description": "Expire images count more than 15",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 15
},
"action": {
"type": "expire"
}
}
]
}
EOF
}

GitHub Actions用のIAMユーザーを作成する
...
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
data "tls_certificate" "github_actions_oidc_provider" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-${local.repo_name}"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}
resource "aws_iam_policy" "github_actions_attachment_policy" {
name = "github-actions-${local.repo_name}-attachment-policy"
policy = data.aws_iam_policy_document.github_actions_attachment_policy.json
}
variable "repo_org" {
type = string
description = "GitHub organization name"
}
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.repo_org}/${local.repo_name}:*"]
}
}
}
data "aws_iam_policy_document" "github_actions_attachment_policy" {
statement {
actions = [
"ecr:*"
]
resources = ["*"]
}
}
resource "aws_iam_role_policy_attachment" "github_actions" {
role = aws_iam_role.github_actions.name
policy_arn = aws_iam_policy.github_actions_attachment_policy.arn
}
terraform init
terraform plan
terraform apply

App Runner用のIAMを作成
...
resource "aws_iam_role" "apprunner" {
name = "${local.repo_name}-apprunner"
assume_role_policy = data.aws_iam_policy_document.apprunner_principals.json
}
data "aws_iam_policy_document" "apprunner_principals" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["build.apprunner.amazonaws.com", "tasks.apprunner.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy_attachment" "apprunner" {
role = aws_iam_role.apprunner.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}
terraform plan
terraform apply

GitHub ActionsからECRにpushしてみる
name: Deploy
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Terraform CLI
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.2.9
terraform_wrapper: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ap-northeast-1
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
- uses: aws-actions/amazon-ecr-login@v1
id: login-ecr
with:
mask-password: 'true'
- name: Login Docker
uses: docker/login-action@v3
with:
registry: ${{ steps.login-ecr.outputs.registry }}
- name: Build CMS
uses: docker/build-push-action@v5
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
REPOSITORY: next-app-runner
with:
file: ./Dockerfile
context: .
push: true
provenance: false
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

これでmainブランチにpushして無事ECRにpushされることを確認

App Runnerのリソースを作成する
...
resource "aws_apprunner_service" "apprunner" {
service_name = local.repo_name
source_configuration {
authentication_configuration {
access_role_arn = aws_iam_role.apprunner.arn
}
image_repository {
image_configuration {
port = 3000
runtime_environment_variables = {
HOSTNAME = "0.0.0.0"
}
}
image_identifier = "${aws_ecr_repository.next_app_runner.repository_url}:latest"
image_repository_type = "ECR"
}
}
network_configuration {}
auto_scaling_configuration_arn = aws_apprunner_auto_scaling_configuration_version.apprunner.arn
}
resource "aws_apprunner_auto_scaling_configuration_version" "apprunner" {
auto_scaling_configuration_name = local.repo_name
max_concurrency = 50
max_size = 3
min_size = 1
}
terraform plan
terraform apply

おまけ1: WAFを設定
IPのwhitelistを作成して、apprunnerにWAFの設定を連携する
...
resource "aws_wafv2_ip_set" "white_list" {
name = "${local.repo_name}-white-list"
description = "IP Whitelist for App Runner"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = local.internal_ips
}
resource "aws_wafv2_web_acl" "whitelist_ip_acl" {
name = "${local.repo_name}-white-list"
scope = "REGIONAL"
default_action {
block {}
}
rule {
name = "whitelist_ip_set"
priority = 1
action {
allow {}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.white_list.arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${local.repo_name}_web_acl_rule_watch"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${local.repo_name}_web_acl_watch"
sampled_requests_enabled = true
}
}
resource "aws_wafv2_web_acl_association" "apprunner" {
resource_arn = aws_apprunner_service.apprunner.arn
web_acl_arn = aws_wafv2_web_acl.whitelist_ip_acl.arn
}

おまけ2: VPCに接続する
RDSとかに接続したいケースがあるのでVPCに接続できるようにする
セキュリティグループを作り、VPCコネクタを作る
resource "aws_security_group" "apprunner" {
...
}
resource "aws_apprunner_vpc_connector" "apprunner" {
vpc_connector_name = local.repo_name
subnets = [
local.subnet_id_a_private,
local.subnet_id_c_private,
local.subnet_id_d_private
]
security_groups = [aws_security_group.apprunner.id]
}
そして、上で作ったaws_apprunner_serviceにegress設定を追加
resource "aws_apprunner_service" "apprunner" {
...
network_configuration {
egress_configuration {
egress_type = "VPC"
vpc_connector_arn = aws_apprunner_vpc_connector.apprunner.arn
}
}
}

おまけ3: カスタムドメインを設定する
resource "aws_acm_certificate" "apprunner" {
domain_name = local.domain_name
validation_method = "DNS"
}
resource "aws_apprunner_custom_domain_association" "apprunner" {
domain_name = local.domain_name
service_arn = aws_apprunner_service.apprunner.arn
}
resource "aws_route53_record" "apprunner" {
zone_id = local.hosted_zone_id_public
name = aws_apprunner_custom_domain_association.apprunner.domain_name
type = "CNAME"
ttl = "300"
records = [aws_apprunner_custom_domain_association.apprunner.dns_target]
}
resource "aws_route53_record" "validation_records_linglinger" {
count = 3
zone_id = local.hosted_zone_id_public
name = tolist(aws_apprunner_custom_domain_association.apprunner.certificate_validation_records)[count.index].name
type = tolist(aws_apprunner_custom_domain_association.apprunner.certificate_validation_records)[count.index].type
records = [tolist(aws_apprunner_custom_domain_association.apprunner.certificate_validation_records)[count.index].value]
ttl = 300
}

総評
VercelやCloudflareとかと比べるとやらないといけないことは少し多い印象だけど、思っていたよりは簡単にアプリケーションを動かせることがわかったので、AWS内のリソースに多くアクセスする可能性のあるアプリケーションをホスティングしたいというのであれば良い選択肢だなと感じました。
少し前に、LambdaでHonoを立ち上げるというのをやってけど、色々しないといけないことが多かったので、それと比べるとだいぶ楽
また、Fargateとかはクラスタやタスクの管理が色々あるので、そのあたりもやらなくていいというのも楽だなと
金額的にはLambdaより高いが、Fargateとかよりは安いイメージなので、アクセスが常にある訳では無いが、コールドスタートは発生してほしくないみたいなユースケースだと良さそう(特に最近だと、基本はCDN側でキャッシュされたコンテンツが返るみたいなパターンが多いと思うので、オリジンがApp Runnerで構えられているみたいなのもいいのかなと感じました)