/note/tech

3回つくってわかった、
定期実行ジョブ基盤の本質情報

要約:

■ 1. 業務アプリケーションと定期ジョブ

  • 業務アプリケーションに必要な定期ジョブ:
    • ビジネスは周期で回っており、取引は継続的に発生する
    • 業務期間は日次、週次、月次、年次で繰り返す
    • データは集計、突合、確定が必要になる
    • システム間でデータの交換と同期が発生する
    • 定期ジョブは業務プロセスから生まれる
  • Railsでの定期ジョブ定義:
    • Rails 8とSolid Queueを使えば、ジョブクラスを定義してrecurring.ymlにスケジュールを書くだけで済む
    • 例として、DailyReportJobを「every day at 2am」で実行する設定がある
  • 実際のジョブはワークフロー:
    • 業務プロセスは通常、複数のステップで構成される
    • 外部API呼び出し、データ取得と検証、変換と保存、別サービス呼び出し、レポート生成や通知という流れになる
    • 業務プロセスは単一のジョブではなくワークフローである

■ 2. スケジューリングより難しい運用

  • 運用上の問い:
    • 前回の実行がまだ終わっていない場合にどうするか
    • ジョブが途中で失敗した場合にどうするか
    • 安全に再実行できるか
    • 何が実行中で何が失敗したかを確認できるか
    • 問題発生時に誰へ通知するか
  • 定期ジョブにはスケジューラ以上のものが必要

■ 3. IVRyでのSidekiq運用と問題

  • IVRyでのSidekiqの使い方:
    • 電話着信をトリガーとするイベント駆動の非同期処理にSidekiq Proを使用していた
    • ユーザー起点のジョブとスケジュールジョブの両方をSidekiqで実行していた
    • 性質の異なるワークロードが同じワーカーを共有していた
  • 実際に起きた問題:
    • ケース1: 月末月初に定期ジョブがユーザー起点のジョブを遅延させた
    • ケース2: 定期ジョブが前回の実行完了前に開始されることがあった
    • ケース3: ジョブ失敗時に再実行して安全か判断できなかった
  • ジョブクラスが知るのは「何を」だけ:
    • ジョブクラスは何をするかは知っている
    • いつ実行すべきか、別の実行が進行中か、前回何が起きたか、再実行して安全かは知らない
    • スケジュールジョブには実行管理が必要

■ 4. 非同期ジョブとスケジュールジョブの違い

  • スケジュールジョブはタイマー付きの非同期ジョブではない
  • 起動契機:
    • 非同期ジョブはイベント駆動、スケジュールジョブは時間駆動
  • 処理単位:
    • 非同期ジョブは単一タスク、スケジュールジョブは逐次的な複数ステップ
  • 同時実行:
    • 非同期ジョブは多数を同時に実行し、スケジュールジョブは重複なしで1つのみ実行する
  • 実行時間:
    • 非同期ジョブは数秒から数分、スケジュールジョブは数分から数時間
  • リトライ:
    • 非同期ジョブはジョブ全体を再試行し、スケジュールジョブは失敗したステップを再試行する
  • 履歴:
    • 非同期ジョブでは任意、スケジュールジョブでは必須

■ 5. 3度構築したジョブ基盤

  • Kuroko(Cookpad、2012年):
    • RabbitMQ、ワークフローエンジン、EC2による分散アーキテクチャ
    • 当時は自分も若く、AWSも若かった
  • Kuroko2(Cookpad、2014年):
    • リレーショナルデータベースを中心としたモノリシックアーキテクチャ
    • 状態管理をシンプルに保つためにモノリシック構成を採用した
  • IVRy Job Scheduler(IVRy、2026年):
    • AWSマネージドサービスのみで構築したサーバーレスアーキテクチャ
    • 運用負荷が小さい
  • 各基盤の構成要素:
    • Kurokoはrufus-scheduler、Ruote、EC2ワーカー、Fluentd、独自コンソールで構成した
    • Kuroko2は独自スケジューラ、独自ワークフロー、独自エグゼキュータ、CloudWatch Logs、独自コンソールで構成した
    • IVRy Job SchedulerはEventBridge Scheduler、Step Functions、ECS/Lambda、CloudWatch LogsとDatadog、独自コンソールで構成した
    • 技術が変わっても責務は変わらなかった
  • 繰り返し現れる5つの構成要素:
    • スケジューラ、ワークフローエンジン、エグゼキュータ、ログ、コンソール

