AWS CDK で AWS Lambda Layers の 新しい LayerVersion が作成される条件はなにか調べた ( Node.js )

2019.10.30

AWS CDK で Lambda Layers をデプロイするやり方はこちらを参照ください。

本記事ではこの Lambda Layers が、どんなときに更新されるのかを調べました。以降、 Lambda Function のランタイムを Node.js(10.x) とし、node_modules を Lambda Layers に上げたいという前提で話を進めます。

忙しい人向け

手元のnode_modules に変更があったときだけ、Lambda Layers のバージョンが更新されます。これは AWS CDK の lambda.LayerVersion Constructs が裏でハッシュ値の計算をやってくれているためです。

なぜ調べるのか

Lambda Layers が更新されると、Lambda Function も追随してデプロイする必要があり、全体のデプロイサイクルに影響があるからです。Lambda Layers や Lambda Function をソフトウェアの世界のコンポーネントとしてとらえたときに、Lambda Layers は他から参照される独立コンポーネント、Lambda Function は他のコンポーネントに依存する従属コンポーネントといえます。独立コンポーネントはいろいろなところから参照されるため、ビルド・デプロイについては必要なときだけ行うことが望ましいです。書籍クリーンアーキテクチャからの助言です。

開発者としては、node_moduels に変化があったときだけ、Lambda Layers を更新してほしいですよね。もし理想と違う挙動であれば、どうなっているのか知りたいところです。

環境

用途 利用ツール バージョン
AWSリソースデプロイ AWS CDK 1.14.0
Lambda Function ランタイム Node.js 10.x
AWS CDK 実装言語 TypeScript 3.6.4
Lambda Function 実装言語 TypeScript 3.6.4

挙動確認のアプローチ - CloudFormationから攻める

AWS CDK でいろいろと試しまくって状況証拠をそろえるのでもよいのですが、何しろ頻繁にリリースが行われているプロジェクトです。いつ挙動が変わってもおかしくありません。そう考えると、動かした結果だけ持っておくのではなく、AWS Lambda Layers のリソースからたどってある程度理詰めた根拠を確保したほうがよさそうです。

そもそも Lambda Layers とはどんなAWSリソースか

EC2インスタンスや Auto Scaling Group と同様、AWS Lambda Layers も CloudFormation で定義できるAWSリソースのひとつです。つまり、ARN(Amazon Resouce Name)をもちます:

  • 例: arn:aws:lambda:ap-northeast-1:123456789012:layer:TestLayer:4

末尾に バージョン番号 をもつのが特徴的ですね。つまり、バージョンが変わる、更新されるということは ARN が変わるということになります。

アプローチ

AWS Lambda Layers はその ARN にバージョン番号をもつことがわかりました。では、このバージョン番号がどういうときに更新されるかを考えましょう。AWS CDK はインフラコードを AWS CloudFormation の形式に出力するところまでを請け負います。実際に Lambda Layers のリソースを作っているのは CloudFormation です。

CloudFormation の AWS::Lambda::LayerVersion ドキュメントによると、

AWS::Lambda::LayerVersion リソースは、ZIP アーカイブから AWS Lambda レイヤーを作成します。

とあります。つまり、CloudFormation は、 CloudFormation テンプレートと S3上の ZIP アーカイブを材料に Lambda Layers を生成します。ここまでを踏まえ、段階別に材料を整理しました。

layer_step.png

  • まず開発者が CDKのインフラコードと Lambda Layers のアセットコード(今回は node_modules) を用意する
  • 開発者が cdk deploy を実行する
    • AWS CDK は、インフラコードをCloudFormation テンプレートに、アセットコードはアップロード用のディレクトリ cdk.out にコピーする
    • AWS CDK が cdk.out をS3にアップロードする
    • AWS CDK が CloudFormation を実行し、Lambda Layers の AWSリソースを生成する

