maybe daily dev notes

私の開発日誌

CDK Tips: CDKのインテグレーションテストに救われた話

AWS CDK Tipsシリーズです。

松尾さんの記事に触発されて、今回はCDKにおけるインテグレーションテストの体験談を書きます。なお、本記事では以降integ testという言葉は、CDKのinteg-testsinteg-runnerモジュ―ルで実現される自動テストのことを指すこととします。

Integ testは自動テストの一種で、与えられたCDKのコードをAWS環境へ実際にデプロイするテストを実行します。詳細は以下のブログをご覧ください。

aws.amazon.com

この記事では、integ testを導入して助かった話をします。松尾さんはCDKアプリのお話だったので、私はCDKコンストラクトライブラリでの側面から見ていきます。

背景

きっかけは自作のコンストラクトライブラリ、deploy-time-build に新機能を追加しようとした時のことです。このライブラリは、CDKデプロイ時にフロントエンドアプリをビルドすることで環境変数の注入を楽にするためのものでした (参考: AWS CDKでWebフロントエンドをデプロイする3つの方法)

このライブラリの新機能として、ECSでタスク起動を高速化するSOCIインデックスをビルドする機能を追加することにしました。ライブラリを分離するかは迷いましたが、新しい機能も「デプロイ中にビルドする」という点で元々のコンセプトに合ってますし、部分的に実装を共有できるのは便利なので、同じライブラリにまとめることにしたのです。

今回は、その機能の開発作業中に起きた話です。

リファクタする

2つの機能は大まかな処理の流れが同じです。CloudFormationカスタムリソースのLambda関数から、CodeBuildプロジェクトのビルドを開始します。

このLambda関数のコードは両機能でほぼ同一になるため、共通のコードにすることにしました。

コードが一緒なので、Lambda関数自体も一つにしたくなりますね。今回はSingletonFunctionを使っていたので、uuidlambdaPurpose を両機能で同じにすれば、自ずと関数の実体も一つになります。

元々は以下のコンストラクトです。

  const handler = new SingletonFunction(this, 'CustomResourceHandler', {
    runtime: new Runtime('nodejs18.x', RuntimeFamily.NODEJS),
    code: Code.fromAsset(join(__dirname, '../lambda/trigger-codebuild/dist')),
    handler: 'index.handler',
    uuid: '25648b21-2c40-4f09-aa65-b6bbb0c44659',
    lambdaPurpose: 'NodejsBuildCustomResourceHandler',
    timeout: Duration.minutes(5),
  });

共有するなら lambdaPurpose も汎用的な名前でないと気持ち悪いので、以下のようにリネームしましょう!

  const handler = new SingletonFunction(this, 'CustomResourceHandler', {
    runtime: new Runtime('nodejs18.x', RuntimeFamily.NODEJS),
    code: Code.fromAsset(join(__dirname, '../lambda/trigger-codebuild/dist')),
    handler: 'index.handler',
    uuid: '25648b21-2c40-4f09-aa65-b6bbb0c44659',
-    lambdaPurpose: 'NodejsBuildCustomResourceHandler',
+    lambdaPurpose: 'DeployTimeBuildCustomResourceHandler',
    timeout: Duration.minutes(5),
  });

これで追加のLambda関数を増やさずに済みます。単純で良いですね!

Ship it!

ちょっと待った!

pushする前にinteg testを走らせるんでした。yarn build すれば実行されるようにProjenを設定してあります。今回はスナップショットに変更があるのは明らか*1なので、 --update-on-failed フラグ付きで実行します。

$  yarn integ-runner --update-on-failed