■ 6. 開発者の意図と現実のギャップ

  • シンプルな意図と複雑な現実:
    • 開発者が望むのは「毎日午前2時にこのジョブを実行する」ことだけである
    • その後、どこで実行するか、二重実行時はどうするか、スケジュールの責任者は誰かという問いが生じる
    • 失敗時の対応、リトライの有無と回数、諦めるタイミングも問題になる
    • 状態、ログの所在、昨夜の出来事、復旧方法、全体か一部ステップの再実行か、通知先も問われる
    • このギャップがアプリケーション開発者の認知負荷を生む
  • 認知負荷と開発者体験:
    • DevExフレームワークはフロー状態、フィードバックループ、認知負荷を3つの中核次元としている
    • 出典はNodaらの「DevEx: What Actually Drives Productivity」(ACM Queue、2023年)
    • 認知負荷はDevExを損なう
  • プラットフォームとしてのスケジュールジョブ基盤:
    • スケジュールジョブ基盤はプラットフォームとして構築すべきである
    • プラットフォームエンジニアリングが意図と現実の橋渡しをする
    • プラットフォームはワークロード、スケジューリング、状態、並行性、リトライ、タイムアウト、ログ、復旧、通知を扱う
    • 開発者は意図を表現し、現実はプラットフォームが処理する

■ 7. IVRy Job Schedulerでのジョブ定義

  • ジョブ作成の3ステップ:
    • 通常のSidekiqジョブを書く
    • DSLでジョブを定義する
    • プルリクエストを作成してマージする
  • 通常のSidekiqジョブ:
    • アプリケーション開発者は既存のSidekiqジョブを再利用し、Railsの中で作業を続けられる
  • DSLによる定義:
    • 開発者はJsonnetで意図を記述する
    • ステップとしてSidekiqTask、スケジュールとしてcron式、実行ポリシーとしてSKIP_IF_RUNNINGなどを指定する
  • プルリクエストのマージによる反映:
    • Jsonnetのジョブ定義からJSONを生成し、TerraformでAWSリソースに適用する
    • 同時にジョブメタデータのJSONを生成し、Webコンソールに取り込む
    • 定義をマージするとプラットフォームが更新される

■ 8. 3つの問題と3つの責務

  • 実際に起きた問題(再掲):
    • ケース1: 月末月初に定期ジョブがユーザー起点のジョブを遅延させた
    • ケース2: 定期ジョブが前回の実行完了前に開始されることがあった
    • ケース3: ジョブ失敗時に再実行して安全か判断できなかった
  • ケース1への対応:
    • 定期ジョブがユーザー向けジョブを遅延させた問題に対し、ワークロードを分離する
  • ケース2への対応:
    • 前回の実行完了前にジョブが開始された問題に対し、実行の重複を防止する
  • ケース3への対応:
    • 失敗ジョブの再実行可否が判断できなかった問題に対し、失敗を可視化し復旧を容易にする

■ 9. ワークロードの分離

  • スケジュールジョブの実行経路:
    • EventBridge SchedulerがStep Functionsを起動し、ECS Fargate上のSidekiq RunnerをRun Taskで実行する
    • Sidekiq RunnerがSidekiqジョブを呼び出す
    • RunnerのイメージはGitHubのCI/CDでECRに登録する
    • プラットフォームは既存のSidekiqジョブを薄いランナー経由で実行する
  • 薄いランナー:
    • ランナーはRailsをロードし、既存のSidekiqジョブを直接実行する
    • 引数で受け取ったジョブクラス名からクラスを取得し、perform_inlineで実行する

■ 10. 実行の重複防止

  • 数行の意図から完全なワークフローへの展開:
    • 短いDSL定義が、数百行のワークフローJSONに展開される
    • 展開後のJSONには状態チェック、リトライ、実行追跡が含まれる
  • 展開されたワークフローの主な処理:
    • DynamoDBからジョブ状態を取得し、実行ポリシーと前回の状態に基づいて実行かスキップかを判定する
    • 実行ポリシーにはALWAYS_RUN、SKIP_IF_RUNNING、SKIP_IF_FAILED、SKIP_IF_RUNNING_OR_FAILEDがある
    • 前回の状態としてRUNNING、FAILED、TIMED_OUT、ABORTEDを判定に用いる
    • 実行時はRUNNING状態を書き込み、Datadogのトレースコンテキストを準備してECSタスクを起動する
    • DynamoDBやECSの一時的なエラーにはバックオフ付きのリトライを設定している
  • 実行後の処理:
    • 実行結果に応じてジョブ状態と実行履歴を更新し、スキップ時はスキップ履歴を記録する
    • タスク起動失敗などのインフラ起因の失敗は、回数制限付きで元の実行をredriveする
    • 失敗、タイムアウト、中断の場合はSNSで失敗通知を送信する

■ 11. 失敗の可視化と容易な復旧

  • Slackへの失敗通知:
    • 失敗したジョブはSlackに通知される
  • ポータルからの復旧:
    • 運用者はアラートから失敗した実行をポータルで開く
  • ポータルでのジョブ運用:
    • ポータルには誰でもジョブを運用するために必要なものが揃っている

■ 12. 3度の構築から得た教訓

  • 学んだこと:
    • スケジュールジョブには固有の運用上の課題がある
    • 開発者が望むことと信頼性の高い実行に必要なことの間にはギャップがある
    • プラットフォームがそのギャップを埋められる
  • スケジュールジョブの運用が苦しくなっているなら、プラットフォームの構築を検討すべきである
  • まとめ:
    • どのドメインにも固有の複雑さがある
    • その複雑さがアーキテクチャを形作る
    • プラットフォームは開発者の意図を動くシステムへと変える