AWS CDKのILocalBundlingでLambda FunctionとLayerを構築する

# 概要

AWS CDKを使ってLambda FunctionとLayerを構築する。
やりたいこととしては、

- FunctionのソースはTypeScriptで書く
- Functionにはnpmモジュールを含めない
- Layerはnpmモジュールのみ含める
- 開発中は `node_modules` を参照/解決できるようにする
- 事前準備のためのスクリプトを用意したくない
- 設定ファイルでパスをゴニョゴニョしたくない
- AWS CDK標準の仕組みを使う（FunctionとLayerへのバンドリングの話）

やってないこと/考慮してないことは、

- Layerにユーザー定義のモジュールを含める
- `sam local invoke` で動作すること
- `bash` の動かない環境でのローカルバンドリング

# ファイル構成

最終的にこのようなファイル構成になった(主要なファイルのみ)。
`project/package.json` の `devDependencies` には型定義やら開発時に必要なものが色々入っている。後述の `NodejsFunction` がローカルでbundling出来るように `esbuild` をインストールしておくこと。
`project/src/lambda/package.json` にはLambda Layerに含めるモジュールのみ入っている。

```
project/
├── bin/
│   └── app.ts
├── lib/
│   └── stack.ts
├── src/
│   └── lambda/
│       ├── handlers/
│       │   └── func1/
│       │       └── main.ts     // ← ./libなどあれば、それも含めてLambda Functionにバンドルされる
│       ├── package-lock.json
│       └── package.json        // ← こいつの dependencies をLambda Layerに含める
├── test/
│   └── app.test.ts
├── cdk.json
├── package-lock.json
├── package.json                // ← AWS CDKとか型定義とか開発時に必要なもの
└── tsconfig.json
```

# デプロイ処理の流れ

おおまかにデプロイ時の処理をまとめるとこうなる。

1. Layerのバンドル処理
    1. AWS CDKの処理内でバンドル処理用のシェルスクリプトを呼び出す
    1. `project/src/lambda/package.json` を使って `npm ci` を実行する
    1. ローカルでバンドル処理できない状態であれば、Dockerでのバンドル処理を試みる
        1. Dockerイメージがなければ `pull`
        1. コンテナを起動し、その内部で `npm ci` を実行する
    1. バンドル結果は `project/cdk.out` 以下に出力される
1. Functionのバンドル処理
    1. Layerに含めたモジュールは明示的に除外する(`package.json` の dependencies)
    1. TypeScriptからJavaScriptにトランスパイル
    1. バンドル結果は `project/cdk.out` 以下に出力される
1. FunctionからLayerを参照する

# 実装

## Layer

まずLayerを作る。
全体はこんな感じ。

```typescript:project/lib/stack.ts
import * as lambda from "aws-cdk-lib/aws-lambda";
  :
const layer = new lambda.LayerVersion(this, 'Layer', {
  layerVersionName: layerName,
  code: lambda.Code.fromAsset('src/lambda', {
    bundling: {
      local: new LocalBundler(path.resolve(__dirname, '../src/lambda')),
      image: lambda.Runtime.NODEJS_16_X.bundlingImage,
      command: [
        'bash',
        '-c',
        [
          'mkdir -p /asset-output/nodejs',
          'cp package.json package-lock.json /asset-output/nodejs/',
          'npm ci --omit=dev --prefix /asset-output/nodejs',
        ].join(' && '),
      ],
      user: 'root',
    },
  }),
  compatibleRuntimes: [lambda.Runtime.NODEJS_16_X],
  removalPolicy: cdk.RemovalPolicy.DESTROY,
});
```

### ローカルでのバンドル処理

`local` に指定した `LocalBundler` は `ILocalBundling` インターフェイスの `tryBundle` メソッドを実装したクラス。
このクラスでローカルでのバンドル処理を行う。

```typescript:project/lib/stack.ts
local: new LocalBundler(absolutePath),
```

`tryBundle` を実装し `bundle.sh` (後述)を実行する。
ダメなら `false` を返し、Dockerにフォールバックさせる。
引数の `outputDir` には `project/cdk.out/asset.xxx` のようなパスが渡される。
Lambda Layerは `node_modules/nodejs` のような構成を期待しているので、`project/cdk.out/asset.xxx/nodejs` 以下にバンドル結果を出力するようにする。
出力すればあとはAWS CDKがよいようにしてくれる。

```typescript:LocalBundler#tryBundle
import { BundlingOptions, Duration, ILocalBundling } from 'aws-cdk-lib';
  :
export class LocalBundler implements ILocalBundling {
    :
  tryBundle(outputDir: string, options: BundlingOptions): boolean {
    try {
      const result = execFileSync(path.resolve(__dirname, 'bundle.sh'), {
        env: {
          PATH: process.env.PATH,
          BUNDLER_OUTPUT_DIR: outputDir,
          BUNDLER_ROOT_DIR: path.resolve(__dirname, '../src/lambda'),
        },
        timeout: Duration.seconds(60).toMilliseconds(),
        encoding: 'utf-8',
      });
      return true;
    } catch (e: any) {
      console.error(e);
      return false;
    }
  }
}
```

