ゆっくりAPIにリクエストを投げる仕組みをDurable Functionで作った。

Microsoft Azure が提供するサーバーレスアーキテクチャのAzure Functions にDurable Functionsという拡張が追加された。これは、状態を持った関数を定義できるようになり、それを使うことで同期・非同期に関数を実行することができる仕組みである。公開当初はC#のみがサポートされていたが、最近になってJavaScript(Node.js)もサポートされるようになった。

先日、JavaScriptでDurable Functionsを利用した関数を使って、外部APIにリクエストを投げる仕組みを作ったので、その解説をします。

背景

諸般の事情により、だいぶ暈した書き方になりますが、この仕組を作るに至る背景について書きます。

現在開発しているサービスでは、とある外部APIを利用しています。時折、このAPIに多くのリクエストを送らなければならないことがあるのですが、利用開始した頃に実施したところアカウントが停止されてしまいました。外部APIのサポートに問い合わせたところ、利用の仕方がスパムのような不正なものとして判定されたため停止されたことがわかりました。事情をサポートに説明し、ある程度は制約を緩和してもらい復旧してもらいました。だからといって、同じようなやり方でリクエストし続けるのもよくないので、実施頻度も少ないので一旦、少しずつリクエストを送るような手動運用としました。

最近になって、その実施頻度をあげなければならなくなりました。この運用が今後要求される規模を考えると実施時間は単純計算2時間ほど。ということで、何らかの方法で自動化しようと思い立ちました。当初のようにリクエストを一斉に投げず、1度に送るリクエスト数に上限を決め、一定時間の間をあけながら繰り返すようにしたいと考えました。

既にAzure Functionsは使っていたので、既存の仕組みでの実装も検討しました。実装自体は1つの関数として実現できそうなのですが、

  • 従量課金プランの場合の実行時間は最長10分なので、総リクエスト数に対してスケールしない。
  • 10分以上稼働させるためにApp Serviceプランにするにしても、当面の使用頻度に見合わなそう。

という、長時間稼働に関する問題が残りました。

そこでDurable Functionsですよ!

Durable Functionsの概説

Durable Functions の概要については、牛尾さんがQiitaで公開している記事がとてもわかりやすいです。

関数チェーンを使って、Durable Functionsの動作を概説する。

Function Chaining Pattern

上図には出ていないが、オーケストレータ関数という特別な関数がおり、それが状態を持つ。状態というのは、関数それ自身が内部でどこまで実行されたのか、そしてその時点での変数の値などの情報を指す。上図の関数チェーンの場合、関数F1を実行している間、オーケストレータ関数は関数としては一時終了している。F1終了時に、自動的に状態を復旧し、あたかもオーケストレータ関数が常にいたかのように実行が継続される。このように、外部の関数を同期的に複数回呼ぶことができ、結果的に長時間稼働する関数を実装することができる。オーケストレータ関数から呼ばれる個々の関数(上図のF1からF4)はアクティビティ関数と呼ばれ、通常の関数と同じ制約(実行時間の上限が10分、など)を持つ。他にもアクティビティ関数を並列で実行させた結果をオーケストレータ関数でまとめて処理するなどのアプリケーションの実装パターンがありますが、詳しくは公式ドキュメントを参照してください。

Durable Functionsを使ってリクエストを少しずつ送る

実際に作った仕組みについて説明します。

Overview of Throwing many requests slowly to External API

オーケストレータ関数は起動するトリガーを定義できないので、起動するためのトリガーを定義した関数を作成する必要があります。今回は、HTTPでリクエストを受け付けることで起動させるための関数としてCallAPIを作成しました。これは、HttpTriggerで起動するためのエンドポイントを持つことができます。また、外部APIに送るリクエストのパラメータはBlobストレージ上のファイルから読み取るようにしています。Blobトリガーも検討したのですが、ファイル作成のタイミングと起動のタイミングが異なるためHTTPトリガーを選びました。CallAPIはオーケストレータ関数を呼び出すためのorchestratorClientバインディングを持っており、これを通じてオーケストレータ関数に入力を渡します。CallAPIは、Blobファイルを読み取り、行ごとにパラメーターが記載されているので、配列にしたものを渡します。

オーケストレータ関数であるCallAPIOrchestratorは、入力として受け取ったパラメータの配列を順番にアクティビティ関数であるCallAPIActivityに渡します。このアクティビティ関数が実際に外部APIにリクエストを送ります。CallAPIActivityが終了すると、CallAPIOrchestratorは次のリクエストを送る前に一定時間(ここでは3分)待ちます。オーケストレータ関数上でsetTimeoutなどを使って一定時間止めることも可能ですが、その間も課金対象(従量課金プランの場合)となります。ここではDurable FunctionsのCreateTimer APIを使うことで、一旦オーケストレータ関数を中断し、指定した時間に中断したところから再度実行するようにしています。

アクティビティ関数CallAPIActivityは、受け取った引数を外部APIに送るのみです。アクティビティ関数の戻り値、エラーはオーケストレータ関数に渡されます。エラーを渡した場合、オーケストレータ関数内で例外として伝えられるので、try catchを使って制御することができます。今回は、外部APIがエラーを返した時点で続けてリクエストを送ることはリスクになるので、CallAPIOrchestratorにエラーを戻し、例外が発生した時点でCallAPIOrchestrator自身もエラーとして終了するように実装しました。

実際のコードは晒せないので、上と同じ仕組みのサンプルコードを用意しました。

使ってみてわかったこと

実際に本番で利用する前に、色々と小規模なリクエスト数で試したりしたのですが、実運用でわかったことを書きます。使い方の誤りやただ知らないだけかもしれませんが。

  • オーケストレータ関数は起動したが、そのあとのアクティビティ関数はまったく反応しないことが1度だけ起きた。Durable Functionsのログを見ると、状態が実行中(Running)であるがアクティビティ関数が呼ばれない状態が長時間起きた。Azure Functionsのアカウントを再起動したところアクティビティ関数が呼ばれるようになった。
  • オーケストレータ関数が出力するログが複数回現れる。
  • オーケストレータの状態確認、中断するための手段を持っておく方が良い。当たり前ですが、やはり持ってないと不安。JavaScriptではまだそのAPIが提供されていないようですが、C#では提供されているようです(参考)。
  • アクティビティ関数に他のバインドが設定できなかった。
  • アクティビティ関数に渡す引数は、オーケストレータ関数からオブジェクトをそのまま渡せない。オーケストレータ関数内でJSON.stringifyで文字列にすると、アクティビティ関数上ではオブジェクトとして扱うことができる。

おわりに

今回は利用頻度が低い且つ実行時間の長い処理をDurable Functionsで実装しました。これまでの方法ならWebJobsやApp ServiceプランのAzure Functionsを使うことで実現できましたが、常に裏側にサーバーが存在しなければならないため使用頻度とそのコストが見合うかがネックでした。ですが、Durable Functionsを利用することで、トリガーが発生した時に必要なインスタンスが用意され、利用した分だけ課金されるようになりました。また、オーケストレータークライアント、オーケストレータ関数、アクティビティ関数が分離されることにより、起動する条件、実際の処理の単位、それらの制御が明確に分かれることで、1つの関数で実装するよりも、それぞれのコードが読みやすくなったように思います。

まだパブリックプレビューではありますが、Durable Functionsを実戦投入してみました。利用できるケースは滅多にないかもしれませんが、1つの手段として持っておくと良いものかと思います。みなさんも機会があれば、ぜひデュラブってみてください!