LambdaのCDKバンドルから逃げたい話

# 背景

みなさん、TypeScriptのCDKプロジェクトで同じTypeScriptを使うLambdaを追加する際に、なんとなくこういうやり方なんでしょうか。

```typescript
export class CdkMybundleExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fn_bundlebycdk = new NodejsFunction(this, 'bundlebycdk', {
      entry: 'lambda-bundle-by-cdk/index.ts',
      handler: 'handler',
      runtime: Runtime.NODEJS_20_X,
    });
  }
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/0277306d-0dca-4614-acb8-698e549c6097.png)

入口のTypeScriptファイル（`NodejsFunction`の`entry`パラム）を指定すれば、CDKが良しなにやってくれて助かります。Lambda関数のフォルダ配下にも、独立なPackageを作らずに済みます。
単純な処理やDependenciesもそんなに多くない場合なら、それでよいと思います。小さいところまでパッケージを切り出すとプロジェクト構成が複雑になりますから。

しかし、こうすると一つ最大な欠点が生じかねません：インフラを管理するCDKプロジェクトと振る舞いを管理するLambda関数が同じパッケージの下につく。

そのため、以下のようなこともあり得ます

* `tsconfig.json`も共有してしまう。[設定いじらないと動かない場合](https://qiita.com/zhang_hang/items/aee1fce2695884eee733)、これなんのために設定変えたの？というのを場合によってインフラ側の責任者に説明しないといけないし、そもそもよくないやり方だ
* `package.json`も同様な理由で、いっぱいDependenciesを導入すると、これインフラのため？Lambdaのため？って混乱を招きかねない

記事内のコードは、こちらの[レポジ](https://github.com/zhang-hang-valuesccg/cdk-mybundle-example)に収納しました。

# ランタイムの切り出し

やることは、まずランタイムの切り出しです。

```bash
mkdir lambda-package
npm init
npx tsc --init
```
上記のコマンドでもう一つ新しいフォルダを作って、それをNPMパッケージ化します。
そうすると、CDKプロジェクトと違う`tsconfig.ts`を搭載できます。公式のCDK boilerplateだと、デフォルトの`target`設定はCommonJSになっていますので、それをES modulesに変えるだけでも結構助かります。デコレーターの有効化設定など、CDKプロジェクトに入れちゃうと意味不明のものを独立のパッケージ内でやる方がよいでしょう。`package.json`にも、上層のCDKプロジェクトに入っていないDependenciesを入れられます。

```typescript
export class CdkMybundleExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fn_package = new NodejsFunction(this, 'package', {
      entry: 'lambda-package/src/index.ts',
      handler: 'handler',
      runtime: Runtime.NODEJS_20_X,
    });
  }
}
```
このように、同じやり方でデプロイできます。

しかし、ここまでやっても、結局**CDKに任している**ような状況だと言わざるを得ないのです。

# CDKバンドル

CDKのConstructーー`NodejsFunction`で`entry`パラムを指定すると、何が起こるのでしょう。
もちろん、公式ドキュメントに明記しております：

> class NodejsFunction (construct)
> A Node.js Lambda function bundled using esbuild.

`esbuild`を使うバンドルを行うのです。一つの入口を指定すれば、`esbuild`はそれを起点にして、関連ファイルを全部一つの`index.js`に整合して、CDKがそれをLambda環境に上がります。

<details><summary>CDK+esbuildの余談ですが、</summary>

CDKは既存環境で`esbuild`を探して、それがない場合Dockerを起動して、Docker内でバンドルをやろうとします（遅い）。両方ない場合は失敗しますので、CDKプロジェクトを生成する前に、

```bash
npm i -D esbuild
```

で、ローカル環境のesbuildを確保したほうがスムーズになります。

</details>

本筋に戻ります。一見何の問題もない、スマートなやり方ですが、場合によって不具合が生じます。

まず、`tsc`によるタイプチェックやトランスパイルが行われないことです。これ自体が普通で、現在（2025年3月）`tsc`を使うトランスパイルに執着する必要がそもそも薄く、`esbuild`などとにかく早いツールに世の中が乗り換えています。`tsc`による動的なタイプチェックが必要の場合、開発環境で`tsc`を使ってチェックすればよいかと、多くの人はそう思っています。しかし、それが100%通用するとは言えません。

筆者は昔TypeORMをLambda関数で使いたいシーンに出会いましたが、`esbuild`がトランスパイル中にデコレーター情報をはがしてしまうため、`tsc`によるトランスパイルが必要でした。それをCDKのバンドルプロセスで実現するworkaround（回避策）を[記事](https://qiita.com/zhang_hang/items/aee1fce2695884eee733)で紹介しました。当時はこんな感じでLambdaを作成しました：

```typescript
    const fn = new NodejsFunction(this, "typeorm-lambda", {
      //...
      bundling: {
        preCompilation: true, 
        esbuildArgs: {
          "--resolve-extensions": ".js",
        },
      },
    });
