Serverless DeepDive #09:失敗後の実行環境に何が残るのか
正常終了、例外、Node.jsの途中終了、実行時間の上限超過を比べます。次の呼び出しと同じ入力の再送で、変数と/tmpのファイルに何が残るかを調べます。
Series: Serverless DeepDive (13 articles)
- …
- 10Serverless DeepDive #09:失敗後の実行環境に何が残るのか This article
失敗前に書いた値を追う
Node.jsのプログラムを、処理の途中で終了させました。次にLambda関数を呼び出すと、変数の値はnullへ戻りました。一方、/tmpのファイルには、終了前に書いた値が残りました。実行時間の上限を超えて停止した場合も、同じ違いを確認しました。
調べた変数は、呼び出しを処理するhandler関数の外に置いたものです。本文では、この変数をglobalと呼びます。
#06では、正常な呼び出しで残る状態を比べました。#08では、応答と保存の完了を分けました。今回は、処理の途中で失敗した後に、何が残るかを追います。作業用のファイルがあっても、処理が完了したとは限りません。
Lambda関数を呼び出すAPIがInvokeです。normal、exception、exit、timeoutの4関数へ、同じhandlerを配備しました。正常終了と3つの失敗条件を関数ごとに分け、各関数を順番に呼び出しました。
| 条件 | 設定 |
|---|---|
| region | ap-northeast-1 |
| managed runtime / OS | nodejs26.x / Amazon Linux 2023 |
| 実際のNode.js | v26.8.2 |
| memory(割り当て容量) / architecture | 各512 MB / arm64 |
| timeout / reserved concurrency(予約する同時実行数) | 各2秒 / 各1 |
| 呼び出し | 通常on-demand、同期Invoke、合計20回 |
| 状態を置く場所 | globalと/tmpのJSON |
runtimeは、Node.jsなどのコードを動かすための仕組みです。2026年10月11日の公式runtime一覧 から、最新のNode.jsを選びました。測定日時点のNode.js 26は、public Preview(正式提供前の試験公開)でした。SLA(稼働率などの保証)とTechnical Supportの対象外なので、今回の検証用に使いました。
reserved concurrency は、関数の同時実行数を予約し、上限も設ける設定です。今回は各関数を1にし、同時に処理する呼び出しを1つまでに制限しました。計画した呼び出しも順番に送っています。この設定は、次のInvokeでも同じ実行環境を使うことを保証しません。

