My Note

自己理解のためのブログ

EventBridge と StepFunctions による ECS Fargate タスクの定期メンテナンス自動化

はじめに

この記事は LITALICO Advent Calendar 2025 の 4日目の記事です。

株式会社 LITALICO で SRE グループに所属しています yhidetoshi です。 今回は 定期的に ECS タスクを入れ替えることについて書きたいと思います。

課題:ECSタスクを定期的に入れ替えたい

※ 前提として、ECS ローリングアップデートでデプロイする場合を想定して記載しています。

弊社では ECS Fargate を利用しています。ECS Fargate を運用していると、以下のように定期的に ECS のタスクを入れ替えたいケースが発生します。

1. メモリの定期的な解放

リリースサイクルが長く、AutoScaling もあまり発生しないプロダクトでは、同じ ECS タスクが長時間起動し続ける傾向にあります。 このような場合、アプリケーションの特性によってはメモリリークがなくとも、定期的にタスクを入れ替えてメモリを解放したいというニーズがあります。

2. AWS Fargate のタスクメンテナンス対応

AWS Fargate は、基盤となるインフラのパッチ適用のために、定期的にタスクのメンテナンス(AWS_ECS_TASK_PATCHING_RETIREMENT)を行います。

You are receiving this notification because AWS Fargate has deployed a new platform version revision...

このメンテナンス自体は AWS によって自動的に行われますが、以下の理由から、能動的に対応したい場合があります。

サービス影響のコントロール

AWS によるメンテナンスの実行タイミングに任せると、サービスアクセスが多い時間帯にタスクの入れ替えが発生する可能性があります。あらかじめ深夜帯などサービス影響の少ない時間帯に、こちら側で再デプロイ(タスク入れ替え)を実行しておくのが無難です。

キャパシティ減少のリスク回避:

AWS_ECS_TASK_PATCHING_RETIREMENT には、以下のドキュメントに記載されているような制約があり、注意が必要です。

docs.aws.amazon.com

Amazon ECSが代替タスクを起動できない場合、停止対象のタスク(retired tasks)は代替なしで停止されます。これにより、サービスの利用可能なキャパシティが減少し、サービス中断を引き起こす可能性があります。」

現状の運用(GitHub Actions)とデメリット

現在、この定期的なタスク入れ替え(force new deployment の実行)を GitHub Actions の schedule (cron) で実行しており、以下のようなデメリットがあります。

1. GHA の cron の属人性:

GHA で cron を設定すると、そのワークフローを定義(または最後に更新)した GitHub ユーザーに紐づいてしまいます。もし、そのユーザーがリポジトリから削除されたり、退職したりすると、スケジュールされていた cron が実行されなくなります。

docs.github.com

2. GHA の実行時間の消費:

GHA は実行時間(分)に基づいて課金されます。 force new deployment を実行する GHA ワークフローは、タスクの入れ替えが完了するまで待機することが多く、実行時間が長くなりがちです。 実環境で計測したところ、1回の定期実行で 2時間20分 も GHA の実行時間を消費していました。

3. 複雑なエラーハンドリングとリトライ処理:

ecs:UpdateService(force new deployment)の API は非同期で実行されるため、API を実行した時点では「デプロイの受付が完了した」だけであり、「デプロイが正常に完了した」わけではありません。

デプロイが本当に成功したか(COMPLETED)、あるいは失敗したか(FAILED)を知るためには、GHA のワークフロー側で ecs:DescribeServices を定期的にポーリング(ループ実行)し、ステータスを確認し続ける必要があります。 GHA でこの「ポーリング」「失敗時のリトライ」といったロジックを実装するのには手間がかかります。 もしデプロイが失敗しても、それを検知できずにワークフローが「成功」として終了してしまう可能性がありました。

やったこと(実装概要)

EventBridge で定期実行(cron)を設定し、ターゲットの StepFunctions を起動します。 StepFunctions は、対象の全 ECS サービスに対して Force new deployment (ecs:UpdateService API の forceNewDeployment=true オプション)を実行します。

