はじめに

2025年12月に発表されたAWS Lambda Durable Functionsを案件で使う機会がありました。
きっかけは、Lambdaの15分の制限を超えてしまうバッチを、Step Functionsを使わずに作りたかったことです。
実際に使ってみると手軽な一方で、本番では思わぬところでハマりました。

Durable Functionsとは

すごく雑に言うと、「Step Functionsでやっていたような状態管理や待機、リトライを、Lambdaのコードの中で書ける仕組み」です。

通常のLambdaは、処理が途中で終了すると基本的には最初からやり直しになります。
Durable Functionsは処理の途中経過を保存できるため、失敗しても完了済みの処理はやり直さず、続きから進められます。
数時間や数日待ってから処理を再開したり、外部からの承認を待ったりもできます。

普通のLambdaが1回の処理を実行するものだとすると、Durable Functionsは複数のステップにまたがる処理をLambdaのコードで管理するもの、という感じです。

触ってみた

ここからは実際にDurable Functionsを動かしてみます。
テストコードは公式の入門ガイドから拝借しました。

関数の作成

Lambdaの関数作成画面で「永続実行」のトグルをONにします。
永続実行は関数を作るときにしか有効にできず、既存の関数にあとから設定できないので注意してください。

ONにすると、実行タイムアウトと実行履歴の保持期間を設定するパネルが開きます。
実行タイムアウトは最大366日(527,040分)、保持期間は1〜90日の範囲で選べます。
今回は動作確認なので、表示された15分と14日のまま進めました。

テストコード

import { withDurableExecution } from "@aws/durable-execution-sdk-js";

// ハンドラー全体をwithDurableExecutionでラップ
export const handler = withDurableExecution(
  async (event, context) => {
    const orderId = event.orderId;

    // ステップ1: 注文の検証
    // context.step()で囲んだ処理はチェックポイントの単位になる
    // 完了すると結果が保存され、リプレイ時には再実行されずに保存済みの結果が返る
    const validationResult = await context.step(async (stepContext) => {
      stepContext.logger.info(`注文 ${orderId} を検証中`);
      return { orderId, status: "validated" };
    });

    // ステップ2: 決済処理
    const paymentResult = await context.step(async (stepContext) => {
      stepContext.logger.info(`注文 ${orderId} の決済を処理中`);
      return { orderId, status: "paid", amount: 99.99 };
    });

    // 外部からの確認を待つ想定で10秒待機
    // wait() 中はLambdaの実行が一度終了し、コンピューティング料金がかからない
    // 待機が明けると関数が再度呼び出され、完了済みステップをスキップしながらリプレイされる
    await context.wait({ seconds: 10 });

    // ステップ3: 注文の確定(待機後の再開時に初めて実行される)
    const confirmationResult = await context.step(async (stepContext) => {
      stepContext.logger.info(`注文 ${orderId} を確定`);
      return { orderId, status: "confirmed" };
    });

    // 全ステップの結果をまとめて返す
    return {
      orderId: orderId,
      status: "completed",
      steps: [validationResult, paymentResult, confirmationResult],
    };
  }
);

Durable Functionsの基本になるのは step と wait の2つです。

step で囲んだ処理は、チェックポイント(途中経過の保存地点)の単位になります。
完了したステップの結果は保存され、リプレイ(関数を先頭から実行し直して続きに戻る動き)のときには、処理を動かさずに保存済みの結果を返します。
いわゆるセーブポイントです。

wait は処理を一時停止します。
停止している間は料金がかからず、wait を挟めば15分を超える処理も書けます。
待機を含めて、1回の実行は最長1年まで続けられます。

実行してみる

イベントに次のJSONを入れて実行します。

{
  "orderId": "order-12345"
}

実行の様子は「永続オペレーション」から確認できます。
2つのステップのあとに10秒の Wait が入り、それが明けた直後に3つ目のステップが実行されたことが分かります。

ログを確認してみる

CloudWatchで先ほどの実行のログを確認してみましょう。
1回の実行に対して、呼び出しが2回記録されています。
1回目が10秒の待機に入るまで、2回目が待機後に再開されたときのログです。
requestId が途中で切り替わっているところが、呼び出しの境目です。

呼び出しが分かれていることから分かるように、wait に入った時点でLambdaの呼び出しは正常終了しています。
プロセスが待機したまま生き残っているわけではないため、待機中のコンピューティング料金はかかりません。

もう1つ注目したいのが、「注文を検証中」と「決済を処理中」のログが1回目にしか出力されていないことです。
10秒後の再開時、コードは先頭から再実行されますが、完了済みのステップは実際には処理を動かさず、チェックポイントに保存された結果を返すだけです。
そのため、ステップ内のログも再出力されません。
「先頭から再実行されるのに、完了済みの処理はやり直されない」というリプレイの仕組みが、ログから読み取れると思います。

わざと失敗させてみる

