CloudFrontの継続的デプロイをパイプラインから実行する

| 24 min read
Author: noboru-kudo noboru-kudoの画像

皆さんの現場ではどのようなリリース戦略を採用しているでしょうか?
安全なリリースを実現するために、ブルーグリーンデプロイやA/Bテスト、カナリアリリース等いろんなやり方やプロダクト/サービスがありますね。
もちろんAWSでも様々なリリース戦略をサポートしていますが、今回はCDNを提供するCloudFrontの継続的デプロイという機能を取り上げます。

CloudFrontの継続的デプロイは、プライマリとステージングのCloudFrontディストリビューションを用意してポリシーに基づいてトラフィックを振り分けます。
ステージングからプライマリへの昇格は、マネジメントコンソールやCLIまたはAPIを通して行うことでゼロダウンタイムのリリースを実現します。
とはいえ、このワークフローはCI/CDパイプライン(実態はAPIコール)を通して実行したいですね。
今回はAWSのマネージドサービスであるCodePipelineを使って継続的デプロイのワークフローを検証します。

全体構成

#

まず、今回構築する環境を眺めてみます。
実行環境側とパイプラインに分けて説明します。

実行環境(CloudFront)

#

Runtime Architecture

S3をオリジンとしたよくある構成のWebサイトです。
継続的デプロイで複数バージョンを同時リリースするために、S3上はバージョンごとのフォルダにアプリをデプロイする構成としています[1]

CloudFrontのディストリビューションは2つ用意します。
プライマリ環境が商用トラフィック、ステージング環境が指定した条件で一部トラフィックのみが流れるディストリビューションです。
継続的デプロイでは、ステージング環境のディストリビューションはプライマリ環境とは別ですが、プライマリ環境側のURLを通してアクセスする形になります。つまりエンドユーザー観点では両環境の違いを意識しません。
プライマリ環境への昇格が実行される時点で、ステージング環境の設定がプライマリ環境に上書きされます。

なお、ここでは静的リソースを配置するS3オリジンとプライマリディストリビューションは事前に構築済みで運用中である前提としています。
また、今回の検証では継続的デプロイの動きを確認するためにキャッシュは無効化します[2]

パイプライン

#

CodePipeline Architecture

パイプラインはGitレポジトリ(ここではCodeCommit。事前構築済み)の変更でトリガーされ、そこに配置されている継続的デプロイに関連する環境を構成します(CloudFormation)。
この後でLambda関数(継続的デプロイ有効化関数)を実行します。詳細は後述しますが、作成した継続的デプロイ環境を有効に機能させるために必要な後処理をここで行っています。

これで継続的デプロイが有効になりますので、ステージング環境でテストを行います。
CloudFrontではステージング環境のルーティングとして以下2種類をサポートしています。

  • ヘッダベース:特定のHTTPヘッダが付与されるリクエストのみをルーティング
  • 重みベース:商用トラフィックのうち一定割合のリクエストをルーティング

これらは継続的デプロイのポリシーとして指定する形になります(CloudFormationテンプレートは後述します)。

テスト後は承認プロセスを挟みます。これはCodePipelineが提供する手動承認アクションを利用します。

承認後の最後のプロセス(Promotionステージ)がプライマリ環境への昇格です。これにはLambda関数(プライマリ環境昇格関数)を利用します。
この中でUpdateDistributionWithStagingConfig APIを使ってステージング環境の設定でプライマリ環境を上書きします。
また、ここで継続的デプロイ自体は無効化され、それ以降のステージング環境へのルーティングは停止されます。

なお、ここでは前ステージの承認プロセスで拒否された場合のことは考慮していません。実運用だと継続的デプロイを無効化する等の後処理が別途必要になると思います。

Lambda関数を準備する

#

パイプラインから呼ばれるLambda関数を準備し、デプロイ[3]しておきます。

継続的デプロイ有効化関数

#

ステージング環境向けのCloudFrontディストリビューション/継続的デプロイのCloudFormation適用後の事後処理を行います。
この関数が実行されると継続的デプロイ環境は完成します。

