はじめに
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 | ワークフローを実行する |
以下のような流れのワークフローを定義します (クリックで拡大できます)
箱がジョブで、箱の中のリストがステップです。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のアップグレードによって合成されたテンプレートも変更される可能性があるため、スナップショットだけに頼って実装が正しいかどうかを確認することはできません。
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 テンプレートの特定のアスペクトをテストする。 これらのテストはリグレッションを検出できる。 また、テスト駆動開発で新機能を開発するときにも便利です。 (まずテストを書いて、 それから正しい実装を書くことで、そのテストをパスさせることができます)。 細かいアサーションは、もっともよく使われるテストです。
例えば以下のテストコードです。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_arn
とrole_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_NAME
と CHANGE_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 という組み合わせにおいて、実用的なワークフローを構成するための設定をひととおり紹介しました。これがベストプラクティスというわけではありませんが、実務に応用可能なプラクティスとして有用であると考えます。