Amazon Web Services ブログ

AWS Lambda の Node.js 依存関係を最適化

この記事は、Optimizing Node.js dependencies in AWS Lambda を翻訳したものです。

AWS Lambda は Node.js バージョン 12、14、および 最近発表されたバージョン 16 をサポートしています。 Node.js は JavaScript をその場で解析、最適化して実行するため、サーバーレス環境での起動を高速化しオーバーヘッドを低く抑えることができます。Node.js は、エントリポイントから必要なすべての依存関係とソースを読み取って解析します。したがって、依存関係を最小限に抑えて実際に使用するものを最適化することが重要です。この記事では、Lambda 関数のコードをバンドル・軽量化 (minify) してパフォーマンスを最適化し、依存関係を最新に保つ方法を紹介します。

Node.js モジュール解決の理解

コード内でリソースを require または import すると、Node.js はファイル名、ディレクトリ名、または node_modules ディレクトリ内でそのリソースを解決しようとします。リソースが見つかると、ストレージから読み込まれ、解析されて実行されます。

そのファイルまたは依存モジュールにさらに他の require または import ステートメントが含まれている場合、プロセスが繰り返され、ストレージの読み取りが発生します。関数にインポートされる依存関係とファイルが多いほど、初期化にかかる時間は長くなります。

これはインポートされたコードと実際に使用されるコードに関してのみ影響します。つまりインポートまたは使用されていないファイルをプロジェクトに含めても、起動時のパフォーマンスへの影響は最小限です。

また、何がインポートされているかを評価する必要があります。esbuildRollupWebPack などの JavaScript バンドラーは tree shaking で実行されないコードを削除しますが、ワイルドカード、グローバルまたはトップレベルのインポートによる依存関係のインポートにより、バンドルサイズが大きくなる可能性があります。

ライブラリでサポートされている場合は、パスを指定したインポートを使用してください。

//es6
import DynamoDB from "aws-sdk/clients/dynamodb"
//es5
const DynamoDB = require("aws-sdk/clients/dynamodb")
JavaScript

ワイルドカードを使ったインポートは避けてください。

//es6
import {* as AWS} from "aws-sdk"
//es5
const AWS = require("aws-sdk")
JavaScript

トップレベルのインポートも避けましょう。

//es6
import AWS from "aws-sdk"
//es5
const AWS = require("aws-sdk")
JavaScript

AWS SDK for JavaScript V3

AWS Lambda の Node.js ランタイムが使用する AWS SDK のバージョンを管理するには、自分で AWS SDK を用意する必要があります。その際には AWS SDK for JavaScript V3 の使用を検討してください。AWS SDK V3は、サービスごとに個別のパッケージを備えたモジュラーアーキテクチャを採用しています。

これには、インストールの高速化や展開サイズの縮小など、多くの利点があります。また、ファーストクラスの TypeScript サポート新しいミドルウェアスタックなど、リクエストの多かった機能も多数含まれています。サービスごとに個別のパッケージがあり、トップレベルのインポートはできないため、起動時のパフォーマンスがさらに向上します。

ランタイムに依存しない AWS SDK を利用することで、ビルドプロセス中に軽量化してバンドルすることができ、コールドスタート時間の削減につながります。

Node.js Lambda 関数のバンドルと軽量化

esbuild を使用して Lambda 関数をバンドルして圧縮できます。これは入手可能な最速の JavaScript バンドラーの1つで、多くの場合、WebPack や Parcel のような代替バンドラーよりも10〜100倍高速です。

esbuild を使用するには:

1. npm または yarn を使用して esbuild を開発時の依存関係に追加します。

  • npm: npm i esbuild --save-dev
  • yarn: yarn add esbuild --dev

2. package.json ファイルの scripts セクションにビルドコマンドを記述します。

 "scripts": {
    "build": "rm -rf dist && esbuild ./src/* --entry-names=[dir]/[name]/index --bundle --minify --sourcemap --platform=node --target=node16.14 --outdir=dist",
 }
JSON

