0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda を深掘りする(3) 公式Node.jsランタイムの分析

0
Posted at

Lambda Node.js 24.x Runtime ソースコードの抽出

Runtime のソースコードを抽出する方法については、How to Extract AWS Lambda Runtime Source Code: Using Node.js as an Example を参照してください。

bootstrap の分析

Runtime を抽出した後、分析を開始します。

すべての Runtime のエントリーポイントは bootstrap であるため、ここから分析を始めます。

完全な bootstrap スクリプトの内容と関連する Runtime の JS ファイルは こちら から取得できます。

Node モジュール検索パスの設定

bootstrap スクリプトを開くと、冒頭に Node モジュール検索パスを設定するコードがあります。

if [ -z "$NODE_PATH" ];
then
  nodejs_mods="/opt/nodejs/node_modules"
  nodejs24_mods="/opt/nodejs/node24/node_modules"
  runtime_mods="/var/runtime/node_modules"
  task="/var/runtime:/var/task"
  export NODE_PATH="$nodejs24_mods:$nodejs_mods:$runtime_mods:$task"
fi

ここではまず環境変数 NODE_PATH が存在するかを確認し、存在しない場合は以下の5つのパスを Node.js モジュールの検索パスとして設定します。

  • /opt/nodejs/node_modules
  • /opt/nodejs/node24/node_modules
  • /var/runtime/node_modules
  • /var/runtime
  • /var/task

Node メモリ制限の設定

if [ -n "$AWS_LAMBDA_FUNCTION_MEMORY_SIZE" ];
then
  # For V8 options, both '_' and '-' are supported
  # Ref: https://github.com/nodejs/node/pull/14093
  semi_space_str_und="--max_semi_space_size"
  old_space_str_und="--max_old_space_size"

  semi_space_str=${semi_space_str_und//[_]/-}
  old_space_str=${old_space_str_und//[_]/-}

  # Do not override customers' semi and old space size options if they specify them
  # with NODE_OPTIONS env var. If they just set one, use the default value from v8
  # for the other.
  case $NODE_OPTIONS in
  *$semi_space_str_und*);;
  *$old_space_str_und*);;
  *$semi_space_str*);;
  *$old_space_str*);;
  *)
    # New space should be 5% of AWS_LAMBDA_FUNCTION_MEMORY_SIZE, leaving 5% available for buffers, for instance,
    # very large images or JSON files, which are allocated as C memory, rather than JavaScript heap in V8.
    new_space=$(($AWS_LAMBDA_FUNCTION_MEMORY_SIZE / 10))
    # The young generation size of the V8 heap is three times the size of the semi-space,
    # an increase of 1 MiB to semi-space applies to each of the three individual semi-spaces
    # and causes the heap size to increase by 3 MiB.
    semi_space=$(($new_space / 6))
    # Old space should be 90% of AWS_LAMBDA_FUNCTION_MEMORY_SIZE
    old_space=$(($AWS_LAMBDA_FUNCTION_MEMORY_SIZE - $new_space))
    MEMORY_ARGS=(
      "$semi_space_str=$semi_space"
      "$old_space_str=$old_space"
    )
    ;;
  esac
fi

次に bootstrap は環境変数 AWS_LAMBDA_FUNCTION_MEMORY_SIZE が存在するかを確認し、存在する場合は V8 エンジンのメモリ制限パラメータ --max_semi_space_size--max_old_space_size を設定し、MEMORY_ARGS 配列に追加します。

この配列は後続の Node コマンド実行時に追加パラメータとして渡され、Lambda 関数が適切なメモリ制限の下で実行されることを保証します。

Node 証明書パスの設定

続いて、bootstrap スクリプトは証明書のディレクトリを設定します。

# If NODE_EXTRA_CA_CERTS is being set by the customer, don't override. Else, include RDS CA
if [ -z "${NODE_EXTRA_CA_CERTS+set}" ]; then
  # Use the default CA bundle in regions that have 3 dashes in their name
  if [ "${AWS_REGION:0:6}" != "us-gov" ] && [ "${AWS_REGION//[^-]}" == "---" ]; then
    export NODE_EXTRA_CA_CERTS=/etc/pki/tls/certs/ca-bundle.crt
  fi
fi