この流れです。だんだん流れがわかってきました。次のアプローチで進めます:

  1. CloudFormation テンプレートと S3上のZIP ファイルがどう変化したら Lambda Layers が更新されるか確認する
  2. CDKコードとアセットソースコードがどのような状態になったら更新条件を満たすか確定させる

CloudFormation と Lambda Layers の関係

最小の CloudFormation テンプレートを用意しました。

layer.yaml

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg.zip'
      CompatibleRuntimes:
        - nodejs10.x

これを使いながら明らかにしていきましょう。次の表を埋めたいです:

# S3Key S3ObjectVersion ZIPファイル LayerVersionが更新されるか
1 変更あり 未指定 変更あり 更新されるはず
2 変更あり 未指定 同じ 更新されそう?
3 同じ 変更あり 同じ
4 同じ 同じ 変更あり
5 同じ 同じ 同じ 更新されないはず

わからないこと多すぎですね。試していきます。Update Stackを試すので、初回のデプロイは先にやってしまいます。

Lambda Layers 初回デプロイ

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:1",
                "Version": 1,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

新しい Lambda Layers が作成されました。

1. S3Key も zip ファイルの中身も変える

これはなんとなく更新されそうですね。試してみます。zipファイルの中身を変更し、S3へ再アップロード、その際新しいKeyにします。

1_s3.png

yamlファイルも修正しデプロイします。

layer.yaml

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-2.zip'
      CompatibleRuntimes:
        - nodejs10.x
> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:2",
                "Version": 2,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

Lambda Layers が更新されました。以降条件を変えて同様に試していきます。

2. S3Keyをリネームする

この場合はどうなるでしょうか。S3上で名前を変更(つまり中身は同じ)で試します。

  • Rename: efg-2.zip to efg-3.zip
> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:3",
                "Version": 3,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

更新されました。中身が同じでも、S3Keyが異なれば新しいバージョンになることがわかりました。 Lambda Layers を作成する際、ZIPファイルの中までは見ていない可能性がありますね。

3. S3ObjectVersion だけ変える

中身と S3Key が同じ、 S3ObjectVersion だけ異なる場合はどうでしょうか。S3バージョニングを有効にして、同名ファイルをアップロード、新しいバージョンを作成します。

3_s3.png

CFnテンプレートも修正します。

layer.yaml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-3.zip'
        S3ObjectVersion: 'bEg_sEYOi3ZcY4qH6i7cbXbsSaV1E5Mb' # 新しいバージョン
      CompatibleRuntimes:
        - nodejs10.x

デプロイして確認します。

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:4",
                "Version": 4,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

更新されました。 S3Key、ZIPファイルの中身が同じでも、バージョンが違えば Lambda Layers は更新されます。では… S3ObjectVersion を前のものに戻してみるとどうでしょうか。

layer.yaml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-3.zip'
        S3ObjectVersion: 'CGahNcKDX7K2qnd_Wk0CCmzff9jKOKh9' # 前のバージョン
      CompatibleRuntimes:
        - nodejs10.x

デプロイして確認します。

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:5",
                "Version": 5,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

更新されました。 さらに念の為、もう一度 新しい S3ObjectVersion を指定してデプロイしてみます。

layer.yaml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-3.zip'
        S3ObjectVersion: 'bEg_sEYOi3ZcY4qH6i7cbXbsSaV1E5Mb' # 新しいバージョン(再度)
      CompatibleRuntimes:
        - nodejs10.x

デプロイして確認します。

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:6",
                "Version": 6,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

更新されました。 なんとなく原理がわかってきましたね。ZIPファイルの中身にかかわらず、CloudFormation で指定する Content のパラメータに変化があった場合に、Lambda Layers が更新される動きになっていそうです。

4. ZIPファイルの中身だけ変える

S3ObjectVersion を未指定とし、ZIPファイルの中身だけ変えてデプロイしましょう。ここでさきほどの仮説が気になります。 Content パラメータに変化があると Lambda Layers が更新されるということは、S3ObjectVersion を指定した状態から、未指定とした場合にも Lambda Layers が更新されるかもしれません。試します。

