Node.jsランタイムのLambdaでNodeモジュールを使いたいときにやったこと

## はじめに
こんにちは。フリューでサーバサイド開発をしています、kitajimaです。最近CDKに入門しました。  
弊チームでは先日、API Gateway + Lambdaの構成をCDKで構築し、APIを実装する機会がありました。その際Node.jsで書いたLambdaスクリプト単体をアップロードしたところ、`"Unable to import module"`が発生しました。
この記事ではその際対応したことを紹介させていただこうと思います。
同じようにNode.jsランタイムのLambdaを初めて構築してみたい方の参考になれば幸いです。

※本記事と同様の内容を弊社テックブログでも掲載しております。
https://tech.furyu.jp/lambda-node-modules/

## 環境
- AWS CDK v2
- CDK実装 TypeScript
- Lambdaランタイム Node.js 16.x
- Lambdaスクリプト実装 TypeScript

## 状況再現
そのときのインフラ構成の一部を再現したものはこちらです。Constructは`aws_lambda.Function`を使用しており、Lambdaに関する他のリソースは作成していませんでした。

```typescript
const function1 = new aws_lambda.Function(this, 'Function', {
  code: AssetCode.fromAsset('./src'),
  handler: "hello-world.handler",
  runtime: Runtime.NODEJS_16_X,
});
```

## 何が求められているか
LambdaではすべてのNode.jsライブラリがパッケージ化されているわけではなく、自前でアップロードする必要があったためでした。  
[Node.js 内の Lambda コードについて「モジュールをインポートできません」エラーを解決する](https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-import-module-error-nodejs/)

これに対処すべく、今回は以下の2つの方法を試してみました。

### node_modulesをLambda Layerにアップロードする
#### やったこと
1. 必要なnode_modulesをプロジェクトのnodejs/bundle内に用意します。
2. 1.をLambda Layerにアップロードするために以下のリソースを定義します。
    ```typescript
    const lambdaLayer = new LayerVersion(this, 'Layer', {
      code: AssetCode.fromAsset("./bundle"),
    });

    const function1 = new aws_lambda.Function(this, 'Function', {
      code: AssetCode.fromAsset( './src'),
      handler: "hello-world.handler",
      runtime: Runtime.NODEJS_16_X,
    });
    function1.addLayers(lambdaLayer);
    ```
3. `cdk deploy`を叩きます。

#### 結果
node_modulesがLambda Layerにアップロードされ、Lambda関数からライブラリが利用できるようになります。


### モジュールをバンドルする
#### やったこと
1. 以下のように[aws_lambda_nodejs.NodejsFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html) を使用してLambda関数を構築します。

    ```typescript
    const function1 = new NodejsFunction(this, 'function', {
      handler: 'hello-world',
      entry: './src/hello-world.ts',
      runtime: Runtime.NODEJS_16_X,
    });
    ```

2. `cdk deploy`を叩きます。

#### 結果
ライブラリとプロダクトコードがバンドルされた1つのJavaScriptファイルが、Lambda関数としてアップロードされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/473717/9f701441-f4db-db6a-bcde-dfc3d39c14a3.png)


<details><summary>index.js</summary>

