Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる
同じhandlerを外部Extensionあり・なしで実行し、応答の完了、REPORTのDuration、課金時間とtimeoutの境界を確かめます。
Series: Serverless DeepDive (13 articles)
- …
- 12Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる This article
Extensionの課金時間
handlerが結果を返しても、課金対象の時間は続くことがあります。今回の検証では、約50 msで終わるhandlerに外部Extensionを足しました。Extensionがhandler終了後に150 ms待つと、REPORTのDurationは約204 msになりました。
Extensionは監視やlog収集を関数へ追加する仕組みです。LambdaがExtensionを含む各processのInit(初期化)、Invoke(呼び出し)、Shutdown(終了)の順序を管理します。利用者は監視処理も含めた時間と資源を確認する必要があります。
前回の#10 はfileを置く場所を比べました。今回は同じ実行環境で動く別processを加えます。応答の完了と課金時間、timeoutの境界を比べます。
Extensionの対照
同じhandlerを2つの関数へ配備しました。
controlはLayerなしです。extendedだけに外部ExtensionのLayerを付けました。Layerは追加fileを配る仕組みで、今回はGoの実行fileを置いています。| 固定した条件 | 設定 |
|---|---|
| Region | ap-northeast-1 |
| 実行方式 | 通常のon-demand、同期Invoke |
| Runtime | nodejs26.x、Node.js v26.8.2、public preview |
| Architectureとmemory | arm64、512 MB |
| timeoutとreserved concurrency(同時実行枠の予約) | 2秒、各関数1 |
| handler | 同じsource hash、50 ms待機後に結果を返す |
| 保存先 | 両方で同じ/tmp終了markerを書く |
| 再試行 | SDKのmaxAttempts=1、失敗時のretryなし |
2026年10月11日時点で、AWSのruntime一覧 にある最新のNode.js managed runtimeは、Node.js 26のpublic previewでした。今回の数値はこのruntimeと下の人工的な待機で得た結果です。
ExtensionはInitで100 ms待ちます。各Invokeではhandlerが書く終了markerを待ち、その後150 ms待ちます。送信や収集の後処理を模擬する、人工的な待機です。待機がREPORTのDurationとBilled Duration(課金対象の時間)へ加わる条件を比べます。

検証時の構成です。Extensionは
extended内の別processです。青は同期Invokeとhandlerの応答、緑はlog出力です。橙は/tmpの終了markerを介した待ち合わせを示します。通常入力を各関数8回、交互に送りました。続いてExtension側だけでtimeoutと復旧を試しました。追加の
LogType対照も含め、同期Invokeは22回です。REPORTのDuration
handler内の待機時間は両方とも50〜51 msでした。一方、REPORTのDurationには約151 msの差が出ました。
初回のInitを含む2回の呼び出しと、timeout後の再初期化は別に扱います。次の表は初回を除く通常7回ずつの結果です。
| 項目 | control:Layerなし | extended:Extensionあり |
|---|---|---|
| handlerの計測 | 50〜51 ms | 50〜51 ms |
| REPORT Durationの範囲 | 52.35〜53.49 ms | 203.57〜204.14 ms |
| REPORT Durationの中央値 | 52.68 ms | 203.72 ms |
| Billed Durationの中央値 | 53 ms | 204 ms |
| REPORT Max Memory Used | 87 MB | 96 MB |

