S3静的ホスティングしているNextJsをCodePipelineでCI/CDしてみた

## はじめに
NextJsには静的エクスポート機能が存在します。S3の静的ホスティングを試してみるのと合わせて動作確認してみました。
おまけでCI/CDパイプラインを組んでみました（課題ありですが・・・）。
注意した部分を残す意味で以下にソースをまとめます。

## アーキテクチャ図
![pic.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/e2346b53-faf0-8e71-be1a-af05d2cddeeb.png)


## 動作確認
### フォルダ構成
#### アプリ
```
.
├── cicd/
|   └── buildspec.yml
|
├── out/
├── src/
|    ├── app/
|    ├── components/
|    ├── providers/
|    ├── hooks/
~(略)~
```
- ルートディレクトリではない場所にbuildspec.ymlを配置しています
- この場合、CodeBuild側でbuildspec.ymlのパスを指定する必要があります
- NextJsはデフォルトでout/に静的ファイルを出力します

#### インフラ部分
```
.
├── main.tf
├── variable.tf
├── terraform.tfvars
├── s3.tf
├── codebuild.tf
├── codepipeline.tf
├── event.tf
|
├── json/
|   └── event_pattern.json
|
└── modules/
    ├── iam_role/
    |   └── main.tf
    └── security_group/
        └── main.tf
```
- modules配下は以前の記事から変更がないため、記載を省略します 

https://qiita.com/suiwave/items/e37338031c07e4dee52a

### ファイル内容

特に注意した場所にコメントを残そうと思います

#### buildspec.yml
```
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 18
  pre_build:
    commands:
      - echo source version $CODEBUILD_RESOLVED_SOURCE_VERSION
      - npm install
  build:
    commands:
      - echo build start `date "+%Y%m%d-%H%M%S"`
      - npm run build
      - echo build end `date "+%Y%m%d-%H%M%S"`
artifacts: 
  files:
    - '**/*'
  base-directory: 'out'
  name: myname-$(date +%Y-%m-%d)
```
- 以下のように指定したところ、ビルドアーティファクトにoutディレクトリが含まれてしまう点に注意です

```diff_yaml
artifacts: 
    files:
-     - out/**/*
+     - '**/*'
+   base-directory: 'out'
    name: myname-$(date +%Y-%m-%d)
```
- ビルドアーティファクトにoutディレクトリが含まれてしまうと、バケットにデプロイした際に「mybucket/out/index.html」というディレクトリ構成になってしまいます

#### tfソース

##### main.tf
<details><summary>main.tf</summary><div>

```main.tf
############################################################################
## terraformブロック
############################################################################
terraform {
  # Terraformのバージョン指定
  required_version = "~> 1.5.0"

  # Terraformのaws用ライブラリのバージョン指定
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31.0"
    }
  }
}

############################################################################
## providerブロック
############################################################################
provider "aws" {
  # リージョンを指定
  region = "ap-northeast-1"
}
```
</div></details>

- 特になし


##### variable.tf
<details><summary>variable.tf</summary><div>

```variable.tf
######################################
## terraform.tfvarsから変数取得
######################################
# 環境名。タグやリソースIDに使用される。
# s3-prod-hosting-frontendなど
variable "env_name" {
  type = string
}

variable "repository_name" {
  type = string
}

variable "branch_name" {
  type = string
}
```
</div></details>

- 特になし


##### s3.tf
<details><summary>s3.tf</summary><div>

```s3.tf
# hostingバケット
resource "aws_s3_bucket" "hosting" {
  bucket = "s3-${var.env_name}-itou-hosting-bucket"
}

# 暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
  bucket = aws_s3_bucket.hosting.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "AES256"
    }
  }
}

# hostingバケットのパブリックアクセスをpolicyで制御
resource "aws_s3_bucket_public_access_block" "hosting" {
  bucket = aws_s3_bucket.hosting.id

  # aclでのアクセス制御を無効化
  block_public_acls       = true
  ignore_public_acls      = true

  # bucket policyでアクセス制御を実施
  block_public_policy     = false
  restrict_public_buckets = false
}

# ポリシードキュメントを定義
data "aws_iam_policy_document" "hosting" {
  statement {
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["arn:aws:s3:::${aws_s3_bucket.hosting.id}/*"]
    principals {
      type        = "*"
      identifiers = ["*"]
    }
  }
}

# s3をhosting公開できるようバケットポリシー設定
resource "aws_s3_bucket_policy" "hosting" {
  bucket = aws_s3_bucket.hosting.id
  policy = data.aws_iam_policy_document.hosting.json

  # バケットポリシー設定を許可した後に設定
  depends_on = [
    aws_s3_bucket_public_access_block.hosting,
  ]
}

# 静的ホスティング設定
resource "aws_s3_bucket_website_configuration" "hosting" {
  bucket = aws_s3_bucket.hosting.id

  index_document {
    suffix = "index.html"
  }
}
```
</div></details>

