AWS Builder Center

Serverless DeepDive #06:実行環境はどこまで再利用されるのか

連続・並行Invoke、空き時間、設定とversionの更新で、メモリと/tmpに残る状態を比べます。S3からの読み出しも確かめ、cacheと引き継ぐ処理結果の保存先を考えます。

Series: Serverless DeepDive (13 articles)

  1. …
  2. 7
    Serverless DeepDive #06:実行環境はどこまで再利用されるのか This article
Senior Evangelist

検証構成

前回の#05 では、runtimeが呼び出しを受け取り、結果を返す流れを追いました。今回は、次の呼び出しで残っている状態を確かめます。Invokeは、Lambda関数を呼び出すAPIです。
handlerの外に作ったclientやcacheは、次にも使える場合があります。読み直せる設定なら、cacheが空でも再取得できます。jobは、始まりと終わりのある一つの処理です。完了したjobの結果をそこだけに置くと、次の呼び出しへ引き継げるかが気になります。
同じcodeのprimaryとcontrolを作りました。primaryで連続・並行Invoke、空き時間、設定とversionの更新を試します。controlは設定を変えず、空き時間と設定更新の対照にします。S3では、primaryの保存結果を別の関数から読みます。
条件設定
regionap-northeast-1
managed runtimenodejs26.x、Amazon Linux 2023
実際のNode.js versionv26.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桁です。
2つのLambdaへ同期Invokeし、初期化ごとのメモリと/tmp、S3、CloudWatch Logsを比べる検証時の構成
矢印は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
139088bf51〜61回目はnull、以後cache-a
2df62f46a4〜9この組の1回目はnull、以後cache-a
339088bf511〜166回ともcache-a
各組の6回は、同じUUIDを持つhandlerが処理しました。値を置いた後は、メモリと/tmpの両方から読めています。handlerが返るたびに、外側の変数が初期値へ戻る動きにはなりませんでした。
次の画像は、1組目と、その直後の並行・連続Invokeです。
連続6回は同じUUIDで初回null、以後cache-aとなり、並行時の別UUIDとその後の2回ではnullとなる実測結果
実行環境のlifecycle では、再利用時に初期化済みのobjectや/tmpの内容を使えると説明されています。今回も、その動作を観測できました。今回の18回から、常に同じ初期化のhandlerが処理するとは決められません。

並行Invoke

同じprimaryへ、2回を並行に送りました。handlerで2秒待ち、処理時間を重ねます。これを3組行いました。
組2回のUUIDの先頭8桁処理区間の重なり
139088bf5 / df62f46a1,655 ms
2df62f46a / 39088bf51,974 ms
339088bf5 / df62f46a1,965 ms
3組とも、重なった2回は別のUUIDを返しました。画像のstarted_msとfinished_msは、Unix epochのmillisecondです。重なりは、早い方のfinished_msから遅い方のstarted_msを引いて求めました。どの組も正の値で、区間が重なっています。
並行Invokeを3組行い、各組の別UUIDと開始・終了時刻の重なりを確認した実測結果
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
primarydf62f46a / 15b01f7460 / 1null
controlda6f1561 / 1a16ca84b / 1null
両方で、初期化のUUIDが変わりました。primaryが局所に持っていたsaved-06も、次の呼び出しでは読めませんでした。画像のconfigは、どの行もAです。
設定AでInvokeを止めた前後のUUIDを比べ、後の別UUIDではメモリと/tmpがnullだった実測結果
ただし、この結果から11分を寿命とは決められません。今回の空き時間後に、以前の状態を使えなかったという観測です。以前の環境が終了したのか、残っていても選ばれなかったのかは、この検証から分かりません。
実行環境のlifecycle にも、環境は終了し得るため、存続を前提にしないよう説明があります。今回、短い連続Invokeで残った状態も、間を空けると引き継げませんでした。

設定を更新する

primaryの環境変数PROBE_CONFIG_MARKだけを、A→B→C→Dと変えました。他の環境変数、code、memory、timeoutは保ちます。実行中のInvokeがない状態で更新し、LastUpdateStatusがSuccessfulになってから次を送りました。
更新primaryのUUID / 回数の変化controlのUUID / 回数
A→Bb01f7460 / 2 → 8d9d6dc4 / 1a16ca84b / 2
B→C8d9d6dc4 / 2 → d52b2f9c / 1a16ca84b / 3
C→Dd52b2f9c / 2 → 20b09490 / 1a16ca84b / 4
3組とも、更新後は新しいUUIDで始まりました。/tmpに書いたUUIDも変わっています。更新しないcontrolは、同じUUIDで回数が増えました。
設定更新の前後でprimaryの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 1Aaace9553observeは1〜3、S3読出しは4
version 2Daf428467observeは1〜3、S3読出しは4
更新後の$LATESTD20b09490更新後の初回は1、S3読出しは2
version 1と2は、別のUUIDで始まりました。各3回は同じ初期化のhandlerが処理しています。設定Dを持つversion 2も、$LATESTとは別のUUIDでした。
version別の連続Invokeで局所の値を再利用し、別versionと$LATESTから同じS3の値を読む実測結果
この補助対照でも、どこへInvokeするかによって局所状態が変わりました。aliasは次の#07で扱います。

S3へ保存する

primaryから、メモリの値saved-06をS3へ保存しました。同じrun_idとidからkeyを作り、primaryとcontrolで読みます。保存ではPutObjectの成功を待ち、読み出しではGetObjectを使いました。SDKの自動retryは無効です。
操作UUIDの先頭8桁書き換え前→後のメモリと/tmpS3の結果
primaryで保存df62f46acache-a→saved-06PutObject成功
primaryで読出しdf62f46asaved-06→saved-06saved-06を取得
controlで読出しda6f1561null→nullsaved-06を取得
controlのhandlerは別のUUIDを持ちます。局所の値はnullでも、primaryが保存した結果を読めました。S3のJSONには、保存元のUUIDとRequestIdも入れました。読み出したJSONを、保存時の記録と照合しました。
primaryの保存と読出し、局所状態がnullのcontrolから同じ保存元と値を読む実測結果
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
各実行roleLambda用の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と追加の入力
1primaryへ連続6回observe、value=cache-a
2primaryへ並行2回observe、delay_ms=2000、valueなし
3primaryへ連続2回observe、valueなし
4primaryへ1回persist、id=receipt、run_id=storage-06、value=saved-06
5primary、controlへ各1回read、id=receipt、run_id=storage-06、valueなし
6最後の応答後、全Invokeを止める設定を変えず、今回は次の送信まで691.435秒
7primary、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
9primaryの更新前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
11primaryの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)

  1. …
  2. 7
    Serverless DeepDive #06:実行環境はどこまで再利用されるのか 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