// importは省略

const cfClient = new CloudFrontClient();
const pipelineClient = new CodePipelineClient();
const s3Client = new S3Client();

// CodePipelineより受け取るパラメータ
type UserParams = {
  PrimaryDistributionId: string;
  StagingDistributionId: string;
  ContinuousDeploymentPolicyId: string;
  StaticResourceBucketName: string;
};

// Lambdaイベントハンドラ
export const handler: CodePipelineHandler = async (event) => {
  const params: UserParams = JSON.parse(
    event["CodePipeline.job"].data.actionConfiguration.configuration
      .UserParameters
  );

  try {
    // バケットポリシーにステージンディストリビューションからのアクセスを許可
    const bucketPolicy = await s3Client.send(
      new GetBucketPolicyCommand({ Bucket: params.StaticResourceBucketName })
    );
    if (!bucketPolicy.Policy?.includes(params.StagingDistributionId)) {
      console.info("updating BucketPolicy...");
      await s3Client.send(
        new PutBucketPolicyCommand({
          Bucket: params.StaticResourceBucketName,
          Policy: JSON.stringify({
            Version: "2012-10-17",
            Statement: [
              {
                Effect: "Allow",
                Principal: {
                  Service: "cloudfront.amazonaws.com",
                },
                Action: "s3:GetObject",
                Resource: `arn:aws:s3:::${params.StaticResourceBucketName}/*`,
                Condition: {
                  "ForAnyValue:StringEquals": {
                    "AWS:SourceArn": [
                      `arn:aws:cloudfront::${process.env.AWS_ACCOUNT_ID}:distribution/${params.PrimaryDistributionId}`,
                      `arn:aws:cloudfront::${process.env.AWS_ACCOUNT_ID}:distribution/${params.StagingDistributionId}`,
                    ],
                  },
                },
              },
            ],
          }),
        })
      );
    }

    // 継続的デプロイポリシーとプライマリディストリビューションを関連付け
    const prod = await cfClient.send(
      new GetDistributionCommand({ Id: params.PrimaryDistributionId })
    );
    console.info({ distribution: prod.Distribution });
    if (
      prod.Distribution?.DistributionConfig?.ContinuousDeploymentPolicyId !==
      params.ContinuousDeploymentPolicyId
    ) {
      console.info("updating primary distribution...");
      await cfClient.send(
        new UpdateDistributionCommand({
          Id: params.PrimaryDistributionId,
          DistributionConfig: {
            ...prod.Distribution?.DistributionConfig,
            ContinuousDeploymentPolicyId: params.ContinuousDeploymentPolicyId,
          } as DistributionConfig,
          IfMatch: prod.ETag,
        })
      );
    }

    // 継続的デプロイ有効化
    // 一度プライマリに昇格するとテンプレート上は有効でも実リソースは無効になっている(ドリフト状態)ので2回目以降は都度有効化が必要(テンプレート上変更がないとCloudFormationの適用はスキップされる)
    const policy = await cfClient.send(
      new GetContinuousDeploymentPolicyCommand({
        Id: params.ContinuousDeploymentPolicyId,
      })
    );
    if (
      !policy.ContinuousDeploymentPolicy?.ContinuousDeploymentPolicyConfig
        ?.Enabled
    ) {
      console.info("enabling continuous deployment...");
      await cfClient.send(
        new UpdateContinuousDeploymentPolicyCommand({
          Id: params.ContinuousDeploymentPolicyId,
          ContinuousDeploymentPolicyConfig: {
            ...policy.ContinuousDeploymentPolicy
              ?.ContinuousDeploymentPolicyConfig,
            Enabled: true, // 有効化
          } as ContinuousDeploymentPolicyConfig,
          IfMatch: policy.ETag,
        })
      );
    }

    await pipelineClient.send(
      new PutJobSuccessResultCommand({
        jobId: event["CodePipeline.job"].id,
      })
    );
  } catch (e) {
    console.error({ e });
    await pipelineClient.send(
      new PutJobFailureResultCommand({
        jobId: event["CodePipeline.job"].id,
        failureDetails: {
          type: "JobFailed",
          message: (e as Error).message,
        },
      })
    );
  }
};

