はじめに

以下の記事で紹介した Cloudfront の Continuous deployment について、CodePipeline でデプロイパイプラインを構築してみました。CloudFront + S3 でホストされたアプリケーションを Blue/Green できます。

CloudFront の継続的デプロイを試してみた

サンプルリポジトリはこちらです。

概要

以下 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 ファイルの置き場所を一括で管理するために使います。

cdk.EXAMPLE.json

{
  ...
  "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 のコードです。

app-stack.ts

構築後、以下を SSM パラメータストアに登録しています。

  • 本番ディストリビューション ID
  • ステージングディストリビューション ID (構築時点では存在しないためダミー値を登録)
  • ホスティング用バケットの名前
  • CloudFront アクセスログ保管用バケットの名前
// 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 コンストラクトで扱えないためエスケープハッチしています。

// 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 が起こりがちかと思います。

cicd-stack.ts

シンプルにパイプラインの中身の要素やそのロールを逆算して順番に定義しています。ただし前述のクリーンアップ処理に関しては、コンテキストで設定したフラグ値を元に構築するリソースを切り替えるような実装にしており、context.cloudfrontConfig.stagingDistributionCleanupEnabledtrue の場合しか synthesize されないリソースがあります (該当コード)

ポイントとしては、CodeBuild の環境変数は SSM パラメータストアを参照するように構成できるので、この機能を以下のように利用しています。

// 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 はリポジトリのルートに配置しますが、今回は数も多いのでディレクトリを掘っています。

./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.shsource します。AWS の API は失敗したら早期終了するように制御しています。

common.sh

また CloudFront ディストリビューションの変更がエッジロケーションに伝播するのを待つシチュエーションがあるので、以下のような待機処理も入れています。

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 内に腹持ちしているためです。

frontend.build.sh

...
npm install --prefix "$1"
npm run lint --prefix "$1"
npm run test --prefix "$1" -- --watchAll=false
npm run build --prefix "$1"
...

buildspec はシェルスクリプトをマッピングしているだけです。

buildspec.frontend.build.yml

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 オプションで古いほうにしかないファイルを削除するようにしています。

frontend.deploy.s3.sh

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 されるからです。

スクリプトは以下のようになります。

frontend.deploy.cfcd.sh

Continuous deployment policy の config を作成する際は以下のようにステージングディストリビューションのドメイン名を渡す必要があります。また、ステージングへのアクセスは開発者だけに限定したいため、リクエストの向き先はカスタムヘッダーの有無で制御する方式を採用します。

...
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 は以下。事後プロセスとしてキャッシュ無効化しています。

buildspec.frontend.deploy.yml

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 が用意されています。

frontend.promote.sh

ディストリビューションが InProgress である場合は Deployed になるまで待ち、本番/ステージング両方の ETag を取得して昇格させます。これはダウンタイムなしで実行されます。

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 は以下。

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 パラメータストアで管理するので、初期値を登録しておきます。

$ aws ssm put-parameter --name "/<serviceName>/version/frontend" --value "v1" --type String --overwrite
{
    "Version": 1,
    "Tier": "Standard"
}

このバージョン情報は CloudFront ディストリビューションのオリジンパスとして使用されます。

app-stack.ts

// 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}`,
    }),
    ...
  },
  ...
});

また、ビルド時に環境変数で渡すようにしています。

cicd-stack.ts

// 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 は以下のような簡単なサンプルコードです。ビルド時にバージョン情報を参照します。

App.js

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 に設定したコンテキストが正しいことを確認し、以下コマンドを叩きます。

npx cdk synth --all
npx cdk deploy --all

初回デプロイの時点でパイプラインが起動し、本番ディストリビューションと同じ設定のステージングディストリビューションを作ろうとします。パイプラインを最後まで終わらせておきましょう。

デプロイが成功すると、ブラウザにはこのように描画されます。

S3 バケットにはバージョン情報の初期値であるプレフィックス v1/ のみが存在する状態です。

$ aws s3 ls s3://<serviceName>-website
                           PRE v1/

Continuous deployment を試す

では SSM パラメータストアの値を変更し、React のコードを一部変更してアプリのバージョンが上がったことをシミュレーションし、パイプラインを起動させてみましょう。

$ aws ssm put-parameter --name "/<serviceName>/version/frontend" --value "v2" --type String --overwrite
{
    "Version": 2,
    "Tier": "Standard"
}

React のコードは、わかりやすいようにシンタックスハイライトのテーマを変更します。

  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>
    );
  }

変更をプッシュし、デプロイステージの完了まで待ちます。

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 と絡めながらまた手を動かしてみたいと思います。