• 作成:

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は全然消えません。

まず設定ファイルを読み込むには--webpackPathnest 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ぐらいなら大丈夫でしょう。

nodeModulesLayerexclude: ["*"]にしていても初期データに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に詳しい人はマトモな方法を知っているのかもしれない。教えてください。