はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

AWS CDK (Cloud Development Kit) を使用している案件において、CDK Pipelines による CI/CDパイプラインを実装しました。

公式ドキュメント がなかなか難解で概念を掴みづらく、また、チュートリアルどおりに作る程度では実運用に投入できるほどの仕上がりにはならないため、業務できっちり使えるレベルになるよう丁寧に設計してみました。

CDK Pipelines とは

CDK Pipelines とは、CDKプロジェクト に適したパイプラインをお手軽に構築するための抽象化されたモジュールです。
CDKプロジェクトをデプロイするために必要な一連のステップを組み込んだパイプラインを一手で作成できます。

似たモジュールに CodePipelineモジュール がありますが、こちらは CodePipeline をきめ細かく扱うための低レイヤーのモジュールです。

CDK Pipelines の大きな特徴として、パイプライン実行中に自分自身 (= パイプライン自体の構成) を更新する点が挙げられます。
これを Self mutation と呼びます。
CDK Pipelines で構築したパイプラインには、自動でこの Self mutation のステップが組み込まれます。

Self mutation を実行したあとに、CDK による本来の管理対象の構成がデプロイされます。

この記事では、この本来の構成のことを ターゲットStack と呼び、一方で、パイプラインの定義は パイプラインStack と呼ぶことにします。

初回はスタッフがローカルマシン上で $ cdk deployコマンドを叩き、手動でパイプラインを構築します。
それ以降はリポジトリへの push をトリガーにしてパイプラインが起動します。

ステージ処理内容
1Sourceデプロイ対象の CDKプロジェクトをリポジトリから取得する。
2BuildStack synthesis を実行し、CDKプロジェクトから CloudFormationテンプレートと付随するアーティファクトを生成する。
3UpdatePipelineパイプラインStack をデプロイする。(= Self mutation)
4<ステージ名>ターゲットStack をデプロイする。

#4 のステージ名は CDKコード上で実装者が任意に定義しますが、それ以外のステージの名称は固定です。

#3 と #4 のデプロイに使用する CloudFormationテンプレートは、#2 で一括して生成されます。

CDKプロジェクト中の各コードとパイプライン定義は以下のようにマッピングされます。

シナリオ

CDK Pipelines は簡単にパイプラインを構築できるため非常に便利ですが、組織によってはそのまま実運用へ投入するには適さない場合もあります。

ここでは、“堅実なリリース体制を敷いている手堅い組織” を想定し、以下のようなパイプライン構成を考えます。

チームの分離

この組織では、インフラ全般を管理するインフラチームとアプリケーション開発を担当するアプリチームが協働しています。
どちらのチームも、CDK を使用してそれぞれの担当領域の構成管理を行いますが、操作する対象が異なるためチームに割り当てられる IAM権限は異なります。

そのため、各チームは自分たち専用のパイプラインを持ち、また、それぞれのパイプラインにはチームに応じた権限が付与されます。

環境ごとのパイプライン

アカウント設計のベストプラクティスに基づき、ステージング環境と本番環境は別々の AWSアカウントに分離されています。

単一のパイプラインで “ステージング » 自動テスト » 本番” というフローを構成することもできますが、各環境に対してデプロイを行うタイミングが異なるケースが多いです。
(例えば、ステージング環境で手動検証の時間を取りたい場合など)

そのため、それぞれのアカウント上にその環境専用のパイプラインを設けることとします。

ステージング用のブランチを staging、本番用のブランチを production とし、これらのブランチへの push をトリガーに、対応するパイプラインが起動する仕組みとします。

承認ステップ

予期しない変更が反映されないよう、本番環境へデプロイされる前にその差分を表示して、問題ないことを目視で確認する手動承認ステップを追加します。

パイプラインの実行ログに差分が記録されるように設定すれば、問題が発生した場合でも状況を追跡しやすくなります。

ステータス通知

パイプラインの実行開始や成功、承認要求など、実行ステータスをリアルタイムにチームへ通知します。

ビルドログの保持期間

この組織では、各種ログの保持期間は社内の規程に基づいて標準化されています。
そのため、ビルドログも同様に、この規程に準拠して適切な期間だけ保持します。

対応方針

上記のシナリオに基づき、具体的な対応方針を検討しました。

なお、チュートリアル にしたがって初期構築したパイプラインのコードを以下に示します。
このコードを出発点として改良を加えていきます。