初回を除く通常14行へ絞ったREPORTです。
fnは関数、reqはRequestIdの先頭8文字です。両関数のmemory設定は512 MBです。絞り込みのqueryは後で載せます。通常7回では、Extensionの終了logの時刻がhandlerの終了markerより152〜153 ms後でした。今回はmarkerの後に待つ直列の条件です。追加した150 ms待機が、Invoke全体の時間に表れています。
AWSのExtensions APIのInvoke phase では、runtimeと全Extensionが
Nextを送るまでInvoke phaseが続きます。Nextは、処理を終えて次のeventを待つとLambdaへ知らせるAPI呼び出しです。handler完了後にExtensionが続ける処理も、この区間に入ります。Max Memory Usedは実行環境全体の最大使用量です。今回は9 MB増えました。ExtensionはmemoryとCPUも関数と共有するため、採用時は時間と併せて確認します。
Initと課金時間
初回はInitも含めてBilled Durationが増えました。
controlは175 ms、extendedは425 msです。| 初回のREPORT | control | extended |
|---|---|---|
| Init Duration | 120.19 ms | 220.53 ms |
| Duration | 54.61 ms | 204.23 ms |
| Billed Duration | 175 ms | 425 ms |
今回の初回値はInit DurationとDurationの和をms単位で切り上げた値と一致しました。追加したInit待機は100 msです。初回は各関数1回の比較です。
課金対象量は設定memoryとBilled Durationから比べられます。512 MBを0.5 GBとして、通常呼び出しの中央値で計算します。
| 課金対象量の計算 | 結果 |
|---|---|
control:0.5 GB × 0.053秒 | 0.0265 GB-second |
extended:0.5 GB × 0.204秒 | 0.102 GB-second |
同じhandlerでも、今回は課金対象量(GB-second)が約3.85倍になりました。これは、人工的な150 ms待機を加えた条件での比較です。金額へ換算する際は、対象RegionのLambda料金 やrequest料金も確認します。
LogTypeの対照
LogTypeを省くと、handlerの結果とInvoke全体の完了を分けて観測できました。最初の18回はSDKの同期Invokeで
LogType=Tailを使いました。Tailは応答へ末尾のlogを付ける設定です。この経路では、応答にExtensionの終了とREPORTも含まれました。本文でいうcaller時間は、測定clientのSDKがsend直前からawait完了直後までに測った経過時間です。通常7回の
extendedでcallerが待った時間は239.89〜253.37 msです。応答が戻った時点で、Extensionの終了とREPORTもすでに出ていました。
保存済みのSDK測定記録とREPORTを、撮影のためCloudWatchへ転記した画面です。追加Invokeは0です。Timestampは転記時刻で、
client_start_msとclient_end_msは元測定の時刻です。statusのsuccessはREPORTのStatus欄がない通常回を転記時に付けた分類です。そこで、同じ関数を
LogTypeなしで追加呼び出ししました。extendedの通常入力はhandlerが50 ms、REPORT Durationが205.00 msです。callerの経過時間は166.54 msでした。この通常入力1回では、全体のREPORT時間より先に応答が戻っています。
こちらも保存済みのSDK測定記録とREPORTを、撮影のため転記した画面です。追加Invokeは0です。Timestampは転記時刻です。
client_start_ms、client_end_ms、extension_finished_msは元測定の時刻で、撮影時刻とも異なります。successは転記時の通常回の分類です。応答と後処理
前掲の
LogTypeなしの画面で、req=74fbcc99のextension_finished_msからclient_end_msを引くと136 msです。Extensionの終了時刻がcallerの応答受信時刻より後になっています。端末とLambdaの時計差は未補正です。この値は記録された時刻の差として読み、精密に測った遅延とは区別します。Extensions APIのInvoke phaseの説明 では、runtimeの応答はExtensionが動いていてもclientへ返ります。今回の
LogType対照も、応答がclientへ戻った後にExtensionが続けて動く仕様と整合します。Tailでlogを取得する経路と、通常の応答だけを取る経路は分けて測ります。timeoutと再初期化
handlerの成功payloadと、REPORTのtimeoutが同じInvokeで出ました。
extendedへ、handler 1800 ms、Extension 500 msの入力を送りました。今回はhandlerの終了marker後にExtensionが待つ直列の条件です。handlerは2秒以内に戻っても、Invoke phase全体はその時間内に収まりません。| 境界と復旧 | handler | REPORT Duration | REPORT Status | 応答 |
|---|---|---|---|---|
| Tail付きの境界 | 1802 ms | 2000.00 ms | timeout | 成功payload |
| その後の通常入力 | 51 ms | 354.05 ms | 記載なし | 成功payload |
LogTypeなしの境界 | 1802 ms | 2000.00 ms | timeout | 成功payload |
| その後の通常入力 | 50 ms | 354.22 ms | 記載なし | 成功payload |
22回の応答はすべてHTTP 200で、FunctionErrorもありませんでした。境界の2回にもhandlerの結果が返りました。一方、同じRequestIdのREPORTはtimeoutです。ExtensionのShutdown reasonもtimeoutでした。Tailの境界では、Invoke event受信から2306 ms後の
extension-doneも記録されました。終了logがあることと、期限内に完了したことを分けて読みます。
Init列がある行と、Durationが354 ms以上の行へ絞った6行です。初回はInitが別の列にあります。timeout後はDurationが約354 msで、別のInit列はありません。

