背景
みなさん、TypeScriptのCDKプロジェクトで同じTypeScriptを使うLambdaを追加する際に、なんとなくこういうやり方なんでしょうか。
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,
});
}
}
入口のTypeScriptファイル(NodejsFunction
のentry
パラム)を指定すれば、CDKが良しなにやってくれて助かります。Lambda関数のフォルダ配下にも、独立なPackageを作らずに済みます。
単純な処理やDependenciesもそんなに多くない場合なら、それでよいと思います。小さいところまでパッケージを切り出すとプロジェクト構成が複雑になりますから。
しかし、こうすると一つ最大な欠点が生じかねません:インフラを管理するCDKプロジェクトと振る舞いを管理するLambda関数が同じパッケージの下につく。
そのため、以下のようなこともあり得ます
-
tsconfig.json
も共有してしまう。設定いじらないと動かない場合、これなんのために設定変えたの?というのを場合によってインフラ側の責任者に説明しないといけないし、そもそもよくないやり方だ -
package.json
も同様な理由で、いっぱいDependenciesを導入すると、これインフラのため?Lambdaのため?って混乱を招きかねない
記事内のコードは、こちらのレポジに収納しました。
ランタイムの切り出し
やることは、まずランタイムの切り出しです。
mkdir lambda-package
npm init
npx tsc --init
上記のコマンドでもう一つ新しいフォルダを作って、それをNPMパッケージ化します。
そうすると、CDKプロジェクトと違うtsconfig.ts
を搭載できます。公式のCDK boilerplateだと、デフォルトのtarget
設定はCommonJSになっていますので、それをES modulesに変えるだけでも結構助かります。デコレーターの有効化設定など、CDKプロジェクトに入れちゃうと意味不明のものを独立のパッケージ内でやる方がよいでしょう。package.json
にも、上層のCDKプロジェクトに入っていないDependenciesを入れられます。
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環境に上がります。
CDK+esbuildの余談ですが、
CDKは既存環境でesbuild
を探して、それがない場合Dockerを起動して、Docker内でバンドルをやろうとします(遅い)。両方ない場合は失敗しますので、CDKプロジェクトを生成する前に、
npm i -D esbuild
で、ローカル環境のesbuildを確保したほうがスムーズになります。
本筋に戻ります。一見何の問題もない、スマートなやり方ですが、場合によって不具合が生じます。
まず、tsc
によるタイプチェックやトランスパイルが行われないことです。これ自体が普通で、現在(2025年3月)tsc
を使うトランスパイルに執着する必要がそもそも薄く、esbuild
などとにかく早いツールに世の中が乗り換えています。tsc
による動的なタイプチェックが必要の場合、開発環境でtsc
を使ってチェックすればよいかと、多くの人はそう思っています。しかし、それが100%通用するとは言えません。
筆者は昔TypeORMをLambda関数で使いたいシーンに出会いましたが、esbuild
がトランスパイル中にデコレーター情報をはがしてしまうため、tsc
によるトランスパイルが必要でした。それをCDKのバンドルプロセスで実現するworkaround(回避策)を記事で紹介しました。当時はこんな感じでLambdaを作成しました:
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によると、少なくとも現時点ではoutDir
が指定できないような状況です。
筆者の推測
普通、tsc
コマンドをファイル指定で実行する場合、フォルダ内のtsconfig
は適用されませんが、CDKのpreCompilation
は多分ちゃんとtsconfig
を見に行って、大体の設定をそのままコマンドのオプションに適用して、一部の設定(outDirなど)を強制指定しています。
せっかくパッケージとして切り出してほしい設定をいろいろ入れてみましたが、これだとモヤモヤが残りますね。
そこで、CDKから主導権を奪いたいと思いました。中身のプロセスも分かった前提なら、それを真似してプロセスをコントロールの下に置くっというのはいかがでしょうか。
バンドルの切り出し
mkdir lambda-mybundle
npm init
npx tsc --init
前々節と同様にNPMパッケージを新に作ります。ちょっと違うのは、tsconfig
のoutDir
パラムを./dist
に指定して、package.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を作成します
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,
});
}
}
ざっくり説明しますと、
- Lambdaを作成する前に、チャイルドプロセスで前に作成したパッケージでトランスパイル・バンドルを行う
- 入口ファイル指定じゃなくて、
package.json
のスクリプトで作成したアセットをLambda関数の中身として指定
これで、CDKと一切関係せずにLambdaのバンドルを行えます。
最後に
CDK?ランタイム?パッケージ?tsconfig?いろんな❔マークが頭に浮かびながらAWSアプリケーション作成を始めた初心者なりに、ようやくちょっと理解し始めたなあと思ったので、記事に残ります。
少しでもご参考になれば幸いです。