Scheduler Agent Supervisor パターンを構築してみる

こんにちは、GxPの平山です。
この記事はグロースエクスパートナーズ Advent Calendar 2023の18日目です。

https://qiita.com/advent-calendar/2023/gxp

例年、内容が長めになってしまい反省しているのですが、今回も無駄に長いかもしれません…なるべくシンプルにまとめたいと思います。

# はじめに

GxPにジョインして以降、バックエンドの開発、保守、運用を行っておりますが、今年は特にバッチ処理系の障害に苦しめられた1年だったなと思いました。
メール通知系のバッチアプリの予期せぬダウンにより、処理がスタックしてしまったり、逆に同じメールを何度も通知してしまったり…。
障害の復旧作業、改善を行いつつ、設計の重要性、難しさを改めて痛感しました。

救いを求めて（？）インターネットをさまよっていたところ、MSのドキュメントにて[バックグランドジョブに関するガイダンス](https://learn.microsoft.com/ja-jp/azure/architecture/best-practices/background-jobs)が公開されており、非常にためになる情報がまとめられていました。

今回はこちらで紹介されていた「Scheduler Agent Supervisor パターン」について、実際に自分で構築してみて得た学び、感じたことを紹介したいと思います。

# Scheduler Agent Supervisor パターン

* バックグラウンドジョブに関するデザインパターンで、タスク内の処理（アクション）に失敗した場合に、復旧、是正といった操作を自動で行う。
* Scheduler、Agent、Supervisorと呼ばれるアクターによって構成されており、それぞれ以下の様な役割で動作する。
    * Scheduler … タスクを構成する手順（アクション）の順序などを調整する。ある手順にリモート サービスまたはリソースへのアクセスが必要な場合、Scheduler は非同期/応答パターンによって Agent を呼び出す。
    * Agent … リモートサービスの呼び出し、またはタスク内の手順によって参照されるリモート リソースへのアクセスをカプセル化する。
    * Supervisor … Scheduler によって実行されているタスク内の手順の状態を定期的に監視し、いずれかの手順が失敗した場合に、その手順を復旧するか、適切な是正アクションを実行する。
* 状態ストアにタスクの進行状況、各手順の状態に関する情報を保持しておき、Schedulerによる手順の調整、Supervisorのタスクの状況管理、復旧に使用する。

詳しい解説は以下のMSのページにありますので、是非ご覧ください。

https://learn.microsoft.com/ja-jp/azure/architecture/patterns/scheduler-agent-supervisor

今回はこちらの記事にある例（eコマースシステムの注文に対するバッググラウンドジョブ）を実装、構築してみようと思います。

# システム構成

記事にあるシステム構成のうち、赤枠の範囲（タスクの実行とSupervisorによる状態ストア監視）をAzure上で構築してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/2a159b71-9341-262c-89f4-771aef984edf.png)

各アプリについては、[Azure Container Apps ジョブ](https://learn.microsoft.com/ja-jp/azure/container-apps/jobs?tabs=azure-cli)としてデプロイしました。
役割に応じて、スケジュールトリガーとイベントトリガーを使用して構築しました。

状態ストアはAzure SQL Database を使用し、メッセージキューはQueue Storageを使用しました。

# 処理フロー

SchedulerとAgentによるタスクの処理は以下の流れで進みます。

　①Schedulerによるタスクの起動
　②Agentによるリモートサービス呼び出し
　③Schedulerによる処理完了の記録

``` mermaid
sequenceDiagram
    participant sw as [Application]<br>Scheduler
    participant db as [DB]<br>StateStore
    participant reqQ as [Queue]<br>RequestQueue
    participant resQ as [Queue]<br>ResponseQueue

    participant a as [Application]<br>Agent 
    participant svc as RemoteService 

    %% ①Scheduler First Step
    sw->>db: 状態ストアから保留中(0)<br>のレコードを取得
    db-->>sw: 
    sw->>reqQ: リモートサービス呼び出し<br>をAgentに依頼（Enqueue）
    reqQ-->>sw: 

    %% ②Agent Working
    a->>reqQ: タスクの依頼を受け取る<br>（Depueue）
    reqQ->>a: 
    a->>svc: リモートサービス呼び出し（Mock）<br> 注文ID が3の倍数の場合、失敗
    svc-->>a: 
    a->>resQ: 成功結果を返信<br>（Enqueue）
    resQ-->>a: 

    %% ③Scheduler Closing Step
    sw->>resQ: 成功結果を受信（Dequeue）
    resQ-->>sw: 
    sw->>db: 成功した注文IDのレコードの<br>ステータスを処理済(10)に更新
    db-->>sw: 
```

今回はSupervisorによる復旧、エラー処理を試したいので、注文ID（1以上の整数）が3の倍数の場合、リモートサービスが~~アホになって~~失敗するしくみにしています。

また上記とは別に、Supervisor が定期的に状態ストアの監視、復旧・停止処理 を行います。
失敗が3回に達した時点で、ステータスを「20.エラー」に更新し、処理を完全に中断させます。

``` mermaid
sequenceDiagram
    participant sp as [App]<br>Supervisor
    participant db as [DB]<br>StateStore

    %% Supervisor
    sp->>db: 実行中のレコードを取得
    db-->>sp: 
    alt 完了予定時間を過ぎている 
    sp->>sp: 失敗カウント再計算（取得したレコードの失敗カウント+1）
    alt 失敗回数が3回未満
    sp->>db: 状態レコード更新（失敗カウント+1, ステータスを保留中(0)に設定）
    db-->>sp: 
    else 失敗回数が3回以上
    sp->>db: 状態レコード更新（失敗カウント+1, ステータスをエラー(20)に設定）
    db-->>sp: 
    end
    end
```

# プログラム

こちらで公開しております。

https://github.com/tkohirayama/scheduler-agent-supervisor-sample

詳細については、後日Readmeにまとめたいと思います。

# 動かしてみる

実際に動かして、状態ストア（DB）、メッセージキュー（Queue Storage）の状態を順番に確認します。

## [事前準備]状態ストアにレコード追加

Scheduler起動のため、状態ストアにレコードを手動で記録しています。
レコードのステータスは「0.保留中」です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/bcbeef80-dd14-6562-c470-4970a367f589.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/87324eae-86f4-749b-e7f7-f20cfe1376a1.png)