図は検証時の構成です。青い矢印は関数の呼び出しと応答、緑はログ出力を示します。handlerは、globalの値と/tmpのファイルを読み、結果をログに出しました。
状態を書いてから失敗する
handlerを含むコードを読み込んだときに、一度だけUUIDを作ります。この値を、初期化がやり直されたかを見分ける目印にしました。本文では、初期化UUIDと呼びます。呼び出し回数とglobalの値も、handlerの外に置きました。このUUIDから、Lambdaを動かす物理hostやmicroVMを識別することはできません。
/tmpのファイルには、値、状態、書き込んだ呼び出しのRequestIdとUUIDを保存します。状態は、pending(未完了)またはcomplete(完了)です。RequestIdは、一回の呼び出しを見分ける識別子です。コードを読み込んだ時点では、/tmpのファイルを書き換えません。observeは、globalと/tmpの状態を読むためのmodeです。ファイルを書き換えずに観測します。
process.exit(1)は、Node.jsのプログラムを終了する命令です。今回は、正常終了とは別の条件を作るため、終了コード1を指定しました。timeoutでは、設定した時間内に処理が終わらず、Lambdaが実行を止めます。
各関数の最初のInvokeで、値seed-09と状態completeを保存しました。次のInvokeでは、値をpending-09へ変更してから、表の動作を比べました。pending-09は値の名前です。未完了かどうかは、この名前ではなく状態のpending/completeで判断します。
| 関数 | mode | 書き込み後の動作 |
|---|---|---|
| normal | commit | 状態completeを書き、returnで正常終了する |
| exception | throw | 状態pendingのままthrowで例外を発生させる |
| exit | exit | pendingのままprocess.exit(1)でNode.jsを終了する |
| timeout | timeout | pendingのまま2.5秒待ち、2秒の期限を超える |
各関数へ、次の順に5回送りました。1回目はseed-09を保存するcommit、2回目は表のmode、3回目はobserveです。4回目は2回目と同じ入力を再送し、5回目はobserveを送ります。normalを正常終了の対照とし、残る3関数で失敗後の状態を調べました。
次は検証に使ったhandlerの全体です。失敗を起こす前に、writeFileSyncでファイルを書き込みます。観測ログの出力が終わることも待ちます。応答を返した後に作業を残す場合との比較は、#08で扱いました。
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
const { randomUUID } = require("node:crypto");
const { readFileSync, writeFileSync } = require("node:fs");
const { setTimeout: sleep } = require("node:timers/promises");
// A module initialization ID is not a physical host or microVM identifier.
const initId = randomUUID();
const initializedAtMs = Date.now();
const tmpPath = "/tmp/lambda-lab-reset-state.json";
let calls = 0;
let memoryValue = null;
let memoryStage = null;
const modes = ["observe", "commit", "throw", "exit", "timeout"];
console.log(
JSON.stringify({
phase: "reset-init",
init_id: initId,
initialized_at_ms: initializedAtMs,
node: process.version,
}),
);
function identifier(value, field) {
if (typeof value !== "string" || !/^[a-z0-9-]{1,64}$/.test(value))
throw new TypeError(`${field} must match [a-z0-9-]{1,64}`);
return value;
}
function readTemporaryState() {
try {
const saved = JSON.parse(readFileSync(tmpPath, "utf8"));
if (
!saved ||
typeof saved !== "object" ||
Array.isArray(saved) ||
typeof saved.init_id !== "string" ||
typeof saved.request_id !== "string" ||
typeof saved.run_id !== "string" ||
typeof saved.id !== "string" ||
typeof saved.value !== "string" ||
!["pending", "complete"].includes(saved.stage)
)
throw new TypeError("invalid temporary state");
return saved;
} catch (error) {
if (error.code !== "ENOENT") throw error;
return null;
}
}
exports.handler = async (event, context) => {
if (!event || typeof event !== "object" || Array.isArray(event))
throw new TypeError("event must be a JSON object");
const mode = event.mode ?? "observe";
if (!modes.includes(mode)) throw new TypeError("unknown mode");
const id = identifier(event.id ?? "work", "id");
const runId = identifier(event.run_id ?? "manual", "run_id");
const value = event.value ?? "pending-09";
if (typeof value !== "string" || value.length > 128)
throw new TypeError("value must be a string of at most 128 characters");
const delayMs = event.delay_ms ?? 2500;
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > 30000)
throw new TypeError("delay_ms must be between 0 and 30000");
const previous = readTemporaryState();
const memoryBefore = memoryValue;
const memoryStageBefore = memoryStage;
calls += 1;
let saved = previous;
if (mode !== "observe") {
memoryValue = value;
memoryStage = "pending";
saved = {
init_id: initId,
request_id: context.awsRequestId,
run_id: runId,
id,
value,
stage: "pending",
};
writeFileSync(tmpPath, JSON.stringify(saved));
if (mode === "commit") {
memoryStage = "complete";
saved.stage = "complete";
writeFileSync(tmpPath, JSON.stringify(saved));
}
}
const record = {
phase: "reset-observe",
run_id: runId,
id,
mode,
request_id: context.awsRequestId,
init_id: initId,
initialized_at_ms: initializedAtMs,
count: calls,
memory_before: memoryBefore,
memory_value: memoryValue,
memory_stage_before: memoryStageBefore,
memory_stage: memoryStage,
tmp_before: previous?.value ?? null,
tmp_before_stage: previous?.stage ?? null,
tmp_value: saved?.value ?? null,
tmp_stage: saved?.stage ?? null,
tmp_init_id: saved?.init_id ?? null,
tmp_request_id: saved?.request_id ?? null,
tmp_same_job: saved?.run_id === runId && saved?.id === id,
tmp_complete: saved?.run_id === runId && saved?.id === id && saved?.stage === "complete",
node: process.version,
version: context.functionVersion,
started_ms: Date.now(),
};
// Flush this pre-fault observation before the intentional process exit.
await new Promise((resolve, reject) =>
process.stdout.write(`${JSON.stringify(record)}\n`, (error) =>
error ? reject(error) : resolve(),
),
);
if (mode === "throw") throw new Error("intentional handler exception after temporary write");
if (mode === "exit") process.exit(1);
if (mode === "timeout") await sleep(delayMs);
return { ...record, finished_ms: Date.now() };
};例外の後を比べる
normalとexceptionの2関数では、5回の呼び出しで初期化UUIDが変わりませんでした。初期化後の呼び出し回数は、どちらも1から5まで進みました。exceptionでは、この間にthrowによる例外が2回発生しています。
| 条件 | 初期化UUIDの先頭8文字 | mode入力後のobserve(3回目) | 再送後のobserve(5回目) |
|---|---|---|---|
| normal | f63a41b4のまま | count 3、global値 pending-09、tmp値 pending-09、tmp状態 complete | count 5、値と状態は同じ |
| exception | 69ba243eのまま | count 3、global値 pending-09、tmp値 pending-09、tmp状態 pending | count 5、値と状態は同じ |
normalでは、状態completeを記録してから正常終了しました。exceptionでは、throwの前に変更したglobalと/tmpの値が、次のobserveでも残っていました。生ログでは、ファイルに記録したRequestIdを照合しました。直前にthrowした呼び出しのRequestIdと一致しています。

