maybe daily dev notes

私の開発日誌

NodejsFunctionのビルド時に依存関係を解決する3つの方法

AWS CDK TIpsシリーズの記事。

タイトルの通り、AWS CDKのNodejsFunctionを使う時に依存関係を解決する方法が数パターンあるので比較してみる。

今回は、以下のTypeScriptのLambdaをデプロイするケースを考える。

// よくあるAPI GatewayのLambda Authorizer
import { APIGatewayRequestAuthorizerHandler } from 'aws-lambda';
import { verify, decode } from 'jsonwebtoken';

export const handler: APIGatewayRequestAuthorizerHandler = async (event, context) => {
  const token = event.headers?.Authorization;
  if (token == null) return denyPolicy;
  if (verifyToken(token)) {
    return allowPolicy;
  }
  return denyPolicy;
};

const verifyToken = (token: string) => {
  const decoded = decode(token, { complete: true });
  return true;  // WIP
};

const allowPolicy =  {  /* WIP */ };
const denyPolicy = {  /* WIP */ };

また、フォルダ構造は以下のようになっているとする:

.                               # CDKプロジェクトのルートディレクトリ
├── bin
│   └── cdk.ts
├── backend
│   └── authorizer              # Lambda関数のコード
│       ├── index.ts            # LambdaのTypeScriptコード
│       ├── package.json        # ① Lambda用のpackage.json 
│       └── package-lock.json
└── lib
│   └── stack.ts                # CDKのスタック定義
├── cdk.json
├── package.json                # ② CDKのpackage.json
└── package-lock.json

上のLambdaが依存するnpmパッケージのうち @types/aws-lambda はただの型定義なので良いとして、jsonwebtoken ライブラリはビルド時に解決される必要がある。

方法の候補

依存関係をビルド時に解決する方法は、以下の3つがある。

方法1. rootのpackage.jsonに書く方法

上のツリーで②の package.json に必要な依存関係 (ここでは jsonwebtoken )を書く方法 。 CDK側のコードは以下で、最もシンプルに済む。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
    }

また、ルートディレクトリで一度 npm ci を実行すれば必要なインストールがすべて完了するのも良い点。

一方明らかなデメリットは、すべての依存関係が一つの package.json に集約されるので、各Lambdaが実際にどのライブラリに依存しているのか不明瞭になること。

例えば3つのLambdaがある場合に、それらの依存関係を一つの package.json で管理すると、以下のような状態になる。これでは誰が何のライブラリを使っているのかわからず、ライブラリの更新・削除などが大変になることが予想できる。

{
  "name": "cdk",
  "bin": {
    "cdk": "bin/cdk.js"
  },
  "scripts": {
    ...
  },
  "devDependencies": {
    ...
  },
  "dependencies": {
    "@types/aws-lambda": "^8.10.93",
    "aws-cdk-lib": "2.20.0",
    "constructs": "^10.0.0",
    "jsonwebtoken": "^8.5.1",  // Lambda 1の依存関係
    "extract-zip": "^2.0.1", // Lambda 2の依存関係
    "node-fetch": "^3.2.4" // Lambda 3の依存関係
  }
}

また、Lambdaごとに異なるバージョンのライブラリを使うことが難しいのも欠点になる場合があるだろう。 とはいえ、あまり依存関係がないLambdaや全Lambdaで共通の依存関係がある場合は、この方法でも十分機能する。

方法2a. 個別のpackage.jsonに書く方法

方法1のデメリットを解消したい場合は、Lambdaごとにpackage.json を作成(上のツリーで①のファイル)して、そこに依存関係を書けば良い。

CDK側の定義は方法1と同じでOK。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
    },

個別の package.json で依存関係を管理するので、方法1のデメリットは解消される。

一方デメリットは、各Lambdaのディレクトリで npm ci などを実行して node_modules がローカルにインストールされた状態でなければ、CDKのsynthesizeが失敗してしまうこと。 例えば以下のようなエラーを目にしたことのある方も多いのではないだろうか。

[ERROR] Could not resolve "jsonwebtoken"
    index.ts:4:20:
      4 │ import { verify, decode } from 'jsonwebtoken';
        ╵                                 ~~~~~~~~~~~~~
  You can mark the path "jsonwebtoken" as external to exclude it from the bundle, which will remove this error.

このあたりを読むとわかるが、CDK は esbuild の bundling 前に自動で npm ci を実行してくれない。このため、esbuild は必要な依存関係を解決できずに失敗する。

このデメリットは、複数人で1プロジェクトを開発しているときに顕著になる。 例えば以下のようなプロジェクト構成の場合、開発者1が cdk deploy するためには、lambda1, 2, 3 すべてのディレクトリで npm ci を実行する必要がある。

.                               # CDKプロジェクトのルートディレクトリ
├── bin
│   └── cdk.ts
├── backend
│   ├── lambda1                 # 主に開発者1が担当
│   │   ├── index.ts
│   │   ├── package.json
│   │   └── package-lock.json
│   ├── lambda2                 # 主に開発者2が担当
│   │   ├── index.ts
│   │   ├── package.json
│   │   └── package-lock.json
│   └── lambda3                 # 主に開発者3が担当
│       ├── index.ts
│       ├── package.json
│       └── package-lock.json
├── lib
│   └── stack.ts
├── cdk.json
├── package.json
└── package-lock.json

これは人によっては面倒に感じるだろうし、環境構築の手間が増える意味でも好ましくはないだろう。

