はじめに

AWS CDK を使ったプロジェクトの CI/CD 環境を CircleCI で構築する機会がありましたので、一連の流れを紹介します。なお各プロダクトの初期セットアップに関しては本記事の対象外とします。

用語

まず CircleCI の用語を整理します。公式の用語集もあるのですが、設定ファイルに記述する用語を中心に、改めてまとめてみました。

用語 説明
pipeline プロジェクトで作業をトリガーする際に実行する一連のプロセスすべて。複数のワークフローで構成される
workflows ジョブの集まりとその実行順序、および依存関係の定義
jobs 一連のステップの集まり
steps Executor (コンテナまたは仮想マシン) 内で実行される実行可能なコマンドの集まり
commands 一連のステップをジョブの外で定義して再利用性を確保したもの
orbs ジョブ、コマンド、Executor などの再利用可能な設定要素を外部パッケージとして共有したもの
parameters 設定ファイルのトップレベルに定義されるパイプライン単位のパラメーター
executors ジョブの実行基盤。docker, machine のいずれかを選択

補足として、最上位にパイプラインという概念があり、その下に複数のワークフローがぶら下がります。ただし、今回対象とするワークフローはひとつです。

概要

CicleCI はリポジトリ直下の .circleci ディレクトリに配置された YAML ファイルを読み込み、その設定に沿ってワークフローを起動します。

.circleci
├── config.yml
└── workflow.yml

今回は特定の条件下でのみワークフローを起動するように制御します。

ファイル 役割
config.yml パイプラインのエントリーポイント。ワークフローを起動するかどうかを判定する
workflow.yml ワークフローを実行する

以下のような流れのワークフローを定義します (クリックで拡大できます)

diagram

箱がジョブで、箱の中のリストがステップです。CDK で管理された CloudFormation スタックのデプロイを効率的かつ安全に行うことを目的としています。今回は、単一のブランチのみを対象とします。各ジョブは以下のような役割を持ちます。

ジョブ 説明
test 静的解析、および単体テストを行い、いずれかで異常を検知した時点で失敗させる
setup スタックの変更セットを作成し、リソースの変更内容を出力する
slack/on-hold Slack に通知する
hold 変更セットの内容をレビューし、手動承認する
deploy 変更セットをデプロイする

AWS の設定

CircleCI の前に、AWS 側で必要な設定を行います。

OIDC 認証

CircleCI は AWS へのアクセスに対する OIDC 認証をサポートしています。つまりアクセスキー/シークレットキーを保持せずに、一時クレデンシャルでアクセスすることができます。これを実現するためには AWS 側で以下の作業が必要です。

  • ID プロバイダーの作成
  • IAM ロールの作成

CircleCI の Web アプリから以下の設定値が確認できます。

設定値 場所
Organization ID Organization Settings > OverView > Organization ID
Project ID Projects > 対象プロジェクト > Project Settings > OverView > Project ID

ID プロバイダーの作成

まず外部 ID プロバイダーの情報を IAM に登録します。設定値は次のとおりです。

設定
プロバイダのタイプ OpenID Connect
プロバイダの URL oidc.circleci.com/org/< Organization ID >
対象者 < Organization ID >

IAM ロールの作成

AssumeRole するための IAM ロールを作成します。

信頼ポリシー

信頼ポリシーでは Condition 句で CircleCI の Project ID を使い、CircleCI Organization の中でも特定のプロジェクトのユーザーだけが AWS にアクセスできるようにしています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::< AWS Account ID >:oidc-provider/oidc.circleci.com/org/< CircleCI Organization ID >"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringLike": {
                    "oidc.circleci.com/org/< CircleCI Organization ID >:sub": "org/< CircleCI Organization ID >/project/< CircleCI Project ID >/user/*"
                }
            }
        }
    ]
}
許可ポリシー

許可ポリシーではステートメントを 2 つ定義しています。

SID 説明
AssumeCDKRoles CDK が管理する各種 IAM ロールを引き受ける権限。タグで条件付けしているため、CDK が管理するロール以外は引き受けられない。CDK 操作における最小権限 (公式ドキュメント)
DescribeChangeSet CDK CLI を介さずに CloudFormation の変更セットを Describe するための権限
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AssumeCDKRoles",
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "*",
            "Condition": {
                "ForAnyValue:StringEquals": {
                    "iam:ResourceTag/aws-cdk:bootstrap-role": [
                        "image-publishing",
                        "file-publishing",
                        "deploy",
                        "lookup"
                    ]
                }
            }
        },
        {
            "Sid": "DescribeChangeSet",
            "Effect": "Allow",
            "Action": [
                "cloudformation:DescribeChangeSet",
            ],
            "Resource": [
                "arn:aws:cloudformation:*:::< AWS Account ID >:stack/*/*",
                "arn:aws:cloudformation:*:::< AWS Account ID >:changeSet/*/*"
            ]
        }
    ]
}

