はじめに #
re:Invent 2025で発表されたLambda Durable Functionsが気になっていたので、Step Functionsとの比較を整理しつつ実際に触ってみました。
そもそもなぜ必要なのか #
Lambdaで複数ステップのワークフローを組みたいとき、専用のプラットフォームを使わないとこうなりがちです。
例:注文処理ワークフロー(自前実装)
Lambda A: 注文受付
→ DynamoDBに { orderId, status: "payment_pending" } を保存
CloudWatch Eventsのcron(1分ごと)
→ Lambda B: status="payment_pending" のレコードを検索
→ 決済API呼び出し
→ DynamoDBを { status: "shipping_pending" } に更新
CloudWatch Eventsのcron(1分ごと)
→ Lambda C: status="shipping_pending" のレコードを検索
→ 配送手配
→ DynamoDBを { status: "completed" } に更新
DynamoDBが本来のデータストアではなく簡易ステートマシンとして使われ、cronで定期ポーリングして次のステップを自分でトリガーしている——いわゆるワークフローの状態がアドホックなストレージに漏洩している状態です。リトライ、タイムアウト、エラーハンドリングもすべて自前で実装することになり、つらい。
この問題を解決するために、状態遷移を管理する専用プラットフォームとしてStep Functionsが存在していました。そして2025年末、コードベースの新しい選択肢としてLambda Durable Functionsが加わりました。
Step Functionsとの使い分け #
Lambda Durable Functionsの登場で「Step Functionsは不要になるのか?」という声もありますが、結論としては用途が異なるので共存する形になります。
比較表 #
| 項目 | Lambda Durable Functions | Step Functions |
|---|---|---|
| 定義方式 | コードベース(TypeScript/Python) | JSON定義(Amazon States Language) |
| 状態管理 | 自動チェックポイント(リプレイモデル) | 明示的な状態遷移(ステートマシン) |
| 実行期間 | 最大1年 | Standard: 最大1年 / Express: 最大5分 |
| テスト | ユニットテストしやすい | テストが複雑になりがち |
| 可視化 | ログ・実行履歴ベース | ビジュアルグラフUI |
| AWS連携 | Lambda中心 | 200以上のAWSサービスと直接統合 |
| ペイロード | 同期6MB / 非同期1MB(チェックポイントは256KB) | 最大256KB(Standard) |
Durable Functionsが向いているケース #
- ワークフローのロジックがLambda内で完結する
- コードベースで管理したい(IaCとの親和性)
- ユニットテストをしっかり書きたい
- AIエージェントのオーケストレーション(LLMの呼び出し→ツール実行→人間のレビュー待ち)
Step Functionsが向いているケース #
- 複数のAWSサービスをまたぐオーケストレーション(S3→Lambda→DynamoDB→SNS等)
- ビジュアルでワークフローを把握・デバッグしたい
- 数千の状態を持つ複雑な分岐ロジック
- チーム内に非エンジニアのステークホルダーがいる
要するに、アプリケーションロジック寄りならDurable Functions、サービスオーケストレーション寄りならStep Functionsという棲み分けです。
ベストプラクティス #
AWS公式ドキュメントを元に、押さえておくべきポイントをまとめます。
1. 決定論的なコードを書く #
Durable Functionsはリプレイモデルで動作します。障害やwait復帰の際、関数は最初から再実行されます。
リプレイ時の挙動は、コードの場所によって異なります:
context.step()の中 → 再実行されず、チェックポイントに保存された結果がそのまま返る(キャッシュ)context.step()の外 → 毎回実行される
SDKはチェックポイントログとコードの実行順序を突き合わせて「どこまで完了したか」を判断します。もしステップ外のコードが実行のたびに違う結果を返すと、2つの問題が起きます:
- 実行順序がログとズレる — 条件分岐の結果が変わると、ステップの実行順がログと合わなくなり、正しく再開できない
- ステップに渡す値が変わる — リトライされるステップに前回と違う引数が渡され、べき等性が壊れる
だからステップ外のコードは決定論的(=何回実行しても同じ結果になる)である必要があります。非決定論的な処理はcontext.step()に入れれば、初回の結果がキャッシュされてリプレイ時も同じ値が返るので安全です。
// ステップの外 → リプレイのたびに値が変わる
const requestId = crypto.randomUUID();
// ステップの中 → 初回の結果がキャッシュされ、リプレイ時も同じ値が返る
const requestId = await context.step("generate-id", async () => {
return crypto.randomUUID();
});
ステップで包むべき操作:
- ランダム値・UUID生成
- 現在時刻の取得
- 外部API呼び出し・DBクエリ
- ファイルシステム操作
2. べき等性を設計する #
context.step()の中は成功すればキャッシュされますが、副作用が発生した後、チェックポイント保存前にクラッシュするケースがあります。
step("pay") 実行
→ 決済API呼び出し → $100引き落とし成功
→ Lambdaがチェックポイント保存前にクラッシュ
リプレイ:
step("pay") → ログにない(保存前にクラッシュした)
→ 「未完了」と判断 → もう一度実行 → さらに$100引き落とし(二重課金)
このように、ステップ内の処理は複数回実行される可能性があるため、同じ操作を何回実行しても同じ結果になる(べき等である)ように設計する必要があります。
// べき等性トークンを使う例(決定論的な値から生成する)
await context.step("charge-payment", async () => {
return paymentService.charge({
amount: order.total,
idempotencyKey: `order-${order.id}-payment` // 同じキーなら2回目は無視される
});
});
べき等性トークンはeventのIDなど決定論的な値から作ります。uuid.uuid4()のような非決定論的な値を使うと、リプレイのたびに別のトークンが生成されてしまい、べき等性の意味がなくなります。
3. ステップの命名と粒度 #
| ルール | 例 |
|---|---|
| 説明的な名前をつける | validate-order, send-notification |
| 静的な名前にする | タイムスタンプやランダム値を含めない |
| 粒度のバランス | 過度に細かくしない、関連操作はグループ化 |
4. バージョニング #
長期間実行される関数は、途中でコードがデプロイされる可能性があります。Lambda Versionsを使って実行をバージョンに固定しましょう。
- 関数をバージョン番号またはエイリアスで起動する
- ステップのリネームやリプレイを破壊する変更は避ける
5. 3種類の「再実行」を理解する #
Durable Functionsには、関数が再び実行される状況が3つあります。これを混同するとエラーハンドリングの設計を誤るので、整理しておきます。
共通の挙動: 3つともhandlerは最初から再実行され、完了済みのステップはチェックポイントログからキャッシュを返してスキップされます。
| Step retry | Backend retry | Replay | |
|---|---|---|---|
| 原因 | ステップ内の例外 | インフラ障害(OOM等) | wait完了、callback受信など |
| 誰が制御 | SDK(retryStrategy) | Lambdaプラットフォーム | Lambdaプラットフォーム |
| リトライカウント | 進む | 進まない | 進まない |
| 未完了ステップ | 再試行する | 実行モードに依存 | なし(正常な再開) |
- Step retry — ステップのコードが例外を投げたとき、SDKのretryStrategyに従って再試行される。アプリケーションレベルの失敗
- Backend retry — Lambda自体がクラッシュしたとき(OOM、タイムアウト、AWS基盤障害など)、Lambdaプラットフォームが自動で再起動する。SDKも一緒に死んでいるのでSDKのリトライではない。クラッシュ時に実行中だったステップがどうなるかは実行モードに依存する(デフォルトでは再実行、AT_MOST_ONCE_PER_RETRYではスキップ)
- Replay — wait完了やcallback受信後に関数が再起動される通常の動作。障害ではない
6. ステップの実行モード(AT_MOST_ONCE_PER_RETRY) #
デフォルトでは、ステップはAT_LEAST_ONCE_PER_RETRY(各リトライで少なくとも1回実行)です。これはBackend retry時にチェックポイント未保存のステップを再実行するため、二重実行の可能性があります。
AT_MOST_ONCE_PER_RETRYに変更すると、同じStep retry内ではステップを最大1回しか実行しないようになります。
await context.step("deduct-inventory", async () => {
return inventoryService.deduct(event.productId, event.quantity);
}, {
semantics: StepSemantics.AtMostOncePerRetry
});
ここでの「PER_RETRY」はStep retryを指します。Backend retryやReplayはStep retryのカウントを進めないので、AT_MOST_ONCEの保証は同じStep retry内で維持されます。
| モード | 二重実行 | 実行漏れ | 用途 |
|---|---|---|---|
| AT_LEAST_ONCE(デフォルト) | 起きうる | 起きない | べき等な操作、外部でべき等性キー対応 |
| AT_MOST_ONCE_PER_RETRY | 起きない | 起きうる | べき等性キー非対応のAPI、在庫引き当て等 |
7. エラーハンドリング #
ステップ内で例外が発生すると、SDKがキャッチしてretryStrategyに基づいてリトライを判断します。アプリコードのtry/catchに例外が届くのはすべてのリトライが失敗した後です。
@durable_execution
def handler(event: dict, context: DurableContext) -> dict:
try:
result = context.step(process_payment(order_id))
# ↑ SDKが内部でリトライを繰り返す。成功すれば結果が返る
except Exception as e:
# ここに来る = リトライも全部失敗した
context.logger.error(f"Payment failed after all retries: {e}")
return {"status": "failed", "error": str(e)}
注意点として、ステップ内で例外をキャッチして握りつぶすとSDKにはリトライの機会が与えられません。一時的なエラーはステップの外に出し、永続的なエラーだけキャッチするのが基本です。
@durable_step
def process_payment(step_ctx: StepContext, order_id: str):
try:
return payment_api.charge(order_id)
except AuthenticationError:
# 永続的なエラー → リトライしても無駄なので自分で処理
return {"status": "auth_failed"}
# NetworkError等の一時的なエラーはキャッチしない → SDKがリトライする
8. ログはcontext.loggerを使う
#
print()やlogging.getLogger()ではなく、SDKが提供するcontext.loggerを使います。
@durable_execution
def lambda_handler(event, context: DurableContext):
context.logger.info("ワークフロー開始") # リプレイ時は抑制される
print("ワークフロー開始") # リプレイのたびに出力される(重複する)
context.step(validate(order_id), name="validate")
context.logger.info("決済処理へ") # 上が完了済みならリプレイ時は抑制
context.step(pay(order_id), name="pay")
context.loggerはチェックポイントログと連動していて、完了済みの区間で出力されたログをリプレイ時に抑制します。未到達の部分に進んだら通常通り出力されます。print()にはこの仕組みがないので、リプレイのたびに同じログがCloudWatchに出続けます。
ステップの中ではstep_context.loggerが使えます。こちらはステップ名やリトライ回数が自動で付与されるので、デバッグ時に便利です。
9. クォータとコストを意識する #
Durable Functionsには以下のクォータがあります:
- 1実行あたり最大3,000オペレーション
- 1実行あたり最大100MBのチェックポイントストレージ
- チェックポイント1件あたり最大256KB
オペレーション(step/wait/parallel等)は従量課金されるため、ステップの粒度を細かくしすぎるとコストが増えます。
10. パフォーマンス #
context.parallel()やcontext.map()で並行実行する- チェックポイントサイズを最小化する(フルペイロードではなく参照を保存)
- 関連操作をバッチ処理してチェックポイントのオーバーヘッドを減らす
ハンズオン:注文処理ワークフローを動かす #
参考: Build multi-step applications and AI workflows with AWS Lambda durable functions | AWS News Blog
座学だけでは分からないので、シンプルな注文処理ワークフローを実際にデプロイして以下を確かめます。
- waitの再開遅延 → wait前後のタイムスタンプで遅延を計測(課金も確認)
- リトライの挙動 → ステップ内で意図的に例外を発生させ、リプレイの動きを観察
アーキテクチャ #
注文イベント
→ [Lambda Durable Function]
→ Step: 注文バリデーション (validate_order)
→ Step: 決済処理 (process_payment)
→ Wait: 10秒待機
→ Step: 注文確定 (confirm_order)
→ 完了
context.step() と context.wait() だけのシンプルな構成で、Durable Functionsの基本動作に集中できます。
前提 #
- AWS CLI v2(設定済み)
- SAM CLI 1.153.1以上
- Python 3.13以上(Durable Functions対応ランタイム: Python 3.13/3.14, Node.js 22/24)
なお、Durable Functionsは関数の新規作成時にのみ有効化できます。既存の関数に後から追加することはできません。
コードの実装 #
from aws_durable_execution_sdk_python import (
DurableContext,
durable_execution,
durable_step,
)
from aws_durable_execution_sdk_python.config import Duration
@durable_step
def validate_order(step_context, order_id):
step_context.logger.info(f"Validating order {order_id}")
return {"orderId": order_id, "status": "validated"}
@durable_step
def process_payment(step_context, order_id):
step_context.logger.info(f"Processing payment for order {order_id}")
return {"orderId": order_id, "status": "paid", "amount": 99.99}
@durable_step
def confirm_order(step_context, order_id):
step_context.logger.info(f"Confirming order {order_id}")
return {"orderId": order_id, "status": "confirmed"}
@durable_execution
def lambda_handler(event, context: DurableContext):
order_id = event["orderId"]
validation_result = context.step(validate_order(order_id))
payment_result = context.step(process_payment(order_id))
# waitの動作確認用。実際のユースケースでは
# 「配送準備が整うまで待つ」「外部システムの処理完了を待つ」等に使う
context.wait(Duration.from_seconds(10))
confirmation_result = context.step(confirm_order(order_id))
return {
"orderId": order_id,
"status": "completed",
"steps": [validation_result, payment_result, confirmation_result],
}
注目ポイント:
@durable_execution— ハンドラーをデコレータでラップするだけで有効化@durable_step+context.step()— 各ステップが自動でチェックポイントされる。障害時はここから再開context.wait()— 10秒サスペンド。この間Lambda課金は発生しない- 全体が普通のPythonコード — 上から下に読めて、見通しが良い
リトライの挙動を確認する #
決済処理を80%の確率で失敗させて、リトライの動きを観察します。
import random
@durable_step
def process_payment_with_failure(step_context, order_id):
step_context.logger.info(f"Processing payment for order {order_id}")
if random.random() < 0.8:
step_context.logger.error(
f"Payment failed for order {order_id} - insufficient funds"
)
raise Exception("Payment processing failed - insufficient funds")
step_context.logger.info(f"Payment successful for order: {order_id}")
return {"orderId": order_id, "status": "paid", "amount": 99.99}
リトライを検証するときは、上のコードでprocess_paymentをprocess_payment_with_failureに差し替えて使います。
random.random() がステップの中にあることに注目。ベストプラクティスで書いた「非決定論的な処理はステップで包む」を実践しています。リプレイ時にステップが再実行されると新しい乱数が生成されますが、ステップ自体がリトライされるのでこれは意図通りの動作です。
デプロイ #
以下の2ファイルを同じディレクトリに配置します。Python 3.13ランタイムにはDurable Execution SDKがプリインストールされているため、requirements.txtは不要です(ローカルでの型チェック等が必要ならpip install aws-durable-execution-sdk-python)。
project/
├── app.py # 上のコード
└── template.yaml # 以下のSAMテンプレート
SAMテンプレート(template.yaml):
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
WaitTestFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: durable-wait-test
Handler: app.handler
Runtime: python3.13
Timeout: 120
MemorySize: 128
CodeUri: .
DurableConfig:
ExecutionTimeout: 300
RetentionPeriodInDays: 1
Policies:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy
AutoPublishAlias: live
ポイント:
DurableConfigでDurable Functionsを有効化。ExecutionTimeoutはワークフロー全体の最大実行時間(秒)PoliciesにAWSLambdaBasicDurableExecutionRolePolicyが必須(チェックポイントAPI権限)AutoPublishAliasでエイリアスを作成。Durable Functionsは**修飾付きARN(バージョンまたはエイリアス)**でしか呼び出せない
sam build
sam deploy --stack-name durable-wait-test --resolve-s3 --capabilities CAPABILITY_IAM
呼び出し:
aws lambda invoke \
--function-name 'durable-wait-test:live' \
--invocation-type RequestResponse \
--durable-execution-name "wait-test-$(date +%s)" \
--payload '{"orderId": "ORD-001"}' \
--region ap-northeast-1 \
response.json
cat response.json
# {"orderId": "ORD-001", "status": "completed", "steps": [...]}
検証結果:waitの再開遅延 #
waitの前後でtime.time()をステップに記録するコードを書き、10秒waitを100回実行してオーバーヘッドを計測しました。各実行は--durable-execution-nameにユニークな値を付けて呼び出しています。
計測条件: リージョン ap-northeast-1、ランタイム Python 3.13、メモリ 128MB、アーキテクチャ arm64
| 指標 | 値 |
|---|---|
| 件数 | 100回 |
| 平均オーバーヘッド | 0.34秒 |
| 中央値 | 0.34秒 |
| 最小 | 0.31秒 |
| 最大 | 0.72秒 |
| P95 | 0.38秒 |
| P99 | 0.72秒 |
| 標準偏差 | 0.041秒 |
10秒waitに対して再開の遅延は平均0.34秒。ほぼ一定で、ばらつきも小さいです。最大0.72秒はコールドスタートの影響と思われますが、それでも1秒以内に収まっています。
waitは「正確なタイマー」ではないですが、秒単位で見ればかなり正確です。Fraud Detectionの「24時間待ち」のようなユースケースではまったく問題にならないレベルです。
なお、wait中はLambdaが停止しているためコンピュート課金は発生しません。aws lambda get-durable-execution-historyで実行履歴を見ると、InvocationCompletedイベントのStartTimestamp/EndTimestampから実際のLambda稼働時間が分かります。今回はwait前の約3.3秒とwait後の約0.3秒、合計約3.6秒だけでした。
検証結果:リトライの挙動 #
80%の確率で失敗するprocess_payment_with_failureに差し替えて実行し、CloudWatchログと実行履歴から挙動を確認しました。
全リトライが失敗したケース #
実行履歴(aws lambda get-durable-execution-history)から、以下が観測されました:
| イベント | ステップ名 | attempt | 次のリトライまで |
|---|---|---|---|
| StepSucceeded | validate_order | 1 | — |
| StepFailed | process_payment_with_failure | 1 | 7秒 |
| StepFailed | process_payment_with_failure | 2 | 20秒 |
| StepFailed | process_payment_with_failure | 3 | 29秒 |
| StepFailed | process_payment_with_failure | 4 | 45秒 |
| StepFailed | process_payment_with_failure | 5 | 72秒 |
| StepFailed | process_payment_with_failure | 6 | — (リトライ終了) |
| ExecutionFailed | — | — | — |
retryStrategyを明示的に設定していないので、SDKのデフォルト(RetryPresets.default())が適用され6回試行されています。バックオフは初期遅延5秒の指数バックオフ+ジッターで増加しています(7→20→29→45→72秒)。全リトライが失敗するとExecutionFailedになります。
attempt 3で成功したケース #
attempt 1: validate_order 成功 → process_payment_with_failure 失敗
attempt 2: (リプレイ) → process_payment_with_failure 失敗
attempt 3: (リプレイ) → process_payment_with_failure 成功 → confirm_order 成功
→ ExecutionSucceeded
CloudWatchログで確認できたこと #
step_context.loggerの出力にはoperationNameとattemptが自動付与されます:
{
"message": "Processing payment for order ORD-RETRY-001",
"operationName": "process_payment_with_failure",
"attempt": 2,
"operationId": "c5faca15..."
}
リプレイ時の挙動:
- attempt 2以降のログに
validate_orderの実行ログは出現しない — リプレイでスキップされている context.logger.info("Validation done, moving to payment")も出現しない — ステップ外のログもリプレイ時に抑制されているprocess_payment_with_failureのログだけが毎回出力される — ここだけが実際に再実行されている
これにより、記事のベストプラクティスで書いた以下が実証できました:
- 完了済みステップはリプレイでスキップされる
context.loggerのログはリプレイ時に抑制される- リトライのバックオフ中はLambdaが停止している(各InvocationCompletedの稼働時間は0.5〜0.7秒程度)
最後に #
実際に触ってみて分かったことをまとめます。
- リプレイモデルの理解が最重要 — 決定論的なコードとべき等性の設計がDurable Functionsを使いこなすカギ。ここを理解せずに使うと、意図しない二重実行やリプレイ失敗に悩まされる
- 3種類の「再実行」を区別する — Step retry / Backend retry / Replayで挙動が異なり、AT_MOST_ONCE_PER_RETRYの「PER_RETRY」がどれを指すかも変わる
- waitの精度は実用十分 — 100回計測で平均0.34秒のオーバーヘッド。wait中の課金もゼロを確認できた
- セットアップは簡単 — SAMテンプレートに
DurableConfigを足すだけ。コードもデコレータを付けるだけで動く
Step Functionsとの使い分けは「アプリケーションロジック寄りならDurable Functions、サービスオーケストレーション寄りならStep Functions」が基本方針です。まだ2025年末に出たばかりの機能ですが、小さなワークフローから試してみる価値はあると感じました。
参考リンク #
- Lambda durable functions(概要)| AWS Docs
- Basic concepts | AWS Docs
- Durable functions or Step Functions | AWS Docs
- Best practices for Lambda durable functions | AWS Docs
- Retries for Lambda durable functions | AWS Docs
- Steps - AWS Durable Execution SDK | AWS Docs
- Idempotency | AWS Docs
- Build multi-step applications and AI workflows with AWS Lambda durable functions | AWS News Blog
- Best practices for Lambda durable functions using a fraud detection example | AWS Compute Blog
- AWS Durable Functions vs Step Functions | DEV Community
- aws-samples/sample-lambda-durable-functions | GitHub