Alias を設定した Lambda 関数のハンドラーコード変更時の AWS CDK スナップショット更新を不要化する

Alias を設定した Lambda 関数のハンドラーコード変更時の AWS CDK スナップショット更新を不要化する

2025.11.09

こんにちは、製造ビジネステクノロジー部の若槻です。

今回は、Alias を設定した Lambda 関数のハンドラーコード変更時の AWS CDK スナップショット更新を不要化する方法について紹介します。

はじめに結論

次のような Alias のハッシュ値をテストから除外するシリアライザーを作成し、スナップショットテスト時に適用します。

packages/iac/test/snapshot-serializer/ignore-dynamic-hash-serializer.ts
import type { SnapshotSerializer } from "@vitest/snapshot";

/**
 * Hash の変更を無視するための Serializer
 */
export const ignoreDynamicHash: SnapshotSerializer = {
  test: (val: unknown) => typeof val === "string",
  serialize: (val: string) => {
    let result = val;

    // Asset Hash (64文字の16進数 + .zip) を置換
    result = result.replace(/([A-Fa-f0-9]{64}\.zip)/, "HASH-REPLACED.zip");

    // Lambda Version の論理 ID 末尾の32文字のハッシュを置換
    result = result.replace(
      /(SampleFunctionCurrentVersion[A-F0-9]{8})[a-f0-9]{32}\b/g,
      "$1HASH-REPLACED",
    );

    // 別の Alias Version が追加された場合はここに追記していく
    // result = result.replace(
    //   /(XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX)[a-f0-9]{32}\b/g,
    //   "$1HASH-REPLACED",
    // );

    return `"${result}"`;
  },
};

これにより、Alias を設定した Lambda 関数のハンドラーコードを変更してもスナップショットの更新が不要になります。

背景

AWS CDK のスナップショットテストでは、AWS Lambda のハンドラーコードのみを修正した場合に、スナップショットの更新をスキップするテクニックがあります。AWS CDK プロジェクトと、ハンドラーコードのプロジェクトを分離している場合に有効な手法です。

例えば下記では、カスタムシリアライザーを利用して Code.S3Key に含まれるアセットハッシュをスナップショットから除外することにより、スナップショット更新の不要化するワークアラウンドが紹介されています。

https://zenn.dev/junkor/articles/3674f576c6f4c0

そして今回は同様のワークアラウンドを Alias を設定した Lambda 関数に対しても適用してみした。

前提

$ npm ls vitest aws-cdk -w iac
(中略)
  ├── aws-cdk@2.172.0
  └── vitest@4.0.8

やってみた

次のように Alias を設定した Lambda 関数を AWS CDK で定義します。

packages/iac/lib/constructs/sample-function.ts
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambda_nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";

/**
 * サンプル Lambda 関数
 */
export class SampleFunctionConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const sampleFunction = new lambda_nodejs.NodejsFunction(this, "Default", {
      entry: "../server/src/sample-handler.ts",
    });

    /**
     * バージョンとエイリアスの作成
     */
    const version = sampleFunction.currentVersion;
    new lambda.Alias(this, "Alias", {
      aliasName: "v1",
      version,
    });
  }
}

CDK スナップショットのテストコードは以下のようになります。

packages/iac/test/sample-stack.test.ts
import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { expect, test } from "vitest";

import { SampleStack } from "../lib/sample-stack";
import { ignoreDynamicHash } from "./snapshot-serializer/ignore-dynamic-hash-serializer";

const app = new cdk.App();

const stack = new SampleStack(app, "Sample");
const template = Template.fromStack(stack).toJSON();

test("snapshot", (): void => {
  expect.addSnapshotSerializer(ignoreDynamicHash); // カスタムシリアライザーを適用
  expect(template).toMatchSnapshot();
});

Alias に対するカスタムシリアライザーを「適用しない」場合 → スナップショット更新「必要」

次のように Alias に対するハッシュ置換を行わない(アセットハッシュに対する置換処理のみ行う)カスタムシリアライザーを用意します。

packages/iac/test/snapshot-serializer/ignore-dynamic-hash-serializer.ts
import type { SnapshotSerializer } from "@vitest/snapshot";

/**
 * Hash の変更を無視するための Serializer
 */
export const ignoreDynamicHash: SnapshotSerializer = {
  test: (val: unknown) => typeof val === "string",
  serialize: (val: string) => {
    let result = val;

    // Asset Hash (64文字の16進数 + .zip) を置換
    result = result.replace(/([A-Fa-f0-9]{64}\.zip)/, "HASH-REPLACED.zip");

    return `"${result}"`;
  },
};

ハンドラーコード(sample-handler.ts)を修正してスナップショットの差分を確認すると、アセットハッシュの差分は無視されますが、Alias に関連する Lambda Version の論理 ID が変わるため、スナップショットの更新が必要になっています。

