AWS Builder Center

Serverless DeepDive #05:Runtime APIの役割を探る

Runtime APIで呼び出しを受け取り、応答とerrorを返す小さなloopを作ります。実測した受信・通知・deadlineから、runtimeが担う処理と、利用者に残る設計を確かめます。

Series: Serverless DeepDive (13 articles)

  1. …
  2. 6
    Serverless DeepDive #05:Runtime APIの役割を探る This article
Senior Evangelist

検証構成

前回の#04 では、同じjobをLambdaとFargateで動かしました。今回は、Lambdaに任せた呼び出しがhandlerの前後でどう進むかを確かめます。
普段はhandlerから値を返すと、呼び出し側へ結果が届きます。その間には、runtimeがLambdaへ結果を送る処理があります。Runtime APIは、この受信と通知を行う実行環境内のHTTP APIです。
今回は小さな受信loopを作り、同じhandlerを標準runtimeでも動かしました。問いは、受信、完了、失敗、期限を誰が受け持つかです。応答を送る処理を省く実験も加えました。
条件設定
regionap-northeast-1
managed runtimenodejs26.x、Amazon Linux 2023
実際のNode.js versionv26.8.2
memory / architecture512 MB / arm64
timeout / reserved concurrency3秒 / 各関数1
呼び出し同期Invokeを1回ずつ、合計16回(managed 6回、protocol 9回、initfail 1回)
対象managed:標準runtime、protocol:受信loop、initfail:初期化失敗
2026年10月10日の公式runtime一覧 で、最新のNode.js managed runtimeを選びました。Node.js 26はpublic Previewです。SLAとTechnical Supportの対象外です。今回は検証用に使い、東京regionで3関数を作りました。実行時のversionも記録しました。
受信loopの起動には、AWS_LAMBDA_EXEC_WRAPPER を使いました。managed runtimeが渡すNode.jsの実行ファイルで、自作のscriptを起動する方法です。このwrapperは元の起動引数を使わず、標準のruntime clientを起動しません。今回、自分で実装する範囲は呼び出しloopです。Node.js本体はAWSが提供します。
一般的なcustom runtime では、provided系のOS-only runtimeを使います。言語の実行処理を含まない、OSの実行環境を提供するruntimeです。そこへ実行ファイルと、起動用fileのbootstrapを配置します。今回の配備方法とは区別して考えます。
3つのLambdaへ同期Invokeし、protocolの受信loopがRuntime APIとhandlerをつなぎ、各関数のログをCloudWatch Logsへ残す検証時の構成
上段は検証時の構成です。矢印は同期Invokeと応答、CloudWatch Logsへのログ出力です。下段はprotocol関数のAPIの流れです。矢印はHTTP通信とhandlerの呼び出し・returnです。

Runtime APIを実装する

Runtime APIの仕様 では、呼び出しを受け取る経路がGETの/2018-06-01/runtime/invocation/nextです。接続先は環境変数AWS_LAMBDA_RUNTIME_APIから取得します。
/nextからは、eventのJSONとHTTP headerが返ります。Lambda-Runtime-Aws-Request-Idは呼び出しの識別子です。Lambda-Runtime-Deadline-Msは、期限を示すUnix epochのmillisecondです。
実際に動かしたruntime.cjsの全体です。requestはNode.jsのhttp.requestを使います。JSONのbodyを送り、応答のstatus、header、bodyを返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
const http = require("node:http");
const { handler } = require("./index.cjs");

const authority = process.env.AWS_LAMBDA_RUNTIME_API;
if (!authority || !/^[A-Za-z0-9._-]+:\d+$/.test(authority))
throw new Error("missing Runtime API endpoint");
const base = `http://${authority}/2018-06-01/runtime`;
const log = (record) => console.log(JSON.stringify(record));
const errorBody = (error) => ({
errorType: error.name || "Error",
errorMessage: error.message || String(error),
stackTrace: [],
});