多少長いですが、やっている内容は以下の通りです。

  1. (必要な場合)ステージングディストリビューションからS3オリジンへのアクセスを許可(バケットポリシー更新)
  2. (必要な場合)継続的デプロイポリシーを事前構築済みのプライマリディストリビューションにアタッチ
  3. (必要な場合)継続的デプロイポリシーを有効化

基本的には1,2は初回構築時のみ、3は2回目以降の継続的デプロイで実行されます。

なぜ継続的デプロイポリシーの有効化が必要か

継続的デプロイポリシーは前アクションのCloudFormationテンプレートにも含まれており、ここで有効化する必要はないと思われるかもしれません。
ただ、継続的デプロイのAPIを使ってプライマリ環境に昇格すると、継続的デプロイ自体は無効に直接更新されます。
この時点でCloudFormation上はドリフトした状態となります(スタック的には有効、実態のリソースは無効)。
このため、次の継続的デプロイ時にルーティングポリシーの変更がない場合は、更新不要と判断されてスキップされます(つまり有効にならない)。
これを回避して確実に継続的デプロイをするために、ここで改めて継続的デプロイポリシーを有効に更新する必要がありました。

プライマリ環境昇格関数

#

ステージング環境でのテストが承認され、プライマリ環境へ昇格する際に実行される関数です。

// importは省略

const cfClient = new CloudFrontClient();
const pipelineClient = new CodePipelineClient();

type UserParams = {
  PrimaryDistributionId: string;
  StagingDistributionId: string;
};
export const handler: CodePipelineHandler = async (event) => {
  const params: UserParams = JSON.parse(
    event["CodePipeline.job"].data.actionConfiguration.configuration
      .UserParameters
  );

  try {
    const prod = await cfClient.send(
      new GetDistributionCommand({ Id: params.PrimaryDistributionId })
    );
    const staging = await cfClient.send(
      new GetDistributionCommand({
        Id: params.StagingDistributionId,
      })
    );

    // StagingのProductionにConfigをコピー
    await cfClient.send(
      new UpdateDistributionWithStagingConfigCommand({
        Id: params.PrimaryDistributionId,
        StagingDistributionId: params.StagingDistributionId,
        IfMatch: `${prod.ETag}, ${staging.ETag}`,
      })
    );

    await pipelineClient.send(
      new PutJobSuccessResultCommand({
        jobId: event["CodePipeline.job"].id,
      })
    );
  } catch (e) {
    console.error({ e });
    await pipelineClient.send(
      new PutJobFailureResultCommand({
        jobId: event["CodePipeline.job"].id,
        failureDetails: {
          type: "JobFailed",
          message: (e as Error).message,
        },
      })
    );
  }
};

ポイントはUpdateDistributionWithStagingConfigCommandコマンドを送信している部分です。
ここで継続的デプロイの昇格APIを実行しています。

パイプラインを構築する

#

CodePipelineのパイプラインはCloudFormationのテンプレート(pipeline.yml)で構築しました。
長いのでCodePipeline部分のみ抜粋して掲載します。

ファイル全体はこちらから参照できます。

継続的デプロイ環境構築ステージ

#

