はじめに

Amazon CloudFront の継続的デプロイを軸にして、いろいろ書いてきました。最初は紹介記事でした。

次に、継続的デプロイを AWS CodePipeline に載せて自動化しました。シェルスクリプトをたくさん書いて AWS CodeBuild で動かしたので辛かったです。

その後、シェルスクリプトをステートマシン化しました。あとはこのふたつを元々の CodePipeline に統合することで、やりたいことがすべて達成できそうです。

本記事では以下の環境を CDK で構築します。S3 バケットへのデプロイは s3 sync が気軽すぎて引き続き CodeBuild にしてしまいました。

ステージ アクション 説明
Source CodeCommit リポジトリのコード変更を検知して AWS CodePipeline を起動する
Build CodeBuild サンプル React アプリを AWS CodeBuild で Lint, Test, Build する
Deploy CodeBuild s3 sync コマンドを実行してデプロイする
Configure StepFunctions Amazon CloudFront ステージングを作成または有効化し、Continuous deployment を構成する
Approve ManualApproval 手動でリリースを承認する
Promote StepFunctions Amazon CloudFront ディストリビューションを昇格する

概要

この構成のパイプラインが、


こうなります。

リポジトリはこちらです。

Running CloudFront continuous deployment with CodePipeline using Step Functions - nekrassov01/cdk-cfcd-with-sfn

補足

今回 CDK によるリソースの準備では、ステージングと Continuous Deployment Policy のセットアップを行っていません。L2 コンストラクト + エスケープハッチで実現できないか試しはしたのですが、以下の通りなかなかうまくいきませんでした。

  • L2 の Distribution クラスではステージングディストリビューションを作るための staging フラグを直接扱えない
  • ステージングでは CNAME の設定ができないが、CNAME なし + 証明書ありの設定だと以下エラーになった
Error: Must specify at least one domain name to use a certificate with a distribution

ディストリビューションで証明書を使用するには、少なくとも1つのドメイン名を指定する必要があります。

  • Continuous Deployment Policy をプライマリディストリビューションにアタッチしようとしたところ、以下のエラーが発生
"Invalid request provided: AWS::CloudFront::Distribution: Continuous deployment policy is not supported during distribution creation.

ディストリビューション作成時の Continuous deployment policy はサポートされていません。

たいていのエラーはエスケープハッチを駆使することで回避できたのですが、最後のエラーだけは初回デプロイ後に変更を加える必要があります。最終的にはこれがネックになりました。
こういった経緯もあり、CloudFront まわりは以前構築したリソース構成から大きく変更しない方針にしました。つまり、パイプライン側でステージングディストリビューションと Continuous Deployment Policy を制御する方式です。前々回のステートマシン設計はこの背景に基づいています。

詳細

Step Functions 関連の CDK の設定を見ていきます。以下 2 つのステートマシンを作ります。

ステージ やること
Configure ステージングを作成または有効化し、Continuous deployment を構成する
Promote 昇格する

ロールの作成

Configure

まずはステートマシンにアタッチするロールを作成します。