とはいえ一度インストールしてしまえばその後気にする必要はないので、この方法を採っている人も多いと思われる。

方法2b. 個別のpackage.jsonに書く方法 (自動インストール)

方法2aの問題は、CDK内で解決することができる。以下のコードを使えば良い。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
      depsLockFilePath: 'backend/authorizer/package-lock.json',
      bundling: {
        commandHooks: {
          beforeBundling: (i, o) => [`cd ${i} && npm ci`],
          afterBundling: (i, o) => [],
          beforeInstall: (i, o) => [],
        },
      },

細かいところは割愛するが、基本的には commandHooks.beforeBundling 内で npm ci を実行し依存関係をインストールしている。 beforeBundling の処理は esbuild の実行前に実行されるので、これで esbuild はエラーなく実行できる。

depsLockFilePath も必要なプロパティだが、とりあえずはおまじない的に捉えても大丈夫だろう。 NodeJsFunction のImplementation detailが漏れ出ている箇所なので、気になる方はコードを読んでみると良い。

CDK内でインストールが完結するので、利用者は何も考えずに cdk deploy を実行すればデプロイできるのがメリット。

一方で、少しCDKのコードが複雑になる点と、synthesizeのたびに npm ci が走るため遅いのがデメリット。 npm ci は実行のたびにすべての依存関係をダウンロードするので、実用的な開発のためには npm install を使うか、yarn install --frozen-lockfile を使うよう検討したいところ。そもそもnpmにも --forzen-lockfile と同等のフラグが実装されればよいのだが……

github.com

方法3. NodejsFunctionnodeModules に書く方法

もう一つ方法があって、 nodeModules プロパティ に必要な依存関係を書くこともできる。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
      depsLockFilePath: 'backend/authorizer/package-lock.json',
      bundling: {
        nodeModules: ["jsonwebtoken"],
      },

この方法なら、方法2aのように明示的に npm ci を実行する必要はないし、方法2bよりも仕組みはシンプルになる。

nodeModules に指定した場合、esbuildはこのライブラリをバンドルせずに external なライブラリとしてimportを残す。このため、バンドルのタイミングでライブラリの実体がなくてもエラーは起きない。バンドルが完了した後、NodeJsFunction内の処理で自動で npm install される

パッケージのバージョンは depsLockFilePath で指定されたバージョンになるので、depsLockFilePath を明示的に指定する必要がある。ここで指定する lockFileは、必要なライブラリのバージョンが明記されてさえいればよく、場所は①でも②でも良い。

明らかなデメリットは、依存関係の名前が二重管理になる点。パッケージを追加するたびに package.jsonnodeModules の両方を編集する必要がある。

ちなみに、そもそも Native extension が含まれるライブラリは esbuild でバンドルできないので、方法1〜3のどれを使うにせよ bundling.nodeModules にライブラリ名を明記する必要がある。この辺りの詳細はesbuildのドキュメントを参照

まとめ

NodejsFunctionのビルド時に依存関係を解決する方法を3通り紹介した。 いずれの方法も一長一短なので、それぞれのメリットデメリットを把握した上で使い分けるのが良いと思われる。

私自身は、単純なLambdaは方法1、複雑なものも方法2aで妥協して (npm scriptでnpm ciをラップするなど) 使う場合が多い。

Appendix

方法1が動作する理由

depsLockFilePath プロパティを指定しない場合、NodeJsFunctionをバンドルする際のrootディレクトリはCDKのプロジェクトディレクトリ(正確にはcdkプロセスのworking directory。気になる人はこの辺を掘るとわかる。)になる。ルートの node_modules ディレクトリに必要な依存関係がインストールされているので、NodeJsFunctionをビルドする際にはそれが参照される。

番外編: NodejsFunctionではなくDockerImageFunctionを使う

そもそもNodejsFunctionではなく、Lambdaのコンテナランタイムで動くDockerImageFunctionを使う方法もある。

以下のようなDockerfileを使い、コンテナイメージを作る。 esbuildを直接呼び出し、NodeJsFunctionに相当するバンドル処理を実行している。パラメータはこちらも参照

FROM public.ecr.aws/lambda/nodejs:18 as build
WORKDIR /build
COPY package-lock.json package.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
RUN npx esbuild handler.ts --bundle --outdir=dist --platform=node

FROM public.ecr.aws/lambda/nodejs:18
WORKDIR ${LAMBDA_TASK_ROOT}

COPY --from=build /build/dist .

CMD ["handler.handler"]

このDockerfileをDockerImageFunctionでビルド・デプロイする。

    new DockerImageFunction(this, "Handler", {
      // ./lambda はDockerfileのあるフォルダ
      code: DockerImageCode.fromImageAsset("./lambda", { platform: Platform.LINUX_AMD64 }),
    });

この方法の利点は npm ci がDocker側でキャッシュされるので、package*.json を変更しない限りはビルドが高速に終わること。 cdk watch のように頻繁にビルドが走る状況において、NodejsFunctionと比べると大きな差になることもある。

ただし、コールドスタートにかかる時間はコンテナランタイムよりNode.jsランタイムのほうが若干良い (-100~200ms程度) 印象なので注意。 個人的には大した差ではないように思う (コールドスタートは通常全呼び出しの1%程度という統計もある) ので、最近は大きめのLambdaに関してはもっぱらこの方法を使っている。

実際のコードの例はこちらも参照 (方法3と合わせて実装例を示している):

github.com