```javascript:index.js
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  mod
));

// asset-input/node_modules/ulid/dist/index.umd.js
var require_index_umd = __commonJS({
  "asset-input/node_modules/ulid/dist/index.umd.js"(exports2, module2) {
    (function(global, factory) {
      typeof exports2 === "object" && typeof module2 !== "undefined" ? factory(exports2) : typeof define === "function" && define.amd ? define(["exports"], factory) : factory(global.ULID = {});
    })(exports2, function(exports3) {
      "use strict";
      function createError(message) {
        var err = new Error(message);
        err.source = "ulid";
        return err;
      }
      var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
      var ENCODING_LEN = ENCODING.length;
      var TIME_MAX = Math.pow(2, 48) - 1;
      var TIME_LEN = 10;
      var RANDOM_LEN = 16;
      function replaceCharAt(str, index, char) {
        if (index > str.length - 1) {
          return str;
        }
        return str.substr(0, index) + char + str.substr(index + 1);
      }
      function incrementBase32(str) {
        var done = void 0;
        var index = str.length;
        var char = void 0;
        var charIndex = void 0;
        var maxCharIndex = ENCODING_LEN - 1;
        while (!done && index-- >= 0) {
          char = str[index];
          charIndex = ENCODING.indexOf(char);
          if (charIndex === -1) {
            throw createError("incorrectly encoded string");
          }
          if (charIndex === maxCharIndex) {
            str = replaceCharAt(str, index, ENCODING[0]);
            continue;
          }
          done = replaceCharAt(str, index, ENCODING[charIndex + 1]);
        }
        if (typeof done === "string") {
          return done;
        }
        throw createError("cannot increment this string");
      }
      function randomChar(prng) {
        var rand = Math.floor(prng() * ENCODING_LEN);
        if (rand === ENCODING_LEN) {
          rand = ENCODING_LEN - 1;
        }
        return ENCODING.charAt(rand);
      }
      function encodeTime(now, len) {
        if (isNaN(now)) {
          throw new Error(now + " must be a number");
        }
        if (now > TIME_MAX) {
          throw createError("cannot encode time greater than " + TIME_MAX);
        }
        if (now < 0) {
          throw createError("time must be positive");
        }
        if (Number.isInteger(now) === false) {
          throw createError("time must be an integer");
        }
        var mod = void 0;
        var str = "";
        for (; len > 0; len--) {
          mod = now % ENCODING_LEN;
          str = ENCODING.charAt(mod) + str;
          now = (now - mod) / ENCODING_LEN;
        }
        return str;
      }
      function encodeRandom(len, prng) {
        var str = "";
        for (; len > 0; len--) {
          str = randomChar(prng) + str;
        }
        return str;
      }
      function decodeTime(id) {
        if (id.length !== TIME_LEN + RANDOM_LEN) {
          throw createError("malformed ulid");
        }
        var time = id.substr(0, TIME_LEN).split("").reverse().reduce(function(carry, char, index) {
          var encodingIndex = ENCODING.indexOf(char);
          if (encodingIndex === -1) {
            throw createError("invalid character found: " + char);
          }
          return carry += encodingIndex * Math.pow(ENCODING_LEN, index);
        }, 0);
        if (time > TIME_MAX) {
          throw createError("malformed ulid, timestamp too large");
        }
        return time;
      }
      function detectPrng() {
        var allowInsecure = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : false;
        var root = arguments[1];
        if (!root) {
          root = typeof window !== "undefined" ? window : null;
        }
        var browserCrypto = root && (root.crypto || root.msCrypto);
        if (browserCrypto) {
          return function() {
            var buffer = new Uint8Array(1);
            browserCrypto.getRandomValues(buffer);
            return buffer[0] / 255;
          };
        } else {
          try {
            var nodeCrypto = require("crypto");
            return function() {
              return nodeCrypto.randomBytes(1).readUInt8() / 255;
            };
          } catch (e) {
          }
        }
        if (allowInsecure) {
          try {
            console.error("secure crypto unusable, falling back to insecure Math.random()!");
          } catch (e) {
          }
          return function() {
            return Math.random();
          };
        }
        throw createError("secure crypto unusable, insecure Math.random not allowed");
      }
      function factory(currPrng) {
        if (!currPrng) {
          currPrng = detectPrng();
        }
        return function ulid3(seedTime) {
          if (isNaN(seedTime)) {
            seedTime = Date.now();
          }
          return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN, currPrng);
        };
      }
      function monotonicFactory(currPrng) {
        if (!currPrng) {
          currPrng = detectPrng();
        }
        var lastTime = 0;
        var lastRandom = void 0;
        return function ulid3(seedTime) {
          if (isNaN(seedTime)) {
            seedTime = Date.now();
          }
          if (seedTime <= lastTime) {
            var incrementedRandom = lastRandom = incrementBase32(lastRandom);
            return encodeTime(lastTime, TIME_LEN) + incrementedRandom;
          }
          lastTime = seedTime;
          var newRandom = lastRandom = encodeRandom(RANDOM_LEN, currPrng);
          return encodeTime(seedTime, TIME_LEN) + newRandom;
        };
      }
      var ulid2 = factory();
      exports3.replaceCharAt = replaceCharAt;
      exports3.incrementBase32 = incrementBase32;
      exports3.randomChar = randomChar;
      exports3.encodeTime = encodeTime;
      exports3.encodeRandom = encodeRandom;
      exports3.decodeTime = decodeTime;
      exports3.detectPrng = detectPrng;
      exports3.factory = factory;
      exports3.monotonicFactory = monotonicFactory;
      exports3.ulid = ulid2;
      Object.defineProperty(exports3, "__esModule", { value: true });
    });
  }
});

// asset-input/src/hello-world.ts
var import_ulid = __toESM(require_index_umd());
exports.handler = async () => {
  const response = {
    statusCode: 200,
    headers: {
      "Content-Type": "text/html"
    },
    body: `random ID: ${(0, import_ulid.ulid)()}`
  };
  return response;
};

```
</details>

