AWS CDKを使用して静的サイトをデプロイする(Amazon S3+Amazon CloudFront+Bitbucket Pipelines) | SEEDS Creators' Blog | 株式会社シーズ

AWS CDKを使用して静的サイトをデプロイする(Amazon S3+Amazon CloudFront+Bitbucket Pipelines)

クラウド事業部エンジニアの川勝です。

最近はサーバレスウェブアプリケーション開発をよく触っています。
サーバレスウェブアプリケーションではフロントエンド側はSPAで作成してS3にデプロイすることが多いのですが、今回はそのデプロイフローをAWS CDK+Bitbucket Pipelinesを使用して自動化してみました。
実装した知見をブログで残しておきたいと思います。

はじめに

AWS CDKとは公式のページによると

AWS クラウド開発キット (AWS CDK) は、使い慣れたプログラミング言語を使用してクラウドアプリケーションリソースをモデル化およびプロビジョニングするためのオープンソースのソフトウェア開発フレームワークです。

https://aws.amazon.com/jp/cdk/

と説明されています。
すごく簡単にいうとAWSにはCloudFormationというプロビジョニングをするためのサービスがありますが、そのCloudFormationのテンプレートを生成して実行してくれるツールです。
ポイントはCloudFormationのテンプレートはjsonまたはyaml形式で記述していたのですが、AWS CDKではプログラムで記述できるようになっています。
条件分岐などが簡単にできるのと、IDE等を使えば入力時に補完してくれるのでドキュメントを調べなくてもある程度予想がつけやすくなりました。

注意点として、AWS CDKは2019/07に一般提供が開始されましたが、githubのreleaseをみてもかなり頻繁にアップデートされています。
https://github.com/aws/aws-cdk/releases
ときには昨日アップデートしたら今日またリリースされてた、、ということもありました。
破壊的変更もまあまああるので今回記載した内容でパラメータやメソッドが変更される可能性は大いにありますのでご注意ください。

またAWS CDKのインストールや基本的なCLIコマンド説明は今回取り上げません。
githubのREADMEや、Developer Guideを参照してください。

目標

react.jsやvue.jsで作成したSPAアプリケーションをgit pushでデプロイしたい。
– AWSリソースへのデプロイはAWS CDK使う
– gitのhookにBitbucket Pipelinesを使う = Bitbucket PipelinesでjsのbuildをしてAWS CDKを実行する

CDK書いてみる

早速ですがCDKのプログラムソースです。
TypeScriptでcdk init して生成された bin/cdk.ts を編集しています。

#!/usr/bin/env node
import cdk = require('@aws-cdk/core');
import { SampleAPPCdkStack } from '../lib/cdk-stack';

// 各種環境変数を取得
// deployEnvで動的にstackを切り替えるようにしています。
const awsAccountID = process.env.AWS_ACCOUNT_ID || ''
const awsRegion = process.env.AWS_DEFAULT_REGION || 'ap-northeast-1'
const deployEnv = process.env.DEPLOY_ENV || 'staging'
const suffix = deployEnv !== 'production' ? '-' + deployEnv : ''

const app = new cdk.App();
// SampleAPPCdkStackというクラスは使いまわして
// 第2引数を `SampleAPPCdkStack${suffix}` としてsuffixで切り替わるようにしました
new SampleAPPCdkStack(app, `SampleAPPCdkStack${suffix}`, { env: { account: awsAccountID, region: awsRegion }});

上記で読み込んでいる実際の実際リソースを生成するクラスです。
lib/cdk-stack.ts を編集しています。

import cdk = require('@aws-cdk/core');
import cloudfront = require("@aws-cdk/aws-cloudfront");
import iam = require('@aws-cdk/aws-iam');
import s3 = require('@aws-cdk/aws-s3');
import s3deploy = require('@aws-cdk/aws-s3-deployment');

export class SampleAPPCdkStack extends cdk.Stack {

  readonly deployEnv: string = process.env.DEPLOY_ENV || 'staging'
  readonly ProjectName: string = process.env.PROJECT_NAME || ''
  readonly CloudFrontReferer: string = process.env.AWS_CLOUD_FRONT_REFERER || ''
  readonly CloudFrontCname: string = process.env.AWS_CLOUD_FRONT_CNAME || ''
  readonly AcmCertArn: string = process.env.AWS_ACM_CERT_ARN || ''

  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const websiteBucket = new s3.Bucket(this, this.ProjectName, {
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html'
    });

    new s3deploy.BucketDeployment(this, this.ProjectName + '-artifact', {
      sources: [s3deploy.Source.asset('../dist')],
      destinationBucket: websiteBucket
    });