$ git diff
diff --git a/packages/iac/test/__snapshots__/sample-stack.test.ts.snap b/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
index c61887c..5d4a757 100644
--- a/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
+++ b/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
@@ -44,7 +44,7 @@ exports[`snapshot 1`] = `
         },
         "FunctionVersion": {
           "Fn::GetAtt": [
-            "SampleFunctionCurrentVersionE02E68CEbf98e9cf7064536d1bf33eb65457cc4a",
+            "SampleFunctionCurrentVersionE02E68CE71b1fe281866d98e3c28ced515d99b74",
             "Version",
           ],
         },
@@ -52,7 +52,7 @@ exports[`snapshot 1`] = `
       },
       "Type": "AWS::Lambda::Alias",
     },
-    "SampleFunctionCurrentVersionE02E68CEbf98e9cf7064536d1bf33eb65457cc4a": {
+    "SampleFunctionCurrentVersionE02E68CE71b1fe281866d98e3c28ced515d99b74": {
       "Properties": {
         "FunctionName": {
           "Ref": "SampleFunction7DB1D36A",
diff --git a/packages/server/src/sample-handler.ts b/packages/server/src/sample-handler.ts
index 56506b0..73a1c96 100644
--- a/packages/server/src/sample-handler.ts
+++ b/packages/server/src/sample-handler.ts
@@ -1,3 +1,3 @@
 export const handler = async (): Promise<void> => {
-  console.log("Sample function executed");
+  console.log("Sample function executed!!!!!");
 };

Alias に対するカスタムシリアライザーを「適用した」場合 → スナップショット更新「不要」

続いて Alias に対してもスナップショット更新を不要化するように、シリアライザーを次のように更新します。

packages/iac/test/snapshot-serializer/ignore-dynamic-hash-serializer.ts
import type { SnapshotSerializer } from "@vitest/snapshot";

/**
 * Hash の変更を無視するための Serializer
 */
export const ignoreDynamicHash: SnapshotSerializer = {
  test: (val: unknown) => typeof val === "string",
  serialize: (val: string) => {
    let result = val;

    // Asset Hash (64文字の16進数 + .zip) を置換
    result = result.replace(/([A-Fa-f0-9]{64}\.zip)/, "HASH-REPLACED.zip");

    // Lambda Version の論理 ID 末尾の32文字のハッシュを置換
    result = result.replace(
      /(SampleFunctionCurrentVersion[A-F0-9]{8})[a-f0-9]{32}\b/g,
      "$1HASH-REPLACED",
    );

    // 別の Alias Version が追加された場合はここに追記していく
    // result = result.replace(
    //   /(XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX)[a-f0-9]{32}\b/g,
    //   "$1HASH-REPLACED",
    // );

    return `"${result}"`;
  },
};

前述のスナップショットの Alias の論理 ID(SampleFunctionCurrentVersionE02E68CEbf98e9cf7064536d1bf33eb65457cc4a)では、前半の SampleFunctionCurrentVersion[A-F0-9]{8} は固定で、後半の32文字のハッシュ値部分が動的に変化していました。そこで後半ハッシュ部分を正規表現でマッチさせて置換しています。

ハンドラーコード(sample-handler.ts)を修正してスナップショットの差分を確認すると、Lambda Version の論理 ID が固定値に置換されます。

$ git diff
diff --git a/packages/iac/test/__snapshots__/sample-stack.test.ts.snap b/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
index c61887c..5d4a757 100644
--- a/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
+++ b/packages/iac/test/__snapshots__/sample-stack.test.ts.snap
@@ -44,7 +44,7 @@ exports[`snapshot 1`] = `
         },
         "FunctionVersion": {
           "Fn::GetAtt": [
-            "SampleFunctionCurrentVersionE02E68CEbf98e9cf7064536d1bf33eb65457cc4a",
+            "SampleFunctionCurrentVersionE02E68CEHASH-REPLACED",
             "Version",
           ],
         },
@@ -52,7 +52,7 @@ exports[`snapshot 1`] = `
       },
       "Type": "AWS::Lambda::Alias",
     },
-    "SampleFunctionCurrentVersionE02E68CEbf98e9cf7064536d1bf33eb65457cc4a": {
+    "SampleFunctionCurrentVersionE02E68CEHASH-REPLACED": {
       "Properties": {
         "FunctionName": {
           "Ref": "SampleFunction7DB1D36A",
diff --git a/packages/server/src/sample-handler.ts b/packages/server/src/sample-handler.ts
index 56506b0..73a1c96 100644
--- a/packages/server/src/sample-handler.ts
+++ b/packages/server/src/sample-handler.ts
@@ -1,3 +1,3 @@
 export const handler = async (): Promise<void> => {
-  console.log("Sample function executed");
+  console.log("Sample function executed!!!!!");
 };

この変更をコミットすれば、以降は Alias を設定した Lambda 関数のハンドラーコード変更時にも、CDK スナップショットの更新が不要になります。

補足

カスタムシリアライザーの適用について

カスタムシリアライザーのテストへの適用は、Vitest(または Jest)の expect.addSnapshotSerializer を利用して行います。

https://vitest.dev/guide/snapshot.html#custom-serializer
https://v0.vitest.dev/api/expect.html#expect-addsnapshotserializer

このカスタムシリアライザーの適用ですが、当初は下記のように複数回 expect.addSnapshotSerializer を呼び出しできると思っていました。

(誤った例)
test("snapshot", (): void => {
  expect.addSnapshotSerializer(ignoreAssetHash);
  expect.addSnapshotSerializer(ignoreLambdaAliasHash);
  expect(template).toMatchSnapshot();
});

しかし複数回呼び出すと後から追加したシリアライザーしか適用されないようです。よって今回作成したように1つのシリアライザーにまとめて対応する必要があります。

Lambda Version の論理 ID 定義ロジック

念の為、Alias を設定した Lambda 関数の Version 論理 ID がハッシュ値を含む形で生成される仕組みについても確認しました。

AWS CDK 自体のソースコードには以下のような実装があり、Lambda Version の論理 ID はハッシュ値を含む形で生成されています。

https://github.com/aws/aws-cdk/blob/f2a31666fa92d284f6e8602e475aa0b7fca05ef7/packages/aws-cdk-lib/aws-lambda/lib/function.ts#L689

packages/aws-cdk-lib/aws-lambda/lib/function.ts
// override the version's logical ID with a lazy string which includes the
// hash of the function itself, so a new version resource is created when
// the function configuration changes.
const cfn = this._currentVersion.node.defaultChild as CfnResource;
const originalLogicalId = this.stack.resolve(cfn.logicalId) as string;

cfn.overrideLogicalId(
  Lazy.uncachedString({
    produce: () => {
      const hash = calculateFunctionHash(this, this.hashMixins.join(""));
      const logicalId = trimFromStart(originalLogicalId, 255 - 32);
      return `${logicalId}${hash}`;
    },
  })
);

255 というのは CloudFormation のリソース論理 ID の最大長制限です。ハッシュ値は32文字の16進数で表現されるため、元の論理 ID の長さに応じて切り詰められた上でハッシュ値が付与される形になります。

よって、今回作成したカスタムシリアライザーでは /(SampleFunctionCurrentVersion[A-F0-9]{8})[a-f0-9]{32}\b/g のように、元の論理 ID 部分をキャプチャしてからハッシュ値部分を置換する形にしています。また、元の論理 ID 部分の長さは Lambda 関数名などによって変わりますが、[a-f0-9]{32} だけでマッチさせると他の部分に影響が出る可能性があるため、SampleFunctionCurrentVersion[A-F0-9]{8} のように Lambda 関数名部分も含めてマッチさせる形にしています。

さらに下記のように、ハッシュ値の算出には Lambda 関数のプロパティが用いられるため、Code プロパティ(ハンドラーコードの内容)を変更するとハッシュ値が変わり、結果として Lambda Version の論理 ID も変わる仕様のようです。

https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-lambda/lib/function-hash.ts#L8

packages/aws-cdk-lib/aws-lambda/lib/function-hash.ts
export function calculateFunctionHash(
  fn: LambdaFunction,
  additional: string = ""
) {
  const stack = Stack.of(fn);

  const functionResource = fn.node.defaultChild as CfnResource;
  const { properties, template, logicalId } = resolveSingleResourceProperties(
    stack,
    functionResource
  );

  let stringifiedConfig;
  if (FeatureFlags.of(fn).isEnabled(LAMBDA_RECOGNIZE_VERSION_PROPS)) {
    const updatedProps = sortFunctionProperties(
      filterUsefulKeys(properties, fn)
    );
    stringifiedConfig = JSON.stringify(updatedProps);
  } else {
    const sorted = sortFunctionProperties(properties);
    template.Resources[logicalId].Properties = sorted;
    stringifiedConfig = JSON.stringify(template);
  }

  if (FeatureFlags.of(fn).isEnabled(LAMBDA_RECOGNIZE_LAYER_VERSION)) {
    stringifiedConfig = stringifiedConfig + calculateLayersHash(fn._layers);
  }

  return md5hash(stringifiedConfig + additional);
}

おわりに

Alias を設定した Lambda 関数のハンドラーコード変更時の AWS CDK スナップショット更新を不要化する方法について紹介しました。

アセットハッシュ部分と同様にカスタムシリアライザーを用いることで対応可能でした。また実装方法を調査するにあたり、Vitest のカスタムシリアライザーや Lambda Version の論理 ID 定義ロジックの理解が深まる良い機会になりました。

以上

この記事をシェアする

FacebookHatena blogX

関連記事