Serverless DeepDive #06:実行環境はどこまで再利用されるのか
連続・並行Invoke、空き時間、設定とversionの更新で、メモリと/tmpに残る状態を比べます。S3からの読み出しも確かめ、cacheと引き継ぐ処理結果の保存先を考えます。
Series: Serverless DeepDive (13 articles)
- …
- 7Serverless DeepDive #06:実行環境はどこまで再利用されるのか This article
検証構成
前回の#05 では、runtimeが呼び出しを受け取り、結果を返す流れを追いました。今回は、次の呼び出しで残っている状態を確かめます。Invokeは、Lambda関数を呼び出すAPIです。
handlerの外に作ったclientやcacheは、次にも使える場合があります。読み直せる設定なら、cacheが空でも再取得できます。jobは、始まりと終わりのある一つの処理です。完了したjobの結果をそこだけに置くと、次の呼び出しへ引き継げるかが気になります。
同じcodeのprimaryとcontrolを作りました。primaryで連続・並行Invoke、空き時間、設定とversionの更新を試します。controlは設定を変えず、空き時間と設定更新の対照にします。S3では、primaryの保存結果を別の関数から読みます。
| 条件 | 設定 |
|---|---|
| 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 | 各5秒 / 各2 |
| 呼び出し | 通常on-demandの同期Invoke、合計53回 |
| 保存先 | module変数、/tmpのJSON、S3の同じkey |
2026年10月11日の公式runtime一覧 で最新のNode.jsを選び、Tokyoで作成しました。Node.js 26はpublic Previewです。SLAとTechnical Supportの対象外なので、今回は検証用に使います。
reserved concurrency は、関数の同時実行数を予約し、上限も設ける設定です。今回は各2とし、並行2回を許します。常に2つの環境を用意する指定ではありません。
handlerの外の変数と/tmpに残る状態を、この稿では局所状態と呼びます。moduleを読み込むたびに、初期化を見分ける識別値UUIDを作りました。図中のinitは、その先頭8桁です。