画像のreqは、そのInvokeのRequestIdの先頭8文字です。initは初期化UUID、tmpinitはファイルを書いた際の初期化UUIDです。countは、初期化後にhandlerが処理した呼び出し回数です。memory列はglobalの値で、関数に割り当てるmemory容量ではありません。tmp列はファイルの値、stage列はファイルの状態です。completeは、完了なら1、未完了なら0を示します。値はどちらもpending-09へ変更しましたが、完了を示すcompleteになったのはnormalだけです。ファイル内のRequestIdとInvoke応答は、この画面には表示していません。保存した生ログと応答ファイルで確認しました。
throwが発生した2回のInvoke応答には、FunctionError=Unhandledがありました。応答ファイルのerrorTypeはErrorです。APIのStatusCodeは200でした。同期Invokeの仕様 が示すように、APIの200は関数内の処理が成功したことを表しません。
今回のhandlerとruntimeでは、例外後のInvokeでも初期化UUIDは変わりませんでした。throwの前に変更したglobalの値も残りました。FunctionErrorがあっても、globalが消えたとは判断できません。次の呼び出しでは、失敗した処理が書いた値を読む場合があります。
終了とtimeout
exitとtimeoutでは、失敗直後のobserveで初期化UUIDが変わりました。初期化後の呼び出し回数は1へ戻り、globalの値はnullでした。それでも、/tmpのファイルには直前の呼び出しが書いたpending-09が残りました。
| 条件 | 失敗前→失敗直後のUUID | observeのglobal | /tmpの保存元UUIDと状態 |
|---|---|---|---|
| exit | ba8ccd25→d90f6a2d | null、count 1 | ba8ccd25、pending |
| timeout | d6a72572→321a06d4 | null、count 1 | d6a72572、pending |
/tmpのファイルにあるUUIDとRequestIdを、失敗前の生ログと照合しました。画像では、tmpinitからファイルを書いた際のUUIDを確認できます。値を読み出せただけでなく、どの呼び出しの記録が残ったかも確かめています。
実行環境のlifecycle(起動から終了までの流れ) では、crashやtimeout後にLambdaがruntimeをresetすると説明しています。resetは、runtimeを終了して状態を戻す処理です。その環境を次のInvokeで使う場合、Lambdaはruntimeを再初期化します。公式資料は、resetで/tmpの内容を消さないことも説明しています。今回の観測でも、globalはnullへ戻りましたが、/tmpのファイルは残りました。

画像のmemory列の空欄は、応答のnullに対応します。initとtmpinitが違う行では、observeが、再初期化する前に書かれたファイルを読んでいます。現在の初期化UUIDと、ファイルを書いた際のUUIDを分けて確認できます。
exitの2回のInvoke応答では、errorTypeはRuntime.ExitErrorでした。timeoutの2回はSandbox.Timedoutでした。次の画像には、REPORTのStatus: errorとStatus: timeoutがあります。timeoutのDurationは、2回とも2000msでした。