    const policy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject'],
      principals: [new iam.Anyone()],
      resources: [
        websiteBucket.bucketArn + '/*'
      ],
      conditions: {
        StringLike: { 'aws:Referer': this.CloudFrontReferer }
      }
    });
    websiteBucket.addToResourcePolicy(policy);

    const cloudFrontWebDistributionProps: cloudfront.CloudFrontWebDistributionProps = this.getProps(websiteBucket)
    const distribution = new cloudfront.CloudFrontWebDistribution(this, this.ProjectName + '-cloudfront', cloudFrontWebDistributionProps);

    new cdk.CfnOutput(this, 'CFTopURL', { value: `https://${distribution.domainName}/` })
    new cdk.CfnOutput(this, 'URL', { value: `https://${this.CloudFrontCname}/` })
  }

  private getProps(websiteBucket: s3.Bucket): cloudfront.CloudFrontWebDistributionProps {
    // CloudFrontの設定
    return {
      // 生のCloudFrontDomainだけでいい場合はaliasConfigurationは不要
      aliasConfiguration: {
        acmCertRef: this.AcmCertArn,
        names: [this.CloudFrontCname]
      },
      defaultRootObject: '/index.html',
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      httpVersion: cloudfront.HttpVersion.HTTP2,
      // 日本が含まれる安い区分で
      priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
      originConfigs: [
        {
          // s3のStaticWebhostingDomainを指定します。S3のbucketではない
          customOriginSource: {
            domainName: websiteBucket.bucketWebsiteDomainName,
            originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY
          },
          behaviors: [
            {
              isDefaultBehavior: true,
              compress: true,
              // とりあえずネガティブキャッシュ。ここは必要に応じてちゃんとしておくのがよい
              minTtl: cdk.Duration.seconds(0),
              maxTtl: cdk.Duration.days(0),
              defaultTtl: cdk.Duration.days(0)
            }
          ],
          // s3に直アクセスできないようにrefererをつけます
          originHeaders :{
            Referer: this.CloudFrontReferer
          }
        }
      ],
      // SPAなのでとりあえず404は/index.htmlへ
      errorConfigurations: [
        {
          errorCode: 404,
          errorCachingMinTtl: 300,
          responseCode: 200,
          responsePagePath: '/index.html'
        }
      ]
    }
  }
}

ここでのポイントは、調べているとs3 deploymentをとりあえげられている記事は結構ありましたがCloudFrontを使用する場合にOAIの設定をされているパターンが多かったです。
404は/index.htmlにして返しているのでそれでもいいのですが、せっかくs3の静的ウェブホスティングを有効にしているのでそちらを活かせるようにCloudFront Refererを使用した制限を採用してみました。
違いは /hogehoge/index.html が/hogehoge/ でもちゃんとs3のリソースが見つかるようになります。

公式のブログでS3+CloudFrontを採用する場合の手法が取り上げられていますのでこちらも参考にしてください。
Amazon S3 でホストされている静的ウェブサイトを提供するために CloudFront をどのように使用したらよいですか ?

あといくつか参考にしたサイトを見ているとCloudFrontのパラメータ名が変更になったりしていてちょっとハマったりしました。
ただTypeScriptだとエディタから宣言元とかみて調べるのが容易なので手元で解決できたのはよかったです。

CDK実行時に環境変数を渡す

コードかいて cdk deploy で基本的にOK なのですが、今回は環境変数で動作を変更するようになっていますので実行時に環境変数を渡さなければいけません。
以下を参考にしてrun-cdk.sh というシェルスクリプトにまとめました。
https://docs.aws.amazon.com/cdk/latest/guide/environments.html

#!/bin/sh

# .envに環境変数は書いていることが多いのでここで読み込んでexport
. .env
export PROJECT_NAME=${PROJECT_NAME}
export DEPLOY_ENV=${DEPLOY_ENV}
export AWS_ACCOUNT_ID=${AWS_ACCOUNT_ID}
export AWS_DEPLOY_ROLE=${AWS_DEPLOY_ROLE}
export AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
export AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
export AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}
export AWS_CLOUD_FRONT_REFERER=${AWS_CLOUD_FRONT_REFERER}
export AWS_CLOUD_FRONT_CNAME=${AWS_CLOUD_FRONT_CNAME}
export AWS_ACM_CERT_ARN=${AWS_ACM_CERT_ARN}

# ci実行時はrole指定と実行確認をしないようにするためのもの
if [ $@ = "deploy-ci" ]; then
  cmd="deploy --role-arn ${AWS_DEPLOY_ROLE} --require-approval never"