Resources
[-] AWS::IAM::Role NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleCB01FBE6 destroy
[-] AWS::IAM::Policy NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleDefaultPolicyCF8879D3 destroy
[-] AWS::Lambda::Function NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c446591C4101F8 destroy
[+] AWS::IAM::Role DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRole0880C187 
[+] AWS::IAM::Policy DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleDefaultPolicy80A0FC9E 
[+] AWS::Lambda::Function DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659FC29CE6F 
[~] Custom::CDKNodejsBuild ExampleBuild61F1D79B 
 └─ [~] ServiceToken
     └─ [~] .Fn::GetAtt:
         └─ @@ -1,4 +1,4 @@
            [ ] [
            [-]   "NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c446591C4101F8",
            [+]   "DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659FC29CE6F",
            [ ]   "Arn"
            [ ] ]

Snapshot Results: 
Tests:    1 failed, 2 total
Running integration tests for failed tests...

# 中略

NodejsBuildIntegTest | 4/10 | 11:13:17 AM | UPDATE_FAILED        | AWS::CloudFormation::Stack  | NodejsBuildIntegTest The following resource(s) failed to update: [ExampleBuild61F1D79B]. 

Failed resources:
NodejsBuildIntegTest | 11:13:14 AM | UPDATE_FAILED        | Custom::CDKNodejsBuild      | ExampleBuild/Resource/Default (ExampleBuild61F1D79B) Modifying service token is not allowed.

 ❌  NodejsBuildIntegTest failed: Error: The stack named NodejsBuildIntegTest failed to deploy: UPDATE_FAILED (The following resource(s) failed to update: [ExampleBuild61F1D79B]. )

なんと!思いがけずデプロイが失敗しました。気づかずにpushしていれば、ライブラリをアップデートしたユーザーのすべてのデプロイコケてしまうところでしたね。

さて、integ testでなぜこんなエラーが起きたのでしょうか?

前提知識

Integ testを実行するinteg-runnerは、以下の順でCloudFormationに対して実際にAPIを発行して動作を検証します:

  1. 変更前の古いスナップショットを使ってCloudFormationスタックをデプロイ
  2. 変更後のテンプレートを同じスタック名でデプロイして、スタックを更新
  3. スタックを削除
  4. 1-3でエラーがなければローカルのスナップショットを更新し、成功を返す

1→2で更新の挙動を検証していることが今回のポイントでした。これにより、既存ユーザーがライブラリをアップグレードした時のシナリオを模擬して、予期せぬリグレッションを可能な限り検知することができます。

失敗の理由

さて、先のinteg testで発生したエラーも UPDATE_FAILED とあり、2のスタック更新時に起きたエラーです。エラーの本文は以下です:

Modifying service token is not allowed.

Service tokenとはなんでしょうか?これはCloudFormationのカスタムリソースに必要なプロパティです:

ServiceToken The service token that was given to the template developer by the service provider to access the service, such as an Amazon SNS topic ARN or Lambda function ARN. The service token must be from the same Region in which you are creating the stack.

Updates aren't supported.

要はカスタムリソースのハンドラとして呼び出すLambda関数のARNですね。ドキュメントにこの値の「更新はサポートされていない」と明記されていました。発生したエラーも、この制約によるものだと分かりました。

つまり、カスタムリソースは一度作成した後は、裏のLambda関数のARNを変更できないことになります (コード自体の変更は可)。これはなかなか未然に知ることは難しい仕様ですし、「古いスタックをデプロイして新しいスタックで更新する」という特殊なシナリオを検証しないかぎりは、気付けない問題ですね。

integ-testを導入しておいて良かった!

まとめ

ということで、integ-test導入しておいて助かった話でした。ちなみにエラー自体の解消は、既存のLambdaはそのままに、新たに別のLambdaを作る方針で解消しました。

integ testはaws-cdk-lib本体でフル活用されていることもあり、コンストラクトライブラリ開発には特に相性が良い手段だと思います。

来月開催のCDK Conference Japan 2024では、このようなライブラリ開発者向けの話をたくさんできればと思いますので、ぜひご視聴ください!

補足

というきれいな話だけで終わらせても良いですが、上記integ testに対する所感も少し書いておきます。