// Create step functions role for cloudfront continuous deployment configuration
const frontendConfigureSfnRole = new iam.Role(this, "FrontendConfigureSfnRole", {
  roleName: `${serviceName}-frontend-configure-sfn-role`,
  assumedBy: new iam.ServicePrincipal("states.amazonaws.com"),
  inlinePolicies: {
    ["FrontendConfigureSfnRoleAdditionalPolicy"]: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["ssm:GetParameter", "ssm:PutParameter"],
          resources: [`arn:aws:ssm:${this.region}:${this.account}:*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:GetBucketAcl", "s3:PutBucketAcl"],
          resources: [cloudfrontLogBucket.bucketArn, cloudfrontLogBucket.bucketArn + "/*"],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "cloudfront:GetDistribution",
            "cloudfront:GetDistributionConfig",
            "cloudfront:CreateDistribution",
            "cloudfront:UpdateDistribution",
            "cloudfront:CopyDistribution",
          ],
          resources: [`arn:aws:cloudfront::${this.account}:distribution/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "cloudfront:GetContinuousDeploymentPolicy",
            "cloudfront:CreateContinuousDeploymentPolicy",
            "cloudfront:UpdateContinuousDeploymentPolicy",
          ],
          resources: [`arn:aws:cloudfront::${this.account}:continuous-deployment-policy/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "xray:PutTraceSegments",
            "xray:PutTelemetryRecords",
            "xray:GetSamplingRules",
            "xray:GetSamplingTargets",
          ],
          resources: ["*"],
        }),
      ],
    }),
  },
});

このロールには以下の権限を設定しています。必要に応じてリソースを絞り込めばよいでしょう。

  • SSM パラメータストアを読み書きするための権限
  • CloudFront がログバケットにアクセスするための権限
  • ステージングを準備するための権限
  • Continuous Deployment Policy を操作するための権限
  • ステートマシンをマネコンで作成した際に設定される X-Ray のデフォルト権限

Promote

Promote に関しても同様です。

// Create step functions role for frontend promote
const frontendPromoteSfnRole = new iam.Role(this, "FrontendPromoteSfnRole", {
  roleName: `${serviceName}-frontend-promote-sfn-role`,
  assumedBy: new iam.ServicePrincipal("states.amazonaws.com"),
  inlinePolicies: {
    ["FrontendPromoteSfnRoleAdditionalPolicy"]: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["ssm:GetParameter", "ssm:PutParameter"],
          resources: [`arn:aws:ssm:${this.region}:${this.account}:*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:GetBucketAcl", "s3:PutBucketAcl"],
          resources: [cloudfrontLogBucket.bucketArn, cloudfrontLogBucket.bucketArn + "/*"],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "cloudfront:GetDistribution",
            "cloudfront:GetDistributionConfig",
            "cloudfront:UpdateDistribution",
            "cloudfront:GetInvalidation",
            "cloudfront:CreateInvalidation",
          ],
          resources: [`arn:aws:cloudfront::${this.account}:distribution/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "xray:PutTraceSegments",
            "xray:PutTelemetryRecords",
            "xray:GetSamplingRules",
            "xray:GetSamplingTargets",
          ],
          resources: ["*"],
        }),
      ],
    }),
  },
});
  • SSM パラメータストアを読み書きするための権限
  • CloudFront がログバケットにアクセスするための権限
  • ディストリビューションを昇格するための権限
  • ディストリビューションのキャッシュを削除するための権限
  • ステートマシンをマネコンで作成した際に設定される X-Ray のデフォルト権限

ステートマシンの作成

前々回前回でステートマシンを作り切っていたので、CDK で作成する際には ASL をファイルで渡すだけで済みました。ロールを渡すのを忘れないようにしましょう。

Configure

// Create step functions state machine for frontend configure
const frontendConfigureSfn = new stepfunctions.StateMachine(this, "FrontendConfigureSfn", {
  stateMachineName: `${serviceName}-frontend-configure-sfn`,
  definitionBody: stepfunctions.DefinitionBody.fromFile("src/sfn/configure.json", {}), // <= ファイルで渡すだけ
  role: frontendConfigureSfnRole,
});

Promote

// Create step functions state machine for frontend promote
const frontendPromoteSfn = new stepfunctions.StateMachine(this, "FrontendPromoteSfn", {
  stateMachineName: `${serviceName}-frontend-promote-sfn`,
  definitionBody: stepfunctions.DefinitionBody.fromFile("src/sfn/promote.json", {}), // <= ファイルで渡すだけ
  role: frontendPromoteSfnRole,
});

パイプラインアクションの作成

各アクションが必要とする権限のロールは、CDK が以下のような形で作ってくれます。

Configure

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "states:DescribeStateMachine",
                "states:StartExecution"
            ],
            "Resource": "arn:aws:states:<region>:<account>:stateMachine:cfcd-test-frontend-configure-sfn",
            "Effect": "Allow"
        },
        {
            "Action": "states:DescribeExecution",
            "Resource": "arn:aws:states:<region>:<account>:execution:cfcd-test-frontend-configure-sfn:*",
            "Effect": "Allow"
        }
    ]
}

Promote

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "states:DescribeStateMachine",
                "states:StartExecution"
            ],
            "Resource": "arn:aws:states:<region>:<account>:stateMachine:cfcd-test-frontend-promote-sfn",
            "Effect": "Allow"
        },
        {
            "Action": "states:DescribeExecution",
            "Resource": "arn:aws:states:<region>:<account>:execution:cfcd-test-frontend-promote-sfn:*",
            "Effect": "Allow"
        }
    ]
}

今回は命名を制御したいので、空のロールを作ることで上記ポリシーがインラインで設定されるようにします。

Configure

// Create step functions configure role for frontend
const frontendConfigureActionRole = new iam.Role(this, "FrontendConfigureActionRole", {
  roleName: `${serviceName}-frontend-configure-action-role`,
  assumedBy: new iam.ArnPrincipal(`arn:aws:iam::${this.account}:root`),
});

Promote

// Create step fucntions promote role for frontend
const frontendPromoteActionRole = new iam.Role(this, "FrontendPromoteActionRole", {
  roleName: `${serviceName}-frontend-promote-action-role`,
  assumedBy: new iam.ArnPrincipal(`arn:aws:iam::${this.account}:root`),
});

パイプラインアクションでは StepFunctionInvokeAction を使います。ここでは StateMachineInput が特に重要です。SSM パラメータストアのキーを渡したりしています。

Configure

// Create frontend pipeline action for configure stage
const frontendConfigureAction = new codepipeline_actions.StepFunctionInvokeAction({
  actionName: configureStageName,
  StateMachineInput: codepipeline_actions.StateMachineInput.literal({
    ParameterKeyFrontendVersion: `/${serviceName}/version/frontend`,
    ParameterKeyStagingDistributionId: `/${serviceName}/cloudfront/cfcd-staging`,
    PrimaryDistributionId: primaryDistributionId,
  }),
  stateMachine: frontendConfigureSfn,
  role: frontendConfigureActionRole,
  runOrder: 1,
});

Promote

// Create frontend pipeline action for promote stage
const frontendPromoteAction = new codepipeline_actions.StepFunctionInvokeAction({
  actionName: promoteStageName,
  StateMachineInput: codepipeline_actions.StateMachineInput.literal({
    ParameterKeyStagingDistributionId: `/${serviceName}/cloudfront/cfcd-staging`,
    PrimaryDistributionId: primaryDistributionId,
  }),
  stateMachine: frontendPromoteSfn,
  role: frontendPromoteActionRole,
  runOrder: 1,
});

パイプラインの作成

あとはパイプラインに各ステージをマッピングすれば OK です。

// Create frontend pipeline
const frontendPipeline = new codepipeline.Pipeline(this, "FrontendPipeline", {
  pipelineName: `${serviceName}-frontend-pipeline`,
  pipelineType: codepipeline.PipelineType.V2,
  role: frontendPipelineRole,
  artifactBucket: frontendArtifactBucket,
});
frontendPipeline.addStage({
  stageName: sourceStageName,
  actions: [frontendSourceAction],
});
frontendPipeline.addStage({
  stageName: buildStageName,
  actions: [frontendBuildAction],
});
frontendPipeline.addStage({
  stageName: deployStageName,
  actions: [frontendDeployAction],
});
frontendPipeline.addStage({
  stageName: configureStageName,
  actions: [frontendConfigureAction],
});
frontendPipeline.addStage({
  stageName: approveStageName,
  actions: [frontendApproveAction],
});
frontendPipeline.addStage({
  stageName: promoteStageName,
  actions: [frontendPromoteAction],
});

動かしてみる

では動作確認します。スタックはすでにデプロイされている前提です。以下のような画面です。

React のサンプルアプリに変更を加えます。

  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 { solarizedDark } 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}>
+       <SyntaxHighlighter language="json" style={solarizedDark}>
          {JSON.stringify({ version: process.env.REACT_APP_VERSION_FRONTEND }, null, 2)}
        </SyntaxHighlighter>
      </div>
    );
  }

  export default App;

アプリケーションのバージョン情報は SSM パラメータストアで管理しているので、値を変更します。この情報はオリジンパスとして使用され、かつ S3 へのデプロイ時に新しいバージョンのプレフィックスとして設定されます。

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

変更を Push します。ステージングに反映され、手動承認ステージで止まるはずです。

git add src/s3/hosting/src/App.js
git commit -m "test: run pipeline"
git push origin main

AWS Step Functions による Configure アクションをパスし、手動承認で止まりました。

初回起動時

2 回目以降起動時

続いて Requestly という Chrome 拡張を使い、ヘッダーを出し分けて動作確認します。

ヘッダーなし (Requestly: Off)

ヘッダーあり (Requestly: On)

ヘッダーの有無でリクエストの向き先が変わっているのがわかります。この状態で開発者はテストを実施し、OK であれば承認して昇格を走らせます。

成功しました!

ヘッダーなし (Requestly: Off) でも v2 になっています。

おわりに

もともと ASL を整備していたのもあると思いますが、AWS CodePipeline と AWS Step Functions の組み合わせの体験がよく、もっといろいろなユースケースをためしてみたくなりました。とはいえ、以下の課題も少し残っています。

  • アプリバージョンがパラメータストア管理であり、更新を失念してもパイプラインはそのまま走ってしまう
  • S3 へのデプロイを引き続き AWS CodeBuild で妥協している

これらについても最適化できないか、機会があれば検証してみたいと思います。