このスクリプトは最初に dist ディレクトリを削除し、次に下記のコマンドライン引数を使用して esbuild を実行します。

  • ./src/* まず、アプリケーションのエントリポイントを指定します。esbuild は、指定されたエントリポイントごとに、使用する依存関係のみを含む1つのバンドル(バンドルオプションが有効な場合)を作成します。
  • --entry-names=[dir]/[name]/index は、esbuild がエントリポイントと同じディレクトリ内のエントリポイントと同じ名前のディレクトリにバンドルを作成するように指定します。このバンドルの名前は index.js になります。
  • --bundle は、すべての依存関係とソースコードを 1 つのファイルにバンドルすることを示します。
  • --minify はコードを軽量化するために使用されます。
  • --sourcemap は、軽量化されたコードのデバッグに必要なソースマップファイルの作成に使用されます。軽量化されたコードは元のソースコードとは異なるため、ソースマップを使用すると JavaScript デバッガーは軽量化されたコードを元のコードにマップできます。ソースマップを生成するとデバッグに役立ちますが、サイズが大きくなってしまいます。ソースマップを適用するには有効化する必要があることに注意してください。Lambda 関数でソースマップを有効化するには、 NODE_OPTIONS 環境変数に --enable-source-maps を指定します。
  • --platform=node--target=node16.14 は、ターゲットとする ECMAScript のバージョンを指定するために使用されます。バンドラーを使用することで、新しい JavaScript の機能や構文を以前の標準に合わせてコンパイルできることがあります。AWS Lambda は Node.js 16 をサポートするようになったので、ここではターゲットを node16.14 に設定します。参考までに、 https://node.green/ を参照して Node.js のバージョンと ECMAScript の機能を確認してください。
  • --outdir=dist は、すべてのファイルが dist ディレクトリに出力されることを示します。

ビルド

yarn build または npm run build でビルドスクリプトを実行します。

パッケージ化とデプロイ

Lambda 関数をパッケージ化するには、dist ディレクトリに移動し、それぞれのディレクトリの内容を圧縮します。index.js と index.js.map のみを含む、関数ごとに 1 つの zip ファイルを作成する必要があることに注意してください。こちらのサンプルプロジェクトをクローンすることもできます。

もしすでに AWS CDK を使用している場合は、 NodeJsFunction コンストラクトの使用を検討してください。このコンストラクトはバンドル手順を抽象化し、内部的に esbuild を使用してコードをバンドルします。

const nodeJsFunction = new lambdaNodejs.NodejsFunction(
  this,
  "NodeJsFunction",
  {
    runtime: lambda.Runtime.NODEJS_16_X,
    handler: "main",
    entry: "../path/to/your/entry.js_or_ts",
  }
);
JavaScript

サンプルプロジェクトのビルドとデプロイ

すべてのソースをバンドルしたら、node_modules と元のソースファイルを圧縮するよりもファイルサイズが小さいことに気付くかもしれません。パッケージは100倍以上小さい場合があります。また、初期化も速くなります。

  1. こちらのサンプルプロジェクトをクローンし、次のコマンドを実行することで、依存関係をインストールし、プロジェクトをビルドしてアプリケーションをパッケージ化します。
    npm install
    npm run build
    npm run package
    npm run package:unbundled
    Bash

    これにより、dist ディレクトリとプロジェクトルートに zip アーティファクトが生成されます。dist/ddbHandler.zipunoptimized.zip のサイズの違いを比較すると、バンドルされていないアーティファクトは 10 倍以上もサイズが大きくなっています。展開すると、依存関係を含むコードのサイズは19Mbを超えますが、バンドル・軽量化されたものでは2.1Mbです。

    この ddBHandler の例では、依存関係にある AWS SDK DynamoDB モジュールに複数のファイルとリソースが含まれているため、影響が大きくなっています。

  2. アプリケーションをデプロイするには、以下を実行します。
    npm run deploy
    Bash

結果の測定と比較

デプロイ後はコールドスタートのパフォーマンスも大幅に改善することが分かります。Artillery を使用して Lambda 関数の負荷テストを行うことができます。下記のコマンドでは、デプロイ時に出力される URL で対象を置き換えてください。

バンドルされていない Lambda 関数の負荷テスト

artillery run -t "https://{YOUR_ID_HERE}.execute-api.eu-west-1.amazonaws.com" -v '{ "url": "/x86/v2-top-level-unbundled" }' loadtest.yml
Bash

バンドルされた Lambda 関数の負荷テスト

artillery run -t "https://{YOUR_ID_HERE}.execute-api.eu-west-1.amazonaws.com" -v '{ "url": "/x86/v3" }' loadtest.yml
Bash

結果を CloudWatch Insights で確認するには、2 つの関数のロググループを選択し、次のクエリを実行します。

Logs Insights

filter @type = "REPORT"
| parse @log /\d+:\/aws\/lambda\/[\w\d]+-(?<function>[\w\d]+)-[\w\d]+/
| stats
count(*) as invocations,
pct(@duration+greatest(@initDuration,0), 0) as p0,
pct(@duration+greatest(@initDuration,0), 25) as p25,
pct(@duration+greatest(@initDuration,0), 50) as p50,
pct(@duration+greatest(@initDuration,0), 75) as p75,
pct(@duration+greatest(@initDuration,0), 90) as p90,
pct(@duration+greatest(@initDuration,0), 95) as p95,
pct(@duration+greatest(@initDuration,0), 99) as p99,
pct(@duration+greatest(@initDuration,0), 100) as p100
group by function, ispresent(@initDuration) as coldstart
| sort by function, coldstart
SQL

下記の結果を見ると、DDBV3x86 のコールドスタート呼び出しは 551 ミリ秒(p90)で実行されますが、ddbvzTopLevelX86Unbundled では 945 ミリ秒(p90)で実行されます。つまり軽量化してバンドルした AWS SDK V3 バージョンでは、コールドスタートが約 1.7 倍高速になり、ウォームスタート時のパフォーマンスも高速になることが分かりました。

Performance results

まとめ

この記事では、コードをバンドルして軽量化することで、Node.js のコールドスタートのパフォーマンスを最大 70% 向上させる方法を学びました。また、JavaScript 用 AWS SDK の別バージョンを提供する方法と、依存関係やそのインポート方法が Node.js Lambda 関数のパフォーマンスに影響を与えることも学びました。最高のパフォーマンスを実現するには、AWS SDK for JavaScript V3 を使用し、コードをバンドルして軽量化し、トップレベルのインポートは避けてください。

その他のサーバーレス学習リソースについては、サーバーレスを始めようServerless Land をご覧ください。

この記事はシニアパートナーソリューションアーキテクトのリチャード・デイヴィッドソンが執筆し、パブリックセクターソリューションアーキテクトの松井佑馬が翻訳しました。