Nest.jsのアプリケーションをなんとかしてAWS Lambdaで動作させるようにした

こんちは、最近コストコに初めて行ったんですが、どれも量が多すぎて結局賞味期限が長いクッキーぐらいしか買えませんでした。
そんな中表題の通り、Nest.jsのアプリケーションをAWS Lambda上で動作させるようにしました。

なんのために?

Nest.jsのアプリケーションをAWSにデプロイする際には、ECS(Fargate)やEC2など様々なパターンが考えられます。コストをできるだけ抑えたいのであれば、ServerlessであるLambdaが候補に上がってくることが多いと思います、ですが、アプリケーションコードをLambda専用で記述する必要が出てきてしまいます。

アプリケーションへのリクエストが次第に増えていくと、Lambdaに比べてEC2やECSなどがコストパフォーマンスに優れるようになるのですが、そのときにアプリケーションのコードをLambdaから分離させるのはそこそこ大変だと思っています。

そこで、アプリケーションをLambdaに(ほぼ)依存させずに通常のWebサーバーとして開発し、EC2やECSに移行が容易にできるようにすることで、この問題を解決できると思っています。

サーバーサイドTypeScriptでこのようなことを可能にするライブラリはすでにいくつかあり、例えばExpressのアプリケーションをCodeGenieApp/serverless-expressというツールでwrappingすることでLambda専用にすることができます。また、Pythonでも同様のものは存在しています。(初学者に優しいスモールスタートで始めるPythonアプリケーションを考えてみた

私が最近作ってるアプリケーションではNest.jsを利用しているので、これをLambdaで動作できるようにしてみました。

1.Nest.jsのアプリケーションの修正

以下の場合にはLambdaで動作させることができません

  • Nest.jsをExpress以外のFW(例えばFastify)上で動作させている場合
  • Lambdaデプロイ時のパッケージサイズが250MBを超える場合

基本的にはNest.js公式ドキュメントのFAQ>Serverlessに記載されている対応内容に沿っていきます。

まずは、CodeGenieApp/serverless-expressをインストールします。以前はライブラリ名が@vendia/serverless-expressでしたが最近変わったみたいです。
また、@nestjs/platform-express がインストールされていない場合はこれも必要になるためインストールしましょう
また、公式ドキュメントでaws-lambdadependenciesに保存していましたが、後述のパッケージサイズの問題からdevDependenciesが好ましいです。
また、webpackでビルドすることでコールドスタートの最適化が図れますが、トラブルが起こる可能性が高いので、一旦はwebpackを利用せずにデプロイできるところまで進みましょう

npm i @codegenie/serverless-express @nestjs/platform-express
npm i -D aws-lambda @types/aws-lambda 

次に、Lambda関数が実行する基点となるファイル(ex. serverless.ts)を作成します。 中身はNest.jsのentryFile(通常はmain.ts)を以下の通り修正する必要があります。

+import * as serverlessExpress from '@codegenie/serverless-express';
+let server: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);
  await app.init();

-  await app.listen(3000);
+  const expressApp = app.getHttpAdapter().getInstance();
+  // @ts-ignore
+  return serverlessExpress({ app: expressApp });
}

+export const handler: Handler = async (
+  event: any,
+  context: Context,
+  callback: Callback,
+) => {
+  server = server ?? (await bootstrap());
+  return server(event, context, callback);
+};

ちなみに、もしserverlessExpressを公式ドキュメントの手順通りに設定すると実行時にエラーが出てしまいます。CommonJS周りのエラーっぽいのでimport * as serverlessExpressとして、servrlessExpressの初期化は型エラーが出たので@ts-ignoreさせてます(多分型定義か何かがおかしいのでしょう)

{
    "errorType": "Runtime.UnhandledPromiseRejection",
    "errorMessage": "TypeError: (0 , serverless_express_1.default) is not a function",
    "reason": {
        "errorType": "TypeError",
        "errorMessage": "(0 , serverless_express_1.default) is not a function",
        "stack": [
            "TypeError: (0 , serverless_express_1.default) is not a function",
            "    at bootstrap (/var/task/dist/src/entrypoint/serverless.js:19:45)"
        ]
    },
    "promise": {},
    "stack": [
        "Runtime.UnhandledPromiseRejection: TypeError: (0 , serverless_express_1.default) is not a function",
        "    at process.<anonymous> (file:///var/runtime/index.mjs:1276:17)",
        "    at process.emit (node:events:517:28)",
        "    at emit (node:internal/process/promises:149:20)",
        "    at processPromiseRejections (node:internal/process/promises:283:27)",
        "    at process.processTicksAndRejections (node:internal/process/task_queues:96:32)"
    ]
}

2.Nest.jsのビルドとLambda関数のデプロイ