function request(method, path, body, invocationId) {
const text = body === undefined ? undefined : JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = http.request(
`${base}${path}`,
{
method,
headers: {
...(text === undefined
? {}
: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(text) }),
...(invocationId ? { "Lambda-Runtime-Invocation-Id": invocationId } : {}),
},
},
(res) => {
let received = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
received += chunk;
});
res.on("error", reject);
res.on("end", () =>
resolve({ status: res.statusCode, headers: res.headers, body: received }),
);
},
);
// /next may block while Lambda freezes the process. No client timeout is set.
req.on("error", reject);
req.end(text);
});
}

async function main() {
log({ phase: "runtime-init", nodeVersion: process.version });
if (process.env.STARTUP_MODE === "init-fail") {
const result = await request("POST", "/init/error", {
errorType: "ExperimentInitError",
errorMessage: "deliberate failure before requesting an invocation",
stackTrace: [],
});
log({ phase: "init-error-post", status: result.status });
process.exit(1);
}
for (;;) {
const next = await request("GET", "/invocation/next");
if (next.status !== 200) throw new Error(`next returned ${next.status}`);
const requestId = next.headers["lambda-runtime-aws-request-id"];
const deadlineMs = Number(next.headers["lambda-runtime-deadline-ms"]);
const invocationId = next.headers["lambda-runtime-invocation-id"];
if (typeof requestId !== "string" || !Number.isFinite(deadlineMs))
throw new Error("invalid invocation headers");
const traceId = next.headers["lambda-runtime-trace-id"];
if (typeof traceId === "string") process.env._X_AMZN_TRACE_ID = traceId;
else delete process.env._X_AMZN_TRACE_ID;
const event = JSON.parse(next.body);
log({ phase: "next", label: event.label, requestId, deadlineMs, receivedAtMs: Date.now() });
const context = {
awsRequestId: requestId,
getRemainingTimeInMillis: () => Math.max(0, deadlineMs - Date.now()),
};
let path;
let payload;
try {
payload = await handler(event, context);
path = `/invocation/${requestId}/response`;
} catch (error) {
payload = errorBody(error);
path = `/invocation/${requestId}/error`;
}
if (event.operation === "omit") {
log({ phase: "response-omitted", label: event.label, requestId });
await new Promise(() => setInterval(() => {}, 1000));
}
const posted = await request("POST", path, payload, invocationId);
log({
phase: path.endsWith("/error") ? "error-post" : "response-post",
label: event.label,
requestId,
status: posted.status,
});
if (posted.status !== 202) throw new Error(`result POST returned ${posted.status}`);
}
}

main().catch((error) => {
log({ phase: "runtime-fatal", ...errorBody(error) });
process.exit(1);
});
handlerが戻ったら/responseへ、例外を捕まえたら/errorへPOSTします。結果を送り終えてから、次の/nextを待ちます。omitだけは、故障を観測するため、結果POSTと次の受信を省きます。
/nextは呼び出しが来るまで待つため、HTTP clientのtimeoutを設定していません。このコードではheaderの検査も行います。省略可能なLambda-Runtime-Invocation-Idが来た場合は、結果のPOSTへ引き継ぎます。trace headerも環境変数へ渡します。これらのheaderを渡す処理は実装していますが、今回はX-Rayのtraceまで検証していません。
同じdirectoryのindex.cjsには、次の共通handlerを置きます。Node.js標準moduleだけを使います。実際にAWSで動かしたコードです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const { setTimeout: sleep } = require("node:timers/promises");