layer.yaml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-3.zip'
      CompatibleRuntimes:
        - nodejs10.x

デプロイして確認します。

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:7",
                "Version": 7,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

やはりS3ObjectVersionを未指定とするだけで Lambda Layers も更新されました。 この状態で ZIP ファイルの中身だけ変えても、

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

Waiting for changeset to be created..

No changes to deploy. Stack LayerTestStack is up to date

と、ChangeSet が発生せずデプロイされません。念の為、CFnテンプレートに別のリソースを追加し、CloudFormation 自体は走らせてみましょう。

layer.yaml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  NodeModulesTestLayer:
    Type: AWS::Lambda::LayerVersion
    Properties:
      Content:
        S3Bucket: 'cdktoolkit-stagingbucket'
        S3Key: 'assets/abc/efg-3.zip'
      CompatibleRuntimes:
        - nodejs10.x
  TestRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

NodeModulesTestLayerは変更していません。デプロイします。

> aws cloudformation deploy \
--template-file layer.yaml \
--stack-name LayerTestStack  \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--profile cm-wada

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "NodeModulesTestLayer",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:NodeModulesTestLayer:7",
                "Version": 7,
                "CreatedDate": "2019-10-29T02:14:06.888+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

新しく追加した TestRole は作成されましたが、Lambda Layers については変化なしということがわかります。いわんや、5番の何も変えない場合をや、ということで同じ結果になります。デプロイ自体が走りませんし、他のリソースに変更があったとしても、新しい Lambda Layers のバージョンが作成されることはありません。

整理

出そろいましたね。整理します。 Lambda Layers は、 ZIPファイルの中身にかかわらず、CloudFormation テンプレートのContentパラメータが変化すると新しいバージョンになる 動きでした。

# S3Key S3ObjectVersion ZIPファイル LayerVersionが更新されるか
1 変更あり 未指定 変更あり 更新される
2 変更あり 未指定 同じ 更新される
3-1 同じ 未指定 => バージョン指定 同じ 更新される
3-2 同じ 新しいバージョンに変更 同じ 更新される
3-3 同じ 古いバージョンに変更 同じ 更新される
3-4 同じ バージョン指定 => 未指定 同じ 更新される
4 同じ 同じ 変更あり 更新されない
5 同じ 同じ 同じ 更新されない

AWS CDK と CloudFormation テンプレート の関係

CFnテンプレートのパラメータが変化することで Lambda Layers が更新されるとわかりました。よって、AWS CDK から CFn テンプレートを生成する際 (cdk synth)に、どのような条件を満たしていれば AWS::Lambda::LayerVersionContent が変化するかを追えば良さそうです。こちらのサンプルアプリケーションで検証します。

このサンプルでは、次のようにして node_modules を Lambda Layers にデプロイしています。

  1. cdk deploy時にはしるプリプロセスを実装
    • プリプロセスは bundle/nodejs/ ディレクトリに package.jsonpackage-lock.json をコピーする
    • プリプロセスは コピーしたファイルを使って npm install を実行
    • bundle/nodejs/node_modules にパッケージがインストールされる
  2. 開発者は、CDK のインフラコードで bundle/nodejs/node_modules を指定して Lambda Layers の Constructs を定義する

2の例です。AWS CDK で Lambda Layers を定義する場合は、次のように書くのでした。

lib/construct/hitcounter.ts

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import { NODE_LAMBDA_LAYER_DIR } from '../process/setup';
import { RemovalPolicy } from '@aws-cdk/core';

export interface HitCounterProps {
  downStream: lambda.IFunction;
}

export class HitCounter extends cdk.Construct {

  public readonly handler: lambda.Function;
  public readonly table: dynamodb.Table;

  constructor(scope: cdk.Construct, id: string, props: HitCounterProps) {
    super(scope, id);

    const nodeModulesLayer = new lambda.LayerVersion(this, 'NodeModulesLayer',
      {
        code: lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR),
        compatibleRuntimes: [lambda.Runtime.NODEJS_10_X]
      }
    );