CDKのinteg testに対してよく耳にする評価は、実行時間が長すぎるために開発イテレーションに組み込むことが難しいという指摘です。これは全くその通りです。テスト実行の度にリソースの作成・削除が必要になるため、例えばAuroraやOpenSearchなど "重たい" リソースが含まれるシステムの場合は、相応に時間がかかります。

Integ testはこの特性を理解したうえで利用するのが良いと思います。私自身がライブラリ開発で利用する際は、integ testとは別に開発用の環境をデプロイし、開発中の高速なイテレーションはそちらの環境で回しています (例えばカスタムリソースハンドラーのデバッグなど)。integ testの実行は、十分に実装に自信が持てた段階で、仕上げとして実行するイメージです。

Integ testが成功すればその時点でリリースできますし、もし途中で失敗した場合は、(やれやれと思いながら)再度開発環境でのイテレーションに戻ります。ここで失敗するのはたいてい考慮漏れがあったときなので、良いことではあるのですが。ちなみに、--parallel-regions オプションでテストをデプロイするリージョンを変更できるので、前回のテスト終了を待たずに次のテストを開始する、という小技も使えます (テストのclean upに時間がかかる場合)。

例えばopensearch-rest-resourcesはinteg testの実行に1回あたり1時間強かかる地獄のような構成なのですが、上記の方法でなんとかやれています。integ testがなければ見落としていたであろう破壊的な変更を未然に防ぐことができたことも実際にあったので、まだメリットのほうが大きいと判断しています。なお、テストシナリオの実装はLambda関数の中身に寄せています。IntegTest.assertions を駆使して書くこともできそうですが、そのコード自体のデバッグイテレーションが大変なので、そちらはできるだけ単純にしています。

// Lambdaを実行し正常終了することだけを確認
const assertion = integ.assertions.invokeFunction({
  functionName: stack.testHandler.functionName,
});
assertion.expect(ExpectedResult.objectLike({ StatusCode: 200 }));

このLambda関数自体のデバッグはinteg testを使わずにできるため、integ testの実行時間に振り回されることがなくなるのは嬉しい点です。(しかしinteg testの機能を活用できてない点で理想的でもないので、Issueで改善要望を出しても良さそうですね。)

一方CDKアプリ (ライブラリでなく、運用されているシステムそのもののことです) の場合は、integ testとは異なるアプローチも可能と考えます。

CDKアプリでは、大抵の場合dev, int, stagingといった、検証用の環境を用意しているのではないでしょうか。であれば、本来integ testで検証される 1. 新テンプレートでのスタック更新 や、2. E2Eテストシナリオの実行 はinteg-runnerを使わずとも、そちらの環境でカバーできます。共用の環境にデプロイするまで検証できないことにはなりますが、例えばdev環境へのデプロイは未検証でも許容できるといった場合であれば、十分実用可能な方策だと思います。(もちろん、スナップショットテストなど安価なテストはより早い段階で成功している前提です。)

あるいは個々の開発者が専有のAWS環境を持つという方針も考えられます。これなら個々人の開発中自由にデプロイ・検証できて便利ですが、ワークロードによってはコストが見合わなくなるかもしれません。その意味では、必要なときだけデプロイするinteg testがコスト的には優位でしょう。開発体験と勘案して選択するのが良いと思います。

上記まとめると、integ testに対する個人的な考えは以下のとおりです:

  • CDKライブラリ開発では導入する方が良いことが多いと思われる。ただし開発体験を犠牲にしないよう柔軟に取り入れる。
  • CDKアプリでは他にも同様の目的を実現できる手段はあるので、それらと比べながら総合的に方針を判断する 。

補足というには少々長くなりましたが、以上です。

*1:integ testはスナップショットテストも兼ねるので、リファクタによりCFnテンプレートが変化しないことも容易に検証可能です。今回は変化があることが事前に分かっているので、その手順を飛ばしています。