Step Functions における Map ステートのリトライ: 組み込み Retry (全体) vs カスタム (個別)

AWS Step Functions の Map ステートで並列処理中に一部のアイテムが失敗した場合、どのようにリトライするのが最適でしょうか?

Map ステートの Retry 句を使えば簡単にリトライを実装できますが、本記事の最初の検証(「Map ステート全体をリトライする」)で示すように、この機能は失敗していないアイテムも含めた全ての処理を最初からやり直します。

もし Iterator 内の処理に冪等性(べきとうせい)(何度実行しても結果が同じになること)が担保されているのであれば、この組み込み Retry はシンプルで有効な戦略です。

しかし、「成功した処理は再実行したくない(冪等でない、または処理コストが高い)」場合や、「失敗したアイテムだけを効率的に再試行したい」場合、組み込みの Retry 機能は適していません。

本記事では、この Map ステート組み込みの Retry 機能の正確な挙動を詳しく解説するとともに、後者の要件(失敗したアイテムだけのリトライ)を実現するための一般的なカスタムパターン(「失敗した処理のみをリトライする」)について、具体的な ASL と動作の違いを比較検証します。

Map ステート全体をリトライする

以下 ASL の Map ステートでは、Iterator 内のいずれか 1 つのアイテム(B-456)への処理が失敗した場合、Map ステート全体の Retry が作動し、成功していた他のアイテム(A-123)も含むすべてのアイテムが再実行されます。
アイテム A-123 と B-456 の 2つがあります。
アイテム A-123 は常に成功します。
random 関数が 1〜5 の整数のうちどれかを返却し、それが 5 だった場合にのみアイテム B-456 が成功する論理になっています。

{
  "Comment": "Mapステートのリトライ(再実行)サンプル【Iterator内乱数版】",
  "StartAt": "1. GenerateSampleArray",
  "States": {
    "1. GenerateSampleArray": {
      "Type": "Pass",
      "Comment": "処理対象のサンプル配列を生成します",
      "Result": {
        "products": [
          {
            "id": "A-123",
            "name": "Item 1"
          },
          {
            "id": "B-456",
            "name": "Item 2 (確率で失敗)"
          }
        ],
        "processId": "xyz-process-001"
      },
      "Next": "2. ProcessItemsInParallel"
    },
    "2. ProcessItemsInParallel": {
      "Type": "Map",
      "Comment": "products配列の各要素を並列処理します",
      "InputPath": "$.products",
      "MaxConcurrency": 2,
      "Iterator": {
        "StartAt": "GenerateRandomValue",
        "States": {
          "GenerateRandomValue": {
            "Type": "Pass",
            "Comment": "リトライのたびに新しい乱数を生成する",
            "ResultPath": "$.randomValue",
            "Parameters": {
              "value.$": "States.MathRandom(1, 6)"
            },
            "Next": "CheckIfShouldFail"
          },
          "CheckIfShouldFail": {
            "Type": "Choice",
            "Comment": "特定のアイテムが確率で失敗するかチェック",
            "Choices": [
              {
                "And": [
                  {
                    "Variable": "$.id",
                    "StringEquals": "B-456"
                  },
                  {
                    "Variable": "$.randomValue.value",
                    "NumericLessThan": 5
                  }
                ],
                "Next": "FailProcessing"
              }
            ],
            "Default": "ProcessSingleItem"
          },
          "FailProcessing": {
            "Type": "Fail",
            "Comment": "Item B-456を意図的に失敗させる",
            "Error": "MyProcessingError",
            "Cause": "Intentional random failure for testing retry"
          },
          "ProcessSingleItem": {
            "Type": "Pass",
            "Comment": "各アイテムに対する個別の処理(成功)",
            "Parameters": {
              "itemId.$": "$.id",
              "itemName.$": "$.name",
              "randomUsed.$": "$.randomValue.value",
              "processedTimestamp.$": "$$.State.EnteredTime"
            },
            "ResultPath": "$.processedInfo",
            "End": true
          }
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "MyProcessingError"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 10,
          "BackoffRate": 1
        }
      ],
      "ResultPath": "$.processedResults",
      "Next": "3. FinalStep"
    },
    "3. FinalStep": {
      "Type": "Pass",
      "Comment": "最終結果の確認用ステップ",
      "End": true
    }
  }
}

