そもそも、CDKにおけるテストとは
世の中のソフトウェアテストといえば、ロジックが正しく動くかという「振る舞いの確認」がメインです。そのために、単体テスト→結合テスト→システムテストみたいに階層ごとのテストを行うわけですが、インフラになると事情が少し違います。
もちろんCDKなどのIaCツールで書かれたプログラムには、振る舞いの確認(つまり、どのようなリソースが作成されるか)は必要ですが、テストを通じて 「セキュリティ・コンプライアンスの確認」 の方がもっと大事なのではないかと、個人的には思っています。
あくまで持論
なぜなら、IaCツールでリソースをコードとして書き下ろした時点で、どのようなリソースが作成されるかはある程度自明であるからです。「このリソース、ホントに作られた?」みたいなテストに時間を割くより、「リソース作成にあたって、ちゃんとレギュレーションに準拠したの」 をテストで担保することに注力する方が時間対効果高いなんじゃないかなと思います。
大前提、CDKに対するアサーションテストは必要だと考えており、それらの作成には時間を割くべきだと思います。しかし、実際の現場では「CDKのテスト」そのものをそもそも意識していないことが多く、そのために本記事はすぐできる「これくらいやっとけ」的なテストを紹介していきたいと思います。
準備
CDKプロジェクトを作成し、実際にテストを追加していきたいと思います。
mkdir cdk-test-demo && cd cdk-test-demo
cdk init --language typescript
- OS:Ubuntu 22.04.3 LTS(WSL)
- CDK:2.237.0
簡単なスタックを作成していきます。
import * as cdk from "aws-cdk-lib";
import { Bucket } from "aws-cdk-lib/aws-s3";
import type { Construct } from "constructs";
interface CdkTestDemoStackProps extends cdk.StackProps {
targetEnv: "dev" | "prd"; // 開発/本番
}
export class CdkTestDemoStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: CdkTestDemoStackProps) {
super(scope, id, props);
// S3バケット:開発環境でスタックの削除でデータ自動削除、本番環境でデータを保留
new Bucket(this, "MyFirstBucket", {
autoDeleteObjects: props.targetEnv === "dev",
removalPolicy:
props.targetEnv === "dev"
? cdk.RemovalPolicy.DESTROY
: cdk.RemovalPolicy.RETAIN,
});
}
}
Demoコードはこちら:https://github.com/zhang-hang-valuesccg/cdk-test-demo-zh
スナップショットテスト
必須レベル
どんなCDKプロジェクトでも、作成した後すぐ入れてください
なぜ必要なのか
(短絡的に言うと)CDKは最終的にCloudFormation (CFN) テンプレートを生成するツールです。CDKプログラムを編集し、インフラの変更を行うのは通常動作ですが、CDKのコードを改変しなくても、パッケージバージョンアップなどで生成されるCFNテンプレートが変わり得ます。スナップショットテストを導入することで、意図しないインフラ変更がないことを検知し、インフラ変更する直前の最終ガードとして機能させることができます。
実装方法
本記事は公式テンプレ付随のJestを使用していますが、他のテストフレームもほぼ同じことできます
テストファイルに以下追加:
import { App } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { CdkTestDemoStack } from "../lib/cdk-test-demo-stack";
describe("basic test", () => {
const app = new App();
const stack = new CdkTestDemoStack(app, "CdkTestDemoStack", {
targetEnv: "prd",
});
it("snapshot test", () => {
expect(Template.fromStack(stack).toJSON()).toMatchSnapshot();
});
});
コマンドも追加:
{
"scripts": {
"build": "tsc",
"watch": "tsc -w",
+ "test": "jest",
+ "test:update": "jest --updateSnapshot", // `jest -u`も可
"cdk": "cdk"
},
}
test:updateコマンドを叩くことで、/test/__snapshots__フォルダーにテンプレファイルが追加されます(すでにある場合、更新されます)。
上記の改修をもって、以下のアクションをCDK関連の開発に取り入れられます:
- 開発者手元でCDKに対して変更を行った後、
test:updateで既存のスナップショットを更新(無い場合は生成)し、コミットに含む - レビューワーがCDKの変更だけでなく、スナップショットで期待されたテンプレ変更であるかどうかも確認
- CIフローにCDKフォルダー配下の
testを組み込み、スナップショットテストが失敗した場合デプロイを阻止
さらに
インフラ変更と関係が薄いハッシュ値の部分をマスキングするようなこともやっています:
(たまにLambdaのバンドル環境でごろごろ変わる部分でもあるので)
// ...
it("snapshot test", () => {
+ const template = Template.fromStack(stack);
+ const templateJson = template.toJSON();
// ハッシュ値になる部分をダミーの名前に
+ const sanitizedTemplate = JSON.parse(
+ JSON.stringify(templateJson)
+ .replace(/"S3Key":\s*"[a-f0-9]{64}\.zip"/g, '"S3Key": "<HASH>.zip"')
+ .replace(
+ /"S3Bucket":\s*\{[^}]*"Fn::Sub":\s*"cdk-[a-z0-9]+-assets-\$\{AWS::AccountId\}-\$\{AWS::Region\}"[^}]*\}/g,
+ '"S3Bucket": "<ASSET_BUCKET>"',
+ ),
+ );
- expect(Template.fromStack(stack).toJSON()).toMatchSnapshot();
+ expect(sanitizedTemplate).toMatchSnapshot();
});
// ...
CDK-NAG
必須レベル
(アサーションテストと比較して)時間対効果の高い手法で、入れて損はない
次におすすめなのがCDK-NAGです。これは厳密にはテストではありませんが、CDKのベストプラクティスに沿っているかをチェックしてくれるツールです。
なぜ必要なのか
CDKは記述が簡潔な分、セキュリティ設定のデフォルト値などで「罠」にかかることがあります。良かれと思った実装も、実はリスクが潜んだりします。CDK-NAGにはいくつかのツールセットがあり、需要に応じて導入すれば手軽にチェックしてくれます。
例えば、以下のようなルール(ルールセットAwsSolutionsの一部)で書かれた全リソースをチェックしてくれます。
-
AwsSolutions-EC23: セキュリティグループが 0.0.0.0/0 を許可していないか -
AwsSolutions-RDS2: RDSのストレージ暗号化が有効になっているか -
AwsSolutions-SQS3: DLQ(デッドレターキュー)が設定されているか
振る舞いを細かに確認するアサーションテスト(あとで紹介)はどうしても時間がかかるものなので、全部とは言わないがCDK-NAGで一部代替できるんじゃないかなと個人的に思います。それと、セキュリティやべスプラに着目することで、少ないテストケースでも高い効果を発揮できます。
実装方法
まずはCDK-NAGパッケージを追加:
npm i cdk-nag
テストファイルに以下のケースを追加します:
describe("cdk-nag tests", () => {
beforeAll(() => {
// スタック全体にAwsSolutionsルールセットを適用
Aspects.of(stack).add(new AwsSolutionsChecks());
});
// ルール違反(エラーレベル)を検出
it("aws solutions errors", () => {
const errors = Annotations.fromStack(stack).findError(
"*",
Match.stringLikeRegexp("AwsSolutions-.*"),
);
console.info("CDK-NAG Errors:");
console.info(
`Errors: \n${errors.map((err, idx) => `${idx + 1}. [${err.entry.type}] ${err.entry.data}\n`).join("")}`,
);
expect(errors).toHaveLength(0);
});
// (オプション)ルール違反(ワーニングレベル)を検出
it("aws solutions warnings", () => {
const warnings = Annotations.fromStack(stack).findWarning(
"*",
Match.stringLikeRegexp("AwsSolutions-.*"),
);
console.info("CDK-NAG Warnings:");
console.info(
`Warnings: \n${warnings.map((warn, idx) => `${idx + 1}. ${warn.entry.data}\n`).join("")}`,
);
expect(warnings).toHaveLength(0);
});
});
今の状態でテストコマンドを叩くと、下の図のようにテストが失敗します。つまり、べスプラに沿っていないインフラが存在すると、CDK-NAGが教えてくれました。
CDKで新しいリソースを追加したり、設定を変更したりすると、テストコマンドでCDK-NAGの評価をできます。
ルール違反があった場合、もちろんべスプラにそってCDKを更新することがベストですが、リスク承知の前提でルールを抑制(ワーニングやエラーにならないようにする)することもできます。以下のように、ルール単位とリソースパス単位の抑制が可能です。
describe("cdk-nag tests", () => {
beforeAll(() => {
// スタック全体にAwsSolutionsルールセットを適用
Aspects.of(stack).add(new AwsSolutionsChecks());
// スタックレベルでルールを抑制
+ NagSuppressions.addStackSuppressions(stack, [
+ {
+ id: "AwsSolutions-S1",
+ reason: "スタックの全リソースに対してルールの違反を許容する",
+ },
+ ]);
// リソースレベルでルールを抑制
+ NagSuppressions.addResourceSuppressionsByPath(
+ stack,
+ "CdkTestDemoStack/MyFirstBucket/Resource",
+ [
+ {
+ id: "AwsSolutions-S10",
+ reason: "このリソースにおけるルールの違反を許容する",
+ },
+ ],
+ );
});
// ...
});
ここまでは、タイトル通り「とりあえずこれくらいやっとけ」の範囲です。インフラのコンプラにそこまで工数かけられない場合はここまででよくて、そのあたりしっかりやっていきたい場合は下記内容もご参考ください。
Aspect
CDK-NAGでカバーできない独自のルール(例:「S3バケットには必ず暗号化を入れること」など)を適用したい場合は、Aspects を自作します 。仕組みはCDK-NAGと同じなもので、一定範囲内(Stack内の特定サービスのリソースとか、Construct内すべてのリソースとか)に同じ基準を適用できます。
一例を挙げると、以下のような全リソースに適切なタグ(この場合はOwnerタグ)が付けているかどうかをチェックするAspectを作れます:
export class OwnerTagAspect implements IAspect {
visit(node: IConstruct): void {
if (!CfnResource.isCfnResource(node)) {
return;
}
const tags = TagManager.of(node);
if (!tags || !Object.hasOwn(tags.tagValues(), "Owner")) {
Annotations.of(node).addError(
`OwnerTag-Required: ${node.cfnResourceType} must have an Owner tag.`,
);
return;
}
}
}
そして、テストではNAGと似たような形式で上記のAspectを追加できます:
describe("owner tag aspect tests", () => {
// ...
beforeAll(() => {
Aspects.of(stack).add(new OwnerTagAspect(), {
priority: AspectPriority.READONLY, // Ensure this aspect runs after all other aspects
});
});
it("has no owner tag errors", () => {
const errors = Annotations.fromStack(stack).findError(
"*",
Match.stringLikeRegexp("OwnerTag-.*"),
);
// ...
});
});
アサーションテスト
必須レベル
重要リソース、環境ごとの分岐に優先的に適用すべき
最後に、アサーションテストの手法も少しだけ紹介したいと思います。今まで紹介したスナップショットテストやCDK-NAGなどを導入したのち、まだ余力がある、大事なPJなのでインフラをしっかりしていきたいの場合は積極的にやっていきましょう。
もちろん、アサーションテストの中でも優先順位を付けた方がよいと私は考えています。分かりやすい例で言うと、「my-bucketという名のS3バケットが作られた」のようなテストよりは、「PITRが本番で設定されるが、開発では必要ない」みたいなテストの方が優先的に作るべきと思いませんか?
例として、環境ごとに異なるRemoval Policyを設定したS3バケットには、以下のようにテスト:
describe("unit test", () => {
it("sets the prd S3 bucket removal policy to retain", () => {
const app = new App();
const prd = new CdkTestDemoStack(app, "PrdStack", {
targetEnv: "prd",
});
const dev = new CdkTestDemoStack(app, "DevStack", {
targetEnv: "dev",
});
const templatePrd = Template.fromStack(prd);
const templateDev = Template.fromStack(dev);
// must have Retain policy for prd stack
templatePrd.hasResource("AWS::S3::Bucket", {
DeletionPolicy: "Retain",
UpdateReplacePolicy: "Retain",
});
// must have Delete policy for dev stack
templateDev.hasResource("AWS::S3::Bucket", {
DeletionPolicy: "Delete",
UpdateReplacePolicy: "Delete",
});
});
});
まとめ
CDKにおけるテストの目的は、振る舞いの担保以上に 「レギュレーションに準拠したインフラの作りを確保すること」 にあります。以下の優先順位でCDKにテストを追加していきましょう!
- スナップショットテスト:最低限これはやりましょう。5分で導入できる
- CDK-NAG:工数をかけずにセキュリティチェックを導入できる
- Aspects:NAGでカバーしきれないカスタムルールを追加しましょう
- アサーションテスト
- 分岐や重点リソースを優先的にテスト
- 余力あれば高カバー率を目指していく
余計な一言:CDKは私たちをCloudFormationの冗長な記述から解放してくれましたが、だからといって生成されるテンプレートに関心を持たなくて良いわけではありません。今回紹介した手法のうち、スナップショットテストはテンプレの差分をレビューしないといけないし、CDK-NAG/Aspectの手法も裏ではテンプレに対して評価を行っています。「書けなくてもいいけど、CFNテンプレートの中身をよく理解し、読めるようになる」ことが、CDKを使いこなす上での重要なステップだと私は思います 。
