Serverless DeepDive #01:Invokeの成功は何の成功か
HTTP 200でも、保存成功・業務上の拒否・例外は違いました。保存後の例外と同じjobの再送、202の後の非同期再試行をAWSで比べ、Lambdaへ任せる実行と、利用者が決める完了条件を考えます。
Series: Serverless DeepDive (13 articles)
- 2Serverless DeepDive #01:Invokeの成功は何の成功か This article
HTTP 200でも、保存された結果は違った
同じLambda関数へ、3つの条件で同期呼び出しを送りました。数値を2倍にして保存する処理、負数を拒否する処理、保存前に例外を出す処理です。Invoke APIのHTTP statusは、すべて200でした。
さらに、保存した直後に例外を出しました。応答にはFunctionErrorが付きましたが、S3には結果が残っていました。関数のエラーを見て再送するだけでは、保存済みの仕事をもう一度進めることになります。
前回の#00 では、管理する実行基盤を減らし、業務の実装と運用へ集中する理由を考えました。今回は、そのとき利用者に残る「何を成功と呼ぶか」を確かめます。Invokeの成功から、業務も完了したと判断できる範囲を調べました。
完了を、保存した業務結果で決める
検証用の仕事は、数値を2倍にしてS3へ保存する小さなjobです。jobは、一つの処理単位を表します。整数の0〜1000を受け付け、負数は業務ルールで拒否します。整数以外と、-1000〜1000の範囲外は入力のエラーにしました。
今回は、job IDに対応する結果のJSONがS3に残った時点を、業務の完了と決めました。外部への送金や通知は行いません。保存前と保存後の違いを、実際のデータで追えるようにするためです。
使ったのは、通常のon-demand Lambda関数への直接Invokeです。Invokeは、関数を呼ぶAPIです。API Gatewayを経由したWeb APIのHTTP応答を測ったものではありません。
| 固定した条件 | 設定 |
|---|---|
| Region | ap-northeast-1 |
| Runtime・architecture | Python 3.13・arm64 |
| Memory・timeout | 256 MB・5秒 |
| reserved concurrency | この関数の同時実行を1件に制限 |
| 非同期の関数エラー再試行 | 追加1回、eventの最大年齢120秒 |
| 失敗時の送り先・DLQ | なし。送り先は失敗した呼び出しの記録を渡す先、DLQは処理できなかったeventを残すqueue |
| 呼び出し元のSDK retry | maxAttempts 1。SDKからの自動再送を止める |
| 保存先 | 検証専用S3 bucketの専用prefix |