ここではユーザーが既に NODE_EXTRA_CA_CERTS 環境変数を設定しているかを確認します。

  • 設定されていない場合、AWS_REGION 環境変数の値(AWS Lambda サービスによって実行時に自動設定される)に基づいて NODE_EXTRA_CA_CERTS 環境変数を設定するかどうかを判断します。
  • 既に設定されている場合、ユーザーの設定を上書きしません。

プリセット環境変数 AWS_EXECUTION_ENV の設定とスレッドプールサイズの設定

export AWS_EXECUTION_ENV=AWS_Lambda_nodejs24.x

# Set UV_THREADPOOL_SIZE to 16 in multi-concurrency environments if not already set
if [ -n "$AWS_LAMBDA_MAX_CONCURRENCY" ] && [ -z "$UV_THREADPOOL_SIZE" ]; then
  export UV_THREADPOOL_SIZE=16
fi

その後、bootstrapAWS_EXECUTION_ENV 環境変数を AWS_Lambda_nodejs24.x に設定します。

そして Lambda Managed Instances モードの場合、スレッドプールのサイズを設定します(これについては後述します)。

NOTE: Lambda 環境には多くのプリセット環境変数があります(参照:Lambda デフォルト環境変数)。ドキュメントによると、AWS_EXECUTION_ENV はドキュメント上では Reserved environment variables に分類されていますが、実際には Lambda サービスが自動設定するのではなく、bootstrap スクリプトによって設定されています。AWS_REGION などの他の環境変数は Lambda サービスによって自動的に設定されます。

Node.js Runtime イベントループの起動

最後に bootstrap スクリプトは以下のコマンドを実行して、Node.js Runtime のイベントループを正式に起動します。

NODE_ARGS=(
    --expose-gc
    --max-http-header-size 81920
    "${EXPERIMENTAL_ARGS[@]}"
    "${MEMORY_ARGS[@]}"
    /var/runtime/index.mjs
    )

if [ -z "$AWS_LAMBDA_EXEC_WRAPPER" ]; then
  exec /var/lang/bin/node "${NODE_ARGS[@]}"
else
  wrapper="$AWS_LAMBDA_EXEC_WRAPPER"
  if [ ! -f "$wrapper" ]; then
    echo "$wrapper: does not exist"
    exit 127
  fi
  if [ ! -x "$wrapper" ]; then
    echo "$wrapper: is not an executable"
    exit 126
  fi
    exec -- "$wrapper" /var/lang/bin/node "${NODE_ARGS[@]}"
fi

これは非常に重要なコードであり、NODE_ARGS を通じて実行する Node コマンドのパラメータリストを構築しています。

  • --expose-gc — このパラメータは V8 のガベージコレクション機能を公開し、ユーザーの Lambda 関数が global.gc() を呼び出してガベージコレクションをトリガーできるようにします。
  • --max-http-header-size 81920 — このパラメータは HTTP リクエストヘッダーの最大サイズを 80KB に設定し、より大きなリクエストヘッダーをサポートします。
  • ${EXPERIMENTAL_ARGS[@]} — 一部の実験的パラメータを設定します。
  • ${MEMORY_ARGS[@]} — 先ほど計算したメモリ制限パラメータを設定します。
  • /var/runtime/index.mjs — これが最も重要なポイントであり、Node.js Runtime 全体のエントリーファイルです。Lambda 関数のイベントループとリクエスト処理を担当します。

/var/runtime/index.mjs の内容を詳しく分析する前に、bootstrap スクリプトの最後の部分、つまり Node をどのように呼び出しているかに注目しましょう:

  • まず AWS_LAMBDA_EXEC_WRAPPER 環境変数が存在するかを確認し、存在しない場合は直接 node コマンドを実行して Node.js Runtime を起動します。

  • AWS_LAMBDA_EXEC_WRAPPER 環境変数が存在する場合、後続で実行する Node コマンドをパラメータとして AWS_LAMBDA_EXEC_WRAPPER で指定された実行ファイルに渡します。

この AWS_LAMBDA_EXEC_WRAPPER は、Lambda Wrapper scripts を使用したことがある方にはお馴染みでしょう。これを設定することで、Lambda はユーザーが Runtime 起動前に追加の初期化ロジックを実行できるようにします。つまり、Lambda Wrapper Script の実装メカニズムは非常にシンプルであり、Runtime を実行する前に対応する変数が存在するかを確認し、存在する場合にユーザーが指定したコマンドを実行するだけです。