    const table = new dynamodb.Table(this, 'Hits', {
      partitionKey: {name: 'path', type: dynamodb.AttributeType.STRING},
      removalPolicy: RemovalPolicy.DESTROY
    });
    this.table = table;

    this.handler = new lambda.Function(this, 'HitCounterHandler', {
      runtime: lambda.Runtime.NODEJS_10_X,
      handler: 'hitcounter.handler',
      code: lambda.Code.fromAsset('src/lambda'),
      layers: [nodeModulesLayer],
      environment: {
        DOWNSTREAM_FUNCTION_NAME: props.downStream.functionName,
        HITS_TABLE_NAME: table.tableName
      }
    });

    table.grantReadWriteData(this.handler);

    props.downStream.grantInvoke(this.handler);
  }
}

lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR) 部分が、Lambda Layers を更新するかどうかに関わっているはずです。ちょっとソースコードを覗いてみます。

@aws-cdk/aws-lambda/lib/code.ts

/**
 * Lambda code from a local directory.
 */
export class AssetCode extends Code {
  public readonly isInline = false;
  private asset?: s3_assets.Asset;

  /**
   * @param path The path to the asset file or directory.
   */
  constructor(public readonly path: string, private readonly options: s3_assets.AssetOptions = { }) {
    super();
  }

  public bind(scope: cdk.Construct): CodeConfig {
    // If the same AssetCode is used multiple times, retain only the first instantiation.
    if (!this.asset) {
      this.asset = new s3_assets.Asset(scope, 'Code', {
        path: this.path,
        ...this.options
      });
    }

    if (!this.asset.isZipArchive) {
      throw new Error(`Asset must be a .zip file or a directory (${this.path})`);
    }

    return {
      s3Location: {
        bucketName: this.asset.s3BucketName,
        objectKey: this.asset.s3ObjectKey
      }
    };
  }
}

最終的に s3Location というオブジェクトを返していますね。bucketNameobjectKeyが含まれているようです。S3ObjectVersion は含まれていない(常に未指定)ようですね。戻り値の材料となるbucketNameobjectKeyはさらに new s3_assets.Asset(scope, 'Code', { path: this.path, ...this.options }); というコードから生成されています。この this.path は AWS CDK のコードで lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR) で指定したパラメータが渡ります。 s3_assets.Asset() が、指定したディレクトリからどのように objectKey を決めているのかが次の話です。

@aws-cdk/aws-s3-assets/lib/asset.ts

/**
 * An asset represents a local file or directory, which is automatically uploaded to S3
 * and then can be referenced within a CDK application.
 */
export class Asset extends cdk.Construct implements assets.IAsset {
  /**
   * Attribute that represents the name of the bucket this asset exists in.
   */
  public readonly s3BucketName: string;

  /**
   * Attribute which represents the S3 object key of this asset.
   */
  public readonly s3ObjectKey: string;

  /**
   * Attribute which represents the S3 URL of this asset.
   * @example https://s3.us-west-1.amazonaws.com/bucket/key
   */
  public readonly s3Url: string;

  /**
   * The path to the asset (stringinfied token).
   *
   * If asset staging is disabled, this will just be the original path.
   * If asset staging is enabled it will be the staged path.
   */
  public readonly assetPath: string;

  /**
   * The S3 bucket in which this asset resides.
   */
  public readonly bucket: s3.IBucket;

  /**
   * Indicates if this asset is a zip archive. Allows constructs to ensure that the
   * correct file type was used.
   */
  public readonly isZipArchive: boolean;

  public readonly sourceHash: string;