全体の処理フロー(リトライ発生時)

  1. 1回目の実行:
    • Map ステートが開始され、A-123 と B-456 の Iterator が並列で起動します。
    • A-123: GenerateRandomValue(例: 乱数 3) -> CheckIfShouldFail (Default) -> ProcessSingleItem成功
    • B-456: GenerateRandomValue(例: 乱数 2) -> CheckIfShouldFail (条件合致) -> FailProcessing失敗: MyProcessingError
  2. リトライの検知:
    • B-456 が失敗したため、Map ステート全体が失敗とみなされます。
    • Retry 設定(ErrorEquals: ["MyProcessingError"])が作動します。
  3. 2回目の実行(リトライ):
    • Map ステートが、元の $.products 配列を使って最初から再実行されます。
    • A-123 (再実行): GenerateRandomValue(例: 乱数 5) -> CheckIfShouldFail (Default) -> ProcessSingleItem成功
    • B-456 (再実行): GenerateRandomValue(例: 乱数 4) -> CheckIfShouldFail (条件合致) -> FailProcessing失敗: MyProcessingError
  4. 3回目の実行(リトライ):
    • 再度、Map ステート全体がリトライされます。
    • A-123 (再実行): GenerateRandomValue(例: 乱数 1) -> CheckIfShouldFail (Default) -> ProcessSingleItem成功
    • B-456 (再実行): GenerateRandomValue(例: 乱数 5) -> CheckIfShouldFail (Default) -> ProcessSingleItem成功
  5. Map ステートの完了:
    • すべてのアイテム(A-123 と B-456)が成功したため、Map ステート全体が正常に完了します。
    • Iterator の実行結果(3回目の実行結果の配列)が $.processedResults に格納されます。
  6. 3. FinalStep (Pass ステート):
    • 最終ステップに進み、ステートマシン全体が正常終了します。

各ステップの詳細解説

1. 1. GenerateSampleArray (Pass ステート)

  • 役割: 処理対象の products 配列(A-123 と B-456)を生成します。
  • 動作: Result の内容を出力し、2. ProcessItemsInParallel へ渡します。

2. 2. ProcessItemsInParallel (Map ステート)

  • 役割: products 配列の各要素(A-123, B-456)を並列処理します。
  • Retry の仕様:
    • この Map ステート(インラインモード)の Retry は、Map ステート全体に対して設定されています。
    • Iterator(反復処理)内で MyProcessingError が発生すると、Map ステート全体が失敗とみなされます。
    • Retry ポリシーに基づき、InputPath$.products)から取得したすべてのアイテム(A-123 と B-456 の両方)の Iterator 処理が最初からやり直されます。

Iterator (Map ステートの反復処理) の詳細

A-123B-456 のそれぞれで、以下の処理が(最大並列度 MaxConcurrency: 2 で)同時に開始されます。

  • GenerateRandomValue (Pass ステート):

    • 役割: 確率判定用の乱数(1〜5)を生成します。
    • 動作: States.MathRandom(1, 6) で乱数を生成し、各アイテムのデータ($.randomValue)に追加します。
  • CheckIfShouldFail (Choice ステート):

    • 役割: 条件に基づき、成功か失敗かを分岐します。
    • 分岐ルール:
      1. もし id"B-456" かつ 乱数が 5 未満 (1, 2, 3, 4) ならば:
        • FailProcessing へ進みます。
      2. それ以外の場合 (Default):
        • id"A-123" の場合(常にこちら)。
        • id"B-456" で、かつ 乱数が 5 の場合。
        • ProcessSingleItem へ進みます。
  • FailProcessing (Fail ステート):

    • 役割: B-456 の処理を意図的に失敗させます。
    • 動作: MyProcessingError を発生させます。
  • ProcessSingleItem (Pass ステート):

    • 役割: アイテム処理の成功を示します。
    • 動作: Iterator の実行を正常終了します。

グラフビュー

障害時のリドライブ

MaxAttempts を超えて失敗した場合、"Type": "Fail" を記録した箇所から再実行可能です。
MaxAttempts を 1 にして意図的に失敗させ、何回かリドライブすると成功しました。いずれにせよ Map 全体をリトライすることになります。

Map ステートで失敗した 処理 (States) のみをリトライする

以下 ASL では Map ステートで失敗したアイテムだけを個別にリトライします。
これは、すべてのアイテムを再処理するのではなく、失敗した特定のアイテムだけを効率的に再試行するための一般的なパターンです。

