🤔

バッチの多重起動を防止するために検討した2つの選択肢

に公開

この記事は、Finatext Advent Calendar 2025 の8日目の記事です。


はじめまして!
今年の10月から株式会社Finatextのバックエンドエンジニアとして働いている堀川です。
今日は、僕が入社後に取り組んだ、バッチの排他制御の設計タスクに関してお話します。

事の発端

ある日、ステージング環境で1時間に1回の頻度で実行していたバッチで大量のエラーが発生しました。
テスト用に大量のデータを投入したタイミングでバッチの実行時間が1時間を超過し、外部APIから1000件以上の重複送信エラーが発生していました。

エラーの起きた処理件数的に、本番環境では同様の問題はそうそう起きなさそうではありますが、万が一のケースに備えて排他制御を導入した方がいいんじゃないか、ということでIssueが立てられましたが、優先度は低めに設定されていました。

要は、別に急いでないから期日も特にないということで、入ったばっかりの僕にはちょうどいいタスクだったわけです。

バッチの構成

僕の所属するチームのプロダクトでは、バッチは以下のような構成で実装されていました。

  1. EventBridge → Step Functions: 定期的なバッチ起動
  2. Step Functions → ECS Task: バッチ実行(引数を渡す)
  3. ECS Task → コアサービス: データ処理
  4. 失敗時: Step Functions → EventBridge → SNS → Slack

解決方法の模索

一定のリスクは存在するが簡易的な解決策と、リスクを排除した分工数のかかる解決策の2通りを検討しました。

1. ListExecutions API

Step Functions ワークフロー内で自身の実行状態をチェックする仕組みです。

  • メリット
    • 実装が容易
  • デメリット
    • ListExecutions API は結果整合性を持つ
    • ListExecutions API には呼び出し回数のクォータ(制限)が存在する

簡易的に実現するには手っ取り早いですが、いくつか明確なデメリットが存在します。

まず、結果整合性は「いずれ整合する」が「いつ整合するか」は保証されません。
とはいえ、通常は数ミリ秒から数秒程度で反映されるようです。(公式ドキュメントには具体的なSLAは記載されていませんが)

This operation is eventually consistent. The results are best effort and may not reflect very recent updates and changes.
https://docs.aws.amazon.com/step-functions/latest/apireference/API_ListExecutions.html

しかし、AWS EventBridge は at-least-once(少なくとも1回配信される) という特性を持ちます。
この特性による重複起動はミリ秒単位の差で発生することが多いため、重複起動がクリティカルな問題に発展する場合は危険です。

EventBridge provides at-least-once event delivery to targets
https://aws.amazon.com/jp/eventbridge/features/

そして、もう一つ見逃せないのがAPIの呼び出し制限です。
利用方法次第ではこの制限に引っかかってしまう可能性がある点にも注意しましょう。

具体的なAPIの呼び出し回数制限としては、瞬間的なバーストは秒間100回まで許容しますが、秒間5回ずつしか回復しません。
つまり、基本的には秒間平均で5回しか呼び出せないと考えた方が良いです。

ListExecutions スロットリングトークンバケットサイズ: 100
ListExecutions スロットリングトークンリフィルレート/秒: 5
https://docs.aws.amazon.com/ja_jp/general/latest/gr/step-functions.html

なお、この回数はアカウント全体・リージョンごとの共有制限です。
つまり、単体のバッチの話ではなく、そのリージョンで ListExecutions API を呼ぶすべてのアクションでこの回数は共有されます。
あんまり気軽にこのAPIを呼びまくっていると痛い目を見るかもしれません。

ちなみに、自分のアカウントで ListExecutions API がどれくらい呼ばれているか心配な方は、AWS CloudTrail のイベント履歴で検索するか、Amazon Athena で集計すると秒間の呼び出し頻度を可視化できます。
ListExecutions API に限らず、レートリミットの厳しいAPIを利用する際は、実装前に一度現状をチェックしてみることをお勧めします。

こういったリスクが存在するため、真に厳密な排他制御を行いたい場合は要件に適合しません。
とはいえ、上記のリスクを許容できるような、そこまで厳密性が求められないタスクなどでは、この方法はかなりコスパのいい実現方法になると思います。

2. DynamoDB分散ロック

DynamoDBの条件付き書き込みを利用した排他制御です。

  • メリット
    • 強整合性を保証できる
    • at-least-once 特性の対策になる
  • デメリット
    • 実装工数がそれなりに必要