lib/pipeline-stack.ts
import { Stack } from "aws-cdk-lib";
import {
  CodePipeline,
  CodePipelineSource,
  ShellStep,
} from "aws-cdk-lib/pipelines";
import { TargetStage } from "./target-stage";

import type { StackProps } from "aws-cdk-lib";
import type { Construct } from "constructs";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      synth: new ShellStep("Synth", {
        input: CodePipelineSource.gitHub("<リポジトリ>", "<ブランチ>"),
        commands: [
          "npm ci",
          "npm run build",
          "npx cdk synth",
        ],
      }),
    });

    codepipeline.addStage(
      new TargetStage(this, "TargetStage", {
        env: props?.env,
      })
    );
  }
}

CloudFormation の実行権限を制限する

CloudFormation が実行時に行使できる権限は、ブートストラップ のタイミングで指定します。

権限は以下の 2種類の オプション で制御できます。

オプション内容デフォルト値
--cloudformation-execution-policiesCloudFormation に割り当てられるロールに対して付与される IAMポリシーAdministratorAccess (= フル権限)
--custom-permissions-boundary上記ロールにセットされる Permissions boundary(なし)

特に有効なのが Permissions boundary で強制的にガードレールを敷く方法です。

Permissions boundary を適切に定義することで、CDK が生成するすべての IAMロールに漏れなくガードレールをセットできます。
これにより、CDK Pipelines を通してスタッフが自身の権限を超えた IAMロールを作成してしまうインシデントを確実に防ぐことが可能となります。
(参考記事)

Permissions boundary としてセットするポリシー名を pb-policy とした場合、あらかじめ以下の内容でポリシーを作成したうえで、

pb-policy
{
  "Version": "2012-10-17",
  "Statement": [
    //
    // Allow: まずはとりあえずフル権限を与える。
    //
    {
      "Sid": "ExplicitAllowAll",
      "Action": "*",
      "Effect": "Allow",
      "Resource": "*"
    },

    //
    // Deny: 指定の Permissions boundary がセットされていない IAMエンティティの作成を許可しない。
    //
    {
      "Sid": "DenyAccessIfRequiredPermBoundaryIsNotBeingApplied",
      "Action": [
        "iam:CreateUser",
        "iam:CreateRole",
        "iam:PutRolePermissionsBoundary",
        "iam:PutUserPermissionsBoundary"
      ],
      "Condition": {
        "StringNotEquals": {
          "iam:PermissionsBoundary": {
            "Fn::Sub": "arn:${ AWS::Partition }:iam::${ AWS::AccountId }:policy/pb-policy"
          }
        }
      },
      "Effect": "Deny",
      "Resource": "*"
    },

    //
    // Deny: Permissions boundary の変更/削除を許可しない。
    // 
    {
      "Sid": "DenyPermBoundaryIAMPolicyAlteration",
      "Action": [
        "iam:CreatePolicyVersion",
        "iam:DeletePolicy",
        "iam:DeletePolicyVersion",
        "iam:SetDefaultPolicyVersion"
      ],
      "Effect": "Deny",
      "Resource": {
        "Fn::Sub": "arn:${ AWS::Partition }:iam::${ AWS::AccountId }:policy/pb-policy"
      }
    },

    //
    // Deny: Permissions boundary の解除を許可しない。
    //
    {
      "Sid": "DenyRemovalOfPermBoundaryFromAnyUserOrRole",
      "Action": [
        "iam:DeleteUserPermissionsBoundary",
        "iam:DeleteRolePermissionsBoundary"
      ],
      "Effect": "Deny",
      "Resource": "*"
    },

    //
    // Deny: 他に許可しない操作を好きなだけ列挙する。
    //
    {
      "Sid": "DenyXXX",
      "Action": "xxx:*",
      "Effect": "Deny",
      "Resource": "*"
    }
  ]
}

このポリシーを指定してブートストラップを実行します。

$ cdk bootstrap --custom-permissions-boundary 'pb-policy'

CDK が生成する IAMエンティティに対してこの Permissions boundary が自動でセットされるように、cdk.json に以下の コンテキスト を追加します。

cdk.json
{
  ...
  "context": {
    ...
    "@aws-cdk/core:permissionsBoundary": {
      "name": "pb-policy"
    }
  }
}

これでパイプラインに持たせる権限を適切な範囲に制限することができました。

チームごとに専用パイプラインを用意する