今度はわざと失敗させて、チェックポイントから復旧するところを見てみます。
ステップ3を、50%の確率で例外を投げるコードに書き換えて実行します。

    // ステップ3: 注文の確定(わざと失敗する版)
    const confirmationResult = await context.step(async (stepContext) => {
      // 50%の確率で例外を投げて、ステップの失敗をシミュレートする
      if (Math.random() < 0.5) {
        stepContext.logger.info(`注文 ${orderId} の確定に失敗!`);
        throw new Error("確定処理に失敗しました");
      }
      stepContext.logger.info(`注文 ${orderId} を確定`);
      return { orderId, status: "confirmed" };
    });

実行してログを見ると、ステップ3が失敗したあと自動でリトライされ、成功した時点で実行全体がSucceededになっています。
執筆時点のJavaScript SDK(v2.4.0)では、リトライの設定を書かなくても、ステップは最大6回(初回とリトライ5回)まで実行されます。
このリトライの間も、ステップ1と2のログは一度も出てきません。
決済のような「二重に実行されたら困る処理」をやり直さず、失敗したところだけをリトライしてくれるわけです。

ログの attempt は、そのステップが何回目の実行かを表します。
1回目の失敗から約5秒後に次の呼び出しが始まり、attempt が2、つまり2回目の実行で成功していることが分かります。

ただし、完了済みのステップがやり直されないのは、結果がチェックポイントに保存された後の話です。
ステップの実行は「少なくとも1回」が基本で、結果を保存する前にLambdaが中断されると、そのステップはもう一度実行されます。
外部に影響を与える処理は、何度実行されても結果が変わらないように作っておく必要があります。

Step Functionsとの比較

ここまで読んで、「Step Functionsと似てない?」と思った人も多いはずです。
ざっくり比較するとこんな感じです。

比較項目 Lambda Durable Functions Step Functions
ワークフローの書き方 JavaScript/TypeScript、Python、Javaなどのコード ASL(JSON)やWorkflow Studioで定義
実行場所 Lambda関数の中 独立したStep Functionsサービス
AWSサービス連携 コードからSDKで呼び出す 220以上のサービスと直接統合
リトライ step() に組み込み Retry / Catch で定義
待機と承認待ち wait() / createCallback() Wait / .waitForTaskToken
並列処理 parallel() / map() Parallel / Map
実行の見え方 オペレーションの一覧とタイムライン ワークフローのグラフ表示
最大実行時間 1年 Standardは1年、Expressは5分
料金のかかり方 Lambdaの実行時間+オペレーション数と保存データ量 Standardは状態遷移の回数

使い分けとしては、

  • 処理の実体がほぼLambdaで完結する → Durable Functions
  • 複数のAWSサービスをオーケストレーションする → Step Functions
  • ロジックと状態管理が密結合していて、コードで一緒に管理したい → Durable Functions

といった感じです。

Durable Functionsを選ぶときに気をつけたいのは、コードがリプレイのたびに先頭から実行し直されることです。
ステップの外で現在時刻や乱数を使ったり、外部のAPIを呼んだりすると、リプレイのたびに結果が変わり、保存済みの結果と食い違うことがあります。
こうした処理はステップの中に入れて、結果をチェックポイントに保存しておく必要があります。

ハマった点

案件では、Athena(Glueデータカタログ)にたまった古いテーブルを削除するバッチを作りました。
テーブルは20万件以上あり、一覧を取るだけで16分ほどかかるため、通常のLambdaの15分の制限に収まりませんでした。
かといって、処理は「一覧を取って、古いものを消す」だけなので、Step Functionsでワークフローを組むほど複雑でもありません。
そこでDurable Functionsを使い、一覧の取得をページ単位でステップに分けて、途中経過をチェックポイントに保存しながら進める設計にしました。

ところが、本番環境で初めて動かしたところ、実行中にエラーが発生し止まりました。
原因はステップの戻り値のサイズです。
ステップの戻り値はチェックポイントとして保存されますが、1回のチェックポイントには256KBの上限があります。
当初は1つのステップで最大5万件のテーブル名を返す作りにしていました。
テーブル名はJSONにすると1件40バイト弱なので、約6,700件で上限に達してしまいます。

対策として、1つのステップで扱う件数を1,500件に減らしました。
1件100バイトと多めに見積もっても約150KBで、上限の6割ほどに収まります。

修正後は本番環境でも止まらずに完走し、古いテーブルをすべて削除できました。

まとめ

使ってみて、Durable Functionsは「Lambdaの中に収まるStep Functionsの簡易版」だと感じました。
15分の制限は超えるけれど、Step Functionsでワークフローを組むほどではない処理にちょうどいい選択肢だと思います。
今回は step と wait しか使っていませんが、parallel や map を使えば並列処理も書けるので、Lambdaのコードだけで組めるワークフローの幅はもっと広がりそうです。