  constructor(scope: cdk.Construct, id: string, props: AssetProps) {
    super(scope, id);

    // (1) stage the asset source (conditionally). 
    const staging = new assets.Staging(this, 'Stage', {
      ...props,
      sourcePath: path.resolve(props.path),
    });

    this.sourceHash = props.sourceHash || staging.sourceHash;

    this.assetPath = staging.stagedPath;

    const packaging = determinePackaging(staging.sourcePath);

    // sets isZipArchive based on the type of packaging and file extension
    this.isZipArchive = packaging === cdk.FileAssetPackaging.ZIP_DIRECTORY
      ? true
      : ARCHIVE_EXTENSIONS.some(ext => staging.sourcePath.toLowerCase().endsWith(ext));

    const stack = cdk.Stack.of(this);

    // (2)
    const location = stack.addFileAsset({
      packaging,
      sourceHash: this.sourceHash,
      fileName: staging.stagedPath
    });

    this.s3BucketName = location.bucketName;
    this.s3ObjectKey = location.objectKey;
    this.s3Url = location.s3Url;

    this.bucket = s3.Bucket.fromBucketName(this, 'AssetBucket', this.s3BucketName);

    for (const reader of (props.readers || [])) {
      this.grantRead(reader);
    }
  }
}

冒頭のコメントに注目です。

An asset represents a local file or directory, which is automatically uploaded to S3 and then can be referenced within a CDK application.

これを使うことで自動的にローカルファイルをS3にアップロードし、アセットとして AWS CDK で使えるようにする、とあります。このライブラリを使っている code.ts では フィールドの this.asset.s3ObjectKey を使っていたので、フィールド s3ObjectKey がどのようにして決まるかを確認します。constructor() を見ましょう。全体の流れとして、

  • 状態に応じてアセットソースコードを "ステージ" します。ステージでは、ソースコードの元ファイルをアップロード用の別ディレクトリにコピーしています。ここで sourceHashが生成されています。(さらに後述)
  • ハッシュとステージされたファイルパスから s3ObjectKey を算出します。この時点ではまだアップロードしません。

となっており、(1) の new assets.Staging() で、アセットソースコードをステージする際に fingerprintを生成し、ハッシュ値としていました。次のソースです。

@aws-cdk/assets/lib/staging.ts

export class Staging extends Construct {

  /**
   * The path to the asset (stringinfied token).
   *
   * If asset staging is disabled, this will just be the original path.
   * If asset staging is enabled it will be the staged path.
   */
  public readonly stagedPath: string;

  /**
   * The path of the asset as it was referenced by the user.
   */
  public readonly sourcePath: string;

  /**
   * A cryptographic hash of the source document(s).
   */
  public readonly sourceHash: string;

  private readonly copyOptions: CopyOptions;

  private readonly relativePath?: string;

  constructor(scope: Construct, id: string, props: StagingProps) {
    super(scope, id);

    this.sourcePath = props.sourcePath;
    this.copyOptions = props;
    this.sourceHash = fingerprint(this.sourcePath, props);

    const stagingDisabled = this.node.tryGetContext(cxapi.DISABLE_ASSET_STAGING_CONTEXT);
    if (stagingDisabled) {
      this.stagedPath = this.sourcePath;
    } else {
      this.relativePath = `asset.` + this.sourceHash + path.extname(this.sourcePath);
      this.stagedPath = this.relativePath; // always relative to outdir
    }
  }
}

ハイライトした行で fingerprint を生成していることがわかります。この function が、アセットソースコードの内容からハッシュ値を生成しています。

ここまでのソースコードの流れを整理すると、同じ内容のアセットだと同じハッシュ値が生成され、同じハッシュ値が S3ObjectKey にあてがわれる 動きです。AWS CDK のコードで書いた lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR) が、裏でいろいろとやってくれていたわけですね。

今回のAWS CDK サンプルのアセット周りは次のような構成です。

> tree -L 2 bundle
bundle
└── nodejs
    ├── node_modules
    ├── package-lock.json
    └── package.json

これをデプロイします。ですので、先の結論に沿うと、たとえnode_modulesに変化がないとしても、package.jsonが修正されると 新しい Lambda Layers が作成されそうです。