パイプラインが使用する IAMロールは、パイプライン自身が保持するのではなく、ブートストラップ時に作成された ブートストラップStack によって管理されます。
このブートストラップStack はデフォルトでは CDKToolkit という名称で作成され、基本的に 1つの AWSアカウントに 1つだけ配置される想定となっています。

複数のパイプラインを異なる IAMロールで動かしたい場合は、ブートストラップStack も同じ数だけ作成する必要があります。

複数のブートストラップStack を共存させるためには、--qualifier--toolkit-stack-name という オプション を使用してブートストラップを実行します。

オプション内容デフォルト値
--qualifierリソースを一意に識別するための文字列hnb659fds
--toolkit-stack-nameブートストラップStack の名称CDKToolkit

ブートストラップStack が管理するリソースは、そのリソース名の中に --qualifier で指定する文字列が含まれます。
例えば CloudFormation実行時に割り当てられる IAMロールは cdk-<Qualifier>-cfn-exec-role-<AWSアカウントID>-<リージョン> という名前で作成されます。

デフォルト値である hnb659fds という文字列の長さや文字の選択に特別な意味はなく、単に適当な文字の羅列に過ぎません。
そのため、明示的に指定する場合でも適当にランダムな英数字を生成すれば OK です。

今回は以下の要領でブートストラップStack を作成します。

チームStackQualifierPermissions boundary
インフラCDKToolkit-infranqtif7r0f (適当な文字列)infra-admin
アプリケーションCDKToolkit-apphnb659fds (デフォルト)developer

アプリケーションチームの Qualifier をデフォルトのままとしている理由は、Amplify などの CDK に依存するツールがデフォルトのブートストラップStack を要求するためです。

https://github.com/aws-amplify/amplify-backend/blob/%40aws-amplify/sandbox%401.2.0/packages/sandbox/src/file_watching_sandbox.ts#L40-L53

file_watching_sandbox.ts
40/**
41 * CDK stores bootstrap version in parameter store. Example parameter name looks like /cdk-bootstrap/<qualifier>/version.
42 * The default value for qualifier is hnb659fds, i.e. default parameter path is /cdk-bootstrap/hnb659fds/version.
43 * The default qualifier is hardcoded value without any significance.
44 * Ability to provide custom qualifier is intended for name isolation between automated tests of the CDK itself.
45 * In order to use custom qualifier all stack synthesizers must be programmatically configured to use it.
46 * That makes bootstraps with custom qualifier incompatible with Amplify Backend and we treat that setup as
47 * not bootstrapped.
48 * See: https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html
49 */
50export const CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME =
51  // suppress spell checker, it is triggered by qualifier value.
52  // eslint-disable-next-line spellcheck/spell-checker
53  '/cdk-bootstrap/hnb659fds/version';

異なる Qualifier で CDK を動作させる には以下のいずれかの方法があります。

  1. Synthesizer で指定する
  2. cdk.json に Context として登録する

今回、Qualifier は CDKプロジェクトごとに固定されるため、cdk.json に記載する方法を採用しました。

cdk.json (インフラ)
{
  ...
  "context": {
    ...
    "@aws-cdk/core:bootstrapQualifier": "nqtif7r0f"
  }
}
cdk.json (アプリケーション)
{
  ...
  "context": {
    ...
    "@aws-cdk/core:bootstrapQualifier": "hnb659fds"
  }
}

環境ごとにパイプラインを分離する

今回のシナリオでは、ステージング環境と本番環境を AWSアカウントレベルで分離しています。
そのため、CDKコードベースに特別な工夫を加える必要はなく、それぞれのアカウントへ普通にデプロイするだけで環境の分離を実現できます。

当たり前のことですが、コードベースに環境固有の値が含まれないように気を付ける必要はあります。
特に cdk.context.json の管理には注意したいところです。

通常、環境固有の値は Secrets ManagerSystems Manager Parameter Store から与えますが、Parameter Store から valueFromLookup() で読み取った値は、Context として cdk.context.json というファイルにキャッシュされます。

cdk.context.json
{
  ...
  "ssm:account=<AWSアカウントID>:parameterName=<パラメータのパス>:region=<リージョン>": "<値>"
}

キャッシュした以降の実行では、Parameter Store に対するルックアップを省略してキャッシュされた値が使用されるため、cdk.context.json をリポジトリに含めることがベストプラクティスとされています。

https://docs.aws.amazon.com/cdk/v2/guide/cdk_pipeline.html#cdk_pipeline_init