{
  "Comment": "Mapステートで失敗したアイテムだけをリトライするサンプル(リトライも失敗する版・クリーンアップ)",
  "StartAt": "GenerateSampleArray",
  "States": {
    "GenerateSampleArray": {
      "Type": "Pass",
      "Result": {
        "products": [
          {
            "id": "A-123",
            "name": "Item 1"
          },
          {
            "id": "B-456",
            "name": "Item 2 (確率で失敗)"
          },
          {
            "id": "C-789",
            "name": "Item 3"
          }
        ]
      },
      "Next": "ProcessItems_1stPass"
    },
    "ProcessItems_1stPass": {
      "Type": "Map",
      "InputPath": "$.products",
      "MaxConcurrency": 2,
      "Iterator": {
        "StartAt": "GenerateRandomValue",
        "States": {
          "GenerateRandomValue": {
            "Type": "Pass",
            "ResultPath": "$.randomValue",
            "Parameters": {
              "value.$": "States.MathRandom(1, 6)"
            },
            "Next": "CheckIfShouldFail"
          },
          "CheckIfShouldFail": {
            "Type": "Choice",
            "Choices": [
              {
                "And": [
                  {
                    "Variable": "$.id",
                    "StringEquals": "B-456"
                  },
                  {
                    "Variable": "$.randomValue.value",
                    "NumericLessThan": 5
                  }
                ],
                "Next": "MarkAsFailed"
              }
            ],
            "Default": "MarkAsSuccess"
          },
          "MarkAsFailed": {
            "Type": "Pass",
            "Parameters": {
              "status": "failed",
              "id.$": "$.id",
              "randomUsed.$": "$.randomValue.value"
            },
            "End": true
          },
          "MarkAsSuccess": {
            "Type": "Pass",
            "Parameters": {
              "status": "success",
              "id.$": "$.id",
              "randomUsed.$": "$.randomValue.value"
            },
            "End": true
          }
        }
      },
      "ResultPath": "$.firstPassResults",
      "Next": "FilterFailedItems"
    },
    "FilterFailedItems": {
      "Type": "Pass",
      "InputPath": "$.firstPassResults",
      "Parameters": {
        "failedItems.$": "$[?(@.status == 'failed')]"
      },
      "ResultPath": "$.retryQueue",
      "Next": "CheckIfRetryNeeded"
    },
    "CheckIfRetryNeeded": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.retryQueue.failedItems[0]",
          "IsPresent": true,
          "Next": "RetryFailedItems_2ndPass"
        }
      ],
      "Default": "FinalSuccess"
    },
    "RetryFailedItems_2ndPass": {
      "Type": "Map",
      "InputPath": "$.retryQueue.failedItems",
      "Iterator": {
        "StartAt": "Retry_GenerateRandomValue",
        "States": {
          "Retry_GenerateRandomValue": {
            "Type": "Pass",
            "ResultPath": "$.randomValue",
            "Parameters": {
              "value.$": "States.MathRandom(1, 6)"
            },
            "Next": "Retry_CheckIfShouldFail"
          },
          "Retry_CheckIfShouldFail": {
            "Type": "Choice",
            "Choices": [
              {
                "And": [
                  {
                    "Variable": "$.id",
                    "StringEquals": "B-456"
                  },
                  {
                    "Variable": "$.randomValue.value",
                    "NumericLessThan": 5
                  }
                ],
                "Next": "Retry_MarkAsFailed"
              }
            ],
            "Default": "Retry_MarkAsSuccess"
          },
          "Retry_MarkAsFailed": {
            "Type": "Pass",
            "Parameters": {
              "status": "failed",
              "id.$": "$.id",
              "randomUsed.$": "$.randomValue.value"
            },
            "End": true
          },
          "Retry_MarkAsSuccess": {
            "Type": "Pass",
            "Parameters": {
              "status": "success",
              "id.$": "$.id",
              "randomUsed.$": "$.randomValue.value"
            },
            "End": true
          }
        }
      },
      "ResultPath": "$.secondPassResults",
      "Next": "FilterFinalFailedItems"
    },
    "FilterFinalFailedItems": {
      "Type": "Pass",
      "InputPath": "$.secondPassResults",
      "Parameters": {
        "failedItems.$": "$[?(@.status == 'failed')]"
      },
      "ResultPath": "$.finalFailedItems",
      "Next": "CheckIfFinalFailures"
    },
    "CheckIfFinalFailures": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.finalFailedItems.failedItems[0]",
          "IsPresent": true,
          "Next": "FinalFailure"
        }
      ],
      "Default": "FinalSuccess"
    },
    "FinalFailure": {
      "Type": "Fail",
      "Error": "RetryFailed",
      "Cause": "Some items failed even after retry."
    },
    "FinalSuccess": {
      "Type": "Pass",
      "End": true
    }
  }
}