// The same finite handler is called by the managed runtime and the teaching loop.
exports.handler = async (event, context) => {
const { label, operation = "success", workMs = 0 } = event;
if (typeof label !== "string" || !/^[a-z0-9-]{1,40}$/.test(label))
throw new TypeError("label must be a short experiment identifier");
if (!Number.isInteger(workMs) || workMs < 0 || workMs > 5000)
throw new TypeError("workMs must be between 0 and 5000");
const remainingMs = context.getRemainingTimeInMillis();
const record = { label, operation, requestId: context.awsRequestId, remainingMs };
console.log(JSON.stringify({ phase: "handler-enter", ...record }));
if (operation === "throw") {
const error = new Error("experiment handler failure");
error.name = "ExperimentError";
throw error;
}
if (operation === "guard" && workMs + 200 >= remainingMs) {
const error = new Error("work does not fit the remaining deadline budget");
error.name = "DeadlineBudgetError";
throw error;
}
if (operation === "reject") return { ...record, accepted: false };
if (!["success", "delay", "guard", "omit"].includes(operation))
throw new TypeError("unknown operation");
await sleep(workMs);
const result = { ...record, nodeVersion: process.version, completed: true };
console.log(JSON.stringify({ phase: "handler-return", ...result }));
return result;
};
共通labのhandlers/runtime-api/にhandler、loop、起動用wrapperを保存しました。wrapperは次の内容です。実行権限を付け、protocolとinitfailだけにAWS_LAMBDA_EXEC_WRAPPER=/var/task/exec-wrapperを設定します。標準runtimeには設定しません。
1
2
3
#!/bin/sh
set -eu
exec "$1" "${LAMBDA_TASK_ROOT}/runtime.cjs"
3つのfileをzipのrootに置きます。ZIPの作成前にchmod +x exec-wrapperを行います。標準runtimeとprotocolはSTARTUP_MODEを設定せず、initfailだけSTARTUP_MODE=init-failにします。
入力と3関数の設定はscenarios/runtime-api.jsonへまとめました。標準runtimeではindex.handlerを呼びます。protocolでは、同じindex.cjsをloopから呼びます。入力の例は次の3つです。
1
{"operation":"success","label":"protocol-1-success","workMs":0}
1
{"operation":"throw","label":"protocol-3-throw","workMs":0}
1
{"operation":"delay","label":"protocol-6-delay","workMs":3500}
同じ入力を同期Invokeで送るには、作成した関数名と実験用profileを指定します。次はCLIで送る例です。実測はSDKのmaxAttempts: 1で行いました。protocol-1-successのJSONをevent.jsonへ保存して実行します。関数名は、自分で作ったものへ置き換えます。
1
2
3
4
5
6
aws lambda invoke \
--function-name lambda-lab-dd05-1010-protocol \
--invocation-type RequestResponse --log-type Tail \
--payload fileb://event.json \
--profile aws-builder --region ap-northeast-1 \
response.json
返るStatusCodeとFunctionError、response.jsonのpayloadを分けて読みます。LogResultはbase64をdecodeし、同じRequestIdのREPORTと照合します。guardはworkMs: 3500、omitはworkMs: 0を指定します。標準runtimeではsuccess、reject、throw、throw後のsuccessを送り、同じhandlerへ渡るcontextを確かめました。

呼び出しと応答を追う

まず、正常なreturn、業務上の拒否、throwを順に送りました。throwの次には、正常な入力を送りました。
受信loopの入力loopの通知先POSTの応答
success/response202
reject/response202
throw/error202
throw後のsuccess/response202
rejectでは、handlerがaccepted: falseを値として返します。loopはこれも/responseへ送ります。業務の成功と正常なreturnを分ける点は、#01で扱った成功の分け方 と同じです。
標準runtimeで同じhandlerが動き、各入力のRequestId、残り時間、成功時のNode.js v26.8.2を記録したCloudWatch Logs
標準runtimeでは、contextからRequestIdと残り時間を取得できました。成功時のhandler-returnには、実際のNode.js versionも残っています。
受信loopがsuccess、reject、throw、次のsuccessを受け取り、response-postまたはerror-postで202を得たCloudWatch Logs
受信loopでは、同じRequestIdでnext、handler-enter、結果のPOSTを追えました。POSTの202は、Runtime APIが結果を受け取った応答です。これはloopとRuntime APIの間で返ります。PCが送ったInvokeへの応答と分けて読みます。

errorを送ってみる