Important

Be sure to commit your cdk.json and cdk.context.json files to source control. The context information (such as feature flags and cached values retrieved from your AWS account) are part of your project’s state. The values may be different in another environment, which can cause unexpected changes in your results. For more information, see Context values and the AWS CDK.

しかし実際の運用では、実行ごとに常に最新の値を参照してほしいケースがほとんどでしょう。
Parameter Store の変更に追随して cdk.context.json を書き換えるのは二度手間であり、作業漏れの恐れもあります。

cdk.context.json をリポジトリに格納しないという手もありますが、そのような運用は避けたいところです。
cdk.context.json には Parameter Store以外の情報もキャッシュされており、それらについては鮮度が重要ではないため、キャッシュを有効に活用できるのです。

そこで、パイプラインの Buildステージにおいて、Synth に先立って Parameter Store のキャッシュを削除するようにしてみます。
これにより、Parameter Store以外のキャッシュを保持しつつ、Parameter Store は常に最新の値がルックアップされるようになります。

まず、以下のシェルスクリプトを用意し、

./bin/context/reset-ssm.sh
#!/bin/bash

npx cdk context --json \
  | jq '. | keys_unsorted | .[] | select(startswith("ssm:"))' \
  | xargs -I @ npx cdk context --reset @

これを package.json のスクリプトとして登録します。

package.json
{
  ...
  "scripts": {
    ...
    "cdk:context:resetSsm": "./bin/context/reset-ssm.sh"
  }
}

このスクリプトが Synth の前に実行されるよう設定します。

併せて、パイプライン中でルックアップを行うための権限がデフォルトでは付与されていないため、IAMポリシーを追加します。
これは、以下のタグが貼られた IAMロール (= ブートストラップによって作成された、ルックアップ用の IAMロール) を Assume できるようにすれば OK です。

キー
aws-cdk:bootstrap-rolelookup

ShellStepクラス には IAMポリシーを追加するためのインタフェースが用意されていなので、代わりに CodeBuildStepクラス を使用します。

lib/pipeline-stack.ts
...
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import {
  CodeBuildStep,
  CodePipeline,
  CodePipelineSource,
} from "aws-cdk-lib/pipelines";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      synth: new CodeBuildStep("Synth", { // `ShellStep` ではなく `CodeBuildStep` を使う
        input: ...
        commands: [
          "npm ci",
          "npm run build",
          "npm run cdk:context:resetSsm", // synth を実行する前に Parameter Store のキャッシュを消去する
          "npx cdk synth",
        ],
        rolePolicyStatements: [
          new PolicyStatement({
            actions: ["sts:AssumeRole"],
            resources: ["*"],
            effect: Effect.ALLOW,
            conditions: {
              StringEquals: {
                "iam:ResourceTag/aws-cdk:bootstrap-role": ["lookup"], // Parameter Store のルックアップ権限を与える
              },
            },
          }),
        ],
      }),
    });

    ...
  }
}

最後に、パイプラインのトリガーとなるソースブランチ (= ステージング環境では staging、本番環境では production) を Parameter Store から取得するようにします。

lib/pipeline-stack.ts
...
import { StringParameter } from "aws-cdk-lib/aws-ssm";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      synth: new CodeBuildStep("Synth", {
        input: CodePipelineSource.gitHub(
          "<リポジトリ>",
          StringParameter.valueFromLookup(this, "<ParameterStoreキー>") // Parameter Store から環境ごとのブランチ名を取得する
        ),
        commands: [
          ...
        ],
        rolePolicyStatements: [
          ...
        ],
      }),
    });

    ...
  }
}

これで環境ごとのパイプラインの分離は完了です。

承認ステップを組み込む

CDK Pipelines では、承認ステップを簡単に組み込めるように ManualApprovalStep というクラスが用意されています。

これをステージの preアクションとして配置するだけで承認ステップを追加できます。

lib/pipeline-stack.ts
...
import {
  CodeBuildStep,
  CodePipeline,
  CodePipelineSource,
  ManualApprovalStep,
} from "aws-cdk-lib/pipelines";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      ...
    });

    codepipeline.addStage(
      new TargetStage(this, "TargetStage", {
        env: props?.env,
      }),
      {
        pre: [new ManualApprovalStep("Approve")], // デプロイ前に手動承認ステップを配置する
      }
    );
  }
}

