しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure Functions の Retry Policy 機能を使って信頼性の高い処理を実装する

GitHub 上で開発が進んでいることは把握していましたが、ついに待望の Retry Policy 機能が Azure Functions に実装されました。まだ Preview 扱いのようですが、1 年以上は待った気がします。

単純にリトライするだけであれば Polly などを使えば済みますが、Cosmos DB や Event Hubs といった一部のトリガーでは Checkpoint が関係するので簡単にはいきません。Retry Policy が必要な理由はここです。

現在は Fixed Time と Exponential Backoff の 2 種類が選べますが、多少カスタマイズ出来るようには計画されているようです。大体のケースではこれで十分でしょう。

Azure Functions のエラーハンドリングとリトライのガイドラインが更新されて、新しく実装された Retry Policy について追加されています。Function の実装が重要になるので読んでおきましょう。

ドキュメントにもあるようにリトライが組み込まれているトリガー向けではありません。QueueTrigger では指定した回数までリトライを行い、失敗した場合は Dead Letter Queue へ送ってくれますし、Event Grid はそれ自身がリトライ機能を持っています。組み合わせて使うことも出来ますが、混乱の元でしょう。

Retry Policy と紹介されていますが、以前は Checkpoint control for Event Hubs / Cosmos DB と呼ばれていた機能なので、主なユースケースは Event Hubs と Cosmos DB Change Feed となります。

この 2 つのサービスは Checkpoint と呼ばれる、メッセージをどこまで処理したかという情報を持っていますが、これまでの Azure Functions の実装では Function が失敗しても Checkpoint が進んでしまっていました。何故このような挙動になったかは不明ですが、DLQ のようなものを Function が独自に実装するよりユーザーに任せることを選んだのかもしれません。

ぶっちゃけ挙動としては最悪で、失敗したメッセージは失われてしまうのですが、今回追加された Retry Policy を使うと Checkpoint は処理が完了するまでは進まないため、メッセージ処理の信頼性が高まります。

Event Hubs and Azure Cosmos DB checkpoints won't be written until the retry policy for the execution has completed, meaning progressing on that partition is paused until the current batch has completed.

Azure Functions error handling and retry guidance | Microsoft Docs

Function の処理が完了するまでは Checkpoint が進まないため、実行中にアプリケーションのデプロイを行っても処理が冪等になっていれば安全です。確実に最低 1 回の処理が行われるため、Event Hubs や Change Feed を使ったアーキテクチャが更に捗ります。

従って Checkpoint を持っていないトリガーに対して使うメリットは、正直あまりないという印象を持っています。既に多くの Azure Functions で作られたアプリケーションは、Queue や Event Grid といった信頼性の高いトリガーが使われているはずです。

Retry Policy を実際に試す

前置きが長くなったので、実際に Cosmos DB Trigger と Event Hubs Trigger でエラーを発生させて Checkpoint が進まずにリトライが行われるのか、そして修正したアプリケーションをデプロイすれば続きから実行されるのかを確認しておきました。

何故か Azure Functions SDK が更新されておらず、そのままでは新しい属性が使えませんでした。昨日試した時には WebJobs SDK も更新されておらず全く使えない状態でしたが、Azure Functions の PM である Jeff に相談したところ対応してくれました。

ExtensionsMetadataGenerator のバージョンも古いままなので、SDK のアップデートを期待しています。一応 GitHub に Issue が上がっていたので、近日中にアップデートされる予感がします。

今のところは FixedDelayRetryExponentialBackoffRetry を使う際にはバージョン 3.0.23 以降の Microsoft.Azure.WebJobs パッケージを NuGet からインストールしてください。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.WebJobs" Version="3.0.23" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.9" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

Visual Studio を使って Azure Functions 開発を行っている場合は、自動的に Azure Functions Core Tools がアップデートされるので気にする必要はありませんが、VS Code などを使っている場合は忘れずに最新版にアップデートしてください。

Cosmos DB Trigger で使った場合

現実的な話をすると、Cosmos DB の Change Feed や Event Hubs で受け取ったメッセージを失っても良いケースは存在しないはずなので、どちらのトリガーを使うにしてもリトライ回数に上限を設定するべきではないでしょう。上限なしは -1 を設定すれば良いです。

今回は固定の待ち時間でリトライ回数の上限なしに設定しています。パブリッククラウドで良くあるリトライすれば回復する障害向けには、Exponential Backoff に最大待ち時間を指定する方が良いです。

public class Function1
{
    [FixedDelayRetry(-1, "00:01:00")]
    [FunctionName("Function1")]
    public void Run([CosmosDBTrigger(
                        databaseName: "HackAzure",
                        collectionName: "TodoItem",
                        ConnectionStringSetting = "CosmosConnection",
                        LeaseCollectionName = "Lease")]
                    IReadOnlyList<Document> input, ILogger log)
    {
        log.LogInformation("Documents modified " + input.Count);

        foreach (var document in input)
        {
            log.LogInformation("Changed document Id " + document.Id);
        }

        throw new Exception();
    }
}

ただし回復不要なエラーが発生した状態で無限にリトライを行うのは、処理が完全に停止することを意味するので Application Insights を使ったモニタリング・アラートの設定は必須でしょう。

リトライが本当に行われて、その時に Change Feed が進まないことを確認するために常に例外を投げるコードを書いたので、しばらくデプロイして動かしておくと以下のようにエラーが出続けることが確認できます。

f:id:shiba-yan:20201105200944p:plain