次のobserveには、コードの読み込み時に出す初期化ログがありました。一方、REPORTにInit Duration(初期化にかかった時間)はありませんでした。Init Durationの有無だけでは、初期化が行われたかどうかを判断できません。今回は、初期化UUIDとcount、ファイルの記録も合わせて確認しました。初期化にかかった時間の内訳は、今回は測っていません。#12で扱います。
同じ入力を再送する
各条件で、observeの後に2回目と同じ入力をもう一度送りました。run_id、id、mode、valueは変えていません。timeoutのdelay_msも同じです。SDKの自動retry(再試行)は無効にしています。caller(呼び出す側)が意図して再送しました。
exitとtimeoutは再び失敗しました。その次のobserveでは、もう一度新しいUUIDになり、globalがnullへ戻っています。
| 条件 | 再送した呼び出し→次のobserveのUUID | 次のobserveが読んだ/tmp |
|---|---|---|
| exit | d90f6a2d→ec711491 | 保存元d90f6a2d/値pending-09/状態pending |
| timeout | 321a06d4→d9194f40 | 保存元321a06d4/値pending-09/状態pending |
再送には、新しいRequestIdが付きました。observeが読んだファイルのRequestIdは、その再送のRequestIdと一致しています。保存した生ログで確認しました。同じ入力を送っても、呼び出しごとにRequestIdが変わります。そのため、どの呼び出しがファイルを書いたかを区別できます。
今回測ったのは、同期Invokeをcallerが明示的に再送する経路です。非同期Invokeの自動retryは、今回の実測に含めていません。再送による外部処理の重複を防ぐ設計も検証していません。
完了状態を確かめる
ここでは、run_idとidが同じ処理を、同じjobとして扱います。
3つの失敗条件では、同じjobのファイルが残りました。しかし、状態はすべてpendingでした。observeのtmp_same_jobはtrueでも、tmp_completeはfalseです。状態completeを返したのはnormalだけでした。
この結果から、ファイルの有無やjob IDの一致に加え、完了状態も確認する設計がよいと考えます。例えば、再利用のために一時保存するcacheでは、内容と版を検査します。使えなければ、元のデータを取得し直します。作業途中のファイルは、試行ごとの名前と完了状態を確認してから次へ渡します。この2つの方法自体は、今回のhandlerでは検証していません。
今回のpending/completeは、未完了と完了の違いを観測するための記録です。単一ファイルへwriteFileSyncで書き込みました。ファイルを書いている途中でNode.jsが終了する場合や、複数の処理が同時に更新する場合は測っていません。途中の状態を見せずにファイルを置き換える方法も検証していません。実際にcheckpoint(再開に使う途中経過の記録)を作る場合は、こうした書き込み方と失敗時の扱いも決める必要があります。
今回のexitとtimeoutの後も、利用者がNode.jsを起動し直すことなく、次の呼び出しを処理できました。この条件では、runtimeの復旧をAWSへ任せられました。globalや/tmpに残す状態を毎回のInvokeで検査し、必要なら作り直せるjobでは、Lambdaが選択肢になります。
runtimeの終了と再初期化をLambdaへ任せても、jobの内容と完了状態を確かめるのは利用者です。別の実行環境へ結果を引き継ぐ場合は、#06で比べたように外部へ保存します。外部への保存では、通信や権限の管理、保存失敗への対処が増えます。どこまで完了を確認する必要があるかを決め、その条件に合う保存先を選びます。
同じ条件で試す
検証用のAWS環境で、東京region(ap-northeast-1)に4関数を作ります。コードはindex.js、handler名はindex.handlerです。実測では、各LogGroupへの書き込みだけを許す実行roleを使いました。実行roleは、関数にAWSへの操作権限を与える仕組みです。手動の追試では、各関数に新しい実行roleを作ります。AWSLambdaBasicExecutionRole を付けると、CloudWatch Logsへログを書けます。関数の設定は、冒頭の条件表に合わせます。
各関数へ、次のseedを同期Invokeします。
1
{"mode":"commit","run_id":"test-09","id":"work","value":"seed-09"}次に、表のmodeを一つずつ指定します。timeoutだけdelay_msを2500にします。
1
{"mode":"exit","run_id":"test-09","id":"work","value":"pending-09"}1
2
3
4
AWS_MAX_ATTEMPTS=1 aws lambda invoke --function-name <検証用の関数名> \
--invocation-type RequestResponse --log-type Tail \
--cli-binary-format raw-in-base64-out \
--payload file://input.json --region ap-northeast-1 response.jsonAWS_MAX_ATTEMPTS=1でCLIの自動retryを止め、callerが意図した再送だけを数えます。まず、Invoke応答のFunctionErrorとresponse.jsonを確認します。次にobserveを送ります。init_id、count、memory_value、tmp_init_id、tmp_stageを比べます。同じmode入力をもう一度送り、observeを繰り返します。毎回の応答は、別のファイルへ保存します。CloudWatch Logsにある同じRequestIdの記録と照合してください。
1
{"mode":"observe","run_id":"test-09","id":"work"}この順序を各関数で行うと、合計20回です。reserved concurrencyを設定できないaccountでは条件が変わるため、実際の設定を別に記録します。試す環境は、検証用のresourceに限ります。
まとめ
例外を発生させた後も、変更したglobalと/tmpの値が残りました。Node.jsを終了させた場合とtimeoutでは、globalがnullへ戻っても、/tmpに未完了の記録が残りました。runtimeの再初期化と、作業用ファイルの有無は別に確かめる必要があります。
globalや/tmpは、再作成できるcacheや試行中の作業に使えます。失敗後も、内容と完了状態を確認します。別の実行環境でも必要な結果は、外部へ保存します。次の#10では、ファイルの書き込み先を比べます。
Series: Serverless DeepDive (13 articles)
- …
- 10Serverless DeepDive #09:失敗後の実行環境に何が残るのか 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