全体の処理フロー

  1. データ準備: 処理対象となる3つの商品アイテムのリストを作成します。
  2. 1回目の処理 (Map): 3つのアイテムすべてを並列で処理します(最大同時実行数は2)。
    • この処理では、特定のアイテム(B-456)が 4/5 (80%) の確率で 意図的に失敗するように設定されています。
  3. 失敗アイテムの抽出: 1回目の処理結果から、ステータスが “failed” のアイテムだけをフィルタリングして新しいリスト(retryQueue)を作成します。
  4. リトライ判定: “failed” のリスト(retryQueue)が空かどうかをチェックします。
    • 空の場合: すべて成功したので、ワークフローを「成功」で終了します。
    • 空でない場合: 失敗したアイテムがあるので、リトライ処理に進みます。
  5. 2回目の処理 (Retry Map): 失敗したアイテムのリスト(retryQueueだけを対象に、再度 Map 処理を実行します。
    • 1回目とまったく同じロジックが実行されるため、ここでも B-456 は 80% の確率で失敗する可能性があります。
  6. 最終失敗の確認: 2回目の処理結果から、それでもまだ “failed” のアイテムが残っているかフィルタリングします。
  7. 最終判定:
    • 失敗アイテムが残っている場合: リトライしても失敗したため、ワークフロー全体を「失敗」 (FinalFailure) として終了します。
    • 失敗アイテムが残っていない場合: リトライで全て成功したため、ワークフローを「成功」 (FinalSuccess) として終了します。

各ステップの詳細解説

  1. GenerateSampleArray (Pass)

    • 処理の開始点です。products というキーで3つのアイテム(A-123, B-456, C-789)を含む固定の配列を生成します。
  2. ProcessItems_1stPass (Map)

    • $.products(ステップ1で作成した配列)を入力として受け取ります。
    • MaxConcurrency: 2 で、最大2件ずつ並列処理します。
    • Iterator(反復処理): 配列の各アイテムに対して以下のサブ・ワークフローを実行します。
      • GenerateRandomValue: States.MathRandom(1, 6) を使い、1〜5のランダムな整数を生成します。
      • CheckIfShouldFail:
        • もし id が “B-456” かつ randomValue が 5 未満 (つまり 1, 2, 3, 4) ならMarkAsFailed に進みます。
        • それ以外の場合(A-123, C-789、またはB-456で 5 が出た場合)は MarkAsSuccess に進みます。
      • MarkAsFailed / MarkAsSuccess: 処理結果を {"status": "failed", ...} または {"status": "success", ...} という形式で返します。
    • ResultPath: "$.firstPassResults": Map 処理の全結果(全アイテムの “status” オブジェクトの配列)を、元の入力に firstPassResults というキーで追加します。
  3. FilterFailedItems (Pass)

    • InputPath: "$.firstPassResults": ステップ2の結果配列のみを入力とします。
    • Parameters: JSONPath のフィルタ式 $[?(@.status == 'failed')] を使用します。
    • これにより、firstPassResults 配列から status が “failed” のアイテムだけを抽出し、failedItems というキーの新しい配列を作成します。
    • ResultPath: "$.retryQueue": フィルタ結果を retryQueue というキーで格納します。
  4. CheckIfRetryNeeded (Choice)

    • $.retryQueue.failedItems[0](失敗リストの先頭アイテム)が IsPresent(存在するか)をチェックします。
    • 存在する場合 (失敗が1件以上ある): RetryFailedItems_2ndPass に進みます。
    • 存在しない場合 (失敗が0件): FinalSuccess に進み、ワークフローは成功終了します。
  5. RetryFailedItems_2ndPass (Map)

    • InputPath: "$.retryQueue.failedItems": ここが重要です。ステップ3で作成した「失敗したアイテムのリスト」だけを入力として受け取ります。
    • Iterator: サブ・ワークフローは ProcessItems_1stPass と全く同じです。
    • ResultPath: "$.secondPassResults": リトライ処理の結果を secondPassResults に格納します。
  6. FilterFinalFailedItems (Pass)

    • RetryFailedItems_2ndPass の結果($.secondPassResults)に対して、再度 $[?(@.status == 'failed')] フィルタを実行します。
    • ResultPath: "$.finalFailedItems": 2回目も失敗したアイテムのリストを finalFailedItems に格納します。
  7. CheckIfFinalFailures (Choice)

    • $.finalFailedItems.failedItems[0](最終失敗リストの先頭アイテム)が存在するかチェックします。
    • 存在する場合 (リトライ後も失敗が残っている): FinalFailure に進みます。
    • 存在しない場合 (リトライで全て成功した): FinalSuccess に進みます。
  8. FinalFailure (Fail)

    • ワークフロー全体を「失敗」として終了させます。エラー理由は “RetryFailed” となります。
  9. FinalSuccess (Pass)

    • ワークフロー全体を「成功」として終了させます。

グラフビュー

障害時のリドライブ

FinalFailure"Type": "Fail" と定義しているため、リドライブは意味がありません。

まとめ

本記事では、AWS Step Functions の Map ステートにおける2つの異なるリトライ戦略、すなわち「組み込み Retry による全体リトライ」と「カスタムロジックによる個別リトライ」を比較検証しました。

それぞれの特性と、どのような場合にどちらを選択すべきかをまとめます。

1. パターン1:Map ステート全体をリトライ(組み込み Retry

Map ステートに定義された Retry 句を使用する最もシンプルな方法です。

  • メリット:
    • ASL (Amazon States Language) の記述が非常にシンプル。
    • Iterator 内で発生したエラー(Fail ステートやタスクの失敗)を自動でキャッチし、リトライポリシーに従って Map ステート全体を再実行してくれます。
  • デメリット:
    • 冪等性が必須。 Iterator 内で1つでも失敗すると、すでに成功していた他のアイテムもすべて再実行されます。
  • 適したユースケース:
    • Iterator で実行される処理が冪等(べきとう)である(何度実行しても結果が変わらない)場合。
    • 各アイテムの処理コストが低く、全体を再実行するオーバーヘッドが許容できる場合。
  • 障害復旧(リドライブ):
    • MaxAttempts を超えて失敗した場合、Map ステートが Fail となるため、コンソールからのリドライブ(Redrive)が有効です。 リドライブすると、Map ステート全体が最初から再実行されます。

2. パターン2:失敗したアイテムのみをリトライ(カスタムパターン)

Iterator 内ではエラーを発生させず(Fail を使わず)、代わりに処理結果にステータス(”success”https://blog.serverworks.co.jp/”failed”)を含めて返却します。その後、FilterChoice を使って失敗したアイテムだけを抽出し、別の Map ステートで再処理するパターンです。

  • メリット:
    • 効率的。 失敗したアイテムだけをピンポイントでリトライできるため、無駄な再実行が発生しません。
    • 処理が冪等でない場合(例:重複を許容しないDB登録、外部APIコールなど)でも安全にリトライロジックを組めます。
    • 処理コストが高い、または時間がかかる場合に最適です。
  • デメリット:
    • ASL が複雑になります(Map が2つ、FilterChoice が必要)。
    • Iterator 内部で Try-Catch を行うか、タスク側でエラーをハンドリングし、必ず “failed” ステータスを返すように設計する必要があります。
  • 適したユースケース:
    • Iterator で実行される処理が冪等でない場合。
    • 各アイテムの処理コストや処理時間が長く、成功した処理の再実行を避けたい場合。
  • 障害復旧(リドライブ):
    • 今回の設計例のように、リトライしても最終的に失敗したアイテムが残った場合に FinalFailureFail ステート)で終了させると、リドライブは実質的に機能しません。(リドライブしても FinalFailure が再実行されるだけ)。
    • もし手動リドライブも考慮する場合は、FinalFailure ステートを設けず、失敗情報を出力して Pass ステートで終了するなどの工夫が必要です。

結論:どちらを選ぶべきか

Map ステートのリトライ戦略を選択する上で最も重要な判断基準は、Iterator で実行する処理の「冪等性」です。

  • 冪等性がある場合:
    • パターン1(全体リトライ) を推奨します。ASL がシンプルで、Step Functions の標準機能で完結します。
  • 冪等性がない場合(または処理コストが非常に高い場合):
    • パターン2(個別リトライ) を選択する必要があります。実装コストはかかりますが、安全かつ効率的なリトライが実現できます。

ご自身のワークフローの要件(特に冪等性)を明確にした上で、最適なリトライ戦略を選択してください。

この記事が、Step Functions の Map ステート設計の一助となれば幸いです。

余談

心の癒しに、谷川連峰のどこかの山の紅葉の写真です。

山本 哲也 (記事一覧)

カスタマーサクセス部のインフラエンジニア。

山を走るのが趣味です。




Source link

関連記事

コメント

この記事へのコメントはありません。