まず前半の継続的デプロイを有効にする部分です。

  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      ArtifactStore:
        Location: !Ref ArtifactStoreBucket
        Type: S3
      Name: !Ref PipelineName
      RoleArn: !GetAtt [ "CodePipelineRole", Arn]
      Stages:
        # トリガー:Gitレポジトリ(継続的デプロイ構成)
        - Name: Source
          Actions:
            - Name: Source
              ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: "1"
              Configuration:
                BranchName: "main"
                RepositoryName: !Ref RepositoryName
              OutputArtifacts:
                - Name: SourceArtifactOutput
              RunOrder: "1"
        - Name: Deploy
          Actions:
            # ステージング用のディストリビューション、継続的デプロイメントポリシーのプロビジョニング
            - Name: Deploy_to_Staging
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Provider: CloudFormation
                Version: "1"
              InputArtifacts:
                - Name: SourceArtifactOutput
              Configuration:
                ActionMode: CREATE_UPDATE
                Capabilities: CAPABILITY_IAM,CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                RoleArn: !GetAtt CloudFormationRole.Arn
                StackName: !Ref CloudFormationStackName
                TemplatePath: !Sub SourceArtifactOutput::${CloudFormationFileName}
                ParameterOverrides: !Sub
                  - |
                    {
                      "PrimaryDistributionId": "${PrimaryDistributionId}", 
                      "StaticResourceBucketName": "${StaticResourceBucketName}", 
                      "OriginAccessControlId": "${OriginAccessControlId}"
                    }
                  -
                    PrimaryDistributionId: !Ref PrimaryDistributionId
                    StaticResourceBucketName: !Ref StaticResourceBucketName
                    OriginAccessControlId: !Ref OriginAccessControlId
              OutputArtifacts:
                - Name: CloudFormationOutputs
              Namespace: CFOutput
              RunOrder: "1"
            # 継続的デプロイ有効化関数実行
            - Name: Enable_CloudFront_CD
              ActionTypeId:
                Category: Invoke
                Owner: AWS
                Provider: Lambda
                Version: "1"
              InputArtifacts:
                - Name: CloudFormationOutputs
              Configuration:
                FunctionName: !Ref EnableCloudFrontCDLambdaName
                UserParameters: !Sub
                  - |
                    {
                      "PrimaryDistributionId": "${PrimaryDistributionId}",
                      "StagingDistributionId": "#{CFOutput.StagingDistributionId}",
                      "StaticResourceBucketName": "${StaticResourceBucketName}",
                      "ContinuousDeploymentPolicyId": "#{CFOutput.ContinuousDeploymentPolicyId}"
                    }
                  -
                    PrimaryDistributionId: !Ref PrimaryDistributionId
                    StaticResourceBucketName: !Ref StaticResourceBucketName
              RunOrder: "2"
              # 後半に続く...

Sourceステージ後のDeployステージで2つのアクションを定義しています。
1つはGitレポジトリに配置されている継続的デプロイ用のCloudFormationテンプレート適用です(Deploy_to_Stagingアクション)。
このCloudFormationテンプレートの内容については後述しますが、ここでステージング環境のディストリビューションやルーティングポリシー等の継続的デプロイの主要な設定をすることを想定しています。

もう1つはLambda関数(継続的デプロイ有効化関数)実行です(Enable_CloudFront_CDアクション)。
Lambda関数実行に必要な情報はUserParametersとして自CloudFormationのパラメータや前アクションのCloudFormationの出力(CFOutput)から設定しています。

承認・プライマリ環境昇格ステージ

#

続いてパイプラインの後半です。

  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      # 省略
      Stages:
        # 前半ステージ(前述) - 省略
        - Name: ManualApproval
          Actions:
            - Name: ManualApproval
              ActionTypeId:
                Category: Approval
                Owner: AWS
                Provider: Manual
                Version: "1"
              RunOrder: "3"
        - Name: Promotion
          Actions:
            - Name: Promote_to_Production
              ActionTypeId:
                Category: Invoke
                Owner: AWS
                Provider: Lambda
                Version: "1"
              InputArtifacts:
                - Name: CloudFormationOutputs
              Configuration:
                FunctionName: !Ref PromoteLambdaName
                UserParameters: !Sub '{"PrimaryDistributionId": "${PrimaryDistributionId}", "StagingDistributionId": "#{CFOutput.StagingDistributionId}"}'
              RunOrder: "4"

ManualApprovalステージでステージング環境の承認アクションを配置します。ここではCodePipelineのUIで承認することを想定しています。

続くPromotionステージはステージング環境のプライマリ環境への昇格です。これは前述のプライマリ環境昇格関数を実行するだけです。

継続的デプロイを試してみる

#

環境面の準備は整いましたので早速試してみます。
オリジンとなるS3バケットには、1.0.0/2.0.0/3.0.0のフォルダを作成して任意のHTML(index.html)を配置しておきます。
S3