CircleCI の設定

次に CircleCI の設定です。

環境変数

CircleCI では、プロジェクト間で共有できる環境変数のセットをコンテキストという形で保持しておき、ワークフローで参照することができます。Web アプリから以下のコンテキストを sample-context という名前で作成しています。

名前 説明
AWS_DEFAULT_REGION デフォルトの AWS リージョン
AWS_ROLE_ARN OIDC 用 IAM ロールの ARN
SLACK_ACCESS_TOKEN Slack 連携用アクセストークン
SLACK_DEFAULT_CHANNEL 通知先 Slack チャンネル

起動条件

モノレポの場合など、特定のファイルに変更があった場合にのみワークフローを走らせたい場面は多いと思います。これは CircleCI の場合 Dynamic Configuration という機能で実現できます。具体的には変更されたファイルのパスが指定した正規表現にマッチした場合のみ特定のワークフローを起動するようにします。

まずエントリーポイントとなる .circleci/config.yml を作成します。

version: 2.1

setup: true

orbs:
  path-filtering: circleci/path-filtering@1.0.0

workflows:
  version: 2
  always-run:
    jobs:
      - path-filtering/filter:
          name: Check update files
          mapping: |
            bin\/.*|lib\/.*|test\/.*|src\/.*|parameter.ts enable-workflow true
          base-revision: main
          config-path: .circleci/workflow.yml

ファイルパスでフィルタリングしたいので、circleci/path-filtering という Orb を使います。とりわけ mapping が重要です。この場合は以下のような意味になります。

bin, lib, test, src ディレクトリ配下、または parameter.ts に変更があった場合、enable-workflow という変数の値を true にする

フィルタリング後に継続して実行する設定ファイルを config-path で指定しています。

ワークフロー

条件に合致した場合に起動する .circleci/workflow.yml の全体像です。

version: 2.1

orbs:
  aws-cli: circleci/aws-cli@5.2.0
  node: circleci/node@7.1.0
  slack: circleci/slack@5.1.1

parameters:
  enable-workflow:
    type: boolean
    default: false
  stack-name:
    type: string
    default: "SampleStack"
  change-set-name:
    type: string
    default: "cicd-change-set"

executors:
  default:
    docker:
      - image: cimg/node:22.11.0
    resource_class: arm.medium

commands:
  install_dependencies:
    steps:
      - restore_cache:
          name: Restore npm package caches
          keys:
            - v1-npm-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}
            - v1-npm-deps-{{ .Branch }}-
      - run:
          name: Install dependencies
          command: |
            npm install
      - save_cache:
          name: Save npm package caches
          key: v1-npm-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}
          paths:
            - "node_modules"
  lint_and_format:
    steps:
      - run:
          name: Lint and format
          command: |
            npm run ci
  test_cdk:
    steps:
      - run:
          name: Test CDK
          command: |
            npm test -- -u
  setup_aws_cli:
    steps:
      - aws-cli/setup:
          profile_name: default
          role_arn: ${AWS_ROLE_ARN}
          role_session_name: circleci-session
  create_change_set:
    steps:
      - run:
          name: Create change set
          environment:
            STACK_NAME: << pipeline.parameters.stack-name >>
            CHANGE_SET_NAME: << pipeline.parameters.change-set-name >>
          command: |
            npx cdk deploy "${STACK_NAME}" --execute false --change-set-name "${CHANGE_SET_NAME}" --require-approval never 1>/dev/null
            aws cloudformation describe-change-set --stack-name "${STACK_NAME}" --change-set-name "${CHANGE_SET_NAME}" --query 'Changes[].ResourceChange'
  deploy_change_set:
    steps:
      - run:
          name: Deploy change set
          environment:
            CHANGE_SET_NAME: << pipeline.parameters.change-set-name >>
          command: |
            npx cdk deploy --change-set-name ${CHANGE_SET_NAME} --require-approval never

jobs:
  test:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - lint_and_format
      - test_cdk
  setup:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - setup_remote_docker
      - setup_aws_cli
      - create_change_set
  deploy:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - setup_remote_docker
      - setup_aws_cli
      - deploy_change_set

workflows:
  version: 2
  workflow:
    when: << pipeline.parameters.enable-workflow >>
    jobs:
      - test:
          filters:
            branches:
              only: main
      - setup:
          context: sample-context
          filters:
            branches:
              only: main
          requires:
            - test
      - slack/on-hold:
          context: sample-context
          requires:
            - setup
      - hold:
          type: approval
          requires:
            - setup
            - slack/on-hold
      - deploy:
          context: sample-context
          filters:
            branches:
              only: main
          requires:
            - hold