なお、`aws_lambda_nodejs.NodejsFunction`の場合、[esbuild](https://esbuild.github.io/)というビルドツールがTypeScriptのトランスパイルも行ってくれます。  
※トランスパイル(トランスコンパイル)...別のプログラミング言語に変換すること。今回のケースでは、TypeScriptをJavaScriptへ変換することです。  
Node.jsランタイムで動作させるにはJavaScriptへの変換が必要ですが、トランスパイルによって手元で変換せずにデプロイが可能となります。

## どちらを採用するか
今回は、以下の理由から[モジュールをバンドルする](https://qiita.com/macaroni_/private/58bd4e462ad9c2f6f7f7#%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%82%92%E3%83%90%E3%83%B3%E3%83%89%E3%83%AB%E3%81%99%E3%82%8B)を採用することにしました。
1. Lambdaのサイズ上限250MBに引っかかった  
node_modulesをLambda Layerにアップロードしたい場合、[Lambdaのデプロイパッケージサイズ上限](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)に引っかかる可能性があります。今回はこちらに引っかかってしまい、デプロイ時にエラーが発生しました。  
`aws_lambda_nodejs.NodejsFunction`を使用してesbuildによる単一ファイルへのバンドルが実行される際、node_modules全てではなく必要最低限の依存関係のみ含むためにサイズが小さくなったのだと推測しています。  
要検証ですが、1.の問題はnode_modulesの適切なダイエットを実施すれば回避可能かもしれません。


2. Lambda Layerの場合、必要なnode_modulesをプロジェクトのnodejs/内に用意する必要がありますが、こちらはConstructがよしなに配置してくれるものではありません。デプロイの度に手動でコピーするなり、コピー用のスクリプトを書いたり、何らかの方法で事前に配置する必要があるようです。  

こちらの参考記事では、デプロイ時に走るようなセットアップ用のスクリプトを自作しておられました。  
[AWS CDK を使って node_modules を AWS Lambda Layers にデプロイするサンプル](https://dev.classmethod.jp/articles/aws-cdk-node-modules-lambda-layer/)

[モジュールをバンドルする](https://qiita.com/macaroni_/private/58bd4e462ad9c2f6f7f7#%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%82%92%E3%83%90%E3%83%B3%E3%83%89%E3%83%AB%E3%81%99%E3%82%8B)を採用したので、スクリプトを用意、管理する手間も発生しませんでした。

## 最後に
CDKをがっつり触ったのは今取り組んでいる施策が初めてで、リソースをプログラマブルに用意できる感覚は非常に便利に感じました。不慣れなインフラ部分もこれで構築すると楽しいです。  
また、API Gateway + Lambdaは代表的なサーバレス構成なので、今回学んだことは今後の業務にも活かされるだろうと思っています。皆様にも参考になれば幸いです。  
お読みいただきありがとうございました！ご指摘などありましたらよろしくお願いいたします。