はじめに

アプリケーションの実行時に他のサービスをコールすることで処理を移譲することがあります。
よくある例の一つとしては Web API の呼び出しがあります。この際に、ネットワークや当該 Web API の障害などにより期待するレスポンスが得られないことが発生する可能性があります。
呼び出し側としては呼び出しのリトライを実装することでこのような状況においてもなるべく最善のレスポンスを得られるように努めます。この際、リトライを闇雲に行うのではなく「指数バックオフアルゴリズム」を用いることがプラクティスとして提唱されています。

任意の自身で実装したアプリケーション内でこのアルゴリズムを実装する手法もありますが、今回は AWS Step Functions を用いて実装したいと思います。

AWS Step Functions とは

AWS の様々なサービス上に実装したアプリケーション群を連結させて一連のワークフローとして稼働させることを可能とするサービスです。
ZapierIFTTT と似たようなものだなと個人的には感じています。

AWS Step Functions による実装

1. AWS Lambda の実装

「30%の割合で成功。60%の割合で失敗。10%の割合で20秒スリープする」という要件の Node.js ランタイムで走る Lambda 関数を実装しました。また、のちの Step Functions でのタイムアウトの有効性を明確にするために、当 Lambda 関数のタイムアウトは10秒として設定しています。

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

export const handler = async (event) => {

    // 0から99までのランダムな整数を生成
    const random = Math.floor(Math.random() * 100);

    if (random < 30) {
        // 30%の確率で成功
        console.log("Success!");
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: "Success!"
            })
        };
    } else if (random < 90) {
        // 60%の確率で失敗
        console.log("Failure!");
        throw new Error("Failure!");
    } else {
        // 10%の確率で20秒スリープ
        console.log("Sleeping for 20 seconds...");
        await sleep(20000);
        console.log("Woke up after 20 seconds");
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: "Slept for 20 seconds!"
            })
        };
    }
};

2. AWS Step Functions の実装

以下の内容を適用してステートマシンを作成します。

{
  "StartAt": "InvokeLambda",
  "States": {
    "InvokeLambda": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "your-lambda-function",
        "Payload": {
          "Input.$": "$"
        }
      },
      "TimeoutSeconds": 5,
      "Retry": [
        {
          "ErrorEquals": ["States.ALL"],
          "IntervalSeconds": 5,
          "MaxAttempts": 3,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "Failure"
        }
      ],
      "End": true
    },
    "Failure": {
      "Type": "Fail",
      "Error": "LambdaTimeoutOrFailure",
      "Cause": "Lambda function timed out or failed after 3 retries."
    }
  }
}

構成内容:

Lambda関数の実行 (InvokeLambda):

  • Resource: 指定した Lambda 関数を実行します。FunctionName に実行したい Lambda 関数(ここでは前項で作成した Lambda 関数)の ARN を指定します。
  • TimeoutSeconds: 5 で、Lambda 関数が5秒以上かかった場合に自動的にタイムアウトとみなされます。
  • Retry: Lambda が失敗した場合は5秒の間隔で最大3度リトライします。リトライの間隔は指数関数的に増加し(BackoffRate: 2.0)、1度目は5秒、2度目は10秒、3度目は20秒になります。

失敗時の処理 (Catch):

  • 3度のリトライ後もLambda関数が失敗した場合、もしくはタイムアウトが発生した場合、Failure ステートに移行します。
  • Failure ステートでは、エラーメッセージが記録されてステートマシンが失敗として終了します。

追加情報:

  • TimeoutSeconds: 30秒以上かかるとタイムアウトで失敗します。
  • Retry: すべてのエラー(States.ALL)に対してリトライが設定されています。リトライは3度まで許容し、それ以上の失敗が発生した場合は処理が中断されます。

実行結果例

実装したステートマシンの「実行を開始」することでワークフローが走ります。
以下に、指数バックオフアルゴリズムによる稼働がわかりやすいものおよびタイムアウトの稼働がわかりやすいものを実例として示します。
グラフビューで最終状態が、イベントビューによって処理の流れがわかりやすく表示されています。

4度とも失敗

前処理失敗からリトライ開始までの時間が下記のように指数関数的に延びているのがわかります。

  • 1度目の実行失敗後から1度目のリトライまでの間隔が5秒
  • 1度目のリトライ失敗後から2度目のリトライまでの間隔が10秒
  • 2度目のリトライ失敗後から3度目のリトライまでの間隔が20秒

1度タイムアウト後に2度目が成功

タイムアウトまでに5秒かかっているのがわかります。

おわりに

AWS Step Functions におけるリトライおよびその際の指数バックオフ設定はは ECS タスクの実行においても設定できるようです。
API コールの箇所をすべて AWS Step Functions によるこのような実装を行う必要はないと思います。管理のしやすさ、Lambda 関数や ECS タスクの起動オーバーヘッドなども考慮に入れ適切なアーキテクチャを設計して実装してみてください。