SPA(React)のビルドとデプロイを自動化するAWS CodePipeline用のCloudFormationテンプレート

# 概要

[create-react-app](https://ja.reactjs.org/docs/create-a-new-react-app.html) から作成したReactのWebアプリケーション(SPA)のビルドとデプロイを自動化するためのCodePipelineを構築する自分用CloudFormationテンプレートの紹介記事です。

Gitリポジトリ(CodeCommit)への特定ブランチへのpushをトリガーに起動してデプロイ先のS3バケットにデプロイするCodePipelineのAWSリソースが構築されます。

デプロイ時にS3オブジェクトにキャッシュコントロール用のメタデータを付与することにより"cache-control"ヘッダがHTTPレスポンスに付与されるようになります。

# buildspec.ymlについて

当記事で紹介しているテンプレートのパイプラインでのビルドには、React側のリソースの最上位階層のフォルダに「buildspec.yml」という名前のファイルを下記の内容で配置しておく必要があります。

```yaml:buildspec.yml
version: 0.2

phases:
  pre_build:
    commands:
      - npm ci
  build:
    on-failure: ABORT
    commands:
      - npm run build
  post_build:
    commands:
      - cd build
      - aws s3 sync --exact-timestamps --cache-control 'max-age=2592000' --exclude '*' --include 'static/**/*' . s3://${DEPLOY_BUKET}
      - aws s3 sync --exact-timestamps --cache-control 'no-cache' --delete . s3://${DEPLOY_BUKET}
```

配置する場所 ※package.jsonと同じ階層に置きます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/673dc59b-273e-93e8-a939-78c6faf89b64.png)

「buildspec.yml」ファイルはCodeBuildで使われるファイルで、"build"フェーズに定義されたコマンドでビルドされたリソースを"post_build"フェーズのAWS CLIのコマンドでS3バケットにデプロイしています。

"post_build"フェーズで、ビルド成果物が出力される"build"フォルダに移動し、2回に分けた"aws s3 sync"コマンド実行でS3バケットにデプロイしています。

最初のs3 syncでは、"static"フォルダ配下のjsやcssなどのファイル群(サイズが大きくビルド毎にファイル名が変わる)に限定して'max-age=2592000'のキャッシュコントロール用メタデータの設定をしてデプロイを行っています(2592000は1ヶ月の秒です)。

2番目のs3 syncでは、"index.html"を含むブラウザキャッシュを効かせたくないファイル群（最初のs3 syncでデプロイされたもの以外のファイル）に対して"no-cache"のキャッシュコントロール用メタデータの設定をしてデプロイを行っています。

こうすることでブラウザとCloudFrontのキャッシュを有効活用しつつもデプロイによるアプリケーションの更新時には即座に反映されるようにすることができます。