これで以下のように、ターゲットStack のデプロイ前に Approveアクションが配置されます。

差分を表示する

現在の Stack との差分を表示するには $ cdk diffコマンドを使用します。
これを承認ステップより前の適当なタイミングで実行すれば OK です。

本来は独立したアクションとしてコマンドを実行したほうが結果をわかりやすく提示できてよいのですが、今回はサボって Buildステージの中で雑に実行してしまいます。

このとき、差分を表示したいのはパイプラインStack ではなくターゲットStack であるため、--appオプションを用いて、ターゲットStack に対する Synth の結果を参照するようにします。

また、デフォルトでは、$ cdk diffコマンドは CloudFormation の Change set の作成を通して変更点を検出しようとします。
そのため、パイプラインに Change set を作成する権限を与える必要があります。
これは Parameter Store のルックアップの場合と同様に、以下のタグが貼られた IAMロールの Assume を許可すればよいです。

キー
aws-cdk:bootstrap-roledeploy
lib/pipeline-stack.ts
...

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      synth: new CodeBuildStep("Synth", {
        input: ...
        commands: [
          "npm ci",
          "npm run build",
          "npm run cdk:context:resetSsm",
          "npx cdk synth",
          "npx cdk diff --app ./cdk.out/assembly-PipelineStack-TargetStage/", // ターゲットStack の差分を表示する
        ],
        rolePolicyStatements: [
          new PolicyStatement({
            ...
            conditions: {
              StringEquals: {
                "iam:ResourceTag/aws-cdk:bootstrap-role": ["deploy", "lookup"], // Change set を作成する権限を与える
              },
            },
          }),
        ],
      }),
    });

    ...
  }
}

以上で Buildステージのビルドログに差分が表示されるようになります。
承認者はビルドログを確認して、処理の継続を許可するか、それとも拒否するかを判断できます。

わざわざビルドログを確認しに行かないといけないのは面倒ですが、今回はあくまでコンセプトの検証なので妥協します。

パイプラインの実行ステータスを通知する

CodePipeline にはイベントの 通知機能 が備わっていますが、CDK Pipelines には直接的に通知を設定するインタフェースが実装されていません。

そこで、pipelineプロパティ を経由し、notifyOn() から設定することにします。

pipelineプロパティにアクセスする際は、事前に buildPipeline() を呼び出しておく必要がある点に注意してください。

lib/pipeline-stack.ts
...
import { SlackChannelConfiguration } from "aws-cdk-lib/aws-chatbot";
import { PipelineNotificationEvents } from "aws-cdk-lib/aws-codepipeline";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { Topic } from "aws-cdk-lib/aws-sns";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      ...
    });

    codepipeline.addStage(
      ...
    );

    // いったんパイプラインを生成する
    codepipeline.buildPipeline();

    // SNS Topic
    const topic = new Topic(this, "SNS-Topic");

    // Chatbot
    const chatbot = new SlackChannelConfiguration(this, "Chatbot", {
      slackChannelConfigurationName: "Pipeline-Notification",
      slackWorkspaceId: "<SlackワークスペースID>",
      slackChannelId: "<SlackチャネルID>",
      logRetention: RetentionDays.THIRTEEN_MONTHS, // CloudWatch Logs の保持期間をお好みで
      userRoleRequired: true,
    });

    // Chatbot と SNS Topic を紐付ける
    chatbot.addNotificationTopic(topic);

    // 通知設定
    codepipeline.pipeline.notifyOn("Pipeline-Notification", topic, {
      events: [
        PipelineNotificationEvents.PIPELINE_EXECUTION_CANCELED,
        PipelineNotificationEvents.PIPELINE_EXECUTION_FAILED,
        PipelineNotificationEvents.PIPELINE_EXECUTION_RESUMED,
        PipelineNotificationEvents.PIPELINE_EXECUTION_STARTED,
        PipelineNotificationEvents.PIPELINE_EXECUTION_SUCCEEDED,
        PipelineNotificationEvents.PIPELINE_EXECUTION_SUPERSEDED,
        PipelineNotificationEvents.MANUAL_APPROVAL_FAILED,
        PipelineNotificationEvents.MANUAL_APPROVAL_NEEDED,
        PipelineNotificationEvents.MANUAL_APPROVAL_SUCCEEDED,
      ],
    });
  }
}

細かいイベントまで通知すると煩わしいので、パイプラインレベルのイベント (= パイプラインが開始、成功、失敗した、など) と、承認に関するもののみ通知するようにしました。