ListExecutions API に存在したリスクを原理的に排除することが可能です。
アトミックな操作なので同時に複数の実行が行われたとしても、DynamoDBが内部でシリアライズしてくれます。
この性質は、AWS EventBridge の持つ at-least-once 特性への対策にもなります。

具体的な実装方針

1. ListExecutions API

こちらは非常にシンプルな仕組みです。
まずは ListExecutions API をコールするために Amazon State Language 内に以下を実装します。

{
  "Type": "Task",
  "Resource": "arn:aws:states:::aws-sdk:sfn:listExecutions",
  "Parameters": {
    "StateMachineArn.$": "$$.StateMachine.Id",
    "StatusFilter": "RUNNING"
  },
  "ResultSelector": {
    "execCount.$": "States.ArrayLength($.Executions)"
  },
  "Next": "CheckRunningCount"
}

あとは多重起動を防止したいバッチの冒頭で、以下のような処理を追記するだけです。

  1. ListExecutions APIで RUNNING 状態の実行数を取得
    ResultSelector で States.ArrayLength を使うと件数に変換できます
  2. execCount > 1 :処理を中断
  3. execCount <= 1 :通常のバッチ処理を実行

判定条件について

Step Functions の実行が開始された時点で、その実行自体も RUNNING ステータスになります。
そのため、ListExecutions で自分自身を含めた実行一覧を取得し、その配列の長さ(件数)を確認します。

ただし、execCount == 1 と厳密に評価するのは推奨しません。
前述の通り ListExecutions API は結果整合性を持ちます。
そのため、稀に自分自身の実行開始がリストへの反映に間に合わず0件が返ってくる可能性があります。
なので、execCount <= 1 という条件にしています。

ちなみに、execCountが1の場合、それが「自分自身のみが起動している状態」なのか、「自分自身の実行開始は反映されていなく、別のタスクが起動中な状態」なのか厳密には判別できません。

もし後者だった場合、すでに別タスクが動いているのに自分も起動してしまい、重複実行となります。
これが先ほど述べた「厳密な排他制御には向かない(リスクがある)」という点の正体です。

2. DynamoDB分散ロック

DynamoDBのテーブルをバッチのロック管理簿として利用します。
こちらは多少複雑な仕組みなので、まずは設計から行いました。

基本的なテーブル設計

TableName: shared-batch-locks
BillingMode: PAY_PER_REQUEST
KeySchema:
  - AttributeName: batchName  # PrimaryKey: [PROJECT_NAME]_[BATCH_NAME]
    KeyType: HASH

DynamoDBはスキーマレスなので、プライマリーキー以外の情報はこの時点では定義する必要がありません。

基本の実行フロー

  1. バッチ開始時: PutItem with ConditionExpression(条件付き書き込み)
    • 保持データ: batchName(PK), expiresAt
    • 条件: attribute_not_exists(batchName) OR expiresAt < now
  2. 書き込み実行:
    1. 成功: ロック取得成功 → バッチ処理実行
    2. 失敗: ConditionalCheckFailedException → 先行バッチ実行中と判断
  3. バッチ終了時: DeleteItem でロック解放

また、バッチが正常終了しなかった場合、ロックが解放されないと困ります。
デッドロック対策として expiresAt を持たせることで、異常終了時にロックが解放されなかったとしても、有効期限を確認することでそのロックが有効かどうかを判別可能です。

ちなみに、有効期限の代わりにDynamoDBのTTL機能を利用するのは得策ではありません。
DynamoDBのTTLが切れたデータは即時削除されるわけではなく、実際に削除されるまで最大48時間のタイムラグが発生する可能性があります。
そのため、ロックデータの確認のためにはちゃんと有効期限をチェックする必要があります。

一旦これで正常系のシンプルな排他制御はできました。

もう少し詳細な設計を考えてみる

せっかく自分で設計して排他制御するなら、汎用化して他のプロジェクトにも転用できるようにライブラリ化したいなと思いました。
そのために、エッジケースを考慮して以下の機能を実装しようと思いました。

ハートビート機能

ハートビート機能とは、開始されたタスクがロックを正常に保持し続けていることを証明するための機能です。
正常に処理を継続していて何も異常は起きていない状態だったとしても、場合によっては処理時間が長引くことはありえます。

例えば有効期限を60分後に設定していたバッチの処理が長引いて、特に異常は起きていないが処理が終了しないまま60分経過してしまったとします。
その場合、時間経過によって勝手にロックが解除されてしまうと困ります。