dbfeb972はTail付きの境界、f2bb9042はその後の通常入力、74fbcc99はLogTypeなしの通常入力です。境界とその後の行を比べると、handler_initはd4d523beから121db6eaへ、ext_initはc6feeee3からe8be43c9へ変わっています。画面の
work_ms列は入力値ではなくhandler_msの実測値です。境界の行では1802 msです。post_msはrequested_ms、finish_msはfinished_msの別名です。通常入力の74fbcc99では、handler終了からExtension終了まで153 msでした。保存した応答payloadでも、復旧時にhandlerの
countが1へ戻ったことを確認しました。この値は上の画像には表示していません。#09の失敗後の再初期化 が、Extensionにも及んでいます。AWSは、再初期化が次のInvokeのDurationへ入るsuppressed init を説明しています。今回も再初期化logがありました。約354 msには再初期化の区間も含まれると読み取れます。REPORTの時間を区間ごとに分けるには、次回扱うTelemetry APIの記録が必要です。
監視処理を選ぶ
応答後にも関数内で監視処理を続けたいとき、Extensionは候補になります。今回の
LogTypeなしの通常入力では、callerが166.54 msで応答を受け、Invoke全体のREPORTは205.00 msでした。後処理の完了を待たずに結果を返せる一方、その処理は課金時間とtimeoutの予算を使います。CloudWatchのmetric
PostRuntimeExtensionsDurationは、runtimeのNextから最後のExtensionのNextまでの時間です。今回の1分集計にはRequestIdがなく、個別のInvokeには結び付けられませんでした。採用時は同じhandlerの対照を残し、Init、通常のDuration、環境全体のMax Memory Used、timeout後の動作を測ります。
今回の初回と
LogTypeなしの各条件は1回ずつです。handlerと並行して動くExtension、CPU負荷、外部への送信、正式提供runtimeや監視製品の性能、実請求額は測っていません。また、reserved concurrencyを各1にし、追加4回では応答後に300 ms、境界の後は700 msの待機を置きました。同時実行や次のInvokeを受け付ける時点、throughputへの影響は今回の対照では測っていません。CloudWatchへのlog出力で要件を満たせる場合は、その経路も選択肢です。Extensionは関数と同じroleを使うため、監視toolがAWSの送信先へ書き込む権限も、そのroleへ付ける必要があります。今回のroleは各関数のCloudWatch Logsへの書き込みに絞っています。
共通handler
両関数へ、次の41行を同じbyteで配備しました。再現時は
index.jsへ保存します。実測ではCloudFormationのinline codeで配備し、Lambda上のfileもindex.js、Handler設定もindex.handlerでした。handlerは
work_msだけ待ち、RequestIdを含む終了markerを書きます。markerのfile名にはRequestIdを使います。/tmp配下のlab-extension-で始まるJSON fileです。controlも同じfileを書き、Layerの有無だけを変えています。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
const { randomUUID } = require("node:crypto");
const fs = require("node:fs");
const sdkVersion = require("@aws-sdk/client-s3/package.json").version;
const initId = randomUUID();
let count = 0;
console.log(JSON.stringify({ phase: "handler-init", init_id: initId, at_ms: Date.now() }));
exports.handler = async (event, context) => {
if (!Number.isInteger(event.work_ms) || event.work_ms < 0 || event.work_ms > 1800) {
throw new Error("work_ms must be between 0 and 1800");
}
if (!Number.isInteger(event.post_ms) || event.post_ms < 0 || event.post_ms > 500) {
throw new Error("post_ms must be between 0 and 500");
}
const startedMs = Date.now();
count += 1;
await new Promise((resolve) => setTimeout(resolve, event.work_ms));
const finishedMs = Date.now();
const result = {
phase: "extension-handler",
request_id: context.awsRequestId,
id: event.id,
run_id: event.run_id,
function_id: process.env.LAB_FUNCTION_ID,
init_id: initId,
count,
work_ms: event.work_ms,
post_ms: event.post_ms,
started_ms: startedMs,
finished_ms: finishedMs,
handler_ms: finishedMs - startedMs,
node_version: process.version,
sdk_version: sdkVersion,
};
fs.writeFileSync(
`/tmp/lab-extension-${context.awsRequestId}.json`,
JSON.stringify({ post_ms: event.post_ms, finished_ms: finishedMs }),
);
console.log(JSON.stringify(result));
return result;
};init_idとcountでprocessの再初期化を追います。handler_msは待機の実測値です。work_msとpost_msは入力値です。上の画面ではhandler_msをwork_ms列に表示しているため、logの項目名と画面の列名を分けて読みます。LAB_FUNCTION_IDにはcontrolまたはextendedを設定します。外部Extension
次は実測したGo sourceの全185行です。
main.goへ保存します。登録先はAWS_LAMBDA_RUNTIME_APIから組み立て、INVOKEとSHUTDOWNを購読します。POST /2020-01-01/extension/registerで得たLambda-Extension-Identifierを、GET /2020-01-01/extension/event/nextへ渡します。Lambda-Extension-Nameは実行file名と同じlab-extensionです。Nextはeventを待つため、HTTP clientのtimeoutを設定していません。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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
)
var outputMu sync.Mutex
var sequence atomic.Uint64
var initID string
func emit(record map[string]any) {
record["extension_init_id"] = initID
record["function_id"] = os.Getenv("LAB_FUNCTION_ID")
outputMu.Lock()
defer outputMu.Unlock()
_ = json.NewEncoder(os.Stdout).Encode(record)
}
func setting(name string, fallback, minimum, maximum int) int {
value := os.Getenv(name)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < minimum || parsed > maximum {
panic(fmt.Sprintf("invalid non-secret setting %s", name))
}
return parsed
}
func api(client *http.Client, method, endpoint, identifier string, body any) (*http.Response, error) {
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(encoded)
}
request, err := http.NewRequest(method, endpoint, reader)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
if identifier == "" {
request.Header.Set("Lambda-Extension-Name", "lab-extension")
} else {
request.Header.Set("Lambda-Extension-Identifier", identifier)
}
return client.Do(request)
}
func subscribe(client *http.Client, base, identifier string) error {
listener, err := net.Listen("tcp", ":4243")
if err != nil {
return err
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
writer.WriteHeader(http.StatusMethodNotAllowed)
return
}
var events []struct {
Type string `json:"type"`
Time string `json:"time"`
Record json.RawMessage `json:"record"`
}
// Subscription uses at most 256 KiB batches. Keep a bounded margin for JSON framing.
if err := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1024*1024)).Decode(&events); err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
for _, event := range events {
emit(map[string]any{
"phase": "telemetry-event", "type": event.Type, "time": event.Time,
"record": event.Record, "received_ms": time.Now().UnixMilli(),
"seq": sequence.Add(1),
})
}
writer.WriteHeader(http.StatusOK)
})
server := &http.Server{Handler: mux, ReadHeaderTimeout: time.Second}
go func() { _ = server.Serve(listener) }()
response, err := api(client, http.MethodPut, base+"/2022-07-01/telemetry", identifier, map[string]any{
"schemaVersion": "2025-01-29", "types": []string{"platform", "function"},
"buffering": map[string]int{"maxItems": 1000, "maxBytes": 262144, "timeoutMs": setting("EXT_BUFFER_MS", 1000, 25, 30000)},
"destination": map[string]string{"protocol": "HTTP", "URI": "http://sandbox.localdomain:4243"},
})
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("telemetry subscribe HTTP %d", response.StatusCode)
}
emit(map[string]any{"phase": "extension-subscribed", "schema": "2025-01-29"})
return nil
}
func main() {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
panic(err)
}
initID = hex.EncodeToString(random)
started := time.Now().UnixMilli()
base := "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API")
client := &http.Client{} // Extension Next intentionally has no request timeout.
response, err := api(client, http.MethodPost, base+"/2020-01-01/extension/register", "", map[string]any{"events": []string{"INVOKE", "SHUTDOWN"}})
if err != nil {
panic(err)
}
identifier := response.Header.Get("Lambda-Extension-Identifier")
response.Body.Close()
if response.StatusCode != http.StatusOK || identifier == "" {
panic("extension registration failed")
}
if os.Getenv("EXT_TELEMETRY") == "1" {
if err := subscribe(client, base, identifier); err != nil {
panic(err)
}
}
initMS := setting("EXT_INIT_MS", 100, 0, 500)
time.Sleep(time.Duration(initMS) * time.Millisecond)
emit(map[string]any{"phase": "extension-init", "started_ms": started, "finished_ms": time.Now().UnixMilli(), "requested_ms": initMS})
for {
response, err := api(client, http.MethodGet, base+"/2020-01-01/extension/event/next", identifier, nil)
if err != nil {
panic(err)
}
var event struct {
EventType string `json:"eventType"`
RequestID string `json:"requestId"`
DeadlineMS int64 `json:"deadlineMs"`
ShutdownReason string `json:"shutdownReason"`
}
err = json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&event)
response.Body.Close()
if err != nil || response.StatusCode != http.StatusOK {
panic("extension Next failed")
}
if event.EventType == "SHUTDOWN" {
emit(map[string]any{"phase": "extension-shutdown", "reason": event.ShutdownReason, "at_ms": time.Now().UnixMilli()})
return
}
invokeStarted := time.Now().UnixMilli()
postMS := setting("EXT_POST_MS", 150, 0, 500)
markerMS := int64(0)
if os.Getenv("EXT_WAIT_FOR_MARKER") != "0" {
markerPath := "/tmp/lab-extension-" + event.RequestID + ".json"
for time.Now().UnixMilli() < event.DeadlineMS {
data, readErr := os.ReadFile(markerPath)
if readErr == nil {
var marker struct {
PostMS int `json:"post_ms"`
FinishedMS int64 `json:"finished_ms"`
}
if json.Unmarshal(data, &marker) == nil && marker.PostMS >= 0 && marker.PostMS <= 500 {
postMS = marker.PostMS
markerMS = marker.FinishedMS
_ = os.Remove(markerPath)
break
}
}
time.Sleep(5 * time.Millisecond)
}
}
emit(map[string]any{"phase": "extension-post-start", "request_id": event.RequestID, "invoke_started_ms": invokeStarted, "marker_ms": markerMS, "post_started_ms": time.Now().UnixMilli(), "requested_ms": postMS})
time.Sleep(time.Duration(postMS) * time.Millisecond)
emit(map[string]any{"phase": "extension-done", "request_id": event.RequestID, "invoke_started_ms": invokeStarted, "marker_ms": markerMS, "finished_ms": time.Now().UnixMilli(), "requested_ms": postMS})
}
}INVOKEでは、同じRequestIdのmarkerを待ちます。markerのpost_msを読み、その時間だけ待って次のNextへ進みます。marker待ちはeventのdeadlineまで、Initとpostの指定は各500 msまでです。subscribe関数はTelemetry APIへPUT /2022-07-01/telemetryを送ります。platformとfunctionを選び、HTTPの4243番portでeventを受けます。この回はEXT_TELEMETRY=0なので、この関数は実行していません。Telemetry APIによる内訳は次回の問いにします。コードの「最大256 KiBのbatch」というcommentは、受信payloadの上限としては誤りです。Telemetry APIのbuffer仕様 では、
2 × maxBytes + metadataBytesまでのpayloadを見込みます。maxBytes=262144はbuffer設定です。このlistenerのhttp.MaxBytesReaderが指定する1 MiBは独自のbody上限です。公式のpayload上限とは分けて読みます。Layerのbuild
掲載した
main.goをLinux arm64向けの実行fileへbuildします。測定時のGoは1.27.1でした。次は同じsourceから再buildするための手順です。1
2
3
4
5
6
7
mkdir -p layer/extensions
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
-ldflags='-s -w' -o layer/extensions/lab-extension main.go
chmod 755 layer/extensions/lab-extension
cd layer
zip -r ../extension-layer.zip extensions
cd ..Layerのzipの直下は
extensionsです。Lambdaでは/opt/extensions/lab-extensionに展開されます。Lambda ConsoleのLayersで、このzipから新規Layerを作ります。compatible architectureはarm64、runtimeはnodejs26.xを指定し、VersionのARNを控えます。Logsとrole
実測時はCDKで配備しました。以下は同じ実行条件をConsoleへ設定する手順です。実験用accountのTokyo Regionで、新規resourceを使います。
先にCloudWatch Logsで
/aws/lambda/dd11-repro-controlと/aws/lambda/dd11-repro-extendedを作ります。保持期間は各1日です。続いてIAMで、各関数専用のexecution roleを1つずつ作ります。信頼policyは次の内容です。1
2
3
4
5
6
7
8
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}各roleには対応するlog groupだけへ書くinline policyを付けます。次は
control用です。123456789012を自分のaccount IDへ置き換えます。extended用はlog group名の末尾をextendedへ変えます。1
2
3
4
5
6
7
8
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:ap-northeast-1:123456789012:log-group:/aws/lambda/dd11-repro-control:*"
}]
}これは実測と同じ2 actionsを、新規の各log groupへ絞るpolicyです。先にlog groupを作るため、execution roleへ
CreateLogGroupは加えません。2つの関数
Lambda Consoleで、
dd11-repro-controlとdd11-repro-extendedを新規作成します。両方にNode.js 26(nodejs26.x)、arm64、先ほど作った対応roleを指定します。zip packageの関数にし、VPCには接続しません。Codeで
index.jsを作り、上の41行をそのまま貼り、Deployします。Handlerはindex.handlerです。memoryを512 MB、timeoutを2秒、reserved concurrencyを各1へ設定します。Log formatはTextとし、各関数に対応する作成済みlog groupを選びます。環境変数は次の表が全件です。
LAB_DEPLOYMENT_IDは測定時のlabelです。実際のNode.jsはv26.8.2、runtime同梱SDKは3.1105.0でした。再現時にも、応答のnode_versionとsdk_versionで実際のversionを読みます。| 環境変数 | control | extended |
|---|---|---|
LAB_DEPLOYMENT_ID | dd11-1011a | dd11-1011a |
LAB_FUNCTION_ID | control | extended |
EXT_INIT_MS | 設定なし | 100 |
EXT_POST_MS | 設定なし | 150 |
EXT_WAIT_FOR_MARKER | 設定なし | 1 |
EXT_TELEMETRY | 設定なし | 0 |
extendedのLayersに、先ほど控えたLayer Version ARNを追加します。controlはLayerなしです。非同期のretry attemptsは0に揃えますが、今回送るRequestResponseは同期Invokeです。事前のTest実行を増やさず、次の22回の順番で送ります。Invokeの入力
通常入力は
work_ms=50、post_ms=150です。境界入力だけ1800と500へ変えました。idは入力の名前、run_idは測定単位の名前です。測定時はSDKでこの2つを加え、handlerは結果へそのまま残しています。| 順番 | 関数と入力 | 回数 | LogType |
|---|---|---|---|
| 1〜16 | controlとextendedを交互に通常入力 | 各8 | Tail |
| 17 | extendedへ境界入力 | 1 | Tail |
| 18 | extendedへ続けて通常入力を送信 | 1 | Tail |
| 19〜20 | extended、controlへ通常入力 | 各1 | 省略 |
| 21 | extendedへ境界入力 | 1 | 省略 |
| 22 | extendedへ続けて通常入力を送信 | 1 | 省略 |
追加4回もSDK
maxAttempts=1で送りました。通常は応答後に300 ms、境界の後は700 msの待機を指定しました。生記録の次のsendまでの間隔は304、303、704 msです。計22回で止め、失敗時のretryは行っていません。18・22回目は境界の後の復旧を確かめる別のInvokeです。CLIで呼び出す
次は、22回の入力をAWS CLIで送る再現用の例です。実験用のCLI profileを
LAB_PROFILEへ指定します。callerのprofileにはこの2関数へのlambda:InvokeFunction権限が必要です。本文に載せたSDKのcaller時間と、CLI process全体の経過時間は、測る区間が異なります。下のSDK例も全22回を送ります。CLIかSDKの一方を選び、合計22回で止めます。
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
LAB_PROFILE=your-lab-profile
LAB_REGION=ap-northeast-1
LAB_CONTROL=dd11-repro-control
LAB_EXTENDED=dd11-repro-extended
export AWS_MAX_ATTEMPTS=1
export AWS_RETRY_MODE=standard
invoke_tail() {
aws lambda invoke \
--function-name "$1" --invocation-type RequestResponse \
--log-type Tail --cli-binary-format raw-in-base64-out \
--payload "$2" --profile "$LAB_PROFILE" --region "$LAB_REGION" "$3" \
> "${3%.json}.invoke.json"
}
for i in 1 2 3 4 5 6 7 8; do
invoke_tail "$LAB_CONTROL" \
"{\"id\":\"control-${i}-1\",\"run_id\":\"ext-11\",\"work_ms\":50,\"post_ms\":150}" \
"control-${i}.json"
invoke_tail "$LAB_EXTENDED" \
"{\"id\":\"extended-${i}-1\",\"run_id\":\"ext-11\",\"work_ms\":50,\"post_ms\":150}" \
"extended-${i}.json"
done
invoke_tail "$LAB_EXTENDED" \
'{"id":"extension-timeout-1","run_id":"ext-11","work_ms":1800,"post_ms":500}' \
timeout-tail.json
invoke_tail "$LAB_EXTENDED" \
'{"id":"after-timeout-1","run_id":"ext-11","work_ms":50,"post_ms":150}' \
recovery-tail.jsonここまでが18回です。
extended-2.invoke.jsonなどのLogResultはbase64です。次の1コマンドで復号し、extension-doneとREPORTを確認できます。1
python3 -c 'import base64,json; r=json.load(open("extended-2.invoke.json")); print(base64.b64decode(r["LogResult"]).decode())'次は
LogTypeを省いた最後の4回です。CLIはprocess起動も含むため、本文のSDK caller時間を再計算する場合は下の計測方法を使います。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
invoke_none() {
aws lambda invoke \
--function-name "$1" --invocation-type RequestResponse \
--cli-binary-format raw-in-base64-out --payload "$2" \
--profile "$LAB_PROFILE" --region "$LAB_REGION" "$3"
}
invoke_none "$LAB_EXTENDED" \
'{"id":"no-tail-extended","run_id":"ext-11-none","work_ms":50,"post_ms":150}' \
extended-none.json
sleep 0.3
invoke_none "$LAB_CONTROL" \
'{"id":"no-tail-control","run_id":"ext-11-none","work_ms":50,"post_ms":150}' \
control-none.json
sleep 0.3
invoke_none "$LAB_EXTENDED" \
'{"id":"no-tail-timeout","run_id":"ext-11-none","work_ms":1800,"post_ms":500}' \
timeout-none.json
sleep 0.7
invoke_none "$LAB_EXTENDED" \
'{"id":"no-tail-recover","run_id":"ext-11-none","work_ms":50,"post_ms":150}' \
recovery-none.jsonSDKで計測する
本文のcaller時間はSDKのsend直前からawaitの完了直後までです。次は、計測する区間を合わせ、RequestResponseと
maxAttempts=1を指定した再現例です。実測時のscriptそのものではありません。実測ではTail側とLogTypeなし側を別のcallerで送りました。新しい作業directoryへclient SDKを入れ、codeを
measure.mjsへ保存します。tail modeは最初の18回、none modeは追加4回です。各modeを1回ずつ送り、合計22回にします。上のCLI実行と重ねません。1
2
npm init -y
npm install @aws-sdk/client-lambda1
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
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const mode = process.argv[2];
if (!['tail', 'none'].includes(mode)) throw new Error('Specify tail or none');
if (!process.env.LAB_CONTROL || !process.env.LAB_EXTENDED) {
throw new Error('Set LAB_CONTROL and LAB_EXTENDED');
}
const tailSteps = [];
for (let i = 1; i <= 8; i++) {
tailSteps.push([`control-${i}-1`, process.env.LAB_CONTROL, 50, 150]);
tailSteps.push([`extended-${i}-1`, process.env.LAB_EXTENDED, 50, 150]);
}
tailSteps.push(['extension-timeout-1', process.env.LAB_EXTENDED, 1800, 500]);
tailSteps.push(['after-timeout-1', process.env.LAB_EXTENDED, 50, 150]);
const noneSteps = [
['no-tail-extended', process.env.LAB_EXTENDED, 50, 150],
['no-tail-control', process.env.LAB_CONTROL, 50, 150],
['no-tail-timeout', process.env.LAB_EXTENDED, 1800, 500],
['no-tail-recover', process.env.LAB_EXTENDED, 50, 150],
];
const steps = mode === 'tail' ? tailSteps : noneSteps;
const client = new LambdaClient({ region: 'ap-northeast-1', maxAttempts: 1 });
for (const [id, functionName, work_ms, post_ms] of steps) {
const input = { id, work_ms, post_ms, run_id: mode === 'tail' ? 'ext-11' : 'ext-11-none' };
const command = new InvokeCommand({
FunctionName: functionName,
InvocationType: 'RequestResponse',
...(mode === 'tail' ? { LogType: 'Tail' } : {}),
Payload: Buffer.from(JSON.stringify(input)),
});
const startEpochMs = Date.now();
const started = process.hrtime.bigint();
const response = await client.send(command);
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
const endEpochMs = Date.now();
const decodedLog = response.LogResult
? Buffer.from(response.LogResult, 'base64').toString('utf8') : null;
console.log(JSON.stringify({
input, client: { startEpochMs, endEpochMs, elapsedMs },
StatusCode: response.StatusCode,
FunctionError: response.FunctionError ?? null,
payload: JSON.parse(Buffer.from(response.Payload).toString('utf8')),
decodedLog,
}));
if (mode === 'none') {
await new Promise((resolve) => setTimeout(resolve, post_ms === 500 ? 700 : 300));
}
}1
2
3
4
5
6
7
LAB_PROFILE=your-lab-profile
LAB_CONTROL=dd11-repro-control
LAB_EXTENDED=dd11-repro-extended
AWS_PROFILE="$LAB_PROFILE" LAB_CONTROL="$LAB_CONTROL" \
LAB_EXTENDED="$LAB_EXTENDED" node measure.mjs tail > tail-results.jsonl
AWS_PROFILE="$LAB_PROFILE" LAB_CONTROL="$LAB_CONTROL" \
LAB_EXTENDED="$LAB_EXTENDED" node measure.mjs none > none-results.jsonlelapsedMsは単調増加するclockで測ります。startEpochMsとendEpochMsは記録時刻を照合する壁時計です。REPORTのDurationはLambda側のInvoke時間です。同じRequestIdへ対応させて読みます。Tailのlogを読む
Tail modeは
LogResultをbase64から復号してdecodedLogへ保存します。次の1コマンドで、各Invokeのcaller時間、payloadのRequestId、Extension終了とREPORTを並べられます。1
2
3
4
5
6
7
8
9
10
11
python3 - <<'PYCODE'
import json
with open("tail-results.jsonl") as records:
for line in records:
record = json.loads(line)
print(record["input"]["id"], record["payload"]["request_id"],
record["client"]["elapsedMs"])
for log in (record["decodedLog"] or "").splitlines():
if '"phase":"extension-done"' in log or "REPORT RequestId:" in log:
print(log)
PYCODE今回のTail付き通常7回は、この末尾logに
extension-doneとREPORTがありました。Invoke APIのLogResult は末尾4 KBです。ログ量が増えると対象行が省かれる場合があります。その場合はCloudWatch Logsで同じRequestIdを検索します。REPORTの照合
CloudWatch Logs Insightsで、新規の2つのlog groupとInvokeの時間範囲を選びます。共通部分でRequestId、Duration、Billed Duration、Max Memory Used、Init、Statusを取り出します。
fnは新規log group名の末尾から取り、reqはRequestIdを8文字へ短縮した値です。掲載した画面では、測定時のlog stream名からfnを取りました。原記録のstream名は2026/10/11/lambda-lab-dd11-1011a-extended[$LATEST]...の形で、関数名を含みます。以下は追試用のdd11-repro-で始まるlog group名へ読み替えたqueryです。1
2
3
4
5
6
filter @message like /REPORT RequestId/
| parse @message /REPORT RequestId: (?<request>[^\s]+)\s+Duration: (?<duration>[0-9.]+) ms\s+Billed Duration: (?<billed>[0-9]+) ms\s+Memory Size: (?<memory>[0-9]+) MB\s+Max Memory Used: (?<max_memory>[0-9]+) MB/
| parse @message /Init Duration: (?<init>[0-9.]+) ms/
| parse @message /Status: (?<status>[^\s]+)/
| parse @log /\/aws\/lambda\/dd11-repro-(?<fn>[a-z]+)/
| fields substr(request,0,8) as req通常14行の画面では、初回を除き、境界入力より前の呼び出しへ絞りました。上の末尾へ次を足します。1791687645380は掲載した画面のqueryで使ったepoch msです。再現時は、16回目の応答記録時刻に置き換えます。
1
2
3
| filter not ispresent(init) and toMillis(@timestamp) < 1791687645380
| sort @timestamp asc
| display fn,req,duration,billed,max_memoryInitとtimeoutを示す6行の画面では、共通部分の末尾へ次を足しました。Init列がある行、またはDurationが354 ms以上の行を選びます。
1
2
3
| filter ispresent(init) or duration >= 354
| sort @timestamp asc
| display fn,req,duration,billed,init,status,max_memoryhandlerとExtensionは同じ
request_idのlogを照合します。前掲の9行の画面には次のqueryを使いました。追試時は3つのRequestIdを自分の境界・復旧・通常入力へ置き換えます。1
2
3
4
5
filter phase in ["extension-handler","extension-post-start","extension-done"]
| filter request_id in ["dbfeb972-372d-43d6-87b2-b3c9287e8066","f2bb9042-24da-405e-949f-aab82a0236bc","74fbcc99-9e2a-45fc-99c0-e642bbee796c"]
| fields substr(request_id,0,8) as req, substr(init_id,0,8) as handler_init, substr(extension_init_id,0,8) as ext_init, handler_ms as work_ms, requested_ms as post_ms, finished_ms as finish_ms
| sort @timestamp asc
| display req,phase,handler_init,ext_init,work_ms,post_ms,finish_msREPORTのRequestIdに加え、handlerの
init_id、Extensionのextension_init_id、応答payloadのcountを読むと、timeout後の復旧を追えます。まとめ
Extensionの処理時間も関数の課金対象になりました。今回の直列の条件では、同じ約50 msのhandlerに150 msの後処理を足し、Billed Durationの中央値が53 msから204 msへ増えました。
応答の完了と、Extensionを含むInvoke phaseの完了は別の観測点です。監視処理を追加するときは、両方の時刻とREPORTを照合します。timeoutへ収めるのは、Invoke開始からruntimeと全Extensionが
Nextを送るまでの経過時間です。今回の境界では、handlerが成功を返してもREPORTはtimeoutでした。Series: Serverless DeepDive (13 articles)
- …
- 12Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる 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