Serverless DeepDive #05:Runtime APIの役割を探る
Runtime APIで呼び出しを受け取り、応答とerrorを返す小さなloopを作ります。実測した受信・通知・deadlineから、runtimeが担う処理と、利用者に残る設計を確かめます。
Series: Serverless DeepDive (13 articles)
- …
- 6Serverless DeepDive #05:Runtime APIの役割を探る This article
検証構成
前回の#04 では、同じjobをLambdaとFargateで動かしました。今回は、Lambdaに任せた呼び出しがhandlerの前後でどう進むかを確かめます。
普段はhandlerから値を返すと、呼び出し側へ結果が届きます。その間には、runtimeがLambdaへ結果を送る処理があります。Runtime APIは、この受信と通知を行う実行環境内のHTTP APIです。
今回は小さな受信loopを作り、同じhandlerを標準runtimeでも動かしました。問いは、受信、完了、失敗、期限を誰が受け持つかです。応答を送る処理を省く実験も加えました。
| 条件 | 設定 |
|---|---|
| region | ap-northeast-1 |
| managed runtime | nodejs26.x、Amazon Linux 2023 |
| 実際のNode.js version | v26.8.2 |
| memory / architecture | 512 MB / arm64 |
| timeout / reserved concurrency | 3秒 / 各関数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を配置します。今回の配備方法とは区別して考えます。

上段は検証時の構成です。矢印は同期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
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 | /response | 202 |
| reject | /response | 202 |
| throw | /error | 202 |
| throw後のsuccess | /response | 202 |
rejectでは、handlerが
accepted: falseを値として返します。loopはこれも/responseへ送ります。業務の成功と正常なreturnを分ける点は、#01で扱った成功の分け方 と同じです。
標準runtimeでは、contextからRequestIdと残り時間を取得できました。成功時のhandler-returnには、実際のNode.js versionも残っています。

受信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: 3500 | 2998 ms | DeadlineBudgetErrorを/errorへ送信 | 19.85 ms |
| delay、workMs: 3500 | 2999 ms | Invoke応答のerrorType: Sandbox.Timedout | 3000 ms |
| omit、workMs: 0 | 2999 ms | handler-return後、Invoke応答のerrorType: Sandbox.Timedout | 3000 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でした。

表のREPORTは、それぞれ直前の呼び出しの終了記録です。@durationの単位はmsです。omitではhandler-returnが先に残っている点が、delayとの違いです。
初期化でerrorを返す
呼び出しを受け取る前の失敗も試しました。initfail関数は/nextを呼ぶ前に、
ExperimentInitErrorを/init/errorへ送ります。その後、processをexit code 1で終了します。実測では、init-error-postの202が残りました。handler-enterはありませんでした。

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)
- …
- 6Serverless DeepDive #05:Runtime APIの役割を探る This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article