暫く動かした後に例外を投げるコードを削除して再デプロイしたので、その後はエラーが止まっています。

この時には 3 つのドキュメントを変更したので、Change Feed には 3 件分の変更が入っているはずです。エラーの数とは一致しませんが、リトライ間隔が 1 分間で Change Feed はバッチでデータを取るからです。

Application Insights にはドキュメントの id を書き出しておいたので、id で検索すると 1 分間隔でログが見つかります。これでリトライの度に Change Feed が進んでいないことが確認できます。

f:id:shiba-yan:20201105201302p:plain

途中で何回かデプロイを行いましたが、Change Feed はエラーを修正するまで進むことはありませんでした。これまで求めていた挙動が Retry Policy によって実現出来ていることが確認できました。

Cosmos DB SDK v3 の Change Feed Processor や Polly などで独自のリトライ処理を組み込まずとも、Azure Functions だけで信頼性の高いメッセージの処理が行えるようになったので、非常に便利になりました。

Event Hubs Trigger で使った場合

Event Hubs Trigger の場合も基本は Change Feed と同じですが一応デプロイして動作を確認しておきました。例によって処理の最後で例外を投げるようにして、Functions を失敗扱いにさせています。

内部エラーなどでイベントを失ってよいはずは無いので、リトライ回数は無制限です。

public class Function2
{
    [FixedDelayRetry(-1, "00:01:00")]
    [FunctionName("Function2")]
    public void Run([EventHubTrigger("events", Connection = "EventHubConnection")]
                    EventData[] events, ILogger log)
    {
        log.LogInformation("Received events " + events.Length);

        foreach (var eventData in events)
        {
            var messageBody = Encoding.UTF8.GetString(eventData.Body.Array, eventData.Body.Offset, eventData.Body.Count);

            log.LogInformation($"C# Event Hub trigger function processed a message: {messageBody}");
        }

        throw new Exception();
    }
}

今回テストに使用した Event Hubs は上限いっぱいの 32 パーティションを持っているので、それぞれのパーティション単位で Function が実行され、同様にリトライも行われていました。

なので Application Insights を見ると 1 分間隔で出力されているログが 2 つ見つかります。イベントを 2 つ投げたので、それぞれでリトライが実行されていることが分かります。

f:id:shiba-yan:20201105201312p:plain

途中でデプロイを行ってもイベントは失われませんし、エラーを修正すれば続きから実行されます。

Retry Policy と冪等な Function を用意すれば信頼性の高いイベント処理が行えるので簡単になりました。リトライを Function の実装で組み込むのは Checkpoint のコントロールが出来ないので限界がありました。

利用上の注意点

基本的には Cosmos DB Trigger と Event Hubs Trigger を使う場合には必須になった Retry Policy ですが、当然ながら注意点も色々とあるので理解した上で組み込んでいきましょう。

とは言え Cosmos DB / Event Hubs 向けに使う限りほぼ弱点は無いです。積極的に使っていきましょう。

最大リトライ回数はベストエフォート

ドキュメントにも書いていますが、現在のリトライ回数はインスタンス単位でメモリ上に保持されているので、インスタンスの移動やデプロイなどでプロセスが再起動すれば失われます。

リトライ回数の信頼性を高めるために値の永続化とか無駄なので、この仕様は妥当だと考えています。

Other triggers, like HTTP and timer, don't resume on a new instance. This means that the max retry count is a best effort, and in some rare cases an execution could be retried more than the maximum, or for triggers like HTTP and timer be retried less than the maximum.

Azure Functions error handling and retry guidance | Microsoft Docs

Cosmos DB と Event Hubs は Checkpoint を持っているおかげで、途中でプロセスが再起動したとしても自動的に続きから実行されて、エラーが発生すれば自動的にリトライが行われるため影響はありません。

それ以外のトリガーでは再起動が走ればリトライは無かったことになるので、リトライ回数を無制限にしたとしても成功するまで必ず実行されるという保証はありません。そもそも Cosmos DB / Event Hubs 以外で使うべきではないでしょう。

Consumption Plan との組み合わせ

誰もが疑問に思う Consumption Plan を使っている場合のリトライの待ち時間中に CPU 課金が行われるかどうかという点ですが、これも Jeff に確認したところ待ち時間中は課金されないらしいです。

ただし待ち時間を 10 分以上に設定した場合、Consumption Plan の場合はインスタンスが 0 まで落ちてしまう可能性があるらしいです。言われてみれば当たり前の挙動ではありますが、はまりやすそうなので特に Exponential Backoff を使う場合には設定に注意しましょう。

Durable Functions が適したケースも多い

リトライが組み込まれている Queue Trigger や Event Grid、そして Checkpoint によって続きからの実行が容易な Cosmos DB や Event Hubs 以外のトリガーでリトライが必要な場合は、多くの場合 Durable Functions を利用した方が信頼性が高まるはずです。

単なるリトライに留まらずに、Orchestrator と Activity の組み合わせで大量データの処理やワークフローも簡単に実装出来るので、Retry Policy を使うよりも圧倒的に便利でしょう。

そういった意味でも Azure Functions のエラーハンドリングとリトライのガイダンスはちゃんと確認しておいた方が良いです。その処理に適したアプローチを正しく見極めて選択する必要があります。

Retry Policy は正しいシチュエーションで使えば非常に便利で信頼性を高めてくれる機能ですが、これで全てのリトライ処理が解決するというような甘い話ではないことを理解しておきましょう。