はじめに

以下の記事では CloudFront + S3 でホストされたアプリケーションを Blue/Green デプロイする方法について解説しました。

CloudFront の継続的デプロイをパイプライン化してみた

今回はそれに関連して、CodePipeline の手動承認ステージで Reject した場合に任意の処理を走らせる方法を紹介します。サンプルリポジトリはこちらです。

概要

図の下側に生えている網掛け部分が対象です。クリーンアップ処理で使うスクリプトは、パイプラインの Cleanup ステージで使っているものを流用するために CodeBuild で実装しています。

実装

GitHub へのリンクとコードの紹介です。

cicd-stack.ts

コンテキストで設定した cloudfrontConfig.stagingDistributionCleanupEnabledtrue の場合にクリーンアップ用のリソースが synthesize されます。

ロールの作成

CodeBuild プロジェクトにアタッチするロールを作ります。ポイントとしては ListContinuousDeploymentPolicies の許可が必要でした。

// Create codebuild project role when approval failed
const frontendPurgeProjectRole = new iam.Role(this, "FrontendPurgeProjectRole", {
  roleName: `${serviceName}-frontend-purge-project-role`,
  assumedBy: new iam.ServicePrincipal("codebuild.amazonaws.com"),
  inlinePolicies: {
    ["FrontendPurgeProjectRoleAdditionalPolicy"]: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["ssm:GetParameter", "ssm:GetParameters", "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:DeleteDistribution",
            "cloudfront:UpdateDistribution",
            "cloudfront:GetInvalidation",
            "cloudfront:CreateInvalidation",
          ],
          resources: [`arn:aws:cloudfront::${this.account}:distribution/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "cloudfront:GetContinuousDeploymentPolicy",
            "cloudfront:DeleteContinuousDeploymentPolicy",
            "cloudfront:ListContinuousDeploymentPolicies", // <- ココ
          ],
          resources: [`arn:aws:cloudfront::${this.account}:continuous-deployment-policy/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["wafv2:GetWebACL"],
          resources: [webAclArn],
        }),
      ],
    }),
  },
});

CodeBuild プロジェクトの作成

CodeBuild ではやはり環境変数が重要です。SSM パラメータストアから CloudFront のディストリビューション ID を取得します。

