Closed21

Next.jsをAWS App Runnerで動かす

kobokobo

Next.jsをホスティングするとなると、Vercel一択と思いきや、データベースとの連携だったり、他のリソースとの通信云々を考えるとどうしてもAWS上にホスティングしたくなる時がある

でも、Fargateとかだと大げさだし、LambdaでNext.jsを動かそうとすると、以下のような面倒さがある

  • Lambda Web Adapterの設定周りの設定
  • アクセス制御のためにAPI Gatewayを準備
  • コールドスタートが入ってしまう

そこで、このあたりを諸々解消してくれるApp Runnerを使って立ち上げる

App Runnerのざっくり特徴

  • ECRのリポジトリを指定すればコンテナを立ち上げてくれる
    • GitHubと連携してビルドもしてくれたりするが、こちらはビルド時間に応じた課金がある
  • オートスケールとかの設定も簡単
  • WAFと統合し、アクセス制御ができる
  • VPC Connectorにより、VPC内のリソースにアクセスできる
  • コンテナインスタンスのメモリを常にプロビジョニングしているらしいので、コールドスタートはほぼない
    • ちなみに課金はこのメモリの容量に応じて発生する模様(アクセスがない場合)
      • アクセスがあればvCPUあたりの課金がプラスで発生する
kobokobo

Terraformの初期設定

Terraform用のディレクトリを作り、そこで作業していく

mkdir terraform
cd terraform
versions.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.19.0"
    }
  }
  required_version = "~> 1.0"
}
locals.tf
# AWS
locals {
  aws_region = "ap-northeast-1"
}

locals {
  repo_name = "next-app-runner"
}
config.tf
provider "aws" {
  region = local.aws_region
  default_tags {
    tags = {
      Terraform   = local.repo_name
      Environment = terraform.workspace
    }
  }
}
backend.tf
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
kobokobo

上記で、一旦terraformのステートを管理するバックエンドを作成しておく

その後、backend.tfに以下のコードを追加し、ステートをS3に同期しておく

backend.tf
...

terraform {
  backend "s3" {
    bucket         = "tf-state-next-app-runner"
    key            = "terraform.tfstate"
  }
}
terraform init

上記でローカルステートをリモートに同期するか聞かれるのでyesを選択しておく

kobokobo

ECRを作成する

main.tf
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
}
kobokobo

GitHub Actions用のIAMユーザーを作成する

main.tf
...

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
kobokobo

ECRへのpushだけ行えればいいのでecr:*だけつけておく(本当はもう少し狭めるべき)

kobokobo
output.tf
output "GITHUB_ACTIONS_IAM_ROLE" {
  value = aws_iam_role.github_actions.arn
}

としておくと、arnを確認できる

kobokobo

このARNは後でGitHub Actionsを実行するときに必要なので、GitHub ActionsのsecretsにAWS_ROLE_ARNという名前で登録しておく

kobokobo

App Runner用のIAMを作成

main.tf
...
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
kobokobo

GitHub ActionsからECRにpushしてみる

.github/workflows/deploy.yaml
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
kobokobo

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

kobokobo

App Runnerのリソースを作成する

main.tf
...

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
kobokobo
output.tf
output "APPRUNNER_URL" {
  value = aws_apprunner_service.apprunner.service_url
}

としておけばApp Runnerが発行するURLがわかる

kobokobo

"${aws_ecr_repository.next_app_runner.repository_url}:latest"で設定しておけば、latestタグで新たなイメージがpushされると自動的にpullして立ち上げ直してくれるらしい

kobokobo

おまけ1: WAFを設定

IPのwhitelistを作成して、apprunnerにWAFの設定を連携する

main.tf
...
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
}
kobokobo

おまけ2: VPCに接続する

RDSとかに接続したいケースがあるのでVPCに接続できるようにする

セキュリティグループを作り、VPCコネクタを作る

main.tf
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設定を追加

main.tf
resource "aws_apprunner_service" "apprunner" {
  ...
  network_configuration {
    egress_configuration {
      egress_type       = "VPC"
      vpc_connector_arn = aws_apprunner_vpc_connector.apprunner.arn
    }
  }
}
kobokobo

おまけ3: カスタムドメインを設定する

main.tf
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
}
kobokobo

総評

VercelやCloudflareとかと比べるとやらないといけないことは少し多い印象だけど、思っていたよりは簡単にアプリケーションを動かせることがわかったので、AWS内のリソースに多くアクセスする可能性のあるアプリケーションをホスティングしたいというのであれば良い選択肢だなと感じました。

少し前に、LambdaでHonoを立ち上げるというのをやってけど、色々しないといけないことが多かったので、それと比べるとだいぶ楽

https://zenn.dev/anneau/scraps/252fb71eb81705

また、Fargateとかはクラスタやタスクの管理が色々あるので、そのあたりもやらなくていいというのも楽だなと

金額的にはLambdaより高いが、Fargateとかよりは安いイメージなので、アクセスが常にある訳では無いが、コールドスタートは発生してほしくないみたいなユースケースだと良さそう(特に最近だと、基本はCDN側でキャッシュされたコンテンツが返るみたいなパターンが多いと思うので、オリジンがApp Runnerで構えられているみたいなのもいいのかなと感じました)

このスクラップは2023/10/26にクローズされました