矢印はInvokeと応答、S3への保存と読み出し、ログ出力です。primary内の2つの枠は、並行時に別々のUUIDを返したhandlerを示します。
状態を記録する
handlerの外には、呼び出し回数と値も置きます。次は、検証に使ったhandlerの全体です。
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
const { randomUUID } = require("node:crypto");
const { readFileSync, writeFileSync } = require("node:fs");
const { setTimeout: sleep } = require("node:timers/promises");
// These identify module initialization and local state, not a physical host or microVM.
const environmentId = randomUUID();
const initializedAtMs = Date.now();
const tmpPath = "/tmp/lambda-lab-node-probe.json";
let calls = 0;
let memoryValue = null;
let s3Client;
const modes = ["observe", "persist", "read", "fail", "sleep"];
console.log(
JSON.stringify({ phase: "probe-init", environmentId, 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 temporaryState() {
try {
return JSON.parse(readFileSync(tmpPath, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
return { environmentId, value: null };
}
}
function storage() {
if (!s3Client) {
const { S3Client } = require("@aws-sdk/client-s3");
s3Client = new S3Client({ maxAttempts: 1 });
}
return s3Client;
}
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 ?? "observe", "id");
const runId = identifier(event.run_id ?? "manual", "run_id");
const delayMs = event.delay_ms ?? 0;
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > 30000)
throw new TypeError("delay_ms must be between 0 and 30000");
if (event.value !== undefined && (typeof event.value !== "string" || event.value.length > 128))
throw new TypeError("value must be a string of at most 128 characters");
const tmp = temporaryState();
const before = { memoryValue, tmpValue: tmp.value };
if (event.value !== undefined) {
memoryValue = event.value;
tmp.value = event.value;
}
writeFileSync(tmpPath, JSON.stringify(tmp));
calls += 1;
const record = {
id,
run_id: runId,
mode,
request_id: context.awsRequestId,
environment_id: environmentId,
initialized_at_ms: initializedAtMs,
count: calls,
memory_before: before.memoryValue,
memory_value: memoryValue,
tmp_environment_id: tmp.environmentId,
tmp_before: before.tmpValue,
tmp_value: tmp.value,
version: context.functionVersion,
config_mark: process.env.PROBE_CONFIG_MARK ?? "A",
node: process.version,
started_ms: Date.now(),
};
console.log(JSON.stringify({ phase: "probe-enter", ...record }));
await sleep(delayMs);
if (mode === "persist" || mode === "read") {
const bucket = process.env.LAB_EVIDENCE_BUCKET;
const prefix = process.env.LAB_EVIDENCE_PREFIX;
if (!bucket || !prefix) throw new Error("storage is not configured");
const key = `${prefix}${runId}/${id}.json`;
const { PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
record.sdk = require("@aws-sdk/client-s3/package.json").version;
if (mode === "persist") {
await storage().send(
new PutObjectCommand({ Bucket: bucket, Key: key, Body: JSON.stringify(record) }),
);
record.persisted = true;
} else {
try {
const response = await storage().send(new GetObjectCommand({ Bucket: bucket, Key: key }));
record.saved = JSON.parse(await response.Body.transformToString());
record.found = true;
} catch (error) {
if (error.name !== "NoSuchKey") throw error;
record.found = false;
}
}
}
record.finished_ms = Date.now();
console.log(JSON.stringify({ phase: "probe-result", ...record }));
if (mode === "fail") throw new Error("intentional failure after observation");
return record;
};temporaryStateは、/tmpにfileがなければnullの値を返します。valueを指定したInvokeだけが、メモリとfileの値を書き換えます。共通probeにはfailとsleepもありますが、今回はobserve、persist、readを使いました。
応答とログには、UUID、回数、書き換え前後の値を残します。RequestId、version、設定の印、開始・終了時刻も記録しました。本文と画像では、UUIDの先頭8桁を使います。同じUUIDを返す呼び出しは、同じmodule初期化のhandlerが処理したと読みます。
公式のlifecycle では、handler外のcodeは実行環境の初期化中に動きます。そこで、同じUUIDを環境再利用の目印と考えます。これは観測と資料からの推測で、hostやmicroVMの配置は特定できません。画像のmemoryとtmpは、各回の書き換え前の値です。nullは値がない状態を表します。
53回とも関数エラーなしでrecordを返しました。応答のRequestIdは、CloudWatch Logsのprobe-result 53件と全件一致しました。
測定には共通labのenvironment-reuse.jsonを使いました。sequence、parallel、after-parallelを、この順で3組実行します。その後、S3へ保存してprimaryとcontrolから読みました。空き時間の観測を挟み、version 1、設定更新、version 2の順で進めました。最後に、version 1、version 2、$LATESTの順にS3を読みました。
次は1組目のsequenceの入力です。並行時はvalueを渡さず、delay_msに2000を指定します。
1
{"id":"sequence-1","mode":"observe","value":"cache-a","run_id":"baseline-1"}連続Invoke
6回を1組として、順に呼び出しました。各回でvalueにcache-aを渡します。これを、後述の並行Invokeと交互に3組行いました。
| 組 | 初期化UUIDの先頭8桁 | handlerの回数 | 書き換え前のメモリと/tmp |
|---|---|---|---|
| 1 | 39088bf5 | 1〜6 | 1回目はnull、以後cache-a |
| 2 | df62f46a | 4〜9 | この組の1回目はnull、以後cache-a |
| 3 | 39088bf5 | 11〜16 | 6回ともcache-a |
各組の6回は、同じUUIDを持つhandlerが処理しました。値を置いた後は、メモリと/tmpの両方から読めています。handlerが返るたびに、外側の変数が初期値へ戻る動きにはなりませんでした。
次の画像は、1組目と、その直後の並行・連続Invokeです。

実行環境のlifecycle では、再利用時に初期化済みのobjectや/tmpの内容を使えると説明されています。今回も、その動作を観測できました。今回の18回から、常に同じ初期化のhandlerが処理するとは決められません。
並行Invoke
同じprimaryへ、2回を並行に送りました。handlerで2秒待ち、処理時間を重ねます。これを3組行いました。
| 組 | 2回のUUIDの先頭8桁 | 処理区間の重なり |
|---|---|---|
| 1 | 39088bf5 / df62f46a | 1,655 ms |
| 2 | df62f46a / 39088bf5 | 1,974 ms |
| 3 | 39088bf5 / df62f46a | 1,965 ms |
3組とも、重なった2回は別のUUIDを返しました。画像のstarted_msとfinished_msは、Unix epochのmillisecondです。重なりは、早い方のfinished_msから遅い方のstarted_msを引いて求めました。どの組も正の値で、区間が重なっています。

1組目の新しいdf62f46aには、cache-aがありませんでした。その直後に1回ずつ送った2回も、df62f46aを持つhandlerが処理しました。メモリと/tmpは、どちらもnullのままです。先ほど値を持っていた39088bf5へ戻る動きは、この2回にはありませんでした。
2組目ではdf62f46aにもcache-aを置いたため、並行した両方から同じ値を読めました。そこで、値が一致することだけで再利用を判断せず、UUIDと回数も比べます。
この観測から、handlerの外の値は関数全体で共有する保存先には向かないと考えます。cacheを使う処理は、別の初期化のhandlerが処理しても再取得できる作りにします。外部APIのclientも、各初期化で作れる必要があります。
空き時間を置いてみる
先に、primaryへsaved-06を渡してS3へ保存しました。この入力で、メモリと/tmpにもsaved-06を置いています。続けてprimaryとcontrolからS3を読みました。表の空き時間前の2行は、この読出し時の記録です。
次に、primaryとcontrolの両方へのInvokeを止めました。最後のInvokeはcontrolのS3読出しです。その完了から次の送信まで、691.435秒です。約11分31秒の間、codeも設定も変更していません。
| 関数 | 空き時間前のUUID / 回数 | 空き時間後のUUID / 回数 | 後のメモリと/tmp |
|---|---|---|---|
| primary | df62f46a / 15 | b01f7460 / 1 | null |
| control | da6f1561 / 1 | a16ca84b / 1 | null |
両方で、初期化のUUIDが変わりました。primaryが局所に持っていたsaved-06も、次の呼び出しでは読めませんでした。画像のconfigは、どの行もAです。

ただし、この結果から11分を寿命とは決められません。今回の空き時間後に、以前の状態を使えなかったという観測です。以前の環境が終了したのか、残っていても選ばれなかったのかは、この検証から分かりません。
実行環境のlifecycle にも、環境は終了し得るため、存続を前提にしないよう説明があります。今回、短い連続Invokeで残った状態も、間を空けると引き継げませんでした。
設定を更新する
primaryの環境変数PROBE_CONFIG_MARKだけを、A→B→C→Dと変えました。他の環境変数、code、memory、timeoutは保ちます。実行中のInvokeがない状態で更新し、LastUpdateStatusがSuccessfulになってから次を送りました。
| 更新 | primaryのUUID / 回数の変化 | controlのUUID / 回数 |
|---|---|---|
| A→B | b01f7460 / 2 → 8d9d6dc4 / 1 | a16ca84b / 2 |
| B→C | 8d9d6dc4 / 2 → d52b2f9c / 1 | a16ca84b / 3 |
| C→D | d52b2f9c / 2 → 20b09490 / 1 | a16ca84b / 4 |
3組とも、更新後は新しいUUIDで始まりました。/tmpに書いたUUIDも変わっています。更新しないcontrolは、同じUUIDで回数が増えました。

この実測は、実行中の呼び出しを含まない更新の対照です。更新時の配置や、処理中のInvokeがどう切り替わるかは測っていません。今回は更新の直後に、別の初期化のhandlerが処理しました。更新を越えて局所状態を引き継ぐ前提にせず、cacheが空でも始められるようにします。
versionを選ぶ
更新前のAをversion 1、更新後のDをversion 2として保存しました。published version は、codeと環境変数などを固定した関数の版です。各versionを明示して、3回ずつ呼びます。値はそれぞれversion-1とversion-2を渡しました。
| 呼び出し先 | 設定の印 | UUIDの先頭8桁 | handlerの回数 |
|---|---|---|---|
| version 1 | A | aace9553 | observeは1〜3、S3読出しは4 |
| version 2 | D | af428467 | observeは1〜3、S3読出しは4 |
| 更新後の$LATEST | D | 20b09490 | 更新後の初回は1、S3読出しは2 |
version 1と2は、別のUUIDで始まりました。各3回は同じ初期化のhandlerが処理しています。設定Dを持つversion 2も、$LATESTとは別のUUIDでした。

この補助対照でも、どこへInvokeするかによって局所状態が変わりました。aliasは次の#07で扱います。
S3へ保存する
primaryから、メモリの値saved-06をS3へ保存しました。同じrun_idとidからkeyを作り、primaryとcontrolで読みます。保存ではPutObjectの成功を待ち、読み出しではGetObjectを使いました。SDKの自動retryは無効です。
| 操作 | UUIDの先頭8桁 | 書き換え前→後のメモリと/tmp | S3の結果 |
|---|---|---|---|
| primaryで保存 | df62f46a | cache-a→saved-06 | PutObject成功 |
| primaryで読出し | df62f46a | saved-06→saved-06 | saved-06を取得 |
| controlで読出し | da6f1561 | null→null | saved-06を取得 |
controlのhandlerは別のUUIDを持ちます。局所の値はnullでも、primaryが保存した結果を読めました。S3のJSONには、保存元のUUIDとRequestIdも入れました。読み出したJSONを、保存時の記録と照合しました。

version 1、version 2、設定更新後の$LATESTの順にも読みました。「versionを選ぶ」の節の画像にある、receiptの3行です。各呼び出し先のメモリと/tmpは、version-1、version-2、nullと異なります。それでも、S3からは同じ保存元のsaved-06を取得できました。このS3の画像では、persistedとfoundの1がtrueを表します。
この結果から、次へ渡す処理結果は外部へ保存し、その完了を確かめる設計が合うと考えます。Lambdaへ実行環境の準備や終了を任せても、結果の存続を局所状態に結び付けずに済みます。
S3を使うと、通信と権限、保存に失敗した場合の処理が増えます。今回試したのは、一つのJSONを保存して読む処理です。共有stateの同時更新や、保存先の性能比較は扱っていません。必要な更新方法や遅延に合わせて、保存先を選びます。
測定を試す
実測は共通labから配備しました。ここでは同じhandlerをConsoleとCLIで試す方法を示します。検証用accountのTokyoに2関数を作ります。上のhandlerをindex.jsへ保存し、次のzipを両方へ配備します。zipの配備手順 に沿って、handler名をindex.handlerに設定します。依存はruntime同梱のSDKを使います。
1
zip function.zip index.js| 対象 | 設定 |
|---|---|
| 両関数 | nodejs26.x、arm64、512 MB、timeout 5秒、reserved concurrency 2、通常on-demand |
| 各実行role | Lambda用のroleを新規作成し、AWSLambdaBasicExecutionRole と下のS3権限を付ける |
| S3 | 同じregionに新規bucketを作る。暗号化はSSE-S3、public accessはblockし、両関数で同じbucketとprefixを使う |
| 両関数の環境変数 | PROBE_CONFIG_MARK=A、LAB_EVIDENCE_BUCKET=作成したbucket名、LAB_EVIDENCE_PREFIX=evidence/dd06/ |
accountの同時実行数の上限が小さい場合など、reserved concurrencyを設定できないことがあります。設定できない場合は今回と条件が異なるため、変更した値を別条件として記録します。
IAM roleは、関数がAWSへアクセスする権限です。次のbucket名を置き換え、両roleに付けます。prefixの末尾の/も必要です。
1
2
3
4
5
6
7
8
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET/evidence/dd06/*"
}]
}AWS CLIのInvoke を使う例です。profileと関数名は自分の検証用の値へ置き換えます。出力fileは毎回変えます。StatusCodeだけで成功とせず、FunctionErrorがないことと、応答・CloudWatch LogsのRequestIdと値を確認します。
1
2
3
4
5
6
7
AWS_MAX_ATTEMPTS=1 aws lambda invoke \
--profile YOUR_PROFILE --region ap-northeast-1 \
--function-name YOUR_PRIMARY \
--invocation-type RequestResponse \
--cli-binary-format raw-in-base64-out \
--payload '{"id":"sequence-1","run_id":"baseline-1","mode":"observe","value":"cache-a"}' \
sequence-1.json最初の3行を1組とし、run_idをbaseline-1、baseline-2、baseline-3として3組行います。idはsequence-1〜6、parallel-1〜2、after-parallel-1〜2です。並行の2回は同時に送り、応答の時刻が重なったかを確かめます。
| 順序 | 呼び出し先・回数 | modeと追加の入力 |
|---|---|---|
| 1 | primaryへ連続6回 | observe、value=cache-a |
| 2 | primaryへ並行2回 | observe、delay_ms=2000、valueなし |
| 3 | primaryへ連続2回 | observe、valueなし |
| 4 | primaryへ1回 | persist、id=receipt、run_id=storage-06、value=saved-06 |
| 5 | primary、controlへ各1回 | read、id=receipt、run_id=storage-06、valueなし |
| 6 | 最後の応答後、全Invokeを止める | 設定を変えず、今回は次の送信まで691.435秒 |
| 7 | primary、controlへ各1回 | observe、idはobserve-primary-1、observe-control-1、run_id=idle-06、valueなし |
| 8 | 設定Aのprimaryをversion 1として発行し、version 1へ連続3回 | observe、id=v1-1〜v1-3、run_id=native-06、value=version-1 |
| 9 | primaryの更新前1回、更新後1回、controlへ1回を3組 | observe、idはpre-b→post-b→control-b、pre-c→post-c→control-c、pre-d→post-d→control-dの順。run_id=native-06、valueなし。設定はA→B→C→D |
| 10 | 設定Dのprimaryをversion 2として発行し、version 2へ連続3回 | observe、id=v2-1〜v2-3、run_id=native-06、value=version-2 |
| 11 | primaryのversion 1→version 2→$LATESTの順に各1回 | read、id=receipt、run_id=storage-06、valueなし |
更新では既存の環境変数を取得してPROBE_CONFIG_MARKだけを変え、Successfulを待ちます。controlは変更しません。version発行では応答のVersionを確認し、Invokeに--qualifierを付けます。これで合計53回です。UUIDの値や再利用先が今回と同じになるとは限りません。
最後に検証用の関数とversion、log group、roleを削除し、bucket内の実験objectを消してbucketも削除します。同じaccountとregionで、それぞれが残っていないことを確認します。
まとめ
実行環境の再利用で、handlerの外の値と/tmpを使い回せました。一方、並行Invokeの後や空き時間、設定とversionの違いでは、別の局所状態へ届いています。
読み直せる設定やclientは、cacheが空でも作り直せるようにします。cacheの更新期限や、利用者ごとの値を混ぜない扱いは、利用側で決めます。完了したjobの結果は、次にどの環境へ届いても読める場所へ保存します。
局所状態を作り直しても同じ結果になるjobなら、環境の準備と終了をLambdaへ任せやすくなります。同じメモリの継続が要件なら、stateの置き方や処理の分け方を見直します。今回の再利用は、処理結果を引き継ぐ保証には使いません。
Series: Serverless DeepDive (13 articles)
- …
- 7Serverless DeepDive #06:実行環境はどこまで再利用されるのか 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