Node.jsのLambdaコールドスタートで熱を上げる方法

ある顧客のLambdaのコールドスタートを90%削減した!

ある顧客が、Momentoを使って初めてのNode.js Lambda関数を書いていました。彼らは、Momento APIを1回呼び出すだけのシンプルな関数で、コールドスタート時間が1秒以上かかることを報告しました。

それは受け入れがたい状況です!Lambda環境についての最初の観察結果は以下の通りでした:

・このLambda関数が行っていたのは、Momentoにgrpcコールを1回行い、レスポンスを返すことだけでした。Lambda関数全体のコードは25行程度でした。
Webpackを使用してビルドおよびバンドル
サーバーレス・フレームワークを使用してデプロイ
・それは、@gomomento/sdkへの単一の依存関係を持っていました
・オリジナルのバンドル・サイズは1.5MB
・コールドスタート時間は最大1000ms
・Lambda関数は768MBのメモリで構成されていました

Lambdaのコールドスタートを深く掘り下げた我々の目標

私たちはこの調査で次のことを達成したいと定義しました:

・Lambdaのパッケージサイズの最小化
・Lambdaのコールドスタート時間の最小化
・LambdaのMBサイズと請求期間/総コストのバランスを見つける

コールドスタート時間を改善するために最初に試したのは、Lambdaのメモリサイズを調整することでした。256、512、1024のメモリー構成をテストしたが、コールドスタート時間に意味のある変化はなありませんでした。

次に、serverless-esbuildプラグインを使ってみました。Node.jsのLambda関数のビルドにはesbuildを社内で使っており、良い結果が出ています。このプラグインを使った経験はなかったが、試してみる価値はあると思いました。

npm i --save-dev serverless-esbuild

そして次の行をesbuildファイルに追加しました:


plugins:
  - serverless-esbuild

custom:
  esbuild:
    bundle: true
    minify: true
    packager: 'npm'
    target: 'node18'
    watch:
      # anymatch-compatible definition ()
      pattern: ['src/**/*.js'] #

この単純な最適化により、バンドルサイズが1.5mbから~260kbに減少しました。同じ768MBの予約メモリでデプロイした場合、コールドスタート時間は〜100msに短縮されました。これは90%の削減です!顧客はこの結果に非常に満足し、この知識を活かして別のNode.jsのLambda関数を260kbから42kbに削減しました。

このプラグインは何をやっているのでしょうか?Node.jsのLambda関数をバンドルすることで、コールドスタート時間を短縮できるのではないか?

Momento Node.js Lambda関数

前述の通り、Momentoではサーバーレス・フレームワークを使用していません。Lambda関数のビルドとバンドルにサーバーレス・フレームワークを使い始め、デプロイにCDKを使い続けることは有効ですが、新たなビルド依存関係を追加することなく、同様の改善が得られるかどうかを確かめたかったのです。

私は、実世界の例を持つために、本番で積極的に使用しているLambda関数でこの実験を行うことにしました。依存関係がない/限定的なテスト用Lambda関数を使うのは、公平な比較とは言えません。もし私たちが導き出したソリューションが、依存関係のないLambda関数でしか機能しないのであれば、それはあまり有用ではないでしょう。

最適化のために選んだLambda関数は、バンドル・サイズが2.8mbでスタートしました。これからいくつかのパラメーターを微調整して、コールドスタートを最適化する方法を試してみます:

・バンドル方法
・メモリサイズ
・アーキテクチャ
私たちは現在、LambdaのアーキテクチャにX86を使用していますが、パフォーマンスについて深く掘り下げる一方で、Armアーキテクチャについても調べる価値があると考え、コールドスタートと課金期間にどのような影響があるかを調べました。

まず最初に、現在の本番環境でのパフォーマンスのベースラインを取得し、今後どのように改善できるかを確認します。本番環境のLambda関数は2.8MBにzip圧縮されています。これは、Node.js18ランタイムを実行するX86アーキテクチャを使用して、256MBのメモリ関数としてデプロイされます。

いくつかの異なる構成でテストした結果、2.8MBバンドルでの結果は以下の通りだった:

では、esbuildを使ってLambda関数をバンドルしてみましょう。まず注目すべき点は、バンドルサイズが2.8mbから677kbにすぐに縮小されたことです。677kbのバンドルで全く同じテストを実行した結果です:

コールドスタート時間は、esbuildを使うことで大幅に短縮されました。バンドルサイズはコールドスタート時間にしか影響しないはずなので、これは理にかなっています。

次に試したかったのは、出力ファイルのminifyです。これにより、理論的には、出力ファイルファイル内の空の空白や冗長なデータがすべて削除されるため、バンドルのサイズが多少小さくなるはずです。これを行うには、esbuild設定ファイルのbuild関数にminify: trueという行を追加します:


build({
  entryPoints,
  bundle: true,
  outdir: path.join(__dirname, outDir),
  outbase: functionsDir,
  platform: 'node',
  sourcemap: 'inline',
  write: true,
  tsconfig: './tsconfig.json',
  keepNames: true,
  minify: true, // this line
}).catch(() => process.exit(1));

Lambda関数を構築した結果は2.7mbになりました。これは意外でした!もっとバンドル・サイズが小さくなると思っていたのに。

次に、ソース・マップを外部化してみましょう。ソース・マップは、コードとミニファイされたコードとをリンクするのに役立ちます。ソースマップはデバッグに使用され、例えばエラーの行番号を取得するために使用されます。バンドル・サイズはパフォーマンスに直接影響するため、バンドル・サイズを小さくすることは、インライン・ソースマップでアプリケーションをバンドルすることよりも重要です。serverless-esbuildライブラリも、Lambdaアセットにインラインでソースマップをバンドルしないことは注目に値します。