ちょっと長いですね。流れとしては、

  • orbs, parameters, executors など、ファイル全体で使う設定を定義
  • jobs で使い回す commands を定義
  • workflows にマッピングする jobs を定義
  • workflows を定義

という順番で設定しています。

コマンドの定義

基本的には commands に処理を書き、再利用可能にしています。これらを jobs 内の steps にマッピングすることで同じ設定の繰り返しが減り、見通しがよくなります。各コマンドの内容を見てみましょう。

依存関係のインストール

AWS CDK は TypeScript で記述している想定で、事前に npm intall が必要です。毎回すべての依存関係をゼロからインストールするオーバーヘッドを回避するため、CircleCI が用意しているキャッシュ機構を利用します。

install_dependencies:
  steps:
    - restore_cache:
        name: Restore npm package caches
        keys:
          - v1-npm-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}
          - v1-npm-deps-{{ .Branch }}-
    - run:
        name: Install dependencies
        command: |
          npm install
    - save_cache:
        name: Save npm package caches
        key: v1-npm-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}
        paths:
          - "node_modules"
  • npm パッケージのキャッシュがあるか確認する
  • あればキャッシュを使い、なければインストールする
  • インストール後、npm パッケージをキャッシュする
静的解析

静的解析には今回 biome を使っています。

lint_and_format:
  steps:
    - run:
        name: Lint and format
        command: |
          npm run ci

npm script に設定しているコマンドです。CI 用のサブコマンドがあり、解析したいパスを指定して実行します。内蔵の Formatter, Linter, Import Sorting が実行されます。

npx biome ci ./bin ./lib ./test parameter.ts
CDK の単体テスト

AWS の公式ドキュメントによると、CDK の単体テストには 2 つのカテゴリーがあります。

Snapshot Tests

Snapshot tests test the synthesized AWS CloudFormation template against a previously stored baseline template. Snapshot tests let you refactor freely, since you can be sure that the refactored code works exactly the same way as the original. If the changes were intentional, you can accept a new baseline for future tests. However, CDK upgrades can also cause synthesized templates to change, so you can’t rely only on snapshots to make sure that your implementation is correct.

機械翻訳) スナップショットテストは、合成されたAWS CloudFormationテンプレートを、以前に保存されたベースラインテンプレートに対してテストします。スナップショットテストでは、リファクタリングされたコードがオリジナルとまったく同じように動作することを確認できるため、自由にリファクタリングできます。変更が意図的なものであれば、将来のテストのために新しいベースラインを受け入れることができる。しかし、CDKのアップグレードによって合成されたテンプレートも変更される可能性があるため、スナップショットだけに頼って実装が正しいかどうかを確認することはできません。

引用元: Test AWS CDK applications

Fine-grained Assertions Tests

Fine-grained assertions test specific aspects of the generated AWS CloudFormation template, such as “this resource has this property with this value.” These tests can detect regressions. They’re also useful when you’re developing new features using test-driven development. (You can write a test first, then make it pass by writing a correct implementation.) Fine-grained assertions are the most frequently used tests.

機械翻訳) 細かいアサーションは、生成された AWS CloudFormation テンプレートの特定のアスペクトをテストする。 これらのテストはリグレッションを検出できる。 また、テスト駆動開発で新機能を開発するときにも便利です。 (まずテストを書いて、 それから正しい実装を書くことで、そのテストをパスさせることができます)。 細かいアサーションは、もっともよく使われるテストです。

引用元: Test AWS CDK applications

例えば以下のテストコードです。Snapshot Tests を実行後、Fine-grained Assertions Tests ではスタックで作成されるリソースの数をテストしています。

import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { TrainingStack } from "../lib/stack";

const app = new cdk.App();
const stack = new TrainingStack(app, "TrainingStack");

describe("Snapshot Tests", () => {
  const template = Template.fromStack(stack);

  test("Snapshot test", () => {
    expect(template.toJSON()).toMatchSnapshot();
  });
});

describe("Fine-grained Assertions Tests", () => {
  const template = Template.fromStack(stack);

  test("DynamoDB Table created", () => {
    template.resourceCountIs("AWS::DynamoDB::Table", 1);
  });
  test("Lambda Function created", () => {
    template.resourceCountIs("AWS::Lambda::Function", 4); // including the custom resource
  });
  test("Lambda Alias created", () => {
    template.resourceCountIs("AWS::Lambda::Alias", 3);
  });
  test("ApiGateway RestApi created", () => {
    template.resourceCountIs("AWS::ApiGateway::RestApi", 1);
  });
});

