💡
Lambda→Step Functions連携時のエラーハンドリング
1. 結論(この記事で得られること)
Lambda と Step Functions を連携させたシステムで、「エラーが握りつぶされる」「リトライが効かない」「原因がわからない」という問題は、エラーの形式と伝搬方式を正しく理解すれば 9 割解決できます。
この記事では以下が得られます:
- Lambda 側の例外が Step Functions にどう伝わるかの仕組み
- 実装レベルで「検知できる/できない」エラーの違い
- AI(Claude / GPT)を使った障害調査の具体的プロンプト例
- 本番運用で必須のリトライ設計・監視設定
私自身、過去に「Lambda では Exception が出てるのに Step Functions は Success になる」という謎現象に半日溶かした経験があります。原因は JSON シリアライズの失敗でした。同じ轍を踏まないよう、実装・テスト・運用まで実務目線で解説します。
2. 前提(環境・読者層)
想定読者
-
Lambda と Step Functions を初めて連携させる方
-
エラーハンドリングで一度でもハマった経験がある方
-
「とりあえず動いてる」を卒業したい方
-
Lambda:Python 3.11 / Node.js 18.x(コード例は Python メイン)
-
Step Functions:Express / Standard どちらでも適用可能
-
IaC:CDK / Terraform / SAM いずれでも設計思想は共通
前提知識
- Lambda の基本的な実装経験
- Step Functions の State 定義(JSON / YAML)が読める
3. Before:よくあるつまずきポイント
実務でよく見るアンチパターンを 3 つ挙げます。
3-1. Lambda が例外を raise しているのに Step Functions で検知されない
# ❌ よくある間違い
def lambda_handler(event, context):
try:
result = some_heavy_process(event)
return result # 例外が起きても try-except で握りつぶされる
except Exception as e:
print(f"Error: {e}") # ログだけ出して正常終了扱い
return {"status": "error", "message": str(e)}
問題点
- Step Functions は Lambda の戻り値が返ってきた時点で「成功」と判断する
- 「Catch」 や 「Retry」 が発動しない
- 開発者の意図と実際の動作がズレる
3-2. エラーは検知できたがリトライで悪化する
{
"Type": "Task",
"Resource": "arn:aws:lambda:...",
"Retry": [
{
"ErrorEquals": ["States.ALL"],
"MaxAttempts": 3
}
]
}
問題点
- べき等性が担保されていない処理(DB への重複書き込みなど)で単純リトライすると、データが壊れる
- 「States.ALL」 で全部リトライすると、本来リトライすべきでないエラー(バリデーション失敗など)まで再実行される
3-3. エラーログはあるが原因がわからない
[ERROR] Runtime.ExitError
[ERROR] JSONDecodeError: Expecting value: line 1 column 1 (char 0)
CloudWatch Logs に出力されているが、どのステートでどの入力に対してエラーが起きたのか が追跡できない。再現もできず、「とりあえず再実行したら直った」で終わる。
4. After:基本的な解決パターン
4-1. Lambda 側:エラーは明示的に raise し、型を揃える
# ✅ 正しいパターン
import json
class ValidationError(Exception):
"""リトライしても無駄なエラー"""
pass
class TransientError(Exception):
"""リトライ可能なエラー"""
pass
def lambda_handler(event, context):
try:
# 入力検証
if "user_id" not in event:
raise ValidationError("user_id is required")
# 外部 API 呼び出しなど
result = call_external_api(event["user_id"])
# 正常時は必ず JSON シリアライズ可能な形で返す
return {
"statusCode": 200,
"body": result
}
except ValidationError as e:
# リトライ不要なエラーは専用のエラー名で raise
raise e
except Exception as e:
# 想定外のエラーは TransientError でラップ
raise TransientError(f"Unexpected error: {str(e)}")
ポイント
- 例外は握りつぶさず、必ず raise する
- エラー種別をカスタム Exception で分ける(リトライ戦略が変わるため)
- 正常時の戻り値は必ず JSON シリアライズ可能な形にする(「datetime」 オブジェクトなどは NG)
4-2. Step Functions 側:エラー種別で Retry / Catch を分岐
{
"Comment": "Lambda エラーハンドリングの例",
"StartAt": "ProcessTask",
"States": {
"ProcessTask": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:MyFunction",
"Retry": [
{
"ErrorEquals": ["TransientError"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{
"ErrorEquals": ["ValidationError"],
"ResultPath": "$.error",
"Next": "ValidationFailedState"
},
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "UnexpectedErrorState"
}
],
"End": true
},
"ValidationFailedState": {
"Type": "Fail",
"Error": "ValidationError",
"Cause": "Input validation failed"
},
"UnexpectedErrorState": {
"Type": "Fail",
"Error": "UnexpectedError",
"Cause": "Unexpected system error"
}
}
}
設計の理由
- 「TransientError」 だけリトライ対象にする(べき等性があるエラーのみ)
- 「ValidationError」 は即座に Fail させる(何度やっても成功しないため)
- 「ResultPath」 で元の入力を保持しつつエラー情報を追加(デバッグで必須)
4-3. エラー情報の構造化(ログとメトリクスに活かす)
# Lambda 側でエラー情報を構造化
def lambda_handler(event, context):
try:
# ... 処理 ...
pass
except Exception as e:
error_info = {
"errorType": type(e).__name__,
"errorMessage": str(e),
"requestId": context.request_id,
"input": event # デバッグ用(機密情報に注意)
}
print(json.dumps(error_info)) # CloudWatch Logs に構造化ログ
raise
これで CloudWatch Logs Insights で以下のように集計できます:
fields @timestamp, errorType, errorMessage
| filter errorType = "TransientError"
| stats count() by errorMessage
続きはnoteで
この記事の実装編・詳細解説はnoteで公開しています。
実際のコード例や、実務で遭遇するハマりポイントなど、より踏み込んだ内容を書いています。
Discussion