AWS CDK v2 + GitHub ActionsでReactアプリをデプロイしてみた

2022.04.09

こんにちは、CX事業本部 IoT事業部の若槻です。

最近、AWS CDK v2GitHub Actionsについて調査することが多いので、今回はそれらの合わせ技として、AWS CDK v2 + GitHub ActionsでReactアプリをデプロイしてみました。

環境

$ cdk --version
2.20.0 (build 738ef49)
$ npm --version
8.5.0
$ node --version
v14.17.0

やってみた

GitHub Repositoryの作成

GitHub上に適当な名前(今回はaws-cdk-v2-project)Repositoryを作成したら、ローカルにCloneします。

$ git clone https://github.com/<Account>/aws-cdk-v2-project.git aws-cdk-v2-project
$ cd aws-cdk-v2-project

AWS CDK v2プロジェクトの作成

cdk initを使用して新しいAWS CDK v2プロジェクトを作成します。

$ npx cdk init --language typescript

一部の型パッケージは手動でインストールします。

$ npm i --save-dev @types/source-map-support

AWS CDK v2(今回は2.19.0)のプロジェクトが作成できました。

$ npm ls --depth=0
aws-cdk-v2-project@0.1.0 /Users/wakatsuki.ryuta/projects/cm-rwakatsuki/aws-cdk-v2-project
├── @types/jest@26.0.24
├── @types/node@10.17.27
├── @types/source-map-support@0.5.4
├── aws-cdk-lib@2.20.0
├── aws-cdk@2.20.0
├── constructs@10.0.109
├── jest@26.6.3
├── source-map-support@0.5.21
├── ts-jest@26.5.6
├── ts-node@9.1.1
└── typescript@3.9.10

CDK Bootstrapを行います。

$ cdk bootstrap --region us-east-1

us-east-1リージョンでBootstrapを行うのは、今回は使用しませんが、CloudFront Distributionに設定するACM Certificateはus-east-1にしか作成できないという制限があるためです。

.gitignoreに、次節で作成するReactアプリのパスを除外する記述を追加します。これにより*.d.tsファイルなどを不本意にGit管理から除外されないようにします。

.gitignore

!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out

!/web/**

Reactアプリの作成

create-react-appを使用して、サブディレクトリ配下にReactアプリを作成します。

$ npx create-react-app web --template typescript

作成できたら、動作確認のためローカルでアプリを起動してみます。

$ npm --prefix web start

起動できました。

AWS CDKでデプロイ用のコードを書く

CDK Appの定義でデプロイ先のリージョンをus-east-1に指定します。

bin/aws-cdk-v2-project.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AwsCdkV2ProjectStack } from '../lib/aws-cdk-v2-project-stack';

const app = new cdk.App();
new AwsCdkV2ProjectStack(app, 'AwsCdkV2ProjectStack', {
  env: { region: 'us-east-1' },
});

Reactアプリをデプロイするためのコードを書きます。

lib/aws-cdk-v2-project-stack.ts

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  aws_s3,
  aws_cloudfront,
  aws_cloudfront_origins,
  aws_s3_deployment,
  aws_iam,
  RemovalPolicy,
  Duration,
} from 'aws-cdk-lib';

export class AwsCdkV2ProjectStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const websiteBucket = new aws_s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity',
      {
        comment: 'website-distribution-originAccessIdentity',
      }
    );

    const webSiteBucketPolicyStatement = new aws_iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: aws_iam.Effect.ALLOW,
      principals: [
        new aws_iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    const distribution = new aws_cloudfront.Distribution(this, 'distribution', {
      comment: 'website-distribution',
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          ttl: Duration.seconds(300),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: '/index.html',
        },
        {
          ttl: Duration.seconds(300),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/index.html',
        },
      ],
      defaultBehavior: {
        allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: aws_cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new aws_cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
      },
      priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    new aws_s3_deployment.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [aws_s3_deployment.Source.asset('./web/build')],
      destinationBucket: websiteBucket,
      distribution: distribution,
      distributionPaths: ['/*'],
    });
  }
}

package.jsonにデプロイ用のscriptsを追記します。

package.json

{
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "deploy": "cdk deploy --require-approval never"
  }
}

動作確認のためローカルからデプロイしてみます。

まずReactアプリをビルドします。

$ npm --prefix web run build

ReactアプリをCDKでデプロイします。

$ npm run deploy

CloudFrontのDistribution Domainをブラウザで開くと、デプロイされたReactアプリにアクセスできました。

GitHubとAWSのOIDC設定

GitHub ActionsではOpen ID Connectがサポートされたので使わない手はないでしょう。

GitHubとAWSのOIDC連携で必要な設定を行います。

ID Providerの作成

GitHubのID Providerが未作成の場合は、Create Identity Providerで次のように指定して、ID Providerを作成します。

  • Provider type:OpenID Connect
  • Provider URL:https://token.actions.githubusercontent.com
  • Audience:sts.amazonaws.com

IAM Roleの作成

GitHub Actions Workflowで使用するIAM Roleを作成します。

作成したIAM Roleに、CDKによるデプロイが行えるポリシーをアタッチします。必要な権限はCDKでどのリソースを作成するかによりけりですが、必要に応じて絞ってください。今回はすべてのリソースの作成に対応できるように*を指定しています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "*",
            "Resource": "*"
        }
    ]
}

信頼ポリシーを次のように編集して、特定のGitHub Repositoryのブランチでのアクション時にのみ、ID Providerを信頼するようにします。Federatedには先程作成したID ProviderのArnを指定します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "<ID Provider Arn>"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:<GitHub Account>/<GitHub Repository>:ref:refs/heads/*"
        }
      }
    }
  ]
}

作成したIRM RoleのArnを控えます。

GitHub Actionsの設定

Repositoryの[Settings]-[Security]-[Secrets]-[Actions]で[New repository secret]をクリック。

適当な名前(今回はAWS_OIDC_ROLE_ARN)で先程控えたIRM RoleのArnを値に指定したSecretを作成します。

GitHub ActionsのWorkflowファイルを作成します。

$ mkdir .github
$ mkdir .github/workflows
$ touch .github/workflows/cd.yaml

Workflowファイルを次のように編集します。

.github/workflows/cd.yaml

on: 
  push:
    paths-ignore:
      - '**/*.md'