`bundle.sh` は開発中に単体で動作確認できるよう別ファイルにした。
ここで `npm ci` を行っている。というかそれしかやってない。

```bash:bundle.sh
output_dir="$BUNDLER_OUTPUT_DIR"
if [ -z "$output_dir" ]; then
  echo '$BUNDLER_OUTPUT_DIR is empty' 1>&2
  exit 1
fi
output_dir="$output_dir/nodejs"
mkdir -p "$output_dir"

root_dir="$BUNDLER_ROOT_DIR"
if [ -z "$root_dir" ]; then
  echo '$BUNDLER_ROOT_DIR is empty' 1>&2
  exit 1
elif [ ! -d "$root_dir" ]; then
  echo '$BUNDLER_ROOT_DIR is not found' 1>&2
  exit 1
fi

cd "$root_dir"
cp package.json package-lock.json "$output_dir"
npm ci --omit=dev --prefix "$output_dir"
```

### Dockerでのバンドル処理

ローカルでのバンドル処理ができなかった場合、Dockerでのバンドル処理にフォールバックする。
具体的には `tryBundle` が `false` を返した時にフォールバックする。
`/asset-output/` 以下に出力する必要があるので、ローカルでのバンドル処理と同様に `/asset-output/nodejs` 以下に出力する。
`lambda.Code.fromAsset('src/lambda')` で指定したパスが `/asset-input` にマウントされ、カレントディレクトリになっているのだと思う。

```typescript:project/lib/stack.ts
image: lambda.Runtime.NODEJS_16_X.bundlingImage,
command: [
  'bash',
  '-c',
  [
    'mkdir -p /asset-output/nodejs',
    'cp package.json package-lock.json /asset-output/nodejs/',
    'npm ci --omit=dev --prefix /asset-output/nodejs',
  ].join(' && '),
],
user: 'root',
```

## Function

`lambda.Function` を使うとTypeScriptのままデプロイされてしまうので言語に特化したモジュールを使う。
TypeScriptの場合 `aws-cdk-lib/aws-lambda-nodejs` の `NodejsFunction` を使う。
バンドル処理以外の使い方は `lambda.Function` と同じ。

```typescript:project/lib/stack.ts
const fn = new NodejsFunction(this, 'Function', {
  entry: `src/lambda/handlers/func1/main.ts`,
  handler: 'handler',
  bundling: {
    externalModules: external,
  },
  runtime: lambda.Runtime.NODEJS_16_X,
  layers: [layer],
});
```

`bundling` にはesbuildのオプションを指定する。
`externalModules` には配列でバンドル対象外のモジュールを指定する。
ここではLayerに含めたモジュールを除外している。

```typescript:project/lib/stack.ts
bundling: {
  externalModules: external,
},
```

除外するモジュールの一覧は `src/lambda/package.json` の `dependencies` から取得した。
`package.json` を import したかったので `tsconfig.json` に `"resolveJsonModule": true` を追加してある。

```typescript:project/lib/stack.ts
import * as manifest from '../../src/lambda/package.json';
  :
const external = Object.keys(manifest.dependencies);
```

先ほど作ったLayerを指定する。
`fn.addLayers(layer)` でもいい。

```typescript:project/lib/stack.ts
layers: [layer],
```

# 実行

実行してみる。
`bundle.sh` で `set -x` しているので色々でている。
Dockerでのバンドル処理は初回pullが走ったり、2回目以降もそれなりに時間がかかる。
環境的に問題がなければローカルでのバンドル処理が手っ取り早いと思う。

```
$ cdk synth

Bundling asset xxx/Layer/Code/Stage...
+ output_dir=/xxx/project/cdk.out/asset.xxx
+ '[' -z /xxx/cdk.out/asset.xxx ']'
+ output_dir=/xxx/cdk.out/asset.xxx/nodejs
+ mkdir -p /xxx/cdk.out/asset.xxx/nodejs
+ root_dir=/xxx/src/lambda
+ '[' -z /xxx/src/lambda ']'
+ '[' '!' -d /xxx/src/lambda ']'
+ cd /xxx/src/lambda
+ cp package.json package-lock.json /xxx/cdk.out/asset.xxx/nodejs
+ npm ci --omit=dev --prefix /xxx/cdk.out/asset.xxx/nodejs
Bundling asset xxx/Function/Code/Stage...

  cdk.out/bundling-temp-xxx/index.js  1.0kb

⚡ Done in 5ms
Successfully synthesized to /xxx/cdk.out
```