AWS Lambda Node.jsのnode_modulesの肥大化に立ち向かったが、NestJSがガンガン動的なrequireをしてきて困った
問題
NestJSのようなバンドルしてくれない動的なrequireを含むTypeScriptプロジェクトをAWS CDKでAWS Lambdaにデプロイする - ncaq
でデプロイに成功したプロジェクトがあるのですが、現在Node.jsのLambdaのnode_modules
が膨れ上がってしまい、
AWS Lambdaの制限に引っかかってデプロイ出来ない。
制限に引っかからないようにnode_modules
をLayerとして分離しているが、それでも制限に引っかかってしまう。
11:05:08 | UPDATE_FAILED | AWS::Lambda::LayerVersion | NodeModulesLayer29E0D577
Unzipped size must be smaller than 262144000 bytes (Service: AWSLambdaInternal; Status Code: 400; Error Code: InvalidParameterValueException; Request ID: 07f5a226-6e2f-4de0-81db-15d51284e379; Proxy: null)
解決案
バンドルしてツリーシェイキングするなどの方法は思いつくが、 NestJSを使ったコードをバンドルする筋の良い方法が思いつかない。 externalを大量指定すれば出来るのだろうけど、自分が去った後に機能追加して読み込まれないトラブルが発生しそうな気がする。
よってとりあえずプロダクションに不要そうなパッケージをnode_modules
から排除することを検討する。
Dockerランタイムを使えば問題は即座に解決するのだが、それは実行負荷もかかるので最終手段にする。
容量の調査
yarn install --frozen-lockfile --production
した結果のnode_modules
を、
sudo du --human-readable --one-file-system .|sort --human-numeric-sort --reverse|less
して長大な容量を取っているパッケージを調査。
322M .
77M ./aws-sdk
62M ./typescript
59M ./typescript/lib
38M ./aws-sdk/clients
33M ./date-fns
28M ./typeorm
という感じ。 250MB制限なので322-62=260で、 aws-sdk v2とTypeScriptさえ削除すればどうにかなりそう。なんでv2を今どき使っているのかと言うと、 typeorm-aurora-data-api-driver - npm が必要としているからです。
aws-sdkの問題解決失敗
aws-sdk v2はランタイムのものを参照するようにすれば良いはずなのですが、聞いた所によると試しにやってみたら、 typeorm-aurora-data-api-driverがエラーを吐いて諦めたらしいです。
Lambda runtimes - AWS Lambda
によると、
AWS SDK for JavaScriptは2.1055.0
が利用できるはず。
ソースコードを読んでみたけどaws-sdkへのrequire
が失敗する理由が全くわからない。
バンドルするけどwebpackは無理
諦めてバンドルすることにします。
ただ前回の記事でも述べた通りNestJSのwebpackサポートはnode_modules
までカバーしてくれないので、
nest build --webpack
を実行するだけではrequire
は全然消えません。
まず設定ファイルを読み込むには--webpackPath
でnest build --webpack --webpackPath webpack.config.js
のように、設定ファイルのパスを完全指定する必要があるようです。ここにもTypeScriptを使いたかったのですが方法がさっとはわからなかったので棚上げ。
うーん全然わからない。ページに載ってるwebpackの設定をいくつか試してみたのですが、全くバンドルがされる気配が無い。
いやもうやってられませんねこれは。 Bundle a NestJS + TypeORM application (with webpack) - Stack Overflow 見ても長大な設定ファイルが並ぶばかり。これを理解してメンテナンスするのは困難です。
NestJSもwebpackもまともに取り合わないほうが良さそうです。
仕事じゃなければwebpackとまともに向き合ったかもしれませんが、時間制限があるから仕方がない。マトモにとりあえる相手では無かった。
だんだんNestJSへの嫌悪感が増してきました。
結局esbuildを使う
NodejsFunction で使われるesbuildにexternalを指定する方針で行った方がまだ良さそうです。後々トラブルの元になりそうなのはちょっと心配ですが…
externalがたくさん必要
こうなりました。
bundling: {
externalModules: [
"aws-sdk", // Use the 'aws-sdk' available in the Lambda runtime
// NestJSが動的遅延読み込みするモジュール。
"@nestjs/microservices",
"@nestjs/platform-express",
"@nestjs/websockets/socket-module",
"cache-manager",
],
},
class-validator
, class-transformer
は実際に使っていました。しかしclass-transformer
の方は古いバージョンを読み込んでいるようで、
0.5.x系ではpackage.json
に記述していてもclass-transformer/storage
は読み込めません。なので、
Module not found: Error: Can't resolve 'class-transformer/storage' - Angular Universal / NestJs - Stack Overflow
に従い0.3.xを使うことにします。
色々といじってたら想定外のエラー
Error: Cannot find module '@nestjs/platform-express'
となります。えっ?
それは普通にdependencies
に入っているのだが…
バンドル後のソースを探ってみるとrequire("@nestjs/platform-express")
だけ残ってしまっている。
https://github.com/nestjs/nest/blob/004c6742fdc90c67ad26227431bc2099c1c4a05b/packages/core/nest-factory.ts#L252
ここですね。シングルファイルにしたいから当然これはエラーになるわけだ。
うーんシングルファイルにするの諦めたほうが良いのだろうか。
技術的な筋としてはランタイムのaws-sdkを読み込めない問題を解決したり、何故かプロダクションにTypeScriptが混入してるのを直したほうが良いのだろうか。いやしかしバンドルしないでデッドコード除去もしないでライブラリを使うというのもそれはそれで異常事態ではないか? そんな異常なデプロイをNestJSは前提にしている気がするのでとても辛い気持ちになってくる。
とりあえず今求められているのは正しい方法ではなくとりあえず動く方法なので、中間択としてrequire
が残っているやつだけをlayerにまとめることにする。
デコレータに対応しようと試みる
esbuildにも問題があり、デコレータに対応してないので以下のようなエラーになってしまいます。
Column type for Foo#bar is not defined and cannot be guessed. Make sure you have turned on an \"emitDecoratorMetadata\": true option in tsconfig.json. Also make sure you have imported \"reflect-metadata\" on top of the main entry file in your application (before any entity imported).If you are using JavaScript instead of TypeScript you must explicitly provide a column type.
素直にpreCompilation: true
したら大量のjsファイルが同じディレクトリに生成されて同じエラー。
TypeScriptを対象に選んでるからそうなるか。
depsLockFilePath
を指定しておきます。
esbuildをAWS CDK外部で制御する
emitDecoratorMetadata
関連のエラーが解決できていなかった。うーん…
esbuildをAWS CDKから呼び出すことに固執せず、ローカルで呼び出してみてはどうか。つまりnest build
を呼び出してtsc経由でJavaScriptにしてそれをバンドルする。これが何故困難だったかというと、
command hookを使った場合でもエントリー指定の方が先になるため、ビルドしてない状態だと実行ファイルが見つからないエラーになってしまうため。しかし通常のLambdaのAssetのコードを使う場合は問題がないはずだ。
AWS CDKに固執しないなら、
thomaschaaf/esbuild-plugin-tsc: An esbuild plugin which uses tsc to compile typescript files.
を使うのも選択肢の一つかもしれない。
ここで問題になるのがnode_modules
に配備した@nestjs/platform-express
軍団がどの程度の大きさになるかですが、
5MBぐらいなら大丈夫でしょう。
nodeModulesLayer
をexclude: ["*"]
にしていても初期データにnode_modules
があることに気が付かなかったので、クリーンなディレクトリを作ってそこに移動する必要がありました。
code: lambda.AssetCode.fromAsset(path.join(__dirname, "..", "api"), {
bundling: {
image: DockerImage.fromRegistry("node:16-bullseye"),
command: [
"bash",
"-c",
`
mkdir /tmp/skeleton/
cd /tmp/skeleton/
yarn add @nestjs/platform-express
cp -r node_modules /asset-output/
`,
],
},
exclude: ["*"],
}),
あとまた@nestjs/common
とかもrequire
取り除けないので追加していく感じですね。
追加していって、これパッケージ管理されたソフトウェアの開発じゃないなと気が付きました。またこれはpackage.json
を見ないため、ライブラリのバージョンが破壊されてしまうでしょう。
ブラックリスト形式なら良いのではないか
ローカルのesbuildのおかげでちゃんとバンドル出来ているライブラリはnode_modules
から消しても構わないのであるので、一度yarn install --frozen-lockfile --production
したものから、デカいけどバンドル出来ているパッケージを削除すれば良さそうだなと感じました。
こういうのをcommand
に指定すれば良し。
yarn install --frozen-lockfile --production
# 中間ディレクトリを作ってデカいがバンドル出来ているパッケージを削除していく。
temp=$(mktemp -d)
cp -r node_modules $temp
cd $temp
rm -r node_modules/{aws-sdk,@aws-sdk,typescript}
# シンボリックリンクが切れるとCDKがエラーを出すので、シンボリックリンクを含むディレクトリは削除してしまう。
find node_modules -name .bin -print0|xargs -0 rm -r
cp -r node_modules /asset-output/
cd /
rm -r $temp
もちろんこれではバンドルしているものとnode_modules
の内部でパッケージが重複しているが、あえて無視することにします。美しくはないが、とりあえず動くものを作らないといけない。
メモリ使用量が増えた
これまでは使わない所は動的にrequire
してなかったけれど、一つのファイルに全部バンドルするようにしたので一気に全部展開するからなのか、メモリ使用量が増えました。
私は間違っていると思う
この方法は絶対に間違っていると思う。
ただwebpack使えば良かったかというと別に問題は解決していないので、謎ですね。
バンドルされたコード辺とか見てると、
return require(path.resolve(process.cwd() + "/node_modules/" + name2));
とか出てきたし、この辺がrequire
出来ない原因なのかもしれない。
そもそも依存関係というのは基本的に静的に定まってほしいと思う。システムの共有ライブラリを使ったりしてあえて変化させる必要のあるものは仕方ないかもしれないが、そうではないものはバンドルしたい。
NestJSに詳しい人はマトモな方法を知っているのかもしれない。教えてください。