• 作成:

NestJSのようなバンドルしてくれない動的なrequireを含むTypeScriptプロジェクトをAWS CDKでAWS Lambdaにデプロイする

問題

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_modulesdevDependenciesのものも含むため、サイズが爆発して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_modulesNODE_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でコードが把握できるというメリットは無くは無いんですよね。一長一短ですね。