aws s3 syncコマンドの"--exact-timestamps"オプションは、更新があってもファイルサイズが変わらなった場合に同期の対象外になってしまう事故を防ぐために指定しています。
[参考: S3 sync で s3からファイルを同期させる時の注意点](https://dev.classmethod.jp/articles/s3-sync-exact-timestamps/)
2番目のsyncで指定している"--delete"オプションは、古いリソースを削除して最新のビルドリソースのみがS3バケットに存在するようにしています。

S3バケットを指定する部分が"${DEPLOY_BUKET}"となっていますが、この部分はCodeBuildで設定する環境変数から設定されますのでこのままで構いません。

# CloudFormationテンプレート

こちらの[GitHub](https://github.com/chs-k-kinoshita/cfn_tmpl_samples/blob/main/tmpl_codepipeline.yaml)からダウンロードもできます。
下記のリソースがこのテンプレートで作成されます。
|リソース|説明|
|:-----------|:------------|
|CodePipeline|"Source"と"Build"の2ステージで構成されるPipelineが作成されます|
|CodeBuild|Pipelineから呼び出されます|
|IAMロール|CodePipeline用、CodeBuild用、EventBridge用の３つが作成されます|
|S3バケット|Pipelineがアーティファクトストアとして一時的に使用するバケットです。※SPAのデプロイ先ではありません|
|EventBridge Rule|CodeCommitのブランチへのpushを検知してPipelineを開始するためのリソースです|

パラメータとして下記３つを受け取ります。
- デプロイ先のS3バケット名
- PipelineのトリガーとなるCodeCommitのリポジトリ名
- PipelineのトリガーとなるGitブランチ名（オプション。デフォルトで"main"）

CodeCommitのリポジトリやデプロイ先のS3バケットは当テンプレートでは作成しないので、別途用意する必要があります。

```yaml:テンプレート全体
AWSTemplateFormatVersion: '2010-09-09'
Description: Sample CodePipeline template for SPA(React) with S3+CloudFront.

Parameters:
  DeployBucketName:
    Type: String
  SourceRepositoryName:
    Type: String
  SourceBranch:
    Type: String
    Default: main

Resources:

  ### CodeBuild and CodePipeline ###
  CodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Sub ${AWS::StackName}-CodeBuild
      Description: !Sub 'Created by ${AWS::StackName}'
      Source:
        BuildSpec: buildspec.yml
        Type: CODEPIPELINE
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: 'BUILD_GENERAL1_SMALL'
        Image: aws/codebuild/amazonlinux2-x86_64-standard:4.0
      ServiceRole: !GetAtt CodeBuildServiceRole.Arn
      LogsConfig:
        CloudWatchLogs:
          Status: ENABLED

  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: !Sub ${AWS::StackName}-CodePipeline
      RoleArn: !GetAtt CodepipelineServiceRole.Arn
      ArtifactStore:
        Location: !Ref ArtifactStoreBucket
        Type: S3
      RestartExecutionOnUpdate: false
      Stages:
        - Name: Source
          Actions:
            - Name: Source
              ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: '1'
              Configuration:
                RepositoryName: !Ref SourceRepositoryName
                BranchName: !Ref SourceBranch
                PollForSourceChanges: false
              OutputArtifacts:
                - Name: SourceArtifact
        - Name: Build
          Actions:
            - Name: Build
              ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: '1'
              Configuration:
                ProjectName: !Ref CodeBuild
                EnvironmentVariables: !Sub |
                  [
                    {
                      "name":"DEPLOY_BUKET",
                      "type":"PLAINTEXT",
                      "value": "${DeployBucketName}"
                    }
                  ]
              InputArtifacts:
                - Name: SourceArtifact
              Namespace: BuildVariables

  ArtifactStoreBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Sub ${AWS::StackName}-artifactstore-${AWS::AccountId}
      LifecycleConfiguration:
        Rules:
          - Id: clear-old-objects-rule
            Status: Enabled
            ExpirationInDays: 7
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True

  ### ServiceRoles for CodeService ###
  CodeBuildServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /service-role/
      RoleName: !Sub ${AWS::StackName}-codebuild-ServiceRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  CodepipelineServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /service-role/
      RoleName: !Sub ${AWS::StackName}-codepipeline-ServiceRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  ### Resorces for EventBridge Rule ###
  EventBridgeIAMPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - 'codepipeline:StartPipelineExecution'
            Resource:
              - !Sub arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipeline}
      ManagedPolicyName: !Sub '${CodePipeline}-policy'

  EventBridgeIAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${AWS::StackName}-eventbridge-Role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - events.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - !Ref EventBridgeIAMPolicy

  EventBridge:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub 'changeEvent-rule-${CodePipeline}'
      Description: !Sub 'for ${CodePipeline}. Created by ${AWS::StackName}'
      EventPattern:
        source:
          - aws.codecommit
        detail-type:
          - 'CodeCommit Repository State Change'
        resources:
          - !Sub arn:${AWS::Partition}:codecommit:${AWS::Region}:${AWS::AccountId}:${SourceRepositoryName}
        detail:
          event: ['referenceCreated', 'referenceUpdated']
          referenceType:
            - branch
          referenceName:
            - !Ref SourceBranch
      State: ENABLED
      Targets:
        - Arn: !Sub arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipeline}
          Id: CodePipeline
          RoleArn: !GetAtt EventBridgeIAMRole.Arn
```

テンプレートからコマンドでリソースを構築する場合は、下記のようなコマンド実行になります。
ここではスタック名を「sample-ppl」としています。構築される各リソース名のプリフィックスとして使われます。
※ "tmpl_codepipeline.yaml"は↑の内容をファイルに保存したものです
```
aws cloudformation deploy \
 --stack-name sample-ppl \
 --template-file ./tmpl_codepipeline.yaml \
 --parameter-overrides DeployBucketName=S3バケット名 SourceRepositoryName=CodeCommitリポジトリ名 \
 --capabilities CAPABILITY_NAMED_IAM
```

（Cloud9環境でのコマンド実行例）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/0650d669-5922-b6f5-b0bf-a8a8d884242c.png)

# 補足説明

### SourceがCodeCommitではない場合
EventBridge Ruleのリソースは、CodeCommitの特定のブランチ(テンプレートのパラメータで指定したもの。省略した場合は"main")の更新を検知した場合にCodePipelineを起動します。
ソースとしてAWS外のサービス（GitHubなど）を使用する場合には別の方法で変更を検知することになるので、必要ないリソースとなります。

また、SourceのConfigurationの設定は種類によって設定項目が異なります。種類別の設定項目については下記UserGuideを参照下さい。
[AWS CodePipeline User Guide #reference-pipeline-structure](https://docs.aws.amazon.com/codepipeline/latest/userguide/reference-pipeline-structure.html#PollForSourceChanges-defaults)

```yaml:CodePipeline SourceのConfiguration部(L53～) ※↓はCodeCommitの場合の設定項目
              Configuration:
                RepositoryName: !Ref SourceRepositoryName
                BranchName: !Ref SourceBranch
                PollForSourceChanges: false
```
(SourceがGitHubなどの場合にどういう変更が必要かは、別記事にしたいかもしれません)

### CodeBuildの環境変数を設定している箇所

下記の部分で、テンプレートのパラメータとして指定されたデプロイ先バケット名を"DEPLOY_BUKET"という環境変数に設定しているので前述の「buildspec.yml」ファイルで変数として使えています。
```yaml:環境変数の設定部（L69～）
              Configuration:
                ProjectName: !Ref CodeBuild
                EnvironmentVariables: !Sub |
                  [
                    {
                      "name":"DEPLOY_BUKET",
                      "type":"PLAINTEXT",
                      "value": "${DeployBucketName}"
                    }
                  ]
```

### 作業用バケットにはライフサイクルルールを設定している

CodePipelineがアーティファクトストアとして使用するS3バケットには、7日経過で自動でファイルが削除されるライフサイクルルールを設定しています。
```yaml:ライフサイクルルール（L85～）
  ArtifactStoreBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Sub ${AWS::StackName}-artifactstore-${AWS::AccountId}
      LifecycleConfiguration:
        Rules:
          - Id: clear-old-objects-rule
            Status: Enabled
            ExpirationInDays: 7
```

### CodeBuildとCodePipeline用のサービスロール

当記事では手順の簡略化のために、CodePipelineと一緒のテンプレートでCodeBuildとCodePipeline用のIAMロールが作成されるようになってます。
しかし、これらのサービスロールは内容的に使い回しができるものなので、別のスタックで作成した上でクロススタック参照あるいはパラメータ指定などで使い回すようにした方が同じようなものを何個も作らなくてよいので良いかと思います。

[こちらのGitHub](https://github.com/chs-k-kinoshita/cfn_tmpl_samples/tree/main/tmpl_codepipeline_servicerole%E5%A4%96%E5%87%BA%E3%81%97ver)に、サービスロールを別のテンプレートに外だしして、CodePipelineのテンプレートではクロススタック参照でロールのArnを設定するバージョンのテンプレートも置いているので、よろしければご参考にしてください。

# 作成されたCodePipelineの実行例

パラメータで指定したCodeCommitリポジトリのブランチに"buildspec.yml"ファイルを含めたReactのソースをpushすることで、CodePipelineが起動してビルドが行われます。

![pipeline_1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/dc531d73-0615-c594-123a-de98adb5c9c6.png)

ビルドが成功すると、パラメータで指定したデプロイ先のS3バケットに"npm run build"コマンド("buildspec.yml"のbuildフェーズで指定したコマンド)で出力されたビルド成果物が配置されます。

![pipeline_2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/2b29dd9c-2f07-a9b1-a2f1-35d329c9cdb4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/c403822f-ae67-8963-ff10-12af5d106aa5.png)

S3バケットにデプロイされた各オブジェクトのメタデータにはキャッシュコントロール用の値が設定されています。
（下の画像は"index.html"ファイルの例です。staticフォルダ配下のファイルには"no-cache"ではなく"max-age=2592000"が設定されているはずです。）
![pipeline_4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/9e57b492-ffdf-4484-c33b-6687b3ba1259.png)

# 最後に

テンプレート化することで、同様の作業をなんども管理コンソール画面上でポチポチする手間が省け、作業ミスも予防できるので便利かと思います。
当記事がなにかのご参考になれば幸いです。

デプロイ先のS3バケットでは当記事のテンプレートでは作成しませんが、デプロイ先として使えるWebリソース公開用のS3バケットとCloudFrontをサクッと構築するためのテンプレートは[「こちら」](https://qiita.com/Keiichi_Kinoshita/items/221b0aaa5dbe67196cb0)で記事にしているので、よろしければ合わせてご参照いただければと思います。


# 参考にさせて頂いた他記事様

- [AWS CodePipelineでS3バケットへSPAアプリケーションをデプロイ！](https://www.ragate.co.jp/blog/articles/6981)
- [CodePipelineをCloudFormationで作成してみた](https://dev.classmethod.jp/articles/created-codepipeline-with-cloudformation/)
- [How to Setup CodePipeline and CodeBuild to Deploy a ReactJS App to AWS S3 and CloudFront](https://www.bluelabellabs.com/blog/deploy-reactjs-app-aws-s3-cloudfront/)
- [Configure a cache policy in AWS for React apps](https://mitchgavan.com/aws-react-cache/)