NestJSのようなバンドルしてくれない動的なrequireを含むTypeScriptプロジェクトをAWS CDKでAWS Lambdaにデプロイする
JavaScriptとかPython見ると分かるけど依存関係を動的に解決することを許した時、それは地獄への直行だ
— エヌユル (@ncaq) July 31, 2022
問題
NestJSがnest build
で吐き出すjsファイルはバンドルされていないので、色々入っているnode_modules
を参照できる環境でないと実行に失敗します。
よって1ファイルにしてAWS Lambdaとかで実行するのにはバンドルが必要だと思い、
aws-cdk-lib.aws_lambda_nodejs module · AWS CDK
を使ってesbuildでバンドルすれば良いのかなと思いましたが、
NestJSは分岐先でpackage.json
に書かれていないライブラリを対象にrequire
しまくるので、
esbuildはCould not resolve "@nestjs/websockets/socket-module"
とか言ってバンドルに失敗します。
Command hooksとか試してみましたがダメでした。
webpack
オプションを有効にしてバンドルさせてみましたが、これはアプリケーションのソースコードをバンドルさせるだけで、
node_modules
以下のソースをバンドルはしてくれません。
なのでlayerとしてnode_modules
を入れる方法を使いましたが、ローカルのnode_modules
はdevDependencies
のものも含むため、サイズが爆発してAWS LambdaがUnzipped size must be smaller than 262144000 bytes
とか言ってデプロイに失敗します。
プロトタイプとしてこれまではデプロイする時に、まずyarn install
してbuildしてyarn install --production
してnode_modules
を生成し直すという方法を使っていたようですが、私は忘れっぽいし怠惰なのでやりたくありませんでした。
CDKのconstructor
でコマンド実行させるという方法はありますが、
cdk ls
とかでビルドが走るのは嫌すぎますね。
この解決策をちゃんと完全に書いているページが見つからなかったため、記述しておきます。
解決
import path from "path";
import {
Stack,
StackProps,
Duration,
DockerImage,
IgnoreMode,
} from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
export class BackendLambdaStack extends Stack {
lambdaFunction: lambda.Function;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// `node_modules`をレイヤーとして分離する。
// なぜこのようなことを行っているのかというと、
// NestJSは普通`node_modules`のバンドルを行わず、
// 別途用意してランタイム時の`require`に任せる必要があるからである。
// `lambda_nodejs`を使ってesbuildで全体をバンドルして一つのファイルにすれば良いのでは無いかと思うかもしれないが、
// NestJSは動的に分岐した先で`require`を行うため、
// esbuildなどのツールは`package.json`に書かれてないライブラリが`require`されたというエラーを返す。
// かと言って全部の`require`を満たすようにinstallすると、
// 本来不要のredisライブラリなども全てインストールする必要がありコードサイズがたいへん膨らむ。
// よって分離してランタイムの失敗可能性がある`require`を許容している。
const nodeModulesLayer = new lambda.LayerVersion(this, "NodeModulesLayer", {
code: lambda.AssetCode.fromAsset(path.join(__dirname, "..", "api"), {
bundling: {
image: DockerImage.fromRegistry("node:16-bullseye"),
command: [
"bash",
"-c",
`
yarn install --frozen-lockfile --production
cp -r node_modules /asset-output/
`,
],
},
ignoreMode: IgnoreMode.GIT,
}),
compatibleRuntimes: [lambda.Runtime.NODEJS_16_X],
});
this.lambdaFunction = new lambda.Function(this, "LambdaFunction", {
runtime: lambda.Runtime.NODEJS_16_X,
code: lambda.AssetCode.fromAsset(path.join(__dirname, "..", "api"), {
bundling: {
image: DockerImage.fromRegistry("node:16-bullseye"),
command: [
"bash",
"-c",
`
yarn install --frozen-lockfile
yarn build
cp -r dist/* /asset-output/
`,
],
},
}),
handler: "main.handler",
layers: [nodeModulesLayer],
environment: {
NODE_PATH: "$NODE_PATH:/opt/node_modules",
AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
},
timeout: Duration.seconds(30),
});
}
}
今回のソースコードの投稿は許可を得てるので完全なものを置きます。
灯台下暗しな感じでlambda_nodejs
ではなく通常のlambda
のモジュールである、
class AssetCode · AWS CDK
を使えば解決できました。
これはバンドルのオプションを豊富に指定できるため、
yarn install --frozen-lockfile --production
で実行に必要なモジュールだけをインストールしてnode_modules
を生成できます。
この指定してあるDockerImage
はあくまでビルドに使っているイメージです。実行時にはruntime: lambda.Runtime.NODEJS_16_X
で指定してあるようにAWS Lambdaの言語ランタイムで動きます。よってslimとかを指定しなくても良いわけです。
神経質に互換性を気にする場合は、
Runtime.NODEJS_16_X.bundlingImage
などを指定して、
public.ecr.aws/sam/build-nodejs16.x
をビルドのイメージに使うことも出来ますが、
yarnとか入ってないので少し面倒です。
Amazon Linux 2ベースのランタイム向けにDebianベースのイメージでビルドしたくない気持ちは少しあります。
corepackとか使うこと前提なら大した問題にはならないのかもしれません。
layerの中身見れなくて少し戸惑いましたが、自動的に/opt
のトップレベルに配置されるため、このcp
の形式だと/opt/node_modules
をNODE_PATH
に指定する必要があります。
別解
これを社内チャットで公開したら、失敗するやつをexcludeに指定すれば良くて、自分はそうしているという意見がありました。確かにlambda.NodejsFunction
でもnodeModules
オプションはあるのでそれ経由でesbuildのexternalに渡れば問題無さそうです。
それなら1ファイルに出来ますし、
require
が動的に失敗する可能性を排除できますし、
aws-cdk-lib.aws_lambda_nodejs module · AWS CDK
というnodejs特化のスタックが使えるから色々と楽ですね。
ただあまりにもNestJSは動的にrequire
しているのが多く、一つ潰したらそれ経由で他のやつがやってきて全貌は把握していないので列挙するのが大変そうなのと、プロジェクト構成が変わって本当に必要になった時にnodeModules
に記述していることを忘れてハマりそうなのが怖いなと思いました。またNestJSのバンドルしないというのも一応AWS Lambdaでコードが把握できるというメリットは無くは無いんですよね。一長一短ですね。
結末
結局バンドルしました。
AWS Lambda Node.jsのnode_modulesの肥大化に立ち向かったが、NestJSがガンガン動的なrequireをしてきて困った - ncaq