以下のように現時点のプライマリ環境ではv1のHTMLが返ってきます。

PRIMARY_DISTRIBUTION_ID=xxxxxxxxxx
DOMAIN_NAME=$(aws cloudfront get-distribution --id ${PRIMARY_DISTRIBUTION_ID} --query "Distribution.DomainName" --output text)

curl https://${DOMAIN_NAME}/index.html

> <!DOCTYPE html><html lang="ja"><body><h1>v1 App</h1></body></html>

これを順次v2、v3へパイプラインを使ってアップデートしていきます。

HTTPヘッダベースルーティング(v1 -> v2)

#

まずは特定のHTTPヘッダの場合にステージング環境へルーティングするようにしてみます。
以下の継続的デプロイのCloudFormationテンプレート(staging-cloudfront-distribution.yml)をGitレポジトリに配置します。

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Staging CloudFront Distribution'

Parameters:
  PrimaryDistributionId:
    Type: String
  StaticResourceBucketName:
    Type: String
  OriginAccessControlId:
    Type: String

Resources:
  # 継続的デプロイポリシー
  ContinuousDeploymentPolicy:
    Type: AWS::CloudFront::ContinuousDeploymentPolicy
    Properties:
      ContinuousDeploymentPolicyConfig:
        Enabled: true
        StagingDistributionDnsNames:
          - !GetAtt SampleWebSiteDistributionStaging.DomainName
        # ステージング環境へのルーティング設定
        TrafficConfig:
          SingleHeaderConfig:
            Header: aws-cf-cd-env
            Value: staging
          Type: SingleHeader

  # ステージング用のディストリビューション
  SampleWebSiteDistributionStaging:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Staging: true
        HttpVersion: http2
        DefaultCacheBehavior:
          TargetOriginId: website-resources
          # AWS Managed Cache Policy(CachingDisabled)
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          ViewerProtocolPolicy: redirect-to-https
        Origins:
          - Id: website-resources
            DomainName: !Sub "${StaticResourceBucketName}.s3.${AWS::Region}.amazonaws.com"
            OriginAccessControlId: !Ref OriginAccessControlId
            S3OriginConfig: {}
            # ステージング環境はバージョン2.0.0のアプリ(プライマリ環境は1.0.0)
            OriginPath: /2.0.0
# 後続のアクションで使用
Outputs:
  StagingDistributionId:
    Value: !GetAtt SampleWebSiteDistributionStaging.Id
  ContinuousDeploymentPolicyId:
    Value: !GetAtt ContinuousDeploymentPolicy.Id

ContinuousDeploymentPolicyリソースで継続的デプロイのルーティング設定(TrafficConfig)をします。
今回はヘッダベースなのでType: SingleHeaderとし、SingleHeaderConfigにステージング環境へルーティングするヘッダ名と値を指定します。
ここではaws-cf-cd-envというヘッダで値にstagingが設定されたリクエストをルーティング対象としました。
なお、CloudFrontの継続的デプロイのヘッダ名はaws-cf-cd-で始まる必要があります。

次にパイプラインを構築します。前述のパイプラインのCloudFormationスタックを作成します。
ここではCLIから実施しますが、マネジメントコンソールでも構いません。

aws cloudformation create-stack --stack-name cloudfront-cd-pipeline --template-body file://pipeline.yml \
  --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
  --parameters ParameterKey=RepositoryName,ParameterValue=xxxxxx \
               ParameterKey=PrimaryDistributionId,ParameterValue=XXXXXXXXXXXXXX \
               ParameterKey=PromoteLambdaName,ParameterValue=cloudfront-cd-tools-dev-promote \
               ParameterKey=EnableCloudFrontCDLambdaName,ParameterValue=cloudfront-cd-tools-dev-enableCloudFrontCD \
               ParameterKey=StaticResourceBucketName,ParameterValue=cloudfront-cd-primary-distrib-staticresourcebucket-xxxxxxxxxx \
               ParameterKey=OriginAccessControlId,ParameterValue=XXXXXXXXXXXXXX