permissions:
  id-token: write
  contents: read

env:
  AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }}
  AWS_REGION: us-east-1

jobs:
  aws-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Assume Role
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ env.AWS_OIDC_ROLE_ARN }}
          aws-region: ${{env.AWS_REGION}}

      - name: Cache CDK Dependency
        uses: actions/cache@v3
        id: cache_cdk_dependency_id
        env:
          cache-name: cache-cdk-dependency
        with:
          path: node_modules
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
          restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}-

      - name: Install CDK Dependency
        if: ${{ steps.cache_cdk_dependency_id.outputs.cache-hit != 'true' }}
        run: npm install

      - name: Cache Web Dependency
        uses: actions/cache@v3
        id: cache_web_dependency_id
        env:
          cache-name: cache-web-dependency
        with:
          path: web/node_modules
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
          restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}-

      - name: Install Web Dependency
        if: ${{ steps.cache_web_dependency_id.outputs.cache-hit != 'true' }}
        run: npm --prefix web install

      - name: Cache Build Web
        uses: actions/cache@v3
        id: cache_build_web_id
        env:
          cache-name: cache-build-web
        with:
          path: web/build
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/*', '!web/node_modules', '!web/**/*.md', '!web/build') }}

      - name: Build Web
        if: ${{ steps.cache_build_web_id.outputs.cache-hit != 'true' }}
        run: npm --prefix web run build

      - name: Deploy
        run: npm run deploy

actions/cacheのあたりが凝っていそうな感じですが、**/node_modulesweb/buildのキャッシュを頑張っています。これに関してはまたの機会に詳しく紹介したいと思います。

デプロイしてみる

Remote RepositoryにGit Pushを行います。

するとGutHub ActionsでWorkflowが実行されてCDK Deployが行われ、正常に完了しました!

Reactアプリの実装を修正(文言変更)します。

web/src/App.tsx

        <a
          className='App-link'
          href='https://reactjs.org'
          target='_blank'
          rel='noopener noreferrer'
        >
          Hello React
        </a>

Git PushするとWorkflowが実行されました。

アプリにアクセスすると修正が反映されています!

おわりに

AWS CDK v2 + GitHub ActionsでReactアプリをデプロイしてみました。

個別の技術要素についてはよく触っていたので何てことないだろうと思いきや、合わせて扱うことによって個別の時には想定していなかったハマり方などをしたりしました(凡ミスが多かったですが)。そういう点含めて一連の手順を確認できてよかったです。

参考

以上