ビルドログの保持期間を指定する

デフォルトでは Buildステージなどの CodeBuild によるログは無期限で CloudWatch Logs に保管されますが、これを一定期間後に自動で削除されるようにします。

CDK Pipelines にはログの保持期間を設定するインタフェースがないため、独自の作り込みが必要です。
方法はいくつかありそうですが、パイプラインの最後に Lambda関数を実行して CloudWatch Logs API を叩く方法が素直でしょう。

まず、asset/lambda/log-retention/ に Lambda関数を配置します。

./asset/lambda/log-retention/
├── index.ts
├── node_modules/
├── package-lock.json
└── package.json
asset/lambda/log-retention/package.json
{
  "name": "log-retention",
  "version": "1.0.0",
  "devDependencies": {
    "@types/aws-lambda": "^8.10.145"
  },
  "dependencies": {
    "@aws-sdk/client-cloudwatch-logs": "^3.649.0",
    "@aws-sdk/client-codepipeline": "^3.649.0"
  }
}

処理内容は単純で、CDK Pipelines によって生成される CodeBuildプロジェクトの LogGroup (= プレフィックスが /aws/codebuild/Pipelines のもの) をすべて取得して、順に保持期間をセットするだけです。

ただし、CodePipeline から呼び出された Lambda関数は、PutJobSuccessResult / PutJobFailureResult API を用いて 実行結果をパイプラインに返す 必要がある点に注意してください。

asset/lambda/log-retention/index.ts
import {
  CloudWatchLogsClient,
  DescribeLogGroupsCommand,
  PutRetentionPolicyCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import {
  CodePipelineClient,
  PutJobFailureResultCommand,
  PutJobSuccessResultCommand,
} from "@aws-sdk/client-codepipeline";

import type { LogGroup } from "@aws-sdk/client-cloudwatch-logs";
import type { CodePipelineEvent, Handler } from "aws-lambda";

// 対象とする LogGroup のプレフィックス
const prefix = "/aws/codebuild/Pipelines";

// 保持日数
const retentionDays = 400;

const cloudWatchLogs = new CloudWatchLogsClient();
const codePipeline = new CodePipelineClient();

//
// Handler
//
export const handler: Handler = async (event: CodePipelineEvent) => {
  console.log("LOG_GROUP_PREFIX: ", prefix);

  console.log("RETENTION_DAYS: ", retentionDays);

  const jobId = event["CodePipeline.job"].id;

  try {
    await run();

    await putJobSuccess(jobId);

    console.log("Success");
  } catch (e) {
    await putJobFailure(jobId, e);
  }
};

//
// 対象の LogGroup すべてに保持期間を設定して回る。
//
const run = async (token?: string) => {
  const logGroups = await getLogGroups(token);

  if (logGroups.logGroups == null) {
    return;
  }

  for (const x of logGroups.logGroups) {
    await putRetentionPolicy(x);
  }

  if (logGroups.nextToken) {
    await run(logGroups.nextToken);
  }
};

//
// 条件に合致する LogGroup のリストを取得する。
//
const getLogGroups = async (token?: string) => {
  return await cloudWatchLogs.send(
    new DescribeLogGroupsCommand({
      logGroupNamePrefix: prefix,
      nextToken: token,
    })
  );
};

//
// 保持期間を設定する。
//
const putRetentionPolicy = async (logGroup: LogGroup) => {
  console.log(
    `${logGroup.logGroupName}: ${logGroup.retentionInDays} => ${retentionDays}`
  );

  await cloudWatchLogs.send(
    new PutRetentionPolicyCommand({
      logGroupName: logGroup.logGroupName,
      retentionInDays: retentionDays,
    })
  );
};

//
// 実行に成功した旨を CodePipeline に通知する。
//
const putJobSuccess = async (jobId: string) => {
  await codePipeline.send(
    new PutJobSuccessResultCommand({
      jobId: jobId,
    })
  );
};

//
// 実行に失敗した旨を CodePipeline に通知する。
//
const putJobFailure = async (jobId: string, message: any) => {
  await codePipeline.send(
    new PutJobFailureResultCommand({
      jobId: jobId,
      failureDetails: {
        message: JSON.stringify(message),
        type: "JobFailed",
      },
    })
  );
};

この Lambda関数がパイプラインの最後に独立したステップとして実行されるように設定します。

なお、デプロイする前に TypeScript から Lambda関数として実行可能な形式へトランスパイルする必要がありますが、これには aws-cdk-lib/aws-lambda-nodejsモジュール を使用します。

このステップはターゲットStack とは無関係の独立した処理なので、CDK Pipelines の addStage() ではなく、pipelineプロパティを経由して aws-cdk-lib/aws_codepipelineモジュールのほうの addStage() でパイプラインに追加します。

lib/pipeline-stack.ts
...
import { Duration } from "aws-cdk-lib";
import { LambdaInvokeAction } from "aws-cdk-lib/aws-codepipeline-actions";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const codepipeline = new CodePipeline(this, "Pipeline", {
      ...
    };

    ...

    // Lambda関数を CodePipeline Action としてラップする
    const action = new LambdaInvokeAction({
      actionName: "LogRetention",
      lambda: new NodejsFunction(this, "LogRetention", { // TypeScript を Transpiling & Bundling する
        entry: __dirname + "/../asset/lambda/log-retention/index.ts",
        runtime: Runtime.NODEJS_20_X,
        architecture: Architecture.ARM_64,
        bundling: {
          minify: true,
        },
        logRetention: RetentionDays.ONE_DAY, // この Lambda関数自体のログ保持期間をお好みで
        timeout: Duration.minutes(3),
        initialPolicy: [
          new PolicyStatement({ // ログ保持期間の変更権限を与える
            actions: [
              "logs:DescribeLogGroups",
              "logs:PutRetentionPolicy",
              "logs:DeleteRetentionPolicy",
            ],
            resources: ["*"],
            effect: Effect.ALLOW,
          }),
        ],
      }),
    });

    // パイプラインのステージとして追加する
    codepipeline.pipeline.addStage({
      stageName: "PostDeploy",
      actions: [action],
    });
  }
}