npm test -- -u を実行します。実際には jest -u が実行されます。

test_cdk:
  steps:
    - run:
        name: Test CDK
        command: |
          npm test -- -u
AWS CLI のセットアップ

Orb を使うことで、AWS CLI のインストールと OIDC 認証が簡単に行えます。

  • AWS CLI をインストールする
  • role_arnrole_session_name がある場合は OIDC 認証を試みる
  • AWS 認証情報を設定し、~/.aws/credentials~/.aws/config に保存する
setup_aws_cli:
  steps:
    - aws-cli/setup:
        profile_name: default
        role_arn: ${AWS_ROLE_ARN}
        role_session_name: circleci-session

ここでの ${AWS_ROLE_ARN} は前述の CircleCI コンテキストから取得するように workflows 内で設定しています。これは後ほど説明します。

変更セットの作成

冒頭で図示した通り [変更セットの作成 > 変更内容をレビュー > 承認後デプロイ] というワークフローにしているため、デプロイの前段で変更セットのみ作成する必要があります。CDK では以下コマンドで実現できます。

cdk deploy "${STACK_NAME}" \
  --execute false \
  --change-set-name "${CHANGE_SET_NAME}" \
  --require-approval never

CDK のコマンドで変更セットを作る場合はいろいろと利点があり、シェルスクリプトをがんばらなくて済みます。

  • すでに同名の変更セットがある場合でもエラーにならず上書きされる
  • 自動で Wait してくれるため、作成完了するまで while 文を回す必要がない

変更セットの作成が完了したら、Web アプリから確認できるように変更内容を Describe しておきます。

aws cloudformation describe-change-set \
  --stack-name "${STACK_NAME}" \
  --change-set-name "${CHANGE_SET_NAME}" \
  --query 'Changes[].ResourceChange'

STACK_NAMECHANGE_SET_NAME はパイプラインパラメーターから取得します。

create_change_set:
  steps:
    - run:
        name: Create change set
        environment:
          STACK_NAME: << pipeline.parameters.stack-name >>
          CHANGE_SET_NAME: << pipeline.parameters.change-set-name >>
        command: |
          npx cdk deploy "${STACK_NAME}" --execute false --change-set-name "${CHANGE_SET_NAME}" --require-approval never 1>/dev/null
          aws cloudformation describe-change-set --stack-name "${STACK_NAME}" --change-set-name "${CHANGE_SET_NAME}" --query 'Changes[].ResourceChange'
変更セットのデプロイ

承認済みの変更セットをデプロイするコマンドも作っておきます。

deploy_change_set:
  steps:
    - run:
        name: Deploy change set
        environment:
          CHANGE_SET_NAME: << pipeline.parameters.change-set-name >>
        command: |
          npx cdk deploy --change-set-name ${CHANGE_SET_NAME} --require-approval never

ジョブの定義

ジョブは、ここまでで定義したコマンドをマッピングしているだけです。また CDK で Docker コンテナをデプロイするパターンを想定し、setup_remote_docker を設定しています。これにより完全に隔離された環境で Docker コンテナのビルドが走ります。

jobs:
  test:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - lint_and_format
      - test_cdk
  setup:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - setup_remote_docker
      - setup_aws_cli
      - create_change_set
  deploy:
    executor: default
    steps:
      - checkout
      - install_dependencies
      - setup_remote_docker
      - setup_aws_cli
      - deploy_change_set

ジョブのマッピング

ワークフローにジョブをマッピングすることで最終的なオーケストレーションを定義します。

  • when で前述の enable-workflow パラメーターを使用し、特定のファイルパスに変更があった場合のみ起動させる
  • context で CIicleCI コンテキストから環境変数を注入し、OIDC 認証や Slack 通知が動作するようにする
  • filters.branches で対象ブランチを定義する
  • requires でジョブ間の依存関係を定義する
workflows:
  version: 2
  workflow:
    when: << pipeline.parameters.enable-workflow >>
    jobs:
      - test:
          filters:
            branches:
              only: main
      - setup:
          context: sample-context
          filters:
            branches:
              only: main
          requires:
            - test
      - slack/on-hold:
          context: sample-context
          requires:
            - setup
      - hold:
          type: approval
          requires:
            - setup
            - slack/on-hold
      - deploy:
          context: sample-context
          filters:
            branches:
              only: main
          requires:
            - hold

ここまでで、CDK のコードが変更された場合に自動で CI/CD パイプラインが動作する環境が構築できました。

おわりに

AWS CDK と CircleCI という組み合わせにおいて、実用的なワークフローを構成するための設定をひととおり紹介しました。これがベストプラクティスというわけではありませんが、実務に応用可能なプラクティスとして有用であると考えます。