else
  cmd=$@
fi

echo "running deploy env: ${DEPLOY_ENV}"
# 毎回yarn install && yarn buildしなくてもいいが、ciでも使用するのでここでまとめて実行
cd cdk && \
  yarn install && \
  yarn build && \
  yarn cdk ${cmd}

もうちょっといい方法はありそうですね..

Bitbucket Pipelines

弊社ではソースコードの管理にBitbucketを使用していますので、CIはBitbucket Pipelinesを使用してみました。
(AWS CodeDeployはBitbucketから使えない…)

bitbucket-pipeline.yml

image: node:12.16.1-alpine3.11

pipelines:
  tags:
    "v*.*.*":
      - step:
          name: Build and release for production
          deployment: production
          script:
            - apk add --no-cache make gettext
            - envsubst < .env.template > .env
            - yarn install && yarn build
            - ./run-cdk.sh $@

    "staging/v*.*.*":
      - step:
          name: Build and release for staging
          deployment: staging
          script:
            - apk add --no-cache make gettext
            - envsubst < .env.template > .env
            - yarn install && yarn build
            - ./run-cdk.sh $@

フロントエンドのSPAはreact.jsとかvue.jsを想定しているのでnode.jsのdocker imageを使用。
git push でbranchに応じて実行することが多いと思いますが、デプロイ目的なのでtagのフォーマットで実行するようにしています。
ちょっとしたREADME.mdの変更だけでデプロイされちゃったりするのを避けたいというのがありました。
環境変数はBitbucket上で設定しておいて envsubst で用意していた.env.template に埋め込みして .env を生成しています。
envsubst便利です。
https://github.com/a8m/envsubst

あとは上記 bitbucket-pipeline.yml をcommitしてtag うってgit push! で自動デプロイされます!

…と思っていましたが最後の壁がありました。

AWS IAM

run-ckd.sh までさかのぼっていただくとデプロイコマンドはこうなっていました。

cmd="deploy --role-arn ${AWS_DEPLOY_ROLE} --require-approval never"

–role-arn, -r ARN of Role to use when invoking CloudFormation [文字列]

はい、こちらはCloudFormationを実行するroleを設定できるオプションになります。
したがって環境変数の AWS_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID に設定しているIAM ユーザーのpolicyには

{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": [
            "iam:GetRole",
            "iam:PassRole"
        ],
        "Resource": "arn:aws:iam::<account-id>:role/sample-app-deploy-role"
    }]
}

こんな感じでroleにアクセスできる権限があればいいと思っていました…
しかし実際はcdkを実行するには、CloudFormationとs3への権限が必要でした。
CloudFormationは実行する権限がいるのはよく考えたらわかるのですが、cdkではstackのeventの実行状況を表示してくれるので、そのあたりの権限も必要でした。
一個ずつ絞って設定していたらえらくドハマリしてしまうことに…

このあたりどのpolicyが必要なのかのドキュメントが見つからなかったので最終的に cloudformation:* にしてしまいましたが、今後の課題としてそのあたり把握しておきたいところです。
S3の権限に関してはソースコードを一時的に置くBucketにアップロードするのはCloudFormation実行前なので、IAMユーザーに権限が必要でした。
(これもPutObjectだけだとだめでs3:*に…)

今回はIAMユーザに上記policyをつけましたが、IAMユーザには本当にassume roleする権限だけ付与して、cdkを実行するrole、cloudformaitonを実行するroleとわけるとよりセキュアかと思います。

必要な権限がそろえば無事git push して自動デプロイが完成です!

まとめ

ここまで書いておいてなんですが、これくらいのフロントエンドアプリケーションだと AWS Amplify Consoleを使うのがおすすめですw
Bitbucketにも対応しています。
実は以前の以下記事ではそっち使っています。
AWS IoT Core にRaspberry Piから赤外線モーションセンサのログを送る その2 ~ブラウザで会議室の使用状況を確認できるようにする

ただCloudFrontの細かな設定はできないというのと、他のAWSリソース(API Gateway+LambdaでAPI側も作ってるとか)もまとめてデプロイしたいとかだとAWS CDKは使い勝手がいいと思います。
個人的に今の所サーバレスAPIはAWS SAM CLI、フロントエンドはAWS CDK でデプロイしているのですが、将来的にはCDKに統一していきたいなと思いました。(CDKでSAMのテンプレートも生成できたりします。)

また今回はTypeScriptを使用しましたが、他にもPython, C#, JAVAでも書けたり、その他の言語もサポート予定がありそうなので間口が広くていいと思いました。

以上川勝でした。