// Create codebuild project when approval failed
const frontendPurgeProject = new codebuild.Project(this, "FrontendPurgeProject", {
  projectName: `${serviceName}-frontend-purge-project`,
  source: codebuild.Source.codeCommit({
    repository: codeCommitRepository,
    branchOrRef: branch,
    cloneDepth: 1,
  }),
  buildSpec: codebuild.BuildSpec.fromSourceFilename(`${buildspecDir}/buildspec.frontend.cleanup.yml`),
  environment: {
    buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_4,
  },
  environmentVariables: {
    SERVICE: {
      type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
      value: serviceName,
    },
    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`,
    },
  },
  badge: false,
  role: frontendPurgeProjectRole,
  logging: {
    cloudWatch: {
      logGroup: new logs.LogGroup(this, "FrontendPurgeProjectLogGroup", {
        logGroupName: `/${serviceName}/codebuild/frontend-purge-project`,
        removalPolicy: RemovalPolicy.DESTROY,
        retention: logs.RetentionDays.THREE_DAYS,
      }),
    },
  },
});

EventBridge ルールのターゲットにアタッチするロール

これは作らなくても裏でいい感じに作ってくれるのですが、命名をちゃんとしたいので作成しています。ポリシーは何も定義しなければ CDK が判断したデフォルトのものだけが適用されます。

// Create event role for frontend cleanup project when apploval failed
const frontendPurgeEventRole = new iam.Role(this, "frontendPurgeEventRole", {
  roleName: `${serviceName}-frontend-purge-event-role`,
  assumedBy: new iam.ServicePrincipal("events.amazonaws.com"),
});

EventBridge ルール

今回の肝ですが、パイプラインの手動承認ステージで Reject した時に任意の処理をフックするためのイベントパターンを作ります。

こういうイベントパターンが必要なので、

{
  "detail-type": ["CodePipeline Action Execution State Change"],
  "resources": ["arn:aws:codepipeline:\:\:\"],
  "source": ["aws.codepipeline"],
  "detail": {
    "stage": ["Approve"],
    "action": ["Approve"],
    "state": ["FAILED"] // <- Reject すると状態が FAILED になるのを利用
  }
}

こんな感じで書きます。

// Create eventbridge rule when approval failed
const frontendPurgeEventRule = new events.Rule(this, "FrontendPurgeEventRule", {
  enabled: true,
  ruleName: `${serviceName}-frontend-purge-rule`,
  eventPattern: {
    source: ["aws.codepipeline"],
    detailType: ["CodePipeline Action Execution State Change"],
    resources: [frontendPipeline.pipelineArn],
    detail: {
      stage: [approveStageName],
      action: [approveStageName],
      state: ["FAILED"],
    },
  },
});

Event ターゲット

最後にイベントのターゲットに CodeBuild プロジェクトを紐付け、イベントが発生した時に発火するようにします。

frontendPurgeEventRule.addTarget(
  new events_targets.CodeBuildProject(frontendPurgeProject, {
    eventRole: frontendPurgeEventRole,
  })
);

クリーンアップ用スクリプト

スクリプトの作成もまぁ大変でしたが、使い回しが効くので作っておくと便利です。

frontend.cleanup.sh

共通関数を以下で定義しています。

common.sh

CloudFront ディストリビューションが InProgress である場合は Deployed になるまで待機します。

echo "PROCESS: Waiting for CloudFront distribution changes to propagate to edge locations."
wait_distribution_deploy "$PRODUCTION_DISTRIBUTION_ID"
wait_distribution_deploy "$STAGING_DISTRIBUTION_ID"

本番ディストリビューションから Continuous deployment policy をデタッチします。

echo "PROCESS: Detaching continuous deployment policy from CloudFront production distribution."
prod_distribution_config=$(get_distribution_config "$PRODUCTION_DISTRIBUTION_ID")
continuous_deployment_policy_id=$(jq -r ".DistributionConfig.ContinuousDeploymentPolicyId" <<<"$prod_distribution_config")
prod_distribution_etag=$(jq -r ".ETag" <<<"$prod_distribution_config")
updated_prod_distribution_config=$(jq ".DistributionConfig.ContinuousDeploymentPolicyId = \"\" | .DistributionConfig" <<<"$prod_distribution_config")
update_distribution "$PRODUCTION_DISTRIBUTION_ID" "$updated_prod_distribution_config" "$prod_distribution_etag"
wait_distribution_deploy "$PRODUCTION_DISTRIBUTION_ID"

Continuous deployment policy を削除します。

echo "PROCESS: Deleting continuous deployment policy."
continuous_deployment_policy_etag=$(get_continuous_deployment_policy_etag "$continuous_deployment_policy_id")
delete_continuous_deployment_policy "$continuous_deployment_policy_id" "$continuous_deployment_policy_etag"

ステージングディストリビューションを無効化します。

echo "PROCESS: Disabling CloudFront staging distribution."
stg_distribution_config=$(get_distribution_config "$STAGING_DISTRIBUTION_ID")
stg_distribution_etag=$(jq -r ".ETag" <<<"$stg_distribution_config")
updated_stg_distribution_config=$(jq ".DistributionConfig.Enabled = false | .DistributionConfig" <<<"$stg_distribution_config")
stg_distribution=$(update_distribution "$STAGING_DISTRIBUTION_ID" "$updated_stg_distribution_config" "$stg_distribution_etag")
wait_distribution_deploy "$STAGING_DISTRIBUTION_ID"

ステージングディストリビューションを削除します。

echo "PROCESS: Deleting CloudFront staging distribution."
stg_distribution_etag=$(jq -r ".ETag" <<<"$stg_distribution")
delete_distribution "$STAGING_DISTRIBUTION_ID" "$stg_distribution_etag"

最後に、ステージングディストリビューションが削除されたことを SSM パラメータストアに記録します。

echo "PROCESS: Putting string literal 'deleted' to SSM parameter store."
put_ssm_parameter "/$SERVICE/cloudfront/cfcd-staging" "deleted"

おわりに

CodePipeline の手動承認ステージで Reject した際に任意の処理をフックしたいケースについて紹介しました。あまり一般的なケースではないですが、知っておいて損はないかと思います。