AWS Builder Center

Serverless DeepDive #01:Invokeの成功は何の成功か

HTTP 200でも、保存成功・業務上の拒否・例外は違いました。保存後の例外と同じjobの再送、202の後の非同期再試行をAWSで比べ、Lambdaへ任せる実行と、利用者が決める完了条件を考えます。

Series: Serverless DeepDive (13 articles)

  1. 2
    Serverless DeepDive #01:Invokeの成功は何の成功か This article
Senior Evangelist

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応答を測ったものではありません。
固定した条件設定
Regionap-northeast-1
Runtime・architecturePython 3.13・arm64
Memory・timeout256 MB・5秒
reserved concurrencyこの関数の同時実行を1件に制限
非同期の関数エラー再試行追加1回、eventの最大年齢120秒
失敗時の送り先・DLQなし。送り先は失敗した呼び出しの記録を渡す先、DLQは処理できなかったeventを残すqueue
呼び出し元のSDK retrymaxAttempts 1。SDKからの自動再送を止める
保存先検証専用S3 bucketの専用prefix
PCからLambdaへ同期とEventでInvokeし、LambdaがS3へ業務結果を保存・再読してCloudWatch Logsへ実行ログを出し、PCが応答と保存結果とログを照合する検証時の構成
図は、検証時の構成です。青い矢印は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 statusFunctionErrorpayloadS3の結果
10を2倍にして保存200なしok true、result 201件
-1を業務ルールで拒否200なしok false、negative_value0件
10で保存前に例外200UnhandledRuntimeError0件
ok は、この検証コードで決めた値です。Lambdaが定めた業務の成功判定ではありません。負数を拒否した処理も、handlerは例外なくreturnしています。
value 10でok trueとresult 20を返し、savedとreturnedのログが残ったLambda Consoleの正常実行画面
正常な処理では、結果を保存してからreturnしました。RequestIdは、Lambdaの実行を追う識別子です。commit_request_idは、保存を行った実行のRequestIdを結果JSONへ残した値です。画面のRequestIdと、この値を照合しています。
value -1でok falseとnegative_valueを返し、同じRequestIdのrejectedログが残ったLambda Consoleの実画面
業務上の拒否も、FunctionErrorなしで戻りました。画像にはok: falseとrejectedのログが残っています。関数エラーがないことを業務の成功として数えると、負数のjobまで成功扱いします。今回は、payloadのokと保存結果を合わせて確認しました。
value 10の保存前にRuntimeErrorを出し、failed_before_saveがログに残ったLambda Consoleの実画面
保存前の例外には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でreturn1件、result 20
RequestId別の処理は、2回とも200とFunctionErrorを返しました。それでもS3には2件残りました。FunctionErrorだけを「保存されていない」と読むことはできません。
同じappend-jobを2回呼び出し、異なるRequestIdを名前にしたJSONが2件残ったS3 Consoleの実画面
同じjobの保存先に、異なる名前のJSONが2件あります。取得した本文は、どちらもvalue 10、result 20でした。
どちらの対照にも、If-None-Match: *を付けました。条件付き書き込みは、保存先で確認と書き込みを一つの操作にする方法です。RequestId別は毎回keyが変わるため、同じjobでも2件残りました。job ID別は同じkeyになり、再送時の書き込みが412へ進みます。
S3へresult 20を保存したログの後にRuntimeErrorがあり、同じRequestIdでfailed_after_saveを追えるLambda Consoleの実画面
この画面は、job ID別の初回です。失敗の表示と、保存済みのログが同じ実行に残っています。S3からもJSONを取得して確認しました。
再送時は、既存objectがあるためS3が412を返します。handlerはその場合だけ既存のJSONを読みます。数値が同じなら、保存済みのresultとreplayed: trueを返します。
同じjobを再送してresult 20とreplayed trueを返し、初回のcommit_request_idと今回のrequest_idが異なるLambda Consoleの実画面
保存を行った初回の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で拒否2021件追加実行なしrejectedのログ、保存0件
保存前に例外2022件59.2秒2回とも例外、保存0件
job ID別で保存後に例外2022件55.2秒初回に保存、再試行はreplayed、保存1件
run_id m1の非同期3条件をCloudWatch Logsで照合し、業務拒否の開始1件、保存前例外の開始2件、保存後例外からreplayedへ進む2件の実行を追う実画面
負数の拒否は、関数エラーにならない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に変え、前回の保存結果を混ぜないようにします。
送信順・方式idvalueactionstorage_policy
1・同期success10省略省略
2・同期rejection-1省略省略
3・同期exception-before10fail-before省略
4・同期zero0省略省略
5・同期upper1000省略省略
6・同期out-of-range1001省略省略
7・同期booleantrue省略省略
8・同期append-job10fail-afterrequest
9・同期append-job10fail-afterrequest
10・同期keyed-job10fail-after省略
11・同期keyed-job10fail-after省略
12・同期keyed-job11fail-after省略
13・Eventasync-rejection-1省略省略
14・Eventasync-before10fail-before省略
15・Eventasync-after10fail-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)

  1. 2
    Serverless DeepDive #01:Invokeの成功は何の成功か 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