Lambdaでは、TypeScriptは実行できないのでNest.jsのアプリケーションのコードはJavascriptにコンパイルする必要があります。

私は今回はCDKでデプロイを考えているので、NodejsFunctionコンストラクタを利用しCDK実行時にビルドするパターンか、あらかじめ手動でビルドするパターンのいずれかが可能です。

NodejsFunctionを利用すると裏側でesbuildが実行されているので高速にビルドできるのですが、Nest.jsが動作するためには追加の設定が必要そうだったので今回はNest.jsが提供するbuildコマンドを使って手動でビルドすることにしました

npm run nest build

実行後、distディレクトリにコンパイルされたコードが出力されます。
あとはこのdistディレクトリとnode_modulesディレクトリの2つをまとめて1つのzipファイルにすることでデプロイが可能です。
Lambda関数のハンドラーには、上記で作成したファイルのhandler関数を指定します(ex. dist/src/entrypoint/serverless.handler

私の環境ではCDKを利用しているので、以下のように記述してLambdaをデプロイしました。


    const mainapp = new lambda.Function(this, 'ftft-mainapp', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'dist/src/entrypoint/serverless.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../../backend/lambda-package')),
      environment: {
        NO_COLOR: "true"
      }
  });

ちなみに、NO_COLORを設定しないと出力されるログがCloudWatcLogs上で見にくくなってしまいます

[Nest] 8  - 01/08/2024, 7:31:58 AM     LOG [InstanceLoader] ConfigHostModule dependencies initialized +1ms

デプロイパッケージのサイズについて

Lambdaにデプロイできるzipファイルは最大で250MBです。何も考えずにnode_modulesをまるごと含めると簡単にこれを超えてしまいます。

Lambda の .zip デプロイパッケージの最大サイズは 250 MB (解凍) です。この制限は、Lambda レイヤーを含む、更新するすべてのファイルの合計サイズに適用されることに注意してください。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-package.html#python-package-create-update

そのため、私の環境ではいくつか工夫をする必要がありました。

devDependenciesのパッケージの削除

nest build後にnode_modulesからdevDependenciesに記載された不要なパッケージを削除し、デプロイパッケージのサイズを削減することをお勧めします。以下のコマンドでできるみたいです

npm prune --production
yarn --production
pnpm prune --prod

実行時に不要なパッケージをdevDependenciesに移動

また、上記で述べたように、aws-lambdaなどの実行に問題がないパッケージは全てdevDependenciesに保存することでも削減が可能です。私の環境ではtypescript

Lambda の .zip デプロイパッケージの最大サイズは 250 MB (解凍) です。この制限は、Lambda レイヤーを含む、更新するすべてのファイルの合計サイズに適用されることに注意してください。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-package.html#python-package-create-update

パッケージマネージャーにはpnpmは利用できなさそう

パッケージマネージャーのpnpmでは、他のものに比べてnode_modulesのサイズが小さくなるとのことなので利用してみましたが、Lambda関数の実行中にnode_modulesのパッケージのimportがうまくいかずエラーが発生してしまいます。
以下でエラーが出てるtslib@nestjs/coreの依存するパッケージなので、おそらく外部パッケージがさらに依存するパッケージのimportがpnpmだとうまくいかないのではないかと考えてpnpm ipnpm i --shamefully-hoistに変えてみましたが特に挙動は変化しませんでした。

{
    "errorType": "Runtime.ImportModuleError",
    "errorMessage": "Error: Cannot find module 'tslib'\nRequire stack:\n- /var/task/node_modules/@nestjs/core/index.js\n- /var/task/dist/src/entrypoint/serverless.js\n- /var/runtime/index.mjs",
    "stack": [
        "Runtime.ImportModuleError: Error: Cannot find module 'tslib'",
        "Require stack:",
        "- /var/task/node_modules/@nestjs/core/index.js",
        "- /var/task/dist/src/entrypoint/serverless.js",
        "- /var/runtime/index.mjs",
        "    at _loadUserApp (file:///var/runtime/index.mjs:1087:17)",
        "    at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
        "    at async start (file:///var/runtime/index.mjs:1282:23)",
        "    at async file:///var/runtime/index.mjs:1288:1"
    ]
}

デプロイスクリプト

毎回手動でビルドやパッケージのインストールをするのが面倒だったのでビルド用のスクリプトを作って運用してます

#!/bin/bash -eu

rm -rf node_modules
yarn
yarn run build
yarn --production
rm -rf lambda-package
mkdir lambda-package
cp -R dist lambda-package
cp -R node_modules lambda-package
yarn run start:prod

まとめ

  • Lambda関数のデプロイパッケージのサイズに上限があるので、パッケージのサイズに気を使いましょう
  • パッケージインストール時にpnpmは避けたほうが良いかもしれません
tech  AWS  Lambda  NestJS 

See also