また、StepFunctions の実行が失敗した場合は、Datadog でStepFunctions の実行ステータスをモニタリングしているので、 Datadog から Slack にアラートを送ることで実行失敗に気付けるようにしています。

ref. Amazon ECS サービスを更新する

StepFunctions で工夫した点

StepFunctions を設計する上で、特に以下の2点に工夫が必要でした。

1. MapState による並列実行:

弊社のあるプロダクトではマイクロサービスアーキテクチャを採用しており、定期実行の対象となる ECS Service が 30 程度あります。 これらを直列で実行すると全体の完了までに時間がかかってしまうため、MapState を利用して、全サービスに対する force new deployment を並列で実行するようにしました。

2. Payload サイズ制限の回避:

AWS Step Functions には、状態(ステップ)間の入出力 Payload サイズに 256KB の上限があります。

MapState は、並列実行したすべての子ステップの結果を配列として集約して次の状態に渡します。今回のように 30 ものサービスに対して ecs:UpdateService を実行すると、その実行結果(詳細なサービス定義情報を含むJSON)がすべて集約され、配列全体で 256KB の上限を超えてしまいました。

実際に、以下のようなエラーが発生しました。

The state/task 'MapState' returned a result with a size exceeding the maximum number of bytes service limit.

この問題を回避するため、MapState の中で ResultSelector を利用し、API の実行結果から本当に必要な情報だけを抽出して返すようにしました。

実行対象の Service が少なく、MapState の集約結果が 256KB を超えない場合は、この対応は必要ありません。

StepFunctions の定義は以下のようにしました。※ Terraform の一部抜粋

resource "aws_sfn_state_machine" "ecs_refresh_service" {
  name     = "${local.project_name}-${local.stage}-ecs-refresh-service"
  role_arn = aws_iam_role.ecs_refresh_service.arn
  definition = jsonencode(yamldecode(<<EOF
Comment: Refresh multiple ECS Services in parallel
StartAt: MapState
States:
  MapState:
    End: true
    ItemsPath: $.services
    Iterator:
      StartAt: UpdateService
      States:
        UpdateService:
          Next: WaitForDeployment
          Parameters:
            Cluster: ${local.project_name}-${local.stage}-cluster
            ForceNewDeployment: true
            Service.$: $.service
          ResultSelector:
            RolloutState.$: $.Service.Deployments[0].RolloutState
          ResultPath: $.RolloutState
          Resource: arn:aws:states:::aws-sdk:ecs:updateService
          Retry:
            - BackoffRate: 1
              ErrorEquals:
                - States.ALL
              IntervalSeconds: 10
              MaxAttempts: 2
          Type: Task
        CheckDeployment:
          Next: EvaluateStatus
          Parameters:
            Cluster: ${local.project_name}-${local.stage}-cluster
            Services.$: States.Array($.service)
          ResultSelector:
            RolloutState.$: $.Services[0].Deployments[0].RolloutState
          ResultPath: $.DescribeResult
          Resource: arn:aws:states:::aws-sdk:ecs:describeServices
          Retry:
            - BackoffRate: 1
              ErrorEquals:
                - States.ALL
              IntervalSeconds: 10
              MaxAttempts: 1
          Type: Task
        EvaluateStatus:
          Type: Choice
          Choices:
            - Variable: $.DescribeResult.RolloutState
              StringEquals: COMPLETED
              Next: DeploymentSuccess
            - Variable: $.DescribeResult.RolloutState
              StringEquals: IN_PROGRESS
              Next: WaitForDeployment
            - Variable: $.DescribeResult.RolloutState
              StringEquals: FAILED
              Next: DeploymentFailed
          Default: WaitForDeployment
        DeploymentFailed:
          Cause: The ECS service deployment failed or timed out.
          Error: DeploymentFailed
          Type: Fail
        DeploymentSuccess:
          Type: Succeed
        WaitForDeployment:
          Next: CheckDeployment
          Seconds: 300
          Type: Wait
    MaxConcurrency: 40
    Type: Map
EOF
  ))
}