## ①Schedulerによるタスクの起動

「0.保留中」のレコードが、「1.処理中」になり、処理が開始されます。
CompleteByに完了期限を設定しています。
LockedByには処理を実行するSchedulerのインスタンス IDが設定されます（今回は1環境しかないため固定値を設定）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/7e335b8a-9e47-4fab-73d9-3fcae56950f7.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/3d75ca62-0fe4-ee1e-9078-ff110a2d01e7.png)

Agent呼び出しのため、Queue Storageにメッセージが登録されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/0ade5019-755b-280e-aaf7-3227915a92f7.png)


## ②Agentによるリモートサービス呼び出し

Schedulerからの処理要求に対して処理を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/403987b9-9529-cb7e-0b86-9e17e6b0dbfd.png)

処理に成功した注文IDに関するメッセージがQueueに登録されます。
リモートサービスの呼び出しに失敗したものについては、特に何もしません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/12b2b8ea-1c25-5387-2057-9bf83ee8fe4d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/1913b6cb-42e1-3816-6615-361bf507826b.png)

この時点では状態ストアへの変更なども通知されません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/3014e1d7-7aa3-7d9e-e6ed-1259a7fcde3c.png)


## ③Schedulerによる処理完了の記録

Agentから正常終了した注文IDが返却されるので、状態ストアのレコードのステータスを「10.処理済」に更新します。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/4575567c-72a7-9548-dab9-61784a9ffd07.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/df7307da-36fb-3b56-7ab5-b9b02486d3fa.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/5e5f4d3a-5627-047f-e4a1-c1a7419d4499.png)


## Ⓧ Supervisorによる状態ストアの監視、復旧・停止処理

「1.処理中」ステータスのレコードのうち、完了期限をすぎたものがSupervisorによってステータス「0.保留中」に更新されます。
次回のScheduler起動時に、上記更新したレコードに対して再度処理が起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/46d3fc85-9734-a10d-9151-a45c28cd69ee.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/58a7012b-cdb0-457a-e4b1-bc9e7d09ac68.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/a18b3ccd-96f4-afe6-61b7-a45759e599ba.png)

以降は同様の流れで、3回失敗するまでリトライが実行されます。
今回はリモートサービスが失敗を続けるため、最終的にSupervisorがステータスを「20.エラー」に更新し、Schedulerによる起動の対象外となります。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/e5d296f0-8a97-4fba-594d-81b1cb5f935f.png)

# 実際に作ってみて

* Supervisor で復旧をコントロールできるため、都合の良いタイミングや内容での復旧を実現でき、信頼性の向上を図れると感じました。
* 今回は単純なリトライのみの検証でしたが、より複雑なタスクについては、途中からの再実行であったり、一部操作の切り戻しなどを、Supervisorを起点としてより柔軟に実装できると感じました。
* 再実行に伴って、同じ操作を再度実行することになった場合に、結果が異なることのないよう、やはり冪等（同じ処理を何度行っても、最終的な結果が同じとなる）であることが重要だと感じました。
* メール送信のような、同じ処理を再実行してしまうことが逆に困る様なケースについては、成功した結果の記録をこまめに行うことや、ログを詳細に記録しておくこと、可能であれば重複除去ロジックを組み込むといった対策が重要だなと感じました。
    * 冒頭で話した障害に関しては、実際にログを詳細に残す改修を行い、復旧時の余分な再送信を防ぐことができるようになりました。


# まとめ

今回はMSのバックグラウンドジョブのガイドラインに掲載されていたScheduler Agent Supervisorパターンを構築してみました。
システムを設計する上で、目的を達成することに意識がいきがちですが、運用していくなかで障害が発生することは必ずといってよいほど発生します。
昨今のシステム開発では分散アーキテクチャを構築することも多く、ネットワークやリモートサービスなど様々な要因で予期せぬエラーが起こりうる可能性があります。

エラーが起きることを前提に、どのように復旧するかを設計時に考慮できているかどうかで、システムの信頼性が高まるのだと改めて感じました。