以下のようにパイプラインの最後に PostDeployステージが追加されました。

これでデプロイのたびに Lambda関数が実行され、CodeBuild のログ保持期間は規定の値となることが保証されます。

パイプラインからリソースをエクスポートする

今回のシナリオではインフラチームとアプリケーションチームがそれぞれの CDKプロジェクトを持つため、インフラチームの Stack で作成されたリソースをアプリケーションチームが参照したいケースなども考えられます。

そのような場合には、Stack.exportValue()Fn.importValue() を使うことで Stack を跨いでリソースを参照できます。
(これらのメソッドは、それぞれ CloudFormation の ExportフィールドFn::ImportValue関数 に相当します。)

例えば、パイプラインの実行ステータス通知に使用した SNSトピックをエクスポートする場合は、以下のように記述します。

lib/pipeline-stack.ts
...

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    ...

    const topic = new Topic(this, "SNS-Topic");

    this.exportValue(topic.topicArn, {
      name: "Infra-PipelineStack-SnsTopicArn",
      description: "The ARN of the SNS topic for notifications.",
    });

    ...
  }
}

あとはアプリケーションチーム側の Stack内で、Fn.importValue("Infra-PipelineStack-SnsTopicArn") とすることで当該リソースを参照できます。

まとめ

この記事では、AWS CDK Pipelines を使用した実運用に耐えるパイプラインの構築方法について解説しました。

CDK Pipelines の基本的な構造に加え、組織の運用体制や要件に合わせたカスタマイズのポイントを紹介しました。

  • チームごとのパイプライン分離: インフラチームとアプリケーションチームが異なる IAM権限でパイプラインを運用できるよう、ブートストラップStack のカスタマイズ方法を解説しました。
  • 環境ごとのパイプライン分離: ステージング環境と本番環境を異なる AWSアカウントレベルで分離し、環境固有のパラメータを扱うポイントに触れました。
  • 承認ステップと差分の確認: 本番環境へのデプロイ前に手動承認ステップを追加し、$ cdk diffコマンドを用いて事前に差分を確認する方法を解説しました。
  • 通知とログ管理: パイプラインの実行ステータスを通知し、CodeBuild のビルドログの保持期間を制御するための工夫について解説しました。
  • リソースのエクスポートとインポート: インフラチームとアプリケーションチームの CDKプロジェクト間でリソースを共有する方法を解説しました。

この記事を参考に、皆様のプロジェクトに最適な CDK Pipelines を構築していただければと思います。

今後ともよろしくお願い申し上げます。


当記事の図表には AWS Architecture Icons を使用しています。