throwの入力では、handlerがExperimentErrorを投げます。loopはこれを捕まえ、RequestIdを含む/errorへ送ります。実測では、同じRequestIdのerror-postに202が残りました。
その次のsuccessでは、別のRequestIdを受け取り、/responseへのPOSTが202になりました。今回のloopでは、handlerの例外を通知した後も、次の呼び出しを処理できました。
ここで確かめたのは、例外の通知経路です。業務処理の再試行はしていません。前の呼び出しと同じ実行環境だったという保証も、この結果からは判断できません。
このloopは、errorType、errorMessage、空のstackTraceを組み立てます。stackを残すかなど、通知する情報もruntime clientの設計に含まれます。通知POSTが失敗した場合の再送は、今回は試していません。

deadlineを確認する

次に、3秒のtimeoutに対して3.5秒の処理を要求しました。guardの入力では、残り時間を確認し、処理時間と200 msの余裕を確保できなければ例外を投げます。
この検査は、前に載せた共通handlerに含めています。
受信loopの入力handler-enter時の残り時間観測結果REPORTのDuration
guard、workMs: 35002998 msDeadlineBudgetErrorを/errorへ送信19.85 ms
delay、workMs: 35002999 msInvoke応答のerrorType: Sandbox.Timedout3000 ms
omit、workMs: 02999 mshandler-return後、Invoke応答のerrorType: Sandbox.Timedout3000 ms
guardでは、deadlineから現在時刻を引いた残り時間が2998 msでした。処理を始める前に/errorを送り、202が返りました。200 msは今回の実装で決めた余裕です。実際の処理に十分な値かは、通信や保存にかかる時間から決める必要があります。
delayでは、残り時間による中止判定をせず、3.5秒の待機を要求しました。handler-returnは残らず、3秒でtimeoutになりました。
omitでは、handlerがすぐ戻った後に/responseを省きました。loopは次の/nextにも進まず、待ち続けるようにしています。handler-returnとresponse-omittedは残りましたが、結果は3秒でtimeoutでした。
guardは2998 msを見てerrorを送り19.85 msで終了し、delayと応答POSTを省いたomitは3000 msになったCloudWatch Logs
表のREPORTは、それぞれ直前の呼び出しの終了記録です。@durationの単位はmsです。omitではhandler-returnが先に残っている点が、delayとの違いです。

初期化でerrorを返す

呼び出しを受け取る前の失敗も試しました。initfail関数は/nextを呼ぶ前に、ExperimentInitErrorを/init/errorへ送ります。その後、processをexit code 1で終了します。
実測では、init-error-postの202が残りました。handler-enterはありませんでした。
初期化でerrorを送った関数に、init-error-postの202とPhase init、Phase invokeのINIT_REPORTが残ったCloudWatch Logs
1回の同期Invokeに対して、ログにはPhase initとPhase invokeの初期化処理が現れました。どちらにも202が残っています。これはhandlerへの入力を2回送った記録ではありません。
INIT_REPORTのError TypeはRuntime.Unknownでした。このloopは、Lambda-Runtime-Function-Error-Typeのheaderを付けていません。この省略が表示に影響したかは、今回は確かめていません。
呼び出し中の例外は、受け取ったRequestIdを付けて/errorへ送ります。初期化中の失敗は、受信前の/init/errorへ送ります。

まとめ

受信と完了・errorの通知はruntime、期限内に終わる処理の設計は利用者が受け持ちます。Node.jsの業務handlerなら、今回書いた通知先の選択やerror payloadの作成を標準runtimeへ任せられます。
managed runtimeにない言語やversionを動かす場合は、custom runtimeが選択肢です。今回のloopは役割を学ぶための最小実装で、provided系への配備は試していません。runtime clientを自作する場合は、初期化、受信、完了とerrorの通知まで維持します。

Series: Serverless DeepDive (13 articles)

  1. …
  2. 6
    Serverless DeepDive #05:Runtime APIの役割を探る This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article