# 初回デプロイ(Create Stack)
> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD:1",
                "Version": 1,
                "CreatedDate": "2019-10-29T23:58:54.224+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

# バージョンを更新すると、package.json の version が自動で修正される
> npm version 0.2.0
v0.2.0

# package.json が修正された状態でデプロイ
> cdk deploy HelloApiStack --context env=stg --profile cm-wada
HelloApiStack: deploying...
Updated: asset (zip)
HelloApiStack: creating CloudFormation changeset...

 ✅  HelloApiStack

# Lambda Layers のバージョンを確認。更新されている。
> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD:2",
                "Version": 2,
                "CreatedDate": "2019-10-29T23:58:54.224+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

Lambda Layers がバージョン1から2へ更新されました。もう少しだけ突っ込みましょう。package.json や package-lock.json は Lambda Function から呼び出されるわけではないので、これらはアセットコードからは除外したいところです。lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR) のオプションパラメータを使います。

const nodeModulesLayer = new lambda.LayerVersion(this, 'NodeModulesLambdaLayer',
  {
    code: lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR,
      {
        exclude: [
          'nodejs/package.json',
          'nodejs/package-lock.json'
        ]
      }),
    compatibleRuntimes: [lambda.Runtime.NODEJS_10_X]
  }
);

exclude キーででアセット対象からの除外設定が可能です。node_modules インストールのためにコピーする nodejs/package.jsonnodejs/package-lock.json は除外します。

注意:更新したファイルが除外対象であっても、Lambda Layers は更新されます。

たとえば、package.json は除外対象ですので、このファイルの中身を変更しても、node_modules に対して変更がなければ新い Lambda Layers は更新されないだろうと私は考えました。しかし実際の挙動は違いました。これは、アセットを生成するロジックが、

  1. アセットソース(除外前)のハッシュ値を計算する
  2. 除外設定を適用して、アセットソースをコピーする

という動きになっているためです。具体的なコードはStagingクラスを参照してください。どうやらコピー先ディレクトリ名に計算したハッシュ値を使いたい意図があるようで、納得できます。開発者としては、除外対象のファイルが更新された場合も Lambda Layers は更新され、新しいバージョンが生成される ことを覚えておきましょう。

# 除外対象の package.json に変更を加えてみる ハッシュ値計算タイミングの関係で除外対象のファイルでも Lambda Layers が更新される
> npm version 0.3.0
v0.3.0

> cdk deploy HelloApiStack --context env=stg --profile cm-wada
HelloApiStack: deploying...
Updated: asset (zip)
HelloApiStack: creating CloudFormation changeset...

 ✅  HelloApiStack

> aws lambda list-layers --profile cm-wada
{
    "Layers": [
        {
            "LayerName": "HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LayerArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD",
            "LatestMatchingVersion": {
                "LayerVersionArn": "arn:aws:lambda:ap-northeast-1:123456789012:layer:HelloHitCounterNodeModulesLambdaLayer337123AD:4",
                "Version": 4,
                "CreatedDate": "2019-10-29T23:58:54.224+0000",
                "CompatibleRuntimes": [
                    "nodejs10.x"
                ]
            }
        }
    ]
}

まとめ

AWS CDK の lambda.LayerVersion Constructs が(正確にはlambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR)が)裏でいろいろやってくれ、node_modules に変更があったときだけ、Lambda Layers を更新する動きでした。また、アセットコードを指定するときに、package.jsonpackage-lock.json はオプションで除外設定が可能です。ただし、除外対象であっても、ソースアセットに含まれているファイルであれば、修正した場合に Lambda Layers が更新されることに注意してください。

なお、現状 AWS CDK がデプロイに使うするS3バケットは、S3バージョニングには対応していないようです(cdk bootstrap 時 バージョニング無効で生成される)。アセットコード生成周りも S3ObjectVersion は考慮しない作りになっています。いまは、われわれ開発者は、アセットコードのバージョニングについては考えなくてよさそうです。

参考