図は、検証時の構成です。青い矢印はInvokeと応答を示します。緑はS3への保存・再読と、PCからの結果照合です。茶色は関数のログ出力と、PCからログを調べる要求です。
同期とEventで同じhandlerを呼び、API応答、実行ログ、保存結果を照合しました。statusはSDK応答の
StatusCodeを読み、FunctionErrorと分けて記録しています。Lambda Consoleの5画像は、同じコードと設定で
run_id: photoとして再実行した画面です。S3とCloudWatch Logsの2画像は、主測定のrun_id: m1の保存結果とログです。returnできても、業務が成功したとは限らない
まず、保存成功、業務上の拒否、保存前の例外を比べます。FunctionErrorは、同期応答に付く関数エラーの情報です。payloadは、関数の戻り値またはエラーの詳細を入れる応答本文です。
| 入力と処理 | API status | FunctionError | payload | S3の結果 |
|---|---|---|---|---|
| 10を2倍にして保存 | 200 | なし | ok true、result 20 | 1件 |
| -1を業務ルールで拒否 | 200 | なし | ok false、negative_value | 0件 |
| 10で保存前に例外 | 200 | Unhandled | RuntimeError | 0件 |
ok は、この検証コードで決めた値です。Lambdaが定めた業務の成功判定ではありません。負数を拒否した処理も、handlerは例外なくreturnしています。
正常な処理では、結果を保存してからreturnしました。RequestIdは、Lambdaの実行を追う識別子です。
commit_request_idは、保存を行った実行のRequestIdを結果JSONへ残した値です。画面のRequestIdと、この値を照合しています。
業務上の拒否も、FunctionErrorなしで戻りました。画像には
ok: falseとrejectedのログが残っています。関数エラーがないことを業務の成功として数えると、負数のjobまで成功扱いします。今回は、payloadのokと保存結果を合わせて確認しました。
保存前の例外にはFunctionErrorが付きました。実行ログにも
failed_before_saveがあり、対応するS3 objectはありませんでした。この3件の200は、Invoke APIのstatusとFunctionErrorの仕様 と一致します。同期の200は、関数内のエラーを表す値ではありません。呼び出し元は、API側のエラー、FunctionError、業務の結果を分けて読みます。
入力の境界も変えて試しました。0はresult 0、1000はresult 2000として保存できました。1001とJSONの
trueはValueErrorで終わり、保存は0件です。4件ともAPI statusは200でした。trueを送ったのは、Pythonではbooleanも整数の型判定に含まれるためです。handlerではtype(value) is intで検査しました。数値の上限も型も、今回の業務コードが決めた条件です。Lambdaの実行上限とは分けて考えます。保存後の例外は、仕事を巻き戻さない
次に、S3への書き込みが成功した直後にRuntimeErrorを出しました。入力はvalue 10に固定し、呼び出し元から同じjobを一度だけ再送します。保存する単位だけを変えて比べました。
入力は、次のJSONです。
run_idは検証区分、idは業務のjob IDです。同じ入力を再送し、別の仕事には新しいIDを付けます。actionは、意図した失敗を起こす検証用の切り替えです。1
2
3
4
5
6
{
"id": "keyed-job",
"value": 10,
"action": "fail-after",
"run_id": "m1"
}
この検証では、
actionをcalculateにすると保存してreturnし、fail-beforeにすると保存前に例外を出します。storage_policyは、省略するとjobです。RequestId別の対照はIDをappend-jobにし、storage_policy: requestを加えました。最初は、呼び出しごとのRequestIdをobject名に入れました。呼び出し元から同じjobを送り直すと、別のRequestIdになります。
| 保存する単位 | 最初の実行 | 同じjobの再送 | jobに残った結果 |
|---|---|---|---|
| RequestIdをkeyにする | 保存後に例外 | 再び保存後に例外 | 2件、どちらもresult 20 |
| job IDをkeyにする | 保存後に例外 | 既存結果を読み、ok trueでreturn | 1件、result 20 |
RequestId別の処理は、2回とも200とFunctionErrorを返しました。それでもS3には2件残りました。FunctionErrorだけを「保存されていない」と読むことはできません。

同じjobの保存先に、異なる名前のJSONが2件あります。取得した本文は、どちらもvalue 10、result 20でした。
どちらの対照にも、
If-None-Match: *を付けました。条件付き書き込みは、保存先で確認と書き込みを一つの操作にする方法です。RequestId別は毎回keyが変わるため、同じjobでも2件残りました。job ID別は同じkeyになり、再送時の書き込みが412へ進みます。
この画面は、job ID別の初回です。失敗の表示と、保存済みのログが同じ実行に残っています。S3からもJSONを取得して確認しました。
再送時は、既存objectがあるためS3が412を返します。handlerはその場合だけ既存のJSONを読みます。数値が同じなら、保存済みのresultと
replayed: trueを返します。
保存を行った初回のcommit_request_idを、再送の応答にも残しました。新しいrequest_idと比べると、初回の結果を使って返したことを追えます。今回は、同じ入力を意図して再送した検証です。
同じjob IDでvalueを11へ変える条件も試しました。応答は200、FunctionErrorなし、
ok: falseとjob_input_conflictでした。保存済みのvalue 10とresult 20は変わりませんでした。IDが同じことだけで成功扱いせず、何を同じ仕事とするかも確認します。S3のIf-None-Matchの仕様 では、既存objectがある場合の412に加え、競合時の409も説明されています。今回のhandlerは、412だけを既存結果の再読へ進めます。ほかのエラーは関数エラーとして返します。
この設計で守ったのは、一つのS3 objectに収めた結果です。送金と記録、複数の保存先の更新まで一度にする仕組みには広げられません。また、実験では同時実行を1件に制限しました。強い並行実行や409、通信のtimeoutは測っていません。
202の後にも、成功判定が残る
非同期は、Invokeの
Event方式で送りました。呼び出し元へ返ったstatusは3件とも202で、処理のpayloadは返りません。後のCloudWatch LogsとS3を読み、何が起きたかを確かめます。| 非同期に送った処理 | 最初の応答 | START行の件数 | 追加実行まで | 最後に確認した結果 |
|---|---|---|---|---|
| -1をreturnで拒否 | 202 | 1件 | 追加実行なし | rejectedのログ、保存0件 |
| 保存前に例外 | 202 | 2件 | 59.2秒 | 2回とも例外、保存0件 |
| job ID別で保存後に例外 | 202 | 2件 | 55.2秒 | 初回に保存、再試行はreplayed、保存1件 |