- 特になし


##### codebuild.tf
<details><summary>codebuild.tf</summary><div>

```codebuild.tf
############################################################################
## codebuild実行ロール
############################################################################
# codebuild用ロールポリシードキュメント
data "aws_iam_policy_document" "codebuild" {
  statement {
    effect    = "Allow"
    resources = ["*"]

    actions = [
      "s3:PutObject",
      "s3:GetObject",
      "s3:GetObjectVersion",
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
  }
}

# codebuild用ロール
module "codebuild_role" {
  source     = "./modules/iam_role"
  name       = "role-${var.env_name}-codebuild-cicd-front"
  identifier = "codebuild.amazonaws.com"
  policy     = data.aws_iam_policy_document.codebuild.json
}

############################################################################
## codebuildプロジェクト
############################################################################
resource "aws_codebuild_project" "build" {
  name         = "build-project-${var.env_name}-cicd-front"
  service_role = module.codebuild_role.iam_role_arn

  source {
    type       = "CODEPIPELINE"
    buildspec  = "cicd/buildspec.yml"
  }

  artifacts {
    type = "CODEPIPELINE"
  }

  environment {
    type            = "LINUX_CONTAINER"
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
  }

  cache {
    type = "LOCAL"
    modes = [
      "LOCAL_DOCKER_LAYER_CACHE",
    ]
  }
}
```
</div></details>

- sourceブロックでbuildspec.ymlの配置場所を設定しています
```diff_hcl
  source {
    type       = "CODEPIPELINE"
+    buildspec  = "cicd/buildspec.yml"
  }
```


##### codepipeline.tf
<details><summary>codepipeline.tf</summary><div>

```codepipeline.tf
############################################################################
## pipelineロール
############################################################################
# pipeline用ロールポリシー
data "aws_iam_policy_document" "codepipeline" {
  statement {
    effect    = "Allow"
    resources = ["*"]

    actions = [
      "s3:PutObject",
      "s3:GetObject",
      "s3:GetObjectVersion",
      "s3:GetBucketVersioning",
      "codecommit:GetBranch",
      "codecommit:GetCommit",
      "codecommit:UploadArchive",
      "codecommit:GetUploadArchiveStatus",
      "codecommit:CancelUploadArchive",
      "codebuild:BatchGetBuilds",
      "codebuild:StartBuild",
    ]
  }
}

# moduleからロール作成
module "codepipeline_role" {
  source     = "./modules/iam_role"
  name       = "role-${var.env_name}-codepipeline-cicd-front"
  identifier = "codepipeline.amazonaws.com"
  policy     = data.aws_iam_policy_document.codepipeline.json
}

############################################################################
## artifactストアS3
############################################################################
resource "aws_s3_bucket" "artifact" {
  bucket = "s3-${var.env_name}-itou-artifact-build-bucket"
}

############################################################################
## codepipeline
############################################################################
resource "aws_codepipeline" "pipeline" {
  name = "pipeline-${var.env_name}-itou-cicd-front"
  role_arn = module.codepipeline_role.iam_role_arn

  artifact_store {
    location = aws_s3_bucket.artifact.id
    type = "S3"
  }

  stage {
    name = "Source"

    action {
      name = "Source"
      category = "Source"
      owner = "AWS"
      provider = "CodeCommit"
      version = 1
      output_artifacts = ["Source"]

      configuration = {
        RepositoryName = var.repository_name
        BranchName = var.branch_name
        PollForSourceChanges = false
      }
    }
  }

  stage {
    name = "Build"

    action {
      name = "Build"
      category = "Build"
      owner = "AWS"
      provider = "CodeBuild"
      version = 1
      input_artifacts = ["Source"]
      output_artifacts = ["Build"]

      configuration = {
        ProjectName = aws_codebuild_project.build.id
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name = "Deploy"
      category = "Deploy"
      owner = "AWS"
      provider = "S3"
      version = 1

      # ステージ内で参照できるようinputsにbuild artifactsを指定
      input_artifacts = ["Build"]

      # 細かい設定
      configuration = {
        # 定義したリソースを指定
        BucketName     = aws_s3_bucket.hosting.id
        Extract        = true
      }
    }
  }
}
```
</div></details>

