AWS Builder Center

Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる

同じhandlerを外部Extensionあり・なしで実行し、応答の完了、REPORTのDuration、課金時間とtimeoutの境界を確かめます。

Series: Serverless DeepDive (13 articles)

  1. …
  2. 12
    Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる This article
Senior Evangelist

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を置いています。
固定した条件設定
Regionap-northeast-1
実行方式通常のon-demand、同期Invoke
Runtimenodejs26.x、Node.js v26.8.2、public preview
Architectureとmemoryarm64、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(課金対象の時間)へ加わる条件を比べます。
同じNode.js handlerをLayerなしと外部Extension Layerありで同期Invokeし、応答とCloudWatch Logsを比較する検証時の構成
検証時の構成です。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 ms50〜51 ms
REPORT Durationの範囲52.35〜53.49 ms203.57〜204.14 ms
REPORT Durationの中央値52.68 ms203.72 ms
Billed Durationの中央値53 ms204 ms
REPORT Max Memory Used87 MB96 MB
通常7回ずつのREPORTでLayerなしとExtensionありのDuration、Billed Duration、Max Memory Usedを比べたCloudWatch Logs
初回を除く通常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です。
初回のREPORTcontrolextended
Init Duration120.19 ms220.53 ms
Duration54.61 ms204.23 ms
Billed Duration175 ms425 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のTail付き7回を撮影用に転記したCloudWatch画面。追加Invokeなし、元測定時刻と転記時刻は別
保存済みの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のLogTypeなし4回を撮影用に転記したCloudWatch画面。追加Invokeなし、元測定時刻と転記時刻は別
こちらも保存済みの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全体はその時間内に収まりません。
境界と復旧handlerREPORT DurationREPORT Status応答
Tail付きの境界1802 ms2000.00 mstimeout成功payload
その後の通常入力51 ms354.05 ms記載なし成功payload
LogTypeなしの境界1802 ms2000.00 mstimeout成功payload
その後の通常入力50 ms354.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、2回のtimeout、続く通常呼び出しのREPORTを照合したCloudWatch Logs
Init列がある行と、Durationが354 ms以上の行へ絞った6行です。初回はInitが別の列にあります。timeout後はDurationが約354 msで、別のInit列はありません。
handler、Extensionの開始と終了をRequestIdで照合したCloudWatch Logs
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を読みます。
環境変数controlextended
LAB_DEPLOYMENT_IDdd11-1011add11-1011a
LAB_FUNCTION_IDcontrolextended
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〜16controlとextendedを交互に通常入力各8Tail
17extendedへ境界入力1Tail
18extendedへ続けて通常入力を送信1Tail
19〜20extended、controlへ通常入力各1省略
21extendedへ境界入力1省略
22extendedへ続けて通常入力を送信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.json

SDKで計測する

本文の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-lambda
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
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.jsonl
elapsedMsは単調増加する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_memory
Initと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_memory
handlerと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_ms
REPORTの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)

  1. …
  2. 12
    Serverless DeepDive #11:Extensionあり・なしの課金時間を比較してみる 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