EventBridge の設定:対象サービスリストを渡す

対象とする ECS Service は以下のように JSON を定義して、ペイロードを作成しました。

locals.tf の一部抜粋

locals {
  group_name            = "default"
  eventbridge_rule_name = "${local.stage}-ecs-refresh-service"
  state = {
    # (省略)
    prd   = "ENABLED"
  }
  description               = "ECS Service を再デプロイ(ForceNewDeployment)する"
  flexible_time_window_mode = "OFF"
  schedule_expression = {
    # (省略)
    prd   = "※ cron 式を書く"
  }
  schedule_expression_timezone = "Asia/Tokyo"
  maximum_window_in_minutes    = null
  maximum_event_age_in_seconds = 60
  maximum_retry_attempts       = 1
  target_sfn_arn               = data.terraform_remote_state.ecs_refresh_service.outputs.step_functions_ecs_refresh_arn
  input_message                = <<EOF
  {
      "services": [
        {
          "service": "service-a"
        },
        {
          "service": "service-b"
        },
        {
          "service": "service-c"
        },
        # (省略)
        {
          "service": "service-z"
        }
      ]
  }
  EOF
  }
  • terraform コードの一部抜粋 (eventBridge)
resource "aws_scheduler_schedule" "this" {
  name        = "${local.project_name}-${local.eventbridge_rule_name}"
  group_name  = local.group_name
  state       = local.state[local.stage]
  description = local.description

  flexible_time_window {
    mode                      = local.flexible_time_window_mode
    maximum_window_in_minutes = local.maximum_window_in_minutes
  }
  schedule_expression_timezone = local.schedule_expression_timezone

  schedule_expression = local.schedule_expression[local.stage]

  target {
    arn      = local.target_sfn_arn
    role_arn = aws_iam_role.sfn_role.arn
    input    = local.input_message
    retry_policy {
      maximum_event_age_in_seconds = local.maximum_event_age_in_seconds
      maximum_retry_attempts       = local.maximum_retry_attempts
    }
  }
}

実行結果

指定した Service リストを並列に実行した結果。

安全なデプロイのための注意点

今回のように force new deployment をする場合、現在稼働中の古いタスクを停止し、新しいタスクと入れ替えるローリングアップデートを実行します。 このとき、実行中のリクエスト処理が完了する前にタスクが強制終了してしまうことを防ぐため、ALB ターゲットグループの 「登録解除の遅延 (deregistration delay)」 を適切に設定しておく必要があります。

docs.aws.amazon.com

まとめ

本記事では、ECS Fargate タスクの定期的な入れ替え(メモリ解放や AWS_ECS_TASK_PATCHING_RETIREMENT 対応)という運用課題に対し、AWS のサーバーレスサービスを使って解決した方法を紹介しました。

従来 GitHub Actions の schedule で実行していた運用には、属人性(実行ユーザーの退職リスク)GHA 実行時間の消費(コスト)といったデメリットがありました。

これらを解決するため、EventBridge Scheduler で定期実行し、StepFunctions で force new deployment を実行するアーキテクチャに変更しました。

この構成のメリットは以下の通りです。

  • AWS ネイティブなサービスで完結するため、GHA のような属人性の問題を排除できる。
  • StepFunctions は GHA のランナー時間のような実行時間課金を気にせず、待機時間を含む処理を低コストで任せられる。
  • MapState を利用することで、対象サービスが多数(30以上)あっても効率的に並列実行できる。

実装の工夫として、MapState が返す結果を集約する際の Payload 256KB 制限を ResultSelector で回避する方法も紹介しました。

最後に、最も重要な注意点として、安全なローリングアップデートのためには ALB の 「登録解除の遅延 (deregistration delay)」 をアプリケーションの特性に合わせて適切に設定することが不可欠です。 ECS Fargate の運用をより安定させ、コスト効率を高めるための一例として、この記事がどなたかの参考になれば幸いです。