index.mjs の分析

次に、Node.js Runtime の最もコアなファイルである /var/runtime/index.mjs の実装を分析し、Lambda Runtime のイベントループとリクエスト処理がどのように実装されているかを見ていきます。

index.mjs は1000行以上のコードを持つファイルであり、実際には複数のモジュールがバンドルされて構成されています。以下では、コアとなるコードを抜粋して分析します。

エントリーポイント

まず、コード全体のエントリーポイントを分析します。

// dist/worker/ignition.js
var { isMainThread } = cjsRequire("node:worker_threads");
var verboseLog3 = logger("Ignition");
async function ignition() {
  if (isMultiConcurrentMode() && isMainThread) {
    verboseLog3.verbose("Running in MultiConcurrent Mode");
    const manager = new WorkerManager();
    await manager.start();
  } else {
    verboseLog3.verbose("Running worker thread");
    const runtime = await createRuntime();
    await runtime.start();
  }
}

// dist/index.js
ignition();

コードは ignition() 関数をエントリーポイントとして使用し、まず現在 MultiConcurrent Mode にあるかどうかを確認します。MultiConcurrent Mode であり、かつメインスレッドである場合、前者の if 分岐のロジックに入り、スレッドプールの初期化を行い、リクエストの並列処理機能を実現します。

Lambda のモデルでは同時に1つのリクエストしか処理できないはずなのに、なぜスレッドプールが出てくるのかと思われるかもしれません。実はこのモードは、Lambda が新たに導入した Managed Instances と呼ばれる機能の一部です。

これにより、ユーザーは Lambda の実行環境(instance)を EC2 インスタンス上にホスティングし、より強力なネットワークおよび計算パフォーマンスを得ることができます。さらに、このモードでは Lambda の実行モデル全体が根本的に変わり、従来の1つの instance が1つのリクエストのみを処理するモデルではなく、通常のサーバーのように1つの instance で複数のリクエストを同時に処理するようになります。

詳細は Node.js runtime for Lambda Managed Instances を参照してください。

NOTE: Managed Instances モードは今回の分析の主眼ではなく、通常の Lambda モデルとは全く異なる動作をするため、以降のすべての分析では Managed Instances モードに関連するコードをスキップし、従来の Lambda モデルにおける Runtime の実装に焦点を当てます。

Runtime の初期化

従来の Lambda モデルでは、ignition() 関数は2番目の else 分岐のロジックに入ります。

  • createRuntime() を呼び出して Runtime インスタンスを作成します。
  • 次に Runtime.start() メソッドを呼び出して Runtime のイベントループを起動します。

次に createRuntime() の実装を分析します。