上記の各パラメータは、事前に構築済みのものを指定する必要があります。

パラメータ名 内容
RepositoryName Gitレポジトリ名
PrimaryDistributionId プライマリディストリビューションのID
EnableCloudFrontCDLambdaName 継続的デプロイ有効化関数
PromoteLambdaName プライマリ環境昇格関数
StaticResourceBucketName 静的リソース格納バケット名
OriginAccessControlId プライマリディストリビューションのオリジンアクセスコントロールのID

CloudFormationの実行が終わると、以下のコードパイプラインが作成されています。

Pipeline picture

初回はそのままパイプラインが実行され、先ほど作成した継続的デプロイポリシーをもとにステージング環境が構築されます。
成功すると承認ステージ(ManualApprove)でパイプラインの実行が止まります。

Pending stage

マネジメントコンソールからCloudFrontディストリビューションの状態を確認してみます。

  • プライマリ環境
    primary distribution cd
  • ステージング環境
    staging distribution cd

両環境のディストリビューションで継続的デプロイポリシーが関連づいています。
この状態で、指定したHTTPヘッダ(aws-cf-cd-env:staging)でアクセスすると、プライマリ環境ではなくステージング環境にトラフィックが流れます。

PRIMARY_DISTRIBUTION_ID=xxxxxxxxxx
DOMAIN_NAME=$(aws cloudfront get-distribution --id ${PRIMARY_DISTRIBUTION_ID} --query "Distribution.DomainName" --output text)

# プライマリ環境(v1)
curl https://${DOMAIN_NAME}/index.html
> <!DOCTYPE html><html lang="ja"><body><h1>v1 App</h1></body></html>

# ステージング環境(v2)
curl -H "aws-cf-cd-env:staging" https://${DOMAIN_NAME}/index.html
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>

ヘッダ付きの方はステージング環境向けに配置してHTMLが返ってきます。ヘッダがないリクエストはステージング環境に流れることはなく、プライマリ環境つまり商用運用中の環境に影響を与えることはありません。

それでは、ステージング環境(v2)でのテストが終わったと仮定して承認します。これはCodePipelineのUIから行います。

保留中になっているアクションから「レビュー」をクリックすると以下のダイアログが表示されます。
「承認します」を選択し、コメント(任意)を記入して「送信」をクリックします。

Approve release

パイプラインが再開し、Promotionステージでプライマリ環境昇格関数が実行されます。

Promote

Promotionステージが終わって再度curlを実行すると今度はプライマリ環境でもv2のHTMLが返ってきます。

curl https://${DOMAIN_NAME}/index.html
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>

マネジメントコンソールからプライマリ環境ディストリビューションの設定を確認します。
Primary Distribution v2

オリジンパスが1.0.0から2.0.0と変わっています。ステージング環境の設定でプライマリ環境が上書きされていることが分かります。

重みベースルーティング(v2 -> v3)

#

一通りの流れを確認できましたので、次はもう1つのルーティングポリシーである重みベースの方も見てみます。

Gitレポジトリに配置する継続的デプロイのCloudFormationテンプレートは以下になります。

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Staging CloudFront Distribution'

Parameters:
  PrimaryDistributionId:
    Type: String
  StaticResourceBucketName:
    Type: String
  OriginAccessControlId:
    Type: String

Resources:
  # 継続的デプロイポリシー
  ContinuousDeploymentPolicy:
    Type: AWS::CloudFront::ContinuousDeploymentPolicy
    Properties:
      ContinuousDeploymentPolicyConfig:
        Enabled: true
        StagingDistributionDnsNames:
          - !GetAtt SampleWebSiteDistributionStaging.DomainName
        # ステージング環境へのルーティング設定 -> 重みベースに変更
        TrafficConfig:
          SingleWeightConfig:
            Weight: 0.15 # 15%のトラフィックをステージング環境に転送(0 - 15%)
            SessionStickinessConfig: # スティッキーセッション有効化
              IdleTTL: 300 # 5分アクセスがなければ無効
              MaximumTTL: 600 # 最大10分間有効
          Type: SingleWeight

  # ステージング用のディストリビューション
  SampleWebSiteDistributionStaging:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Staging: true
        HttpVersion: http2
        DefaultCacheBehavior:
          TargetOriginId: website-resources
          # AWS Managed Cache Policy(CachingDisabled)
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          ViewerProtocolPolicy: redirect-to-https
        Origins:
          - Id: website-resources
            DomainName: !Sub "${StaticResourceBucketName}.s3.${AWS::Region}.amazonaws.com"
            OriginAccessControlId: !Ref OriginAccessControlId
            S3OriginConfig: {}
            # 2.0.0 -> 3.0.0
            OriginPath: /3.0.0
