はじめに
以下の記事で紹介した Cloudfront の Continuous deployment について、CodePipeline でデプロイパイプラインを構築してみました。CloudFront + S3 でホストされたアプリケーションを Blue/Green できます。
サンプルリポジトリはこちらです。
概要
以下 2 パターンを試しており、今回はより一般的な A を紹介します。B は本記事においては A と比較する形で概要だけ説明し、詳細は別記事で紹介します。
A. 昇格 (Promote) まで完了してもステージングディストリビューションを削除せず使い回す(今回はこっち)
B. 昇格 (Promote) まで完了後、ステージングディストリビューションを毎回削除する
各ステージでやっていることは以下の通り。今回、結果的にシェルスクリプトをかなり書くことになったので、その資産を流用するために CodeBuild の出番が多くなっています (CodeBuild、汎用性が高くて使ってしまいがち)
Stage | Service | Description | |
---|---|---|---|
1 | Source | CodeCommit | CodeCommit リポジトリから変更されたコードを取得する |
2 | Build | CodeBuild | S3 バケットにアップロードするために React のソースコードに対して Lint, Test, Build を行う |
3 | Deploy | CodeBuild | 1. バケットにビルドアーティファクトをデプロイする 2. ステージングディストリビューションを作成または有効化し、Continuous deployment を構成する |
4 | Approve | CodePipeline | 手動承認ステージで変更内容をレビューする (SNS でメール通知を飛ばす) |
5 | Promote | CodeBuild | 1. ステージングディストリビューションの設定で本番ディストリビューションを上書きする (昇格) 2. ステージングディストリビューションを削除する (B のみ) |
構成図
以下のようなパイプライン構成です。
パターン A
パターン B
リソースの状態遷移
通常の状態はこうです。シンプルですね。S3 バケットのプレフィックスごとに各バージョンのコンテンツをアップロードするので、プレフィックスをオリジンパスと一致させます。
Deploy ステージではこうなります。ユーザーからのリクエストは本番ディストリビューションにルーティングされつづける一方、Continuous deployment policy で設定したカスタムヘッダーが Viewer request に付与されている場合はステージングディストリビューションにルーティングされます。
どのコンテンツを参照するかはオリジンパスで制御します。開発者は前記事で紹介した Requestly などで意図的にカスタムヘッダーを設定してアクセスする形を想定しています。
Promote ステージです。テスト OK となった場合、ステージングディストリビューションの設定で本番ディストリビューションを上書きします。このプロセスが実質的なリリースとなり、ダウンタイムなしで実行されます。
パターン B の場合のみ Cleanup ステージを生やしており、リリースの終わったステージングディストリビューションと Continuous deployment policy をクリーンアップします。
Pros/Cons
A, B それぞれのプロコンは以下となります。構築には CDK を使用する前提ですが、CDK では記事執筆時点において、Continuous deployment policy の L1 コンストラクトが存在する一方で CopyDistribution API に相当する機能がなく、ステージングディストリビューションを CDK の標準的な使い方で作成するのがどうやら困難であるという課題があります。
従ってステージングディストリビューションの作成に関しては CDK のスコープ外、つまりパイプラインで行うことになります。以下はそのコンテキストでのプロコンになっています。いずれにせよちょっと面倒だなというのが正直なお気持ちです。
Pros | Cons | |
---|---|---|
A | 1. 時間のかかるクリーンアップ処理を待たなくてよい | 1. CDK のスコープ外で作成したステージングディストリビューションに CDK で作った ACM 証明書や OriginAccessControl が紐づくため、スタック削除時はステージングディストリビューションを事前削除しておかないと失敗する 2. ステージングディストリビューションの有無で処理を分岐させる必要がある (初回デプロイか 2 回目以降のデプロイかを判定する必要がある) |
B | 1. ステージングディストリビューションを毎回削除するので、CDK への影響が最小限で済む | 1. クリーンアップ処理の実装が面倒、かつ処理に時間がかかる 2. 手動承認で却下した場合はステージングディストリビューションが残ってしまうため、却下後のクリーンアップ処理も必要 |
使用する主な API
Deploy ステージや Promote ステージでは Continuous deployment で必要な以下の API を叩きます。マネジメントコンソールから手動で実行する場合は簡単に操作できますが、その裏では複数の API が叩かれていることがわかります。
Stage | API | Description | Pattern |
---|---|---|---|
Deploy | CopyDistribution | 本番ディストリビューションを複製し、ステージングディストリビューションを作成する | A, B |
CreateContinuousDeploymentPolicy | Continuous deployment policy を作成する | A, B | |
UpdateDistribution | 本番ディストリビューションに Continuous deployment policy をアタッチする | A, B | |
UpdateDistribution | ステージングディストリビューションの設定を更新する | A, B | |
Promote | UpdateDistributionWithStagingConfig | ステージングディストリビューションを昇格する | A, B |
Cleanup | UpdateDistribution | 本番ディストリビューションから Continuous deployment policy をデタッチする | B |
DeleteContinuousDeploymentPolicy | Continuous deployment policy を削除する | B | |
UpdateDIstribution | ステージングディストリビューションを無効化する | B | |
DeleteDistribution | ステージングディストリビューションを削除する | B |
実装
GitHub へのリンクとコードの紹介です。
コンテキスト
まず各スタックで参照するコンテキストです。処理を分岐するパラメータとして使ったりするので重要です。cloudfrontConfig
は Continuous deployment における重要なパラメータを定義しています。buildspecDir
は buildspec ファイルの置き場所を一括で管理するために使います。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | { ... "context": { ... "owner": "user", "addresses": ["user@your-domain.jp"], "serviceName": "cfcd-test", "repositoryName": "test-repo", "branch": "main", "hostedZoneName": "your-domain.com", "webAclArn": "dummy-arn", "buildspecDir": "scripts/build", "cloudfrontConfig": { "singleHeaderConfig": { "header": "aws-cf-cd-staging", "value": true }, "stagingDistributionCleanupEnabled": false } } } |
Application スタック
デプロイ対象であるコンテンツをホストする CloudFront + S3 のコードです。
構築後、以下を SSM パラメータストアに登録しています。
- 本番ディストリビューション ID
- ステージングディストリビューション ID (構築時点では存在しないためダミー値を登録)
- ホスティング用バケットの名前
- CloudFront アクセスログ保管用バケットの名前
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | // Put distributionId to SSM parameter store new ssm.StringParameter( this , "CloudFrontProductionDistributionParameter" , { parameterName: `/${serviceName}/cloudfront/cfcd-production`, stringValue: distribution.distributionId, }); new ssm.StringParameter( this , "CloudFrontStagingDistributionParameter" , { parameterName: `/${serviceName}/cloudfront/cfcd-staging`, stringValue: "dummy" , }) // Put bucketName to SSM parameter store new ssm.StringParameter( this , "HostingBucketParameter" , { parameterName: `/${serviceName}/s3/website`, stringValue: hostingBucket.bucketName, }); new ssm.StringParameter( this , "CloudFrontLogBucketParameter" , { parameterName: `/${serviceName}/s3/cloudfront-log`, stringValue: cloudfrontLogBucket.bucketName, }); |
記事執筆時点で OriginAccessControl を L2 コンストラクトで扱えないためエスケープハッチしています。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | // Create OriginAccessControl const hostingOac = new cloudfront.CfnOriginAccessControl( this , "HostingOac" , { originAccessControlConfig: { name: hostingBucket.bucketDomainName, originAccessControlOriginType: "s3" , signingBehavior: "always" , signingProtocol: "sigv4" , description: hostingBucket.bucketDomainName, }, }); ... const cfnDistribution = distribution.node.defaultChild as cloudfront.CfnDistribution; cfnDistribution.addPropertyOverride( "DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity" , "" ); cfnDistribution.addPropertyOverride( "DistributionConfig.Origins.0.OriginAccessControlId" , hostingOac.attrId); |
CI/CD スタック
パイプラインの実装です。ポリシーはわりと絞っています。流用の際は AccessDenied が起こりがちかと思います。
シンプルにパイプラインの中身の要素やそのロールを逆算して順番に定義しています。ただし前述のクリーンアップ処理に関しては、コンテキストで設定したフラグ値を元に構築するリソースを切り替えるような実装にしており、context.cloudfrontConfig.stagingDistributionCleanupEnabled
が true
の場合しか synthesize されないリソースがあります (該当コード)
ポイントとしては、CodeBuild の環境変数は SSM パラメータストアを参照するように構成できるので、この機能を以下のように利用しています。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | // Create codebuild project for frontend deploy const frontendDeployProject = new codebuild.PipelineProject( this , "FrontendDeployProject" , { projectName: `${serviceName}-frontend-deploy-project`, ... environmentVariables: { SERVICE: { type: codebuild.BuildEnvironmentVariableType.PLAINTEXT, value: serviceName, }, BUCKET_NAME: { type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE, value: `/${serviceName}/s3/website`, }, PRODUCTION_DISTRIBUTION_ID: { type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE, value: `/${serviceName}/cloudfront/cfcd-production`, }, STAGING_DISTRIBUTION_ID: { type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE, value: `/${serviceName}/cloudfront/cfcd-staging`, }, STAGING_DISTRIBUTION_CLEANUP_ENABLED: { type: codebuild.BuildEnvironmentVariableType.PLAINTEXT, value: cloudfrontConfig.stagingDistributionCleanupEnabled, }, CONTINUOUS_DEPLOYMENT_POLICY_CUSTOM_HEADER: { type: codebuild.BuildEnvironmentVariableType.PLAINTEXT, value: JSON.stringify(cloudfrontConfig.singleHeaderConfig), }, FRONTEND_VERSION: { type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE, value: `/${serviceName}/version/frontend`, }, }, ... }); |
CI/CD 用スクリプト
この記事のメインです。buildspec ファイルを複数使いますし、似たような処理を組み合わせなければならないので、yaml にコマンドを直接書くのはほぼ無理ゲーでした。このため、シェルスクリプトを yaml 内でマッピングして使い回す方式を採用しています。また通常 buildspec.yml
はリポジトリのルートに配置しますが、今回は数も多いのでディレクトリを掘っています。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | ./scripts/ └── build ├── bin │ ├── common.sh │ ├── frontend.build.sh │ ├── frontend.cleanup.sh │ ├── frontend.deploy.cfcd.sh │ ├── frontend.deploy.s3.sh │ ├── frontend.invalidate.sh │ ├── frontend.promote.sh │ └── install.awscliv2.sh ├── buildspec.frontend.build.yml ├── buildspec.frontend.cleanup.yml ├── buildspec.frontend.deploy.yml └── buildspec.frontend.promote.yml |
共通関数
bin/common.sh
が関数ファイルとなっており、bin
配下のスクリプトのほとんどで common.sh
を source
します。AWS の API は失敗したら早期終了するように制御しています。
また CloudFront ディストリビューションの変更がエッジロケーションに伝播するのを待つシチュエーションがあるので、以下のような待機処理も入れています。
01 02 03 04 05 06 07 08 09 10 11 12 | wait_distribution_deploy() { echo "PROCESS: Waiting for '$1' to deploy..." status= "" while [ "$status" != "Deployed" ]; do sleep 5 status=$(aws cloudfront get-distribution -- id "$1" --query "Distribution.Status" --output text) || { echo "ERROR: Failed to get 'Distribution.Status' from '$1'." exit 1 } echo "STATUS: $status" done } |
ビルド
React の Lint, Test, Build を行うサンプルです。引数で npm スクリプトを走らせる際のプレフィックスを渡します。これは今回ポリレポ構成で React のコードを src/s3/hosting
内に腹持ちしているためです。
1 2 3 4 5 6 | ... npm install --prefix "$1" npm run lint --prefix "$1" npm run test --prefix "$1" -- --watchAll= false npm run build --prefix "$1" ... |
buildspec はシェルスクリプトをマッピングしているだけです。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | version: 0.2 phases: install: runtime-versions: nodejs: 16 pre_build: commands: - bash scripts/build/bin/install.awscliv2.sh build: commands: - bash scripts/build/bin/frontend.build.sh src/s3/hosting artifacts: files: - "**/*" |
デプロイ
デプロイの処理を書くのがかなり大変でした。ざっくり以下を行っています。
- ビルドしたアーティファクトを S3 バケットに配置
- コマンドラインで CloudFront continuous deployment を動かす
S3 へのデプロイでは s3 sync
を使っています。--exact-timestamps
オプションで差分検出の際にタイムスタンプも見るようにし、--delete
オプションで古いほうにしかないファイルを削除するようにしています。
1 2 3 4 5 6 | deploy_content() { aws s3 sync "$1" "$2" --exact-timestamps --delete 1> /dev/null || { echo "ERROR: Failed to synchronize contents to hosting bucket." exit 1 } } |
CloudFront の処理では使用する主な APIでも触れた通り複数の API を駆使する必要があります。以下のような処理になります。2 回目以降の処理で Continuous deployment policy を有効化するのは、Promote すると自動的に Disable されるからです。
スクリプトは以下のようになります。
Continuous deployment policy の config を作成する際は以下のようにステージングディストリビューションのドメイン名を渡す必要があります。また、ステージングへのアクセスは開発者だけに限定したいため、リクエストの向き先はカスタムヘッダーの有無で制御する方式を採用します。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | ... stg_distribution=$(copy_distribution "$PRODUCTION_DISTRIBUTION_ID" "$prod_distribution_etag" ) ... header_k=$(jq -r .header <<< "$CONTINUOUS_DEPLOYMENT_POLICY_CUSTOM_HEADER" ) header_v=$(jq .value <<< "$CONTINUOUS_DEPLOYMENT_POLICY_CUSTOM_HEADER" ) ... echo "PROCESS: Creating CloudFront continuous deployment policy." continuous_deployment_policy_config=$( cat <<-EOS { "StagingDistributionDnsNames" : { "Quantity" : 1, "Items" : [ $(jq '.Distribution.DomainName' <<< "$stg_distribution" ) ] }, "Enabled" : true , "TrafficConfig" : { "SingleHeaderConfig" : { "Header" : "$header_k" , "Value" : "$header_v" }, "Type" : "SingleHeader" } } EOS ) continuous_deployment_policy=$(create_continuous_deployment_policy "$continuous_deployment_policy_config" ) |
スクリプトを複雑にしている要因のひとつに、更新系の処理では必ず事前に ETag
を取得して引数 --if-match
に渡さなければならないというのがあります。update-distribution
の前には必ず get-distribution(-config)
しなければならないのです。グローバルなリソースの整合性を保つために必要なので仕方ありませんが。
buildspec は以下。事後プロセスとしてキャッシュ無効化しています。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | version: 0.2 phases: pre_build: commands: - bash scripts/build/bin/install.awscliv2.sh build: commands: - bash scripts/build/bin/frontend.deploy.s3.sh src/s3/hosting/build/ - bash scripts/build/bin/frontend.deploy.cfcd.sh post_build: commands: - bash scripts/build/bin/frontend.invalidate.sh artifacts: files: - "**/*" |
昇格 (Promote)
ステージングの設定で本番ディストリビューションを上書きすることを昇格 (Promote) と表現するようです。この用途のために UpdateDistributionWithStagingConfig API が用意されています。
ディストリビューションが InProgress
である場合は Deployed
になるまで待ち、本番/ステージング両方の ETag
を取得して昇格させます。これはダウンタイムなしで実行されます。
1 2 3 4 5 6 7 8 | echo "PROCESS: Waiting for CloudFront distribution changes to propagate to edge locations." wait_distribution_deploy "$PRODUCTION_DISTRIBUTION_ID" wait_distribution_deploy "$STAGING_DISTRIBUTION_ID" echo "PROCESS: Overriding CloudFront production distribution config with staging config." prod_distribution_etag=$(get_distribution_etag "$PRODUCTION_DISTRIBUTION_ID" ) stg_distribution_etag=$(get_distribution_etag "$STAGING_DISTRIBUTION_ID" ) update_distribution_with_staging_config "$PRODUCTION_DISTRIBUTION_ID" "$STAGING_DISTRIBUTION_ID" "$prod_distribution_etag" "$stg_distribution_etag" |
buildspec は以下。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | version: 0.2 phases: pre_build: commands: - bash scripts/build/bin/install.awscliv2.sh build: commands: - bash scripts/build/bin/frontend.promote.sh post_build: commands: - bash scripts/build/bin/frontend.invalidate.sh artifacts: files: - "**/*" |
デモ
前提の整理
デモの前に前提を整理します。まず、アプリケーションのバージョン情報は SSM パラメータストアで管理するので、初期値を登録しておきます。
1 2 3 4 5 | $ aws ssm put-parameter --name "/<serviceName>/version/frontend" --value "v1" -- type String --overwrite { "Version" : 1, "Tier" : "Standard" } |
このバージョン情報は CloudFront ディストリビューションのオリジンパスとして使用されます。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | // Get version of application frontend from SSM parameter store const frontendVersion = ssm.StringParameter.valueForTypedStringParameterV2( this , `/${serviceName}/version/frontend`, ssm.ParameterValueType.STRING ); ... const distribution = new cloudfront.Distribution( this , "Distribution" , { ... defaultBehavior: { origin: new cloudfront_origins.S3Origin(hostingBucket, { originPath: `/${frontendVersion}`, }), ... }, ... }); |
また、ビルド時に環境変数で渡すようにしています。
01 02 03 04 05 06 07 08 09 10 11 12 | // Create codebuild project for frontend build const frontendBuildProject = new codebuild.PipelineProject( this , "FrontendBuildProject" , { projectName: `${serviceName}-frontend-build-project`, ... environmentVariables: { REACT_APP_VERSION_FRONTEND: { type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE, value: `/${serviceName}/version/frontend`, }, }, ... }); |
React は以下のような簡単なサンプルコードです。ビルド時にバージョン情報を参照します。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | import React from "react" ; import { Light as SyntaxHighlighter } from "react-syntax-highlighter" ; import json from "react-syntax-highlighter/dist/esm/languages/hljs/json" ; import { githubGist } from "react-syntax-highlighter/dist/esm/styles/hljs" ; import "./App.css" ; SyntaxHighlighter.registerLanguage( "json" , json); function App() { return ( <div className= "container" > <SyntaxHighlighter language= "json" style={githubGist}> {JSON.stringify({ version: process.env.REACT_APP_VERSION_FRONTEND }, null , 2)} </SyntaxHighlighter> </div> ); } export default App; |
スタックのデプロイ
cdk.json
に設定したコンテキストが正しいことを確認し、以下コマンドを叩きます。
1 2 | npx cdk synth --all npx cdk deploy --all |
初回デプロイの時点でパイプラインが起動し、本番ディストリビューションと同じ設定のステージングディストリビューションを作ろうとします。パイプラインを最後まで終わらせておきましょう。
デプロイが成功すると、ブラウザにはこのように描画されます。
S3 バケットにはバージョン情報の初期値であるプレフィックス v1/
のみが存在する状態です。
1 2 | $ aws s3 ls s3: // <serviceName>-website PRE v1/ |
Continuous deployment を試す
では SSM パラメータストアの値を変更し、React のコードを一部変更してアプリのバージョンが上がったことをシミュレーションし、パイプラインを起動させてみましょう。
1 2 3 4 5 | $ aws ssm put-parameter --name "/<serviceName>/version/frontend" --value "v2" -- type String --overwrite { "Version" : 2, "Tier" : "Standard" } |
React のコードは、わかりやすいようにシンタックスハイライトのテーマを変更します。
01 02 03 04 05 06 07 08 09 10 | function App() { return ( <div className= "container" > - <SyntaxHighlighter language= "json" style={githubGist}> + <SyntaxHighlighter language= "json" style={dracula}> {JSON.stringify({ version: process.env.REACT_APP_VERSION_FRONTEND }, null , 2)} </SyntaxHighlighter> </div> ); } |
変更をプッシュし、デプロイステージの完了まで待ちます。
1 2 3 | git add src /s3/hosting/src/App .js git commit -m "style: change syntax highlight theme" git push origin main |
ブラウザアクセスして確認します。Requestly を使い、まずはカスタムヘッダーを付与するルールを Disable した状態です。
次にルールを Enable した状態です。
同じ URL 宛のリクエストでもヘッダーの有無でリクエストのルーティングが変わっていることが確認できました。この状態で開発者はテストを行い、問題なければ手動承認ステージで Approve すればステージングディストリビューションの設定が昇格し、リリース完了となります。
長くなりましたが、これで CloudFront + S3 構成での、CI/CD パイプラインを使用した Blue/Green デプロイメントが実現できました。
おわりに
2022 年 11 月に GA した CloudFront continuous deployment 機能ですが、パイプライン化しようとすると相当しんどいことがわかりました。冒頭で説明したパターン B を別の記事で紹介する予定ですが、実質的に手動承認ステージで Reject した場合に何らかの処理を走らせる方法の解説になりそうです。
また、さすがにシェルスクリプトでゴリ押ししすぎた感があるので、AWS Step Functions などで置き換えられないか検証してみたいと思います。まだなにも調べていませんが、個人的にはこの文脈でパイプライン処理と Step Functions が統合できるのであれば熱いなぁと思うので、CDK と絡めながらまた手を動かしてみたいと思います。