function setupGlobals() {
  const NoGlobalAwsLambda = process.env["AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA"] === "1" || process.env["AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA"] === "true";
  if (!NoGlobalAwsLambda) {
    globalThis.awslambda = {
      ...globalThis.awslambda,
      streamifyResponse: (handler, options) => { ...... };
  }
}

async function createRuntime(rapidClientOptions = {}) {
  setupGlobals();
  const runtimeApi = process.env.AWS_LAMBDA_RUNTIME_API;
  const handlerString = process.env._HANDLER;
  const taskRoot = process.env.LAMBDA_TASK_ROOT;
  // ...
  const rapidClient = await RAPIDClient.create(runtimeApi, rapidClientOptions, isMultiConcurrent);
  try {
    const { handler, metadata: handlerMetadata } = await UserFunctionLoader.load(taskRoot, handlerString);
    errorOnDeprecatedCallback(handlerMetadata);
    return Runtime.create({
      rapidClient,
      handler,
      handlerMetadata,
      isMultiConcurrent
    });
  } catch (error) {
    structuredConsole.logError("Init Error", error);
    await rapidClient.postInitError(error);
    throw error;
  }
}

globalThis.awslambda オブジェクトの設定

createRuntime() はまず setupGlobals() を呼び出して、グローバル変数の awslambda オブジェクトを拡張します。

ここでの globalThis.awslambda はグローバルスコープのコードによって事前に設定されており、環境変数 AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA を読み取って、グローバル変数に awslambda オブジェクトを設定するかどうかを決定します。

var NO_GLOBAL_AWS_LAMBDA = ["true", "1"].includes(process.env?.AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA ?? "");
if (!NO_GLOBAL_AWS_LAMBDA) {
  globalThis.awslambda = globalThis.awslambda || {};
}
...
var InvokeStore;
(function(InvokeStore2) {
  let instance = null;
  async function getInstanceAsync() {
    if (!instance) {
      instance = (async () => {
        ...
        const newInstance = isMulti ? await InvokeStoreMulti.create() : new InvokeStoreSingle();
        if (!NO_GLOBAL_AWS_LAMBDA && globalThis.awslambda?.InvokeStore) {
          return globalThis.awslambda.InvokeStore;
        } else if (!NO_GLOBAL_AWS_LAMBDA && globalThis.awslambda) {
          globalThis.awslambda.InvokeStore = newInstance;
          return newInstance;
        } else {
          return newInstance;
        }
      })();
    }
    return instance;
  }
  ...
})(InvokeStore || (InvokeStore = {}));

上記のコードから、globalThis.awslambda の設定が許可されている場合、globalThis.awslambda オブジェクトに InvokeStore プロパティも追加されることがわかります。

この InvokeStore は実際には AWS がオープンソースとして公開している aws-lambda-invoke-store というライブラリの機能です。AWS Lambda Node.js 実行環境に対して、呼び出しごとに独立したコンテキストストレージを提供するためのものです(例えば、各リクエストの Request ID などの情報を保存するために使用されます)。

このライブラリの最後には、以下のように記載されています。

Integration with AWS Lambda Runtime
The @aws/lambda-invoke-store package is designed to be integrated with the AWS Lambda Node.js Runtime Interface Client (RIC). The RIC automatically
...
The InvokeStore integrates with the Lambda runtime's global namespace:
const globalInstance = globalThis.awslambda.InvokeStore;
...
If you prefer not to modify the global namespace, you can opt out by setting the environment variable:
AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA=1

つまり、InvokeStore と Runtime の統合、および環境変数 AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA によるスイッチ制御は、本質的にはすべて上記の Runtime コードによって実装されています。

rapidClient の初期化

次に createRuntime()rapidClient インスタンスを作成します。

  const rapidClient = await RAPIDClient.create(runtimeApi, rapidClientOptions, isMultiConcurrent);

rapidClient は Node のネイティブプラグインであり、先ほど抽出した Runtime ファイルの中では rapid-client.node として存在しています。

RAPIDClient クラスは cjsRequire("./rapid-client.node"); を通じてこのプラグインを動的にロードします。

rapidClient の主な機能は以下の通りです。

  • カプセル化された HttpClient として Lambda Runtime API にリクエストを送信する(例えば /next /response API の呼び出し)ことで、リクエストの構築とエラー処理を簡素化する
  • Lambda Runtime API の結果をカプセル化して、より扱いやすい形に処理する
  • 一部のエラー処理

NOTE: Lambda の異なるバージョンの Runtime には大きな違いがある可能性があります。本記事では Node.js 24.x の Runtime を例に分析しています。実際には、以前のバージョン(例えば Node.js 20.x)では Runtime の実装が全く異なり、以前のバージョンの Runtime 実装はより複雑なものでした。

_HANDLER で指定された handler コードの解析とロード

次は、設定した handler(例:index.handler)を解析し、対応する JS コードファイルをロードする非常に重要な部分です。

NOTE: AWS コンソールで設定した handler 情報は、環境変数 _HANDLER の値として Lambda 実行環境に渡されます。そのため、Runtime は _HANDLER 環境変数を解析し、対応するコードをロードする必要があります。

createRuntime() は以下のコードを使用して、環境変数から _HANDLER の値を読み取り、UserFunctionLoader.load() を呼び出します。この UserFunctionLoader.load() が handler コードの解析とロードを実現するための鍵となります。

const handlerString = process.env._HANDLER
const { handler, metadata: handlerMetadata } = await UserFunctionLoader.load(taskRoot, handlerString);

さらに UserFunctionLoader.load() の実装を深掘りしましょう。

var UserFunctionLoader = class {
  ...
  static async load(appRoot, handlerString) {
    ...
    const { moduleRoot, moduleName, handlerName } = parseHandlerString(handlerString);
    const module = await loadModule({
      appRoot,
      moduleRoot,
      moduleName
    });
    const handler = resolveHandler(module, handlerName, handlerString);
    return {
      handler,
      metadata: this.getHandlerMetadata(handler)
    };
  }
  ...
};

まず UserFunctionLoader.load()parseHandlerString() を呼び出して handlerString を解析します。例えば src/index.handler のような handler 文字列を以下のような構造に解析します。

{
  moduleRoot: "src",
  moduleName: "index",
  handlerName: "handler"
}

解析が完了すると、loadModule({ appRoot, moduleRoot, moduleName }) を呼び出して handler モジュールをロードします。loadModule() の実装を続けて分析しましょう。

var path2 = cjsRequire("node:path");
async function loadModule(options) {
  const fullPathWithoutExtension = path2.resolve(options.appRoot, options.moduleRoot, options.moduleName);
  const extensionLookupOrder = ["", ".js", ".mjs", ".cjs"];
  try {
    for (const extension of extensionLookupOrder) {
      const module = await tryAwaitImport(fullPathWithoutExtension, extension);
      if (module)
        return module;
    }
    const resolvedPath = cjsRequire.resolve(options.moduleName, {
      paths: [options.appRoot, path2.join(options.appRoot, options.moduleRoot)]
    });
    return cjsRequire(resolvedPath);
  } catch (err) {
    if (err instanceof SyntaxError) {
      throw new UserCodeSyntaxError(err);
    } else if (err instanceof Error && err.code === "MODULE_NOT_FOUND") {
      throw new ImportModuleError(err);
    } else {
      throw err;
    }
  }
}

分析してみると、loadModule() の実装は実際には非常にシンプルです。

まず loadModule() は渡された情報に基づいて appRoot moduleRoot moduleName の各部分を結合して完全なパス(拡張子なし)を構築します(src/index.handler の場合は "/var/task"+"src"+"index" となります)。

次に、このパスにさまざまな拡張子("" .js .mjs .cjs)を付与して試行します。例:

  • /var/task/src/index
  • /var/task/src/index.js
  • /var/task/src/index.mjs
  • /var/task/src/index.cjs

そして tryAwaitImport() を使用して、これらのパスを ESM モジュールとしてそれぞれロードを試みます。存在しない場合は次の拡張子の組み合わせに進み、ロードに成功するまで続けます。

すべてのロード試行が失敗した場合は、cjsRequire() を使用して CommonJS モジュールとしてロードを試みます。

handler モジュールから handler 関数を抽出する

ここまでは handler モジュールのロードが完了しただけです。次にモジュールから handler 関数を抽出する必要があり、これが resolveHandler() の機能です。

function resolveHandler(module, handlerName, fullHandlerString) {
  let handler = findIn(handlerName, module);
  if (!handler && typeof module === "object" && module !== null && "default" in module) {
    handler = findIn(handlerName, module.default);
  }
  if (!handler) {
    throw new HandlerNotFoundError(`${fullHandlerString} is undefined or not exported`);
  }
  if (!isUserHandler(handler)) {
    throw new HandlerNotFoundError(`${fullHandlerString} is not a function`);
  }
  return handler;
}

function findIn(handlerName, module) {
  return handlerName.split(".").reduce((nested, key) => {
    return nested && typeof nested === "object" ? nested[key] : void 0;
  }, module);
}

分析の結果、resolveHandler() の実装も非常にシンプルであることがわかります。module[handlerName] を通じてモジュールから対応する handler 関数を直接抽出し、存在しない場合はエラーをスローします。

これで handler モジュールの解析と対応する handler の解析がすべて完了しました。次に、重要な Runtime オブジェクトの実装とイベントループの分析に移ります。

Runtime インスタンスの作成

handler の解析とロードが完了した後、createRuntime() は最後に handler オブジェクト、rapidClient オブジェクトなどの情報を Runtime.create() に渡して Runtime インスタンスを作成し、Runtime のイベントループを起動します。

const runtime = Runtime.create({
  rapidClient,
  handler,
  handlerMetadata,
  isMultiConcurrent
});

await runtime.start();

Runtime.start() の分析

次に Runtime クラスと対応する .start() メソッドの実装を分析します。

var Runtime = class _Runtime {
  ...

  constructor(handler, handlerMetadata, isMultiConcurrent, lifecycle) {
    this.handler = handler;
    this.handlerMetadata = handlerMetadata;
    this.isMultiConcurrent = isMultiConcurrent;
    this.lifecycle = lifecycle;
  }

  async start() {
    const processor = this.createProcessor();
    if (this.isMultiConcurrent) {
      await this.processMultiConcurrent(processor);
    } else {
      await this.processSingleConcurrent(processor);
    }
  }
  createProcessor() {
    if (this.handlerMetadata.streaming) {
      return new StreamingInvokeProcessor(this.handler, this.lifecycle, this.handlerMetadata);
    } else {
      return new BufferedInvokeProcessor(this.handler, this.lifecycle);
    }
  }

  async processSingleConcurrent(processor) {
    while (true) {
      const { context, event } = await this.lifecycle.next();
      await this.runWithInvokeContext(context.awsRequestId, context.xRayTraceId, () => processor.processInvoke(context, event));
    }
  }
...
}
  • まず、Runtime クラスのコンストラクタは handler などのパラメータを内部プロパティとして保存します。
  • 次に start() メソッドで、まず createProcessor() を呼び出して Processor インスタンスを作成します(Processor は handler 関数の呼び出しと実行を担当するコンポーネントです)。
  • その後、processSingleConcurrent() を呼び出して無限の while(true) ループに入り、/next API を継続的に呼び出してイベントを取得します。イベントが取得されると、対応する Processor を呼び出して handler を実行し、Lambda 関数を正式に実行します。

processSingleConcurrent() の実装分析

従来の Lambda のシングルリクエスト・シングル処理モデルに焦点を当てるため、processSingleConcurrent() の実装のみを分析します。これは、このモード専用の Processor です。

  async processSingleConcurrent(processor) {
    // infinite loop to keep processing incoming events
    while (true) {
      // call /next API to get the next event and context
      const { context, event } = await this.lifecycle.next();
      // call
      await this.runWithInvokeContext(context.awsRequestId, context.xRayTraceId, () => processor.processInvoke(context, event));
    }
  }

以前の記事で紹介した while + curl で実装した Lambda Runtime のイベントループと非常に似ていませんか?

実は Node.js の Runtime も本質的には無限ループ + /next API の継続的な呼び出しによってイベントを取得する仕組みで実装されています。

ここでは

  • await this.lifecycle.next();/next API を呼び出してイベントを取得し、分析処理した後に contextevent を含むオブジェクトを返します。
  • .next() を通じてイベントを取得した後、processSingleConcurrent()runWithInvokeContext() を呼び出して handler 関数を実行します。

runWithInvokeContext() の実装分析

では runWithInvokeContext() はどのように呼び出されるのでしょうか?実装は想像以上にシンプルです。

  async processInvoke(context, event) {
    try {
      const result = await this.handler(event, context);
      await this.lifecycle.succeed(context.awsRequestId, result);
    } catch (err) {
      await this.lifecycle.fail(context.awsRequestId, err);
    }
  }

はい、単純に await handler(event, context) を通じてユーザーの handler 関数を呼び出してリクエストを処理しています。await は同期関数を呼び出しても正常に動作するため、シンプルな await this.handler(event, context) で同期と非同期の両方の handler をサポートしています。

そして handler の呼び出しが成功した後、await this.lifecycle.succeed(context.awsRequestId, result); を呼び出して /response API 経由で handler の処理結果を返します。

これで、Runtime イベントループの最もコアな部分の分析が完了しました。

追加機能の実装分析

次に、その他の追加機能がどのように実装されているかを分析します。

handler の引数 eventcontext はどのように構築されるか

前述の内容によると、lifeCycle.next()/next API にリクエストしてイベントを取得し、eventcontext を構築します。

lifeCycle.next()eventcontext オブジェクトをどのように分析・構築するかを詳しく見ていきましょう。

async next() {
  const invocationRequest = await this.client.nextInvocation();
  const context = ContextBuilder.build(invocationRequest.headers);
  const event = JSON.parse(invocationRequest.bodyJson);
  return {
    context,
    event
  };
}

以下のことがわかります。

  1. まず rapidClient を通じて /next API にリクエストしてイベントを取得します(this.client.nextInvocation()
  2. 次に ContextBuilder.build() を呼び出して context オブジェクトを構築します
  3. そして /next API が返す bodyJson を直接 JSON として解析し、event オブジェクトとします

Context がどのように構築されるかを分析しましょう。

var REQUIRED_INVOKE_HEADERS = {
  FUNCTION_ARN: "lambda-runtime-invoked-function-arn",
  REQUEST_ID: "lambda-runtime-aws-request-id",
  DEADLINE_MS: "lambda-runtime-deadline-ms"
};
var OPTIONAL_INVOKE_HEADERS = {
  CLIENT_CONTEXT: "lambda-runtime-client-context",
  COGNITO_IDENTITY: "lambda-runtime-cognito-identity",
  X_RAY_TRACE_ID: "lambda-runtime-trace-id",
  TENANT_ID: "lambda-runtime-aws-tenant-id"
};

var ContextBuilder = class {
  static build(headers) {
    ...
    const invokeHeaders = this.validateAndNormalizeHeaders(headers);
    const headerData = this.getHeaderData(invokeHeaders);
    const environmentData = this.getEnvironmentData();
    moveXRayHeaderToEnv(invokeHeaders);
    return Object.assign(headerData, environmentData);
  }
  static getEnvironmentData() {
    return {
      functionName: process.env.AWS_LAMBDA_FUNCTION_NAME,
      functionVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION,
      memoryLimitInMB: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
      logGroupName: process.env.AWS_LAMBDA_LOG_GROUP_NAME,
      logStreamName: process.env.AWS_LAMBDA_LOG_STREAM_NAME
    };
  }
  static getHeaderData(invokeHeaders) {
    const deadline = this.parseDeadline(invokeHeaders);
    return {
      clientContext: this.parseJsonHeader(invokeHeaders[OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT], OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT),
      identity: this.parseJsonHeader(invokeHeaders[OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY], OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY),
      invokedFunctionArn: invokeHeaders[REQUIRED_INVOKE_HEADERS.FUNCTION_ARN],
      awsRequestId: invokeHeaders[REQUIRED_INVOKE_HEADERS.REQUEST_ID],
      tenantId: invokeHeaders[OPTIONAL_INVOKE_HEADERS.TENANT_ID],
      xRayTraceId: invokeHeaders[OPTIONAL_INVOKE_HEADERS.X_RAY_TRACE_ID],
      getRemainingTimeInMillis: function() {
        return deadline - Date.now();
      }
    };
  }
};

実際には、Context の構築も非常にシンプルであることがわかります。

  • validateAndNormalizeHeaders()getHeaderData() を呼び出して、ヘッダーから lambda-runtime-invoked-function-arnlambda-runtime-client-context などの情報を取得します。
  • さらに環境変数から AWS_LAMBDA_FUNCTION_NAMEAWS_LAMBDA_FUNCTION_VERSION などの情報を取得します。
  • 最後にこれらの情報を組み合わせて context オブジェクトを構築し返します。

これらの情報は、公式ドキュメントに記載されている Lambda context object の構造と一致しています。

handler の callback サポートは削除済み

ご存知の通り、以前のバージョンの Node.js では、Lambda handler は非同期処理を実現するために第3引数の callback パラメータもサポートしていました。Callback-based function handlers

export const handler = (event, context, callback) => { }
;

しかしドキュメントの説明によると、Node.js 24 以降 handler は callback 方式をサポートしなくなりました。

Callback-based function handlers are only supported up to Node.js 22. Starting from Node.js 24, asynchronous tasks should be implemented using async function handlers.

そのため、現在の Node.js 24.x の Runtime では callback のサポートが削除されており、対応する callback の呼び出し方式はサポートされておらず、Runtime 全体に callback 関連の実装は存在しません。

Stream handler

handler はストリーミング方式で返すこともサポートしています。Response streaming function handlers を参照してください。

公式ドキュメントでは、以下のようにストリーミング方式の handler を使用できます。

export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => { });

上記で触れた Runtime による globalThis.awslambda の処理を覚えていますか?実際には、上記の awslambda.streamifyResponse() は Runtime が起動時に設定した globalThis.awslambda オブジェクトそのものです。

function setupGlobals() {
  const NoGlobalAwsLambda = process.env["AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA"] === "1" || process.env["AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA"] === "true";
  if (!NoGlobalAwsLambda) {
    globalThis.awslambda = {
      ...globalThis.awslambda,
      streamifyResponse: (handler, options) => {
        const typedHandler = handler;
        typedHandler[UserFunctionLoader.HANDLER_STREAMING] = UserFunctionLoader.STREAM_RESPONSE;
        if (typeof options?.highWaterMark === "number") {
          typedHandler[UserFunctionLoader.HANDLER_HIGHWATERMARK] = parseInt(String(options.highWaterMark));
        }
        return handler;
      },
      HttpResponseStream
    };
  }
}

console.log 関連メソッドのフック

Log and monitor Node.js Lambda functions で述べられているように、handler は追加の処理なしに直接 console.log() などのメソッドを呼び出してログを出力できます。

さらに、出力されたログはそのまま出力されるのではなく、以下のように requestId などの情報が付加されます。これはどのように実現されているのでしょうか?

exports.handler = async function(event, context) {
  console.log("ENVIRONMENT VARIABLES\n" + JSON.stringify(process.env, null, 2))
  console.info("EVENT\n" + JSON.stringify(event, null, 2))
  console.warn("Event not processed.")
  return context.logStreamName
}

出力

2019-06-07T19:11:20.562Z	c793869b-ee49-115b-a5b6-4fd21e8dedac	INFO	ENVIRONMENT VARIABLES
{
  "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST",
  "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/my-function",
  "AWS_LAMBDA_LOG_STREAM_NAME": "2019/06/07/[$LATEST]e6f4a0c4241adcd70c262d34c0bbc85c",
  "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs12.x",
  "AWS_LAMBDA_FUNCTION_NAME": "my-function",
  "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin",
  "NODE_PATH": "/opt/nodejs/node10/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules",
  ...
}
2019-06-07T19:11:20.563Z	c793869b-ee49-115b-a5b6-4fd21e8dedac	INFO	EVENT
{
  "key": "value"
}

実際には、Runtime の起動前に LogPatch.patchConsole() を呼び出して console のすべてのメソッドをフックしています。

  static patchConsoleMethods(logger2) {
    const createLogFunction = (level) => {
      if (!logger2.shouldLog(level)) {
        return this.NopLog;
      }
      return (message, ...params) => {
        logger2.log(level, message, ...params);
      };
    };
    console.trace = createLogFunction(LOG_LEVEL.TRACE);
    console.debug = createLogFunction(LOG_LEVEL.DEBUG);
    console.info = createLogFunction(LOG_LEVEL.INFO);
    console.warn = createLogFunction(LOG_LEVEL.WARN);
    console.error = createLogFunction(LOG_LEVEL.ERROR);
    console.fatal = createLogFunction(LOG_LEVEL.FATAL);
    console.log = console.info;
  }

上記のように、patchConsoleMethods()console のすべてのメソッド(tracedebuginfowarnerrorfatal)を logger2.log() に置き換えます。

そのため、すべての console.log() の呼び出しは logger2.log() にマッピングされます。

logger2 の対応するクラスは StdoutLogger であり、クラスと対応する .log() メソッドの実装は以下の通りです。

.log()levelmessage...params をパラメータとして受け取り、これらの情報を追加処理した上で process.stdout.write() を通じて標準出力に書き込みます。

var StdoutLogger = class extends BaseLogger {
  log(level, message, ...params) {
    if (!this.shouldLog(level))
      return;
    const timestamp = (/* @__PURE__ */ new Date()).toISOString();
    const requestId = this.invokeStore.getRequestId();
    const tenantId = this.invokeStore.getTenantId() || "";
    if (this.options.format === LOG_FORMAT.JSON) {
      this.logJsonMessage(timestamp, requestId, tenantId, level, message, ...params);
    } else {
      this.logTextMessge(timestamp, requestId, level, message, ...params);
    }
  }
  logTextMessge(timestamp, requestId, level, message, ...params) {
    const line = formatTextMessage(timestamp, requestId, level, message, ...params).replace(/\n/g, FORMAT.CARRIAGE_RETURN);
    process.stdout.write(line + FORMAT.LINE_DELIMITER);
  }
  logJsonMessage(timestamp, requestId, tenantId, level, message, ...params) {
    const line = formatJsonMessage(timestamp, requestId, tenantId, level, message, ...params).replace(/\n/g, FORMAT.CARRIAGE_RETURN);
    process.stdout.write(line + FORMAT.LINE_DELIMITER);
  }
};

これが Node.js Lambda Runtime における console.log() などのメソッドに対するフックの実装です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?