- SourceステージでのPollForSourceChanges設定をfalseにしています
- trueにするとEventBridgeが自動作成され、ブランチの変更を2重に監視することになってしまいます


##### event.tf
<details><summary>event.tf</summary><div>

```event.tf
############################################################################
## EventBridge用ロール
############################################################################
# EventBridge用ポリシードキュメント
data "aws_iam_policy_document" "pipeline_events_role_policy" {
  statement {
    effect    = "Allow"
    resources = [aws_codepipeline.pipeline.arn]

    actions = [
      "codepipeline:StartPipelineExecution"
    ]
  }
}

# EventBridge用IAMロール
module "exec_pipeline_role" {
  source     = "./modules/iam_role"
  name       = "role-${var.env_name}-event-exec-pipeline"
  identifier = "events.amazonaws.com"
  policy     = data.aws_iam_policy_document.pipeline_events_role_policy.json
}

############################################################################
## EventBridgeイベント
############################################################################
# rule作成
resource "aws_cloudwatch_event_rule" "exec_pipeline" {
  name        = "event-${var.env_name}-cicd-front"

  /* codecommit変更を検知する場合 */
  event_pattern = templatefile("./json/event_pattern.json", {
    repository_name = var.repository_name
    branch_name = var.branch_name
  })
}

# ruleにターゲット追加
resource "aws_cloudwatch_event_target" "exec_pipeline" {
  target_id = "target-${var.env_name}-cicd-front"
  rule      = aws_cloudwatch_event_rule.exec_pipeline.name
  role_arn  = module.exec_pipeline_role.iam_role_arn
  arn       = aws_codepipeline.pipeline.arn
}
```
</div></details>

- 特になし
- 小ネタ？なのですが、event_targetであるCodePipeLineへ渡すinputは```input     = "{\"test\":\"value\"}"```など、適当な値でも良いようでした

##### event_pattern.json
```json/event_pattern.json
{
    "detail-type": [
        "CodeCommit Repository State Change"
    ],
    "source": [
        "aws.codecommit"
    ],
    "detail": {
        "event": [
            "referenceUpdated"
        ],
        "repositoryName": [
            "${repository_name}"
        ],
        "referenceType": [
            "branch"
        ],
        "referenceName": [
            "${branch_name}"
        ]
    }
}
```
- jsonで定義した部分が全て一致するイベントが発生した際、EventBridgeがターゲットにアクションを起こします
- ブランチが更新された際のイベントパターンは以下で確認できます

https://docs.aws.amazon.com/ja_jp/codecommit/latest/userguide/monitoring-events.html#referenceUpdated

- 今回はリポジトリ名とブランチ名が一致するすべてのブランチの変更がトリガーとなります

## 今回の構成の問題点
S3へビルドアーティファクトを展開する方式だと、不要ファイルの削除を行うことができません。
同名ファイルは上書きできるため問題ありませんが、NextJSの静的生成ではCSSファイルなどにランダムなファイル名が割り振られるため、デプロイの度にファイルの数が増加していってしまいます。

![pic1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/30ac5e49-04fd-b6e4-f75b-602b2a9c2bff.png)

NextJSのserver componentはbuild時に1度だけ処理される仕様ですので、ソースのbuild結果に冪等性がある場合はS3の内容を毎回空にしても良いかもしれないです（冪等性があってもデプロイ前にS3を空にするのが怖かったので今回は見送りましたが・・・）。

https://nextjs.org/docs/app/building-your-application/deploying/static-exports#server-components

## おわりに
S3の静的ホスティングとNextJSの静的生成を試してみました。
CodePipelineやCodebuild、EventBridgeの細かい所やNextJSのserver componentのコンパイル時の挙動なども知れて、結構有意義だったかなと思います。