tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

TypeScript の AWS Lambda function を2000倍見栄え良くする

AWS Lambda Advent Calendar 13日目の記事です。

qiita.com

今回は AWS Lambda function で TypeScript の 関数を書くときにコンソール上で2000倍(※当社比)見栄え良くする方法を書きます。

ちなみにここでの"見栄えの良さ"はAWS コンソール上での読みさすさのことです! 副次的な効果として、最初のロード時のコードサイズが限界まで小さくなるので、パフォーマンスも良くなるかもしれません。(未検証)

なお、これはAWS CDKを使うことが前提となっています。

通常の場合

まずは比較対象として、何もせずにTypeScriptでLambda functionを書いてデプロイしてみます。コードはこちらです。

https://github.com/tomoima525/clean-nodejs-lambda

TypeScriptを使う場合、 aws_lambda_nodejs.NodejsFunction を使ってデプロイするのが一般的です。

new lambda_nodejs.NodejsFunction(this, "simple-llm", {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: "handler",
      entry: path.join(`${__dirname}/../`, "functions", "simple-llm/index.ts"),
    });

今回は、今はやりのLLMフレームワークである Langchainを使って簡単な応答を返す関数を書きました。

import { OpenAI } from "langchain/llms/openai";

export const handler = async (event: any = {}): Promise<any> => {
  console.log("request:", JSON.stringify(event, undefined, 2));

  const llm = new OpenAI({
    temperature: 0.9,
  });

  const text =
    "What would be a good company name for a company that makes colorful socks?";

  const llmResult = await llm.predict(text);

  console.log("=====", llmResult);
};

これをデプロイした結果が以下です。

AWSコンソールのスクショ

行数
何もしないとLambda functionのサイズは 509 kb、行数はおよそ 32000行となります。スクショを見ても明らかな通り、大変可読性が低いですね。

NodejsFunction は内部的に esbuildを利用しているため、全てのライブラリをバンドルしてひとつのindex.jsを生成します。そのためこのようにバカでかいファイルが生成されてしまうのです。

それでは2つのステップを通じてこれを読みやすくしてみましょう。

見栄え良く書くステップ1 - Lambda Layer

最初のステップは、lambda layerを活用することです。”当たり前じゃん!” と思う人もいるかもしれませんが、よく紹介される /opt/ 配下にライブラリを置く方法(例 https://www.cloudtechsimplified.com/aws-lambda-layers/ ) だと、esbuildによってすべてのコードがバンドルされてしまうため、ファイルサイズは減らず、可読性はあがりません。ここでは nodejs 配下にlayerのファイルを置く方法でバンドルファイルに含まない方法を取ります。

まず トップディレクトリに layers/llm-layer/nodejs というディレクトリを作ります。

ディレクトリ構造

次に nodejs 以下で npm init します。ここがポイントで、プロジェクトがパッケージマネジャーに pnpm を使おうが yarn を使おうが、npmを使ってください。なぜかというと、 pnpmyarn はキャッシュを利用するため、その分 node_modules のサイズが大きくなってしまうからです。

そして必要なライブラリ(今回は langchain )をインストールします。

次に LLM用のLayerを追加します。

const llmLayer = new lambda.LayerVersion(this, "llmLayer", {
      compatibleRuntimes: [lambda.Runtime.NODEJS_18_X],
      code: lambda.Code.fromAsset(
        path.join(`${__dirname}/../`, "layers/llm-layer"),
      ),
      description: "Langchain LLM Layer",
    });

そしてこのLayerを最初の Lambda functionに追加します。もう一点、レイヤーのライブラリを利用するため、 externalModules を指定します

new lambda_nodejs.NodejsFunction(this, "simple-llm", {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: "handler",
      entry: path.join(`${__dirname}/../`, "functions", "simple-llm/index.ts"),
      layers: [llmLayer],
      bundling: {
        // layerに含まれるライブラリはbundleから省く
        externalModules: ["langchain"],
      },
    });

注意点として、Lambda functionに含まれるライブラリのバージョンとlayerに含まれるライブラリのバージョンをあわせることがあります。ローカル開発時ではLambda functionに含まれるライブラリを参照するので問題ないですが、デプロイ後はLayerのライブラリを参照することになるので、場合によっては動かなくなることもあります。

では、これを再度デプロイしてみます。

layer適用後

関数のサイズは 852b、コード数は 35行 まで削減できました!

見栄え良く書くステップ2 - ESM

ステップ1だけでもだいぶ見やすくなったのですが、まだ前半の commonjs のコードが気になりますね。そこで登場するのが ESM(ECMA Script Module) です。実は Lambda function はしばらく前から ESM がサポートされています。これを有効化すると、commonjs の変換コードが不要になるわけです。以下をNodejsFunctionに追加します。

new lambda_nodejs.NodejsFunction(this, "simple-llm", {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: "handler",
      entry: path.join(`${__dirname}/../`, "functions", "simple-llm/index.ts"),
      layers: [llmLayer],
      bundling: {
        // layerに含まれるライブラリ
        externalModules: ["langchain"],
        format: lambda_nodejs.OutputFormat.ESM,
      },
    });

そして ESMをpackageでも有効化します。

{
  "name": "simple-llm",
  "packageManager": "yarn@3.2.1",
  "dependencies": {
    "langchain": "^0.0.206"
  },
  "type": "module"
}

デプロイしてみます。

行数 2000倍分!

サイズは442b、コードはわずか15行まで削減されました。
元のコードと比較すると 2140倍分もコードが削減されたことになります! めちゃくちゃ見やすいしコンソール上でちょっといじったりもしやすい!!

最終的なコードはこちらです。

https://github.com/tomoima525/clean-nodejs-lambda/tree/clean-typescript

小ネタ: ESMに対応してないライブラリについて

Lambda functionはESMに対応しているのに、非常に残念ながら一部の aws-sdkのライブラリ(例 secret-manager-sdk)は対応対応しておらず、Lambda functionに含むとランタイムエラーを起こします。

{
    "errorType": "Error",
    "errorMessage": "Dynamic require of \"crypto\" is not supported",
    "stack": [
        "Error: Dynamic require of \"crypto\" is not supported",
        "    at file:///var/task/index.mjs:12:9",
     ...

この回避策としては bundleにエントリーポイントを追加することと、ESM シンタックスのサポートを加えてあげる方法があります。

bundling: {
        format: OutputFormat.ESM,
        mainFields: ['module', 'main'], // moduleを先にする
        target: 'esnext',
     // ESMでCommonjsを読めるようにする
        banner: "import { fileURLToPath } from 'url'; import { createRequire } from 'module';const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename);",
      }

まとめ

この記事ではAWSコンソール上で 32000 行 あったTypeScriptベースの Lambda functionを 15 行まで減らし(※なのでおおよそ2000倍分 ^^;)、可読性を劇的に上げる方法を紹介しました。"2000倍"ってケースバイケースやんというツッコミは年末ということでご容赦ください。ESMはTypeScriptに記法が近いので、積極的に切り替えて行きたいところです。