```

ここの`preCompilation`は、`esbuild`バンドルの前に`tsc`トランスパイルすることでデコレーター情報を守るためです。中身の振る舞いとしては、

まず
```
tsc "...\index.ts"
    --alwaysStrict
    ...[options]...
    --outDir ./
    --rootDir ./
    ...
```
`tsc`コマンドで目標ファイル（入口ファイル）指定でtscを実行します。

そして
```
    esbuild 
    --bundle "...\index.js"
    --target=node20
    --platform=node
    --outfile="...\cdk.out\...\index.js"
    --external
```
`esbuild`コマンドでバンドルを行います。

このプロセスの欠点を申し上げますと、`tsc`コマンドはフォルダ内の`tsconfig`を一部適用しない場合があります。[こちらのissue](https://github.com/aws/aws-cdk/issues/19841)によると、少なくとも現時点では`outDir`が指定できないような状況です。

<details><summary>筆者の推測</summary>

普通、`tsc`コマンドをファイル指定で実行する場合、フォルダ内の`tsconfig`は適用されませんが、CDKの`preCompilation`は多分ちゃんと`tsconfig`を見に行って、大体の設定をそのままコマンドのオプションに適用して、一部の設定（outDirなど）を強制指定しています。

</details>


せっかくパッケージとして切り出してほしい設定をいろいろ入れてみましたが、これだとモヤモヤが残りますね。

そこで、**CDKから主導権を奪いたい**と思いました。中身のプロセスも分かった前提なら、それを真似してプロセスをコントロールの下に置くっというのはいかがでしょうか。

# バンドルの切り出し

```bash
mkdir lambda-mybundle
npm init
npx tsc --init
```
前々節と同様にNPMパッケージを新に作ります。ちょっと違うのは、`tsconfig`の`outDir`パラムを`./dist`に指定して、`package.json`でいくつかスクリプトを追加します：

```json
{
  "scripts": {
    "build": "tsc",
    "bundle": "esbuild --bundle dist/index.js --outfile=bundle/index.js --platform=node --target=node20",
    "produce": "npm-run-all2 build bundle"
  },
}
```
CDKでは、こういう風にLambdaを作成します

```typescript
export class CdkMybundleExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    execSync('npm i && npm run produce', {
      cwd: 'lambda-mybundle',
      stdio: 'inherit',
      shell: 'bash',
    });

    const fn_mybundle = new NodejsFunction(this, 'mybundle', {
      code: Code.fromAsset('lambda-mybundle/bundle'),
      handler: 'index.handler',
      runtime: Runtime.NODEJS_20_X,
    });
  }
}
```
ざっくり説明しますと、

1. Lambdaを作成する前に、チャイルドプロセスで前に作成したパッケージでトランスパイル・バンドルを行う
1. 入口ファイル指定じゃなくて、`package.json`のスクリプトで作成したアセットをLambda関数の中身として指定

これで、CDKと一切関係せずにLambdaのバンドルを行えます。

# 最後に

CDK？ランタイム？パッケージ？tsconfig？いろんな❔マークが頭に浮かびながらAWSアプリケーション作成を始めた初心者なりに、ようやくちょっと理解し始めたなあと思ったので、記事に残ります。

少しでもご参考になれば幸いです。