負数の拒否は、関数エラーにならないreturnです。今回の観測期間には、追加の実行はありませんでした。非同期でこの拒否を使うなら、業務側の失敗を保存し、利用者や運用担当へ伝える方法が必要です。この検証では、拒否の結果はログだけに残しました。
保存前の例外は、追加の実行でも同じ場所で失敗しました。再試行の回数を増やしても、今回のように毎回同じ例外を出すコードは直りません。失敗したjobを取り出して直す経路も、業務の設計に含めます。
保存後の例外では、初回がresult 20を残しました。55.2秒後の追加実行は、その結果を再読してreturnしています。今回は呼び出し元がEventを再送していません。SDKの自動再送も止めています。
大文字のSTARTは、Lambdaが実行ごとに出す開始行です。画像の小文字の
startは、handlerが出した処理ログです。両方で実行数が1・2・2件となることを照合しました。非同期3条件のSTARTは、合わせて5件です。保存後のhandler start時刻は、14:50:25.007Zと14:51:20.220Zでした。保存前は14:50:24.976Zと14:51:24.132Zです。この差が、表の55.2秒と59.2秒です。
この非同期の追加実行では、RequestIdは同じでした。同期の手動再送とは違います。今回はRequestIdの種類の数ではなく、時刻の異なるSTARTで実行回数を数えました。
RequestIdは、応答と実行ログを突き合わせるために使います。再送を同じ仕事として扱うkeyには、業務のjob IDを使います。RequestIdだけでは、呼び出し元が再送した仕事の重複を防げません。
この動作は、非同期Invokeの関数エラー再試行 の範囲で確認しています。正常なreturnでも重複して届く可能性があるため、開始1件という観測を重複しない保証へは広げません。
非同期の202を、利用者へ返す「完了」の意味に使うと、失敗したjobまで完了扱いになります。受付を返す業務では、job IDから結果を確認できる保存先を別に持ちます。完了まで待つ業務では、同期のpayloadと保存結果を照合します。
今回は、失敗時の送り先とDLQを設定していません。前述の非同期エラー処理の仕様では、すべての試行に失敗したeventや期限切れのeventを破棄します。この検証で確認したのは実行ログで、破棄そのものは観測していません。
同じ条件で確かめる手順
検証はAWS CDKで作った専用の関数・S3 bucket・LogGroupで行いました。CDKの関数定義は、次の部分です。
fnは冒頭の条件表の値、handler.codeは後に載せるPython全文です。handler.handlerはindex.handler、RUNTIMES[handler.runtime]はPython 3.13を指します。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const lambdaFunction = new lambda.Function(this, `Function-${fn.id}`, {
functionName: name,
description: `lambda-lab ${scenario.id}/${fn.id}`,
runtime: RUNTIMES[handler.runtime],
handler: handler.handler,
code:
handler.kind === "inline"
? lambda.Code.fromInline(handler.code)
: lambda.Code.fromAsset(handler.directory),
architecture:
fn.architecture === "arm64" ? lambda.Architecture.ARM_64 : lambda.Architecture.X86_64,
memorySize: fn.memoryMb,
timeout: Duration.seconds(fn.timeoutSeconds),
environment: Object.fromEntries(environment),
reservedConcurrentExecutions: fn.reservedConcurrency ?? undefined,
// Always set: without them AWS applies its own async defaults and the run is no longer bounded.
retryAttempts: fn.asyncRetryAttempts,
maxEventAge: Duration.seconds(fn.maxEventAgeSeconds),
role,
logGroup,
});
roleはLambdaを信頼する実行ロールです。作成済みLogGroupへのlogs:CreateLogStreamとlogs:PutLogEvents、専用prefix配下のobjectへのs3:GetObjectとs3:PutObjectを許可しました。s3:ListBucketも、同じprefixの条件を付けて許可しています。このhandlerは一覧取得を呼ばず、PutObjectとGetObjectを使います。S3 bucketはversioningなし、SSE-S3というS3側の暗号化を設定しました。Block Public Accessで公開を止め、TLS接続を必須にしています。LogGroupの保持期間は1日です。これらは、実際にdeployしたCloudFormation templateと照合しました。
environmentには、LAB_EVIDENCE_BUCKETとして専用bucket名を入れます。LAB_EVIDENCE_PREFIXには、末尾が/の専用prefixを入れます。検証ではevidence/invoke01-20261006a/を使いました。nameは検証用の関数名、logGroupはその関数専用のログ保存先です。次は、実際に実行したhandlerの全文です。inline codeの
index.pyに置き、index.handlerで呼びました。入力検査、keyの生成、412の再読、値の衝突判定まで含めています。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
"""Compare a function response with its single S3 business result."""
import json
import os
import re
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
s3 = boto3.client("s3", config=Config(retries={"total_max_attempts": 1}))
def emit(record, phase, **fields):
print(json.dumps({**record, "phase": phase, **fields}, sort_keys=True))
def handler(event, context):
if type(event) is not dict:
raise ValueError("event must be an object")
for name in ("id", "run_id"):
if type(event.get(name)) is not str or not re.fullmatch(
r"[a-z0-9-]{1,64}", event[name]
):
raise ValueError("id and run_id must match [a-z0-9-]{1,64}")
value = event.get("value")
action = event.get("action", "calculate")
policy = event.get("storage_policy", "job")
if type(value) is not int or not -1000 <= value <= 1000:
raise ValueError("value must be an integer in [-1000,1000]")
if action not in ("calculate", "fail-before", "fail-after"):
raise ValueError("unknown action")
if policy not in ("request", "job"):
raise ValueError("unknown storage_policy")
record = {
"id": event["id"],
"run_id": event["run_id"],
"request_id": context.aws_request_id,
"value": value,
"action": action,
"storage_policy": policy,
}
emit(record, "start")
if value < 0:
result = {**record, "ok": False, "reason": "negative_value"}
emit(result, "rejected")
return result
if action == "fail-before":
emit(record, "failed_before_save")
raise RuntimeError("intentional failure before saving result")
bucket = os.environ["LAB_EVIDENCE_BUCKET"]
base = os.environ["LAB_EVIDENCE_PREFIX"] + event["run_id"] + "/" + event["id"]
key = base + "/" + (
context.aws_request_id + ".json" if policy == "request" else "result.json"
)
saved = {
**record,
"ok": True,
"result": value * 2,
"commit_request_id": context.aws_request_id,
"saved_unix": time.time(),
}
try:
s3.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps(saved, sort_keys=True).encode(),
ContentType="application/json",
IfNoneMatch="*",
)
except ClientError as error:
if error.response["ResponseMetadata"]["HTTPStatusCode"] != 412:
raise
response = s3.get_object(Bucket=bucket, Key=key)
try:
previous = json.loads(response["Body"].read())
finally:
response["Body"].close()
if previous["value"] != value:
result = {**record, "ok": False, "reason": "job_input_conflict"}
emit(result, "conflict")
return result
result = {
**previous,
"request_id": context.aws_request_id,
"replayed": True,
}
emit(result, "replayed")
return result
emit(saved, "saved")
if action == "fail-after":
emit(saved, "failed_after_save")
raise RuntimeError("intentional failure after saving result")
emit(saved, "returned")
return {**saved, "replayed": False}呼び出し元は、AWS SDK for JavaScript v3の3.1146.0を使いました。
LambdaClientの設定はregion: ap-northeast-1、検証用profile、maxAttempts: 1です。送信処理の抜粋は次のとおりです。clients.lambdaがそのclient、request.functionNameがCDKで作った関数名です。1
2
3
4
5
6
7
8
9
10
const sync = request.invocation === "sync";
const output = await clients.lambda.send(
new InvokeCommand({
FunctionName: request.functionName,
InvocationType: sync ? "RequestResponse" : "Event",
LogType: sync ? "Tail" : undefined,
Payload: new TextEncoder().encode(request.payload),
}),
{ abortSignal },
);
request.payloadには入力JSONの文字列を入れます。request.invocationをsyncにすると同期、asyncにするとEventです。abortSignalは検証の待ち時間を制限する値で、同期は10秒、Eventは5秒にしました。戻ったoutput.StatusCode、output.FunctionError、output.Payloadを保存します。SDKがthrowしたAPIのエラーは、FunctionErrorと別に扱います。主測定では、次の15送信を上から順に行いました。全入力に
run_id: m1を加えています。表の「省略」はhandlerの既定値を使います。同じ検証をやり直す場合は、新しいrun_idに変え、前回の保存結果を混ぜないようにします。| 送信順・方式 | id | value | action | storage_policy |
|---|---|---|---|---|
| 1・同期 | success | 10 | 省略 | 省略 |
| 2・同期 | rejection | -1 | 省略 | 省略 |
| 3・同期 | exception-before | 10 | fail-before | 省略 |
| 4・同期 | zero | 0 | 省略 | 省略 |
| 5・同期 | upper | 1000 | 省略 | 省略 |
| 6・同期 | out-of-range | 1001 | 省略 | 省略 |
| 7・同期 | boolean | true | 省略 | 省略 |
| 8・同期 | append-job | 10 | fail-after | request |
| 9・同期 | append-job | 10 | fail-after | request |
| 10・同期 | keyed-job | 10 | fail-after | 省略 |
| 11・同期 | keyed-job | 10 | fail-after | 省略 |
| 12・同期 | keyed-job | 11 | fail-after | 省略 |
| 13・Event | async-rejection | -1 | 省略 | 省略 |
| 14・Event | async-before | 10 | fail-before | 省略 |
| 15・Event | async-after | 10 | fail-after | 省略 |
Eventは送信後に関数が動くため、202を記録してからログを採取しました。今回の追加実行は約1分後に現れました。再試行とeventの最大年齢を含む期間を取り、ログの反映を待って照合します。CloudWatch Logs Insightsでは、次のqueryで画像の11行を確認しました。
1
2
3
fields @timestamp, id, phase, value, result, request_id, commit_request_id
| filter run_id = "m1" and id like /^async-/
| sort @timestamp asc
START行は、同じLogGroupを14:50:00〜15:20:01 UTCの窓で
FilterLogEventsにより取得しました。ページを最後まで取り、START RequestId:で始まる行を抽出します。画像の各request_idに対応する行を取り、時刻ごとに数えました。これは実際に採取したLogsを使った手順です。処理ログは、
start、saved、failed_after_save、replayedを時刻順に読みます。S3では、専用prefixのm1/id/ごとにobject数を取り、各JSONのvalue、result、commit_request_idを確認しました。主測定の保存は計7件です。RequestId別のappend-jobだけ2件で、job ID別のkeyed-jobは1件でした。Lambdaへ実行を任せられる条件
今回のjobは、入力の照合と計算をコードで行い、一回の処理で結果をS3へ保存できます。実行環境の用意は、#00で確認したAWSの管理範囲です。転送や保存の設定だけで足りる処理なら、独自のコードを管理する必要があるかも先に考えます。
実測では、呼び出し元へ再送のコードを足さず、設定した追加1回が55.2〜59.2秒後に動きました。保存後の例外でも、追加実行が既存結果を再読して終わっています。この実行と再試行を任せ、業務の完了を一つの保存結果で追える点を、採用時の判断に使います。
再試行まで任せるには、同じjobをもう一度処理したときの意味を決めます。今回は、job ID別の結果を残し、同じ値なら再読し、異なる値なら拒否しました。そのために、条件付き保存、結果の読み取り、衝突の判定をコードへ加えています。
基盤管理の工数、料金、処理時間への効果は、この検証では比較していません。条件付き保存と再読で増えるS3呼び出しの負担も評価していません。測った再試行の動作と、必要になったコード・運用を並べて選びます。
結果の記録、失敗の通知、再処理の手順は、利用者が持つ仕事です。関数エラーが続くeventを残すには、DLQや失敗時の送り先を設けます。一方、
ok: falseでreturnした業務拒否は、handlerでjob IDに対応する失敗結果を保存します。保持した結果から通知し、担当者が再処理できる経路を作ります。これらの保持・通知は、今回の検証には追加していません。一つの結果へ完了を集約できない業務では、処理ごとの進捗や補償を持つworkflowを考えます。応答を受け取れない場合も含め、複数の外部処理を一度だけ行う要件を、このhandlerだけで満たしたとは判断できません。
同期の200は呼び出しの応答、非同期の202は受付を示しました。業務の完了は、FunctionErrorとpayloadを読み、job IDに対応する保存結果まで確かめて決めます。基盤の管理をAWSへ任せるときも、成功と再試行の条件は、業務から決めて記録します。
Series: Serverless DeepDive (13 articles)
- 2Serverless DeepDive #01:Invokeの成功は何の成功か 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