出力からソース・マップを削除するには、sourcemap: 'inline'sourcemap: external'に変更します。ビルド・フォルダーの出力は次のようになります:


dist
├── integrations-api
│   ├── handler.js
│   ├── handler.js.map
│   └── integrations-api.zip
└── webhooks
    ├── handler.js
    ├── handler.js.map
    └── webhooks.zip

.js.mapファイルはソース・マップ・ファイルで、handler.jsから取り出されます。

新しいバンドルサイズの結果は…590kb 😀です。これはserverless-esbuildが生成するものよりさらに小さい!

しかし、それ以上のことができるだろうか?keepNamesをfalseに設定したらどうだろう?これはesbuildに関数名と変数名を最小化するように指示します。keepNames: falseでビルドした結果は566kbです。この566kbのバンドルを使ってテストしてみましょう:

これはすごいことです!既存のLambda構成を使用することで、コールドスタート時間を1秒から600ミリ秒に短縮することができました!

Lambdaファンクションでは、AWS SDKを多用しています。AWS SDKは特別で、そのバージョンがLambdaランタイムに含まれています。これにより、Node.jsパッケージからaws-sdkを外部化することができます。これは、私が読んだことのある一般的な最適化です。Node.jsのLambdaランタイムには実行時に利用可能なAWS SDKのバージョンが含まれているので、理論的にはLambdaアセットにバンドルする必要はありません。試してみましょう。

そのためには、ビルド・パラメーターにexternal: ['@aws-sdk/*']をビルド・パラメーターに追加すします。


import * as fs from 'fs';
import * as path from 'path';
import {build} from 'esbuild';

const functionsDir = 'src';
const outDir = 'dist';
const entryPoints = fs
  .readdirSync(path.join(__dirname, functionsDir))
  .filter(entry => entry !== 'common')
  .map(entry => `${functionsDir}/${entry}/handler.ts`);

build({
  entryPoints,
  bundle: true,
  outdir: path.join(__dirname, outDir),
  outbase: functionsDir,
  platform: 'node',
  sourcemap: 'external',
  write: true,
  minify: true,
  tsconfig: './tsconfig.json',
  keepNames: false,
  external: ['@aws-sdk/*'], // adding this line here
}).catch(() => process.exit(1));

これで、Lambda・アセットをバンドルすると、375kbになります😱。パフォーマンスを見てみましょう。

興味深いことに…バンドルサイズが大幅に削減されたにもかかわらず、コールドスタートの時間は元の時間に戻ったようです。コールドスタート時に、Lambdaランタイム内に存在するAWS SDKのバージョンと私たちのコードをリンクする、動的なリンクが行われているのでしょう。

これではうまくいかないので、ビルドからaws-sdkの外部化を削除しても大丈夫だと思われます。これには次のような利点があります:

・私たちのバンドルには実行に必要なすべての依存関係が含まれているため、ローカルでLambdaを実行できます。
・Lambda ランタイムにバンドルされているバージョンに依存するのではなく、AWS SDK を明示的なバージョンに固定できること。

これが最終的なesbuildの設定です。


import * as fs from 'fs';
import * as path from 'path';
import {build} from 'esbuild';

const functionsDir = 'src';
const outDir = 'dist';
const entryPoints = fs
  .readdirSync(path.join(__dirname, functionsDir))
  .filter(entry => entry !== 'common')
  .map(entry => `${functionsDir}/${entry}/handler.ts`);

build({
  entryPoints,
  bundle: true,
  outdir: path.join(__dirname, outDir),
  outbase: functionsDir,
  platform: 'node',
  sourcemap: 'external',
  write: true,
  tsconfig: './tsconfig.json',
  minify: true,
  keepNames: false,
}).catch(() => process.exit(1));

アーキテクチャ

これらのテストを通じて、我々はx86arm_64の両方のLambdaアーキテクチャをテストし、両者に違いがあるかどうかを確認しました。arm_64アーキテクチャは一貫してx86アーキテクチャよりも遅くなりました。arm_64の方が高速なEC2のバックエンドワークロードをいくつかベンチマークしているので、これは少し意外でした。しかし、Lambdaの内部では、arm_64で一貫して~15%の性能低下が見られました。このため、残りのテストはx86アーキテクチャに集中することにします。

最後の数回のテストは、Lambdaのメモリ構成を最適化するためのものです。この表では、2つの新しいカラムを追加しています: 実行時間(cold)と実行時間(warm)です。これらは、Lambdaメモリの量が何らかの形で実行を制限しているかどうかを判断するのに使われます。

これらの結果から、パフォーマンスとラムダ・メモリのスイート・スポットは、512mb → 768mbあたりにあるようです。今のところはラムダ関数のメモリーを512mbに増やし、コールドスタート時に速度低下が見られるようになったら、次のステップとしてラムダメモリーのサイズを1段階上げることになると思います。

結論

Momentoでは、esbuildを使用してLambdaのパッケージングとコールドスタート時間を改善しました。コードを最小化し、ソースマップを外部化することで、コールドスタート時間を〜40%短縮することができました!今後もこのパターンを使用して、Lambda関数を小さく高速に保つ予定です。また、新しい複数のLambda関数のリポジトリを立ち上げたり、既存のアプリケーションに関数を追加したりするための定型的なテンプレートを提供することで、開発者のサイクルを短縮します。

コールドスタートの王様、AJ Stuyvenbergについても触れないわけにはいきません。コールドスタートをさらに減らしたいなら、遅延ロードの依存関係の特定に関する彼の記事をぜひチェックしてください!

これらの実験から得られた知見に基づく、Node.js Lambda関数のバンドル方法の完全な実例に興味がありますか?この GitHub リポジトリをチェックしてください!