# 後続のアクションで使用
Outputs:
  StagingDistributionId:
    Value: !GetAtt SampleWebSiteDistributionStaging.Id
  ContinuousDeploymentPolicyId:
    Value: !GetAtt ContinuousDeploymentPolicy.Id

ポイントはContinuousDeploymentPolicyリソースのTrafficConfigです。
今回は重みベースのルーティング設定であるType: SingleWeightを指定し、SingleWeightConfigに詳細な設定をします。
Weightでステージング環境へ振り向ける割合を指定します。
理由はよく分かりませんが、現時点では最大15%の割合までしかステージング環境へのルーティングはできないようです。

継続的デプロイはスティッキーセッション(SessionStickinessConfig)もサポートしており、クライアントは一定期間は同一環境に振り向けられます。
ブラウザベースのアプリケーションでは、環境が混在しないよう通常は有効にする形になると思います(上記は検証のため短めの値を指定しています)。

こちらをGitレポジトリにコミットします。
これをトリガーに再度パイプラインが再開し、先ほど同様にManualApprovalで保留中となり、継続的デプロイが有効になります。

curlで確認して見ると、指定した割合(ここでは15%)でステージング環境(v3)にルーティングされている様子が分かります。

for i in {1..10}; do curl https://${DOMAIN_NAME}/index.html; sleep 1; done

> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v3 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v2 App</h1></body></html>
> <!DOCTYPE html><html lang="ja"><body><h1>v3 App</h1></body></html>

なお、筆者が検証した環境では、実際にステージング環境にも振り分けられるようになるまで10分ほど時間がかかりました。

上記はcurlコマンドのためスティッキーセッションが効いていませんが、ブラウザで確認してみると同一クライアントの場合は最大TTLまでは同一環境(v2 or v3)が表示されます。
Chrome DevToolで確認してみると、これにはx-amz-continuous-deployment-stateというセッションCookieが利用されているようです。

chrome dev tools

このCookieを削除すればセッションはリセットされて再度指定した割合で振り向けられます。

最後に

#

少し長い記事になってしまいましたが、CloudFrontの継続的デプロイをCodePipelineから実行できました。
今回やってみて感じたのは、この継続的デプロイ機能はIaCツールと相性があまりよくないかなと思いました。
プライマリ環境への昇格APIはプライマリディストリビューションの設定更新や継続的デプロイポリシーの無効化とリソースの状態を直接変更してしまいます。
このためCloudFormationテンプレートからのドリフトが発生して、意図しない動作でハマったりしました[4]

GitOpsの原則に徹するのであれば、継続的デプロイはトラフィックルーティングだけに限定し、昇格はAPIではなくプライマリ環境のCloudFormationテンプレート更新という形でもいいのかなと思ったりもしました。


参考記事・レポジトリ


  1. ステージング、プライマリディストリビューションでオリジンを分ける方法でもいいと思います。 ↩︎

  2. キャッシュはある程度長い時間有効にしている場合が多いかと思います。実運用だと早期の切り替えのために適宜キャッシュを無効化する必要がありそうです。 ↩︎

  3. ここではデプロイ方法については特に触れていませんが、マネジメントコンソール、CloudFormationやAWS CDK、Serverless Framework等何でも構いません。 ↩︎

  4. それ以外でもプライマリ環境のディストリビューション作成時に継続的デプロイを同時に作ろうとするとエラーになったりしました(別途更新が必要)。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。