Loading
BLOG 開発者ブログ

2023年12月7日

【Step Functions なし】失敗した ECS RunTask を自動でリトライする

Amazon EventBridge で設定したスケジュールから Amazon ECS のタスクを AWS Fargate で起動する場合、
AWS 基盤側のキャパシティ不足などの理由により起動が失敗した場合に自動でリトライすることができません。

以下は、起動失敗時に CloudTrail の RunTask イベントログから確認できるエラーの一例です。

Capacity is unavailable at this time. Please try again later or in a different availability zone

参考: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-event-messages.html

手動再実行を除いたこの問題に対する対策としては

  • EC2 起動タイプを使用して、キャパシティをあらかじめ確保する
  • Step Functions を使用して自動でリトライできるようにする

などが考えられますが、EC2 起動タイプでは EC2 に対する管理・運用コストが発生しますし、
リトライのために Step Functions を導入するというのも腰が重い(個人の感想)です。

そこで今回の記事では、第3のソリューションとして
Amazon CloudWatch Logs と AWS Lambda を組み合わせた方式をご紹介したいと思います。

目次

はじめに

こんにちは。クラウドソリューション第一グループの namiki.t です。

この記事は アイソルート Advent Calendar 2023 7日目の記事です。

冒頭の事象は、私が参画している案件で実際に遭遇したものです。
起動する AZ は ap-northeast-1aap-northeast-1c を指定していましたが、
日本時間の 0:00 や 9:00 など、所謂バッチ処理の需要が高そうな時間帯で複数の起動失敗エラーが発生していました。

単純に起動する時間帯を変えるのも一つの手ですが、それでも根本解決にはならないので
本記事ではそのソリューションの一つをご紹介します。

前提事項

リトライ方式構築後の全体像(概略)は以下の通りです。

本記事では、主に方式のポイントとなる以下について言及します。

  • Lambda 関数コード
  • Lambda 関数用 IAM ポリシー・ロール
  • CloudWatch Logs サブスクリプションフィルター

そのため、以下については言及せず、既に構築済であることを前提とします。

  • ECS タスクをスケジュール起動するための各種設定 (EventBridge, ECS, VPC, IAM, etc…)
  • CloudTrail のイベントログを CloudWatch Logs に送信するための設定

構築手順

はじめに、この後作成する Lambda 関数にアタッチする IAM ポリシーとロールを作成します。

ポリシーには、EventBridge のスケジュールルールにアタッチしている iam:PassRoleecs:RunTask を追加します。
今回は、Lambda 動作確認ログを確認したいため logs のアクションも含めますが、リトライの動作上は必須ではありません。

ポリシー名を retry-run-task-policy、それをアタッチするロール名を retry-run-task-role とします。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "iam:PassRole",
            "Resource": [
                "${タスク実行ロールの ARN}",
                "${タスクロールの ARN}"
            ],
            "Effect": "Allow"
        },
        {
            "Action": "ecs:RunTask",
            "Resource": "${起動するタスク定義の ARN}",
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:${リージョン}:${AWS アカウント ID}:log-group:/aws/lambda/retry-run-task:*",
            "Effect": "Allow"
        }
    ]
}

${} の部分はお使いの環境の値に書き換えてください。

次に、RunTask のリトライリクエストを行う Lambda 関数を作成します。
関数名は retry-run-task、ランタイムは Node.js 20.x、IAM ロールは先ほど作成した retry-run-task-role を選択します。


[関数の作成] 押下後、index.mjs に記述するコードは以下の通りです。

import { gunzipSync } from "zlib";
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";

/**
 * @see https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/SubscriptionFilters.html#LambdaFunctionExample
 */
const parseLogEvents = (event) => {
  const compressedBuffer = Buffer.from(event.awslogs.data, "base64");
  const uncompressedBuffer = gunzipSync(compressedBuffer);
  const uncompressedString = uncompressedBuffer.toString("utf-8");
  const logData = JSON.parse(uncompressedString);
  const { logEvents } = logData;
  return { logEvents };
};

/**
 * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/ecs/command/RunTaskCommand/
 */
const executeRunTask = (input) => {
  const client = new ECSClient();
  const command = new RunTaskCommand(input);
  try {
    return client.send(command);
  } catch (e) {
    console.error(e);
    throw e;
  } finally {
    client.destroy();
  }
};

export const handler = async (event) => {
  const { logEvents } = parseLogEvents(event);

  await Promise.allSettled(
    logEvents.map(({ message }) => {
      const { eventTime, requestParameters } = JSON.parse(message);
      const {
        taskDefinition,
        cluster,
        count,
        launchType,
        networkConfiguration,
      } = requestParameters;

      console.info(`${eventTime} に ${taskDefinition} の起動が失敗しました`);

      return executeRunTask({
        taskDefinition,
        cluster,
        count,
        launchType,
        networkConfiguration,
      });
    })
  );

  return;
};

仮にこの Lambda 関数で RunTask が失敗してもその失敗の CloudTrail イベントログから再びこの Lambda 関数が呼び出されるため、コード上では RunTask のリトライは行いません。

最後に、CloudTrail のイベントログが送信される CloudWatch Logs ロググループに Lambda サブスクリプションフィルターを設定します。


Lambda 関数: retry-run-task
ログ形式: Amazon CloudTrail
サブスクリプションフィルターのパターン※: { ($.eventName = "RunTask") && ($.responseElements.tasks[0] NOT EXISTS) }
サブスクリプションフィルター名: RunTaskFailureFilter

※直感的には $.responseElements.tasks[0] NOT EXISTS の部分を $.responseElements.failures[0] EXISTS としたいところですが、NOT なしの EXISTS 構文がサポートされていないためこのようにパターンを指定しています。

入力が完了したら、ページ下部の [ストリーミングを開始] を押下します。

動作確認

RunTask 失敗時の CloudTrail ログを使用して手動でログストリーム・ログイベントを作成し、RunTask がリトライされることを確認します。

以下の ${} の部分を有効な値に書き換えて、ログイベントを作成してください。

{
  "eventTime": "2023-12-04T13:00:00Z",
  "eventName": "RunTask",
  "requestParameters": {
    "count": 1,
    "launchType": "FARGATE",
    "networkConfiguration": {
      "awsvpcConfiguration": {
        "securityGroups": [
          "${SecurityGroupId}"
        ],
        "subnets": [
          "${SubnetId1}",
          "${SubnetId2}"
        ]
      }
    },
    "cluster": "${ClusterArn}",
    "taskDefinition": "${TaskDefinition}"
  },
  "responseElements": {
    "failures": [
      {
        "reason": "Capacity is unavailable at this time. Please try again later or in a different availability zone"
      }
    ],
    "tasks": []
  }
}

ログイベント作成後、retry-run-task による RunTask の実行が CloudTrail 上のログから確認できます。
ここでは割愛しますが、retry-run-task と ECS タスクのログも確認できるはずです。

終わりに

今回は、Step Functions や EC2 起動タイプを使わずに、失敗した ECS RunTask を自動でリトライする方法をご紹介しました。
私のように ECS タスクのリトライに Step Functions も EC2 も使いたくない!という方に本記事が届きますように。

namiki.tのブログ