暫定的な回避策としては有効期限を長めに設定しておくなどが考えられますが、根本的に解決する場合はハートビート機能が必要になります。

処理フロー

  1. HeartbeatInterval が設定されている場合、ハートビート処理を開始
  2. 定期的に UpdateItem で expiresAt を延長
  3. 連続失敗がN回に達した場合、ハートビートを停止
  4. ハートビート停止時に適切にプロセスを終了

これは、バッチの生存通知メカニズムです。
ロックを保持しているプロセスが、有効期限を迎える前に定期的に更新リクエストを送ることで、徐々に有効期限を延長する仕組みです。

この機能を実装することで、長時間実行される可能性のあるタスクや、所要時間の見積もりが難しいタスクのサポートを実現することができます。

ハートビート機能を実装することで起きる懸念点

  1. プロセスがハングした時に、タスクは起動中だがハートビートは送られてこないので、有効期限が切れて次のタスクが開始されてしまう可能性がある
  2. ハートビートを送る機能は生きているが、実際の処理は進んでいないケース
  3. 無限ループに陥ったときに一生次のタスクが開始できない

こういったケースに対処しようとすると、プロセス監視するような機能も必要になります。

例えば以下のようなことができそうです。

  • 処理の進捗を記録する仕組みを導入し、一定期間進捗がない場合はハートビートが送られていても異常と判断する
  • タスクの最大処理時間を定義し、タイムアウトした場合はエラーとして扱う

あとはプロセス監視の結果、エラー検知時にどのような振る舞いをするのかについても、タスクの特性次第で以下の中から選択できるとよさそうです。

  1. 強制終了(自動復旧):
    アラートを鳴らして異常なタスクを強制終了させ、ロックを解放して次の実行に備える。
  2. 後続優先(可用性重視):
    アラートは鳴らすが強制終了はさせず、ロックだけ解放して次の実行に備える。
  3. 安全停止(整合性重視):
    アラートを鳴らし、エンジニアが調査してエラーを解消するまで、次のタスクは一切開始させない。

結局どうしたのか

ここまで読んで、結局 ListExecutions API と DynamoDB のどっちを採用したのか、と気になった方もいるでしょう。
実は、まだどちらを採用するか決まってません。笑

冒頭に書いた通り、これってあんまり緊急度が高いタスクじゃないんですよね。
2通りパターン考えてみて、「個人的には DynamoDB 推しなんですけどね」って話まではチーム内でしたんですが、ぶっちゃけ現時点では ListExecutions API でも十分だなぁって僕自身も思っています。

何より、他にもいろいろとタスクがある状況で、もはや ListExecutions API の実装を行うことすら後回しになっている現状があります。

とはいえタスクを積みっぱなしにしているのも気持ち悪いので、どこかで手の空いたタイミングでとりあえず ListExecutions API の実装だけはパパッと入れてしまおうかな、そのあとで更に暇な時間があれば、趣味がてら DynamoDB のライブラリ作ってみようかな、なんて今は考えています。

まとめ

前職にいたときからそうでしたが、規模の大小を問わず、やっぱり設計してる時間が一番楽しいなって思いました。

ただ、いろいろな点を考慮して抜け漏れのない緻密な設計をするのも大事ですが、工数/コスト/リソースとのトレードオフも非常に大切です。
なので、実際に採用するとなったタイミングでは、一旦冷静になって客観的にどの選択肢が最も優れているのかしっかり考えないといけないな、とも感じました。
それがどれだけ手塩にかけて育てた可愛い設計だとしても、YAGNI(You Aren't Gonna Need It)の原則は忘れちゃいけません。

また、どれを選ぶか悩めるのは、ちゃんと複数の実現可能性を検討したからです。
ちゃんと正しく悩むためにも、常に複数の解法を自分の中に用意することは大切だな、と記事を書くために振り返ってみて改めて感じました。

結局僕の設計した DynamoDB による排他制御ライブラリはまだ形になっていないのですが、設計は結構しっかり考えたつもりです。
この設計が誰かの役に立つといいなと思っているので、よかったら参考にしてもらえたら嬉しいです。

さいごに

Finatextでは一緒に働く仲間を募集中です!
https://herp.careers/v1/finatexthd/iRI8okPMACP_

カジュアル面談も絶賛募集中です!
https://herp.careers/v1/finatexthd/vZWzSlI_B-qk

ここまで読んでくださり、ありがとうございました!

Finatext Tech Blog

Discussion