maybe daily dev notes

私の開発日誌

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

タイトルの通り、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

aws-lambda はただの型定義のためesbuildに無視されるので良いとして、jsonwebtoken ライブラリは依存関係として解決される必要がある。

方法の候補

方法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で共通の依存関係がある場合は、この方法でも十分機能する。

方法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はこのライブラリをbundlingせずに external なライブラリとしてimportを残す。このため、bundlingのタイミングでライブラリの実体がなくてもエラーなく実行される。bundlingが完了した後、NodeJsFunction内の処理で自動で npm install される

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

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

また、そもそも Native extension が含まれるライブラリは esbuild で bundling できないので、方法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をビルドする際にはそれが参照される。