しばやん雑記

ASP.NET Core とメイドさんが大好きなフリーランスのプログラマーのブログ

C# と Polly を使って回復力の高いアプリケーションを書く

昔のように高い信頼性を持つオンプレのハードウェア上で動いていたアプリケーションとは異なり、昨今のクラウド上で動いているアプリケーションは障害が発生する前提でコードを書く必要があります。クラウドのハードウェアは毎日どこかで壊れるので、それを前提にソフトウェアで高可用性を担保しているわけです。

最近は Microservices の流行もあって 1 つのアプリケーションを構成するコンポーネントが増えているため、実装によっては 1 つのコンポーネントで発生した障害が全体に波及してしまい、アプリケーション全体が停止してしまうこともあり得ます。例としてよく挙げられるのがリコメンド周りでしょう、リコメンドサービスの障害でアプリケーション全体が落ちてはいけないのです。

今のアプリケーションは障害時には敢えてサービスレベルを下げてでも、全体としての可用性を維持することが必要です。そして復旧時には人の介入なしで適切に回復出来るような実装も必要となっています。

長い導入でしたが、そういったコードを書く際に非常に役に立つ Polly について、真面目に調査と検証しながら触ったのでメモがてら残します。

便利な機能が揃っているかつ信頼性が高いライブラリので安心して使えます。単純なリトライからサーキットブレーカー、バルクヘッド、キャッシュ、フォールバックといった可用性を維持するために必要な機能を、手軽にアプリケーションに組み込めます。

当たり前なことを言っている自覚はありますが、当たり前のことを当たり前にやるのは難しいんです。

Polly の基本

主な使い方は必要な Policy を作成して、実行するという流れなので非常にシンプルです。Policy の作成は PolicyPolicy<TResult> を起点にした Fluent Interface なので結構理解しやすいと思います。

Polly には多くの Policy が用意されていますが、まずは共通な部分を軽くおさらいしておきます。

例外のハンドリング

Polly を使う理由の 8 割はこの例外のハンドリングを行って、リトライなりサーキットブレーカーを挟むなりだと思います。まずはありがちな HTTP 周りのサンプルを用意しました。

最近は見なくなりましたが WebClient を使って HTTP 通信を行っています。コメントに書いた通り、失敗したとしても 3 回まではリトライを行ってくれます。

var client = new WebClient();

// WebException が投げられた場合には 3 回までリトライする
// リトライしても失敗した場合には例外が再スローされる
Policy.Handle<WebException>()
      .Retry(3)
      .Execute(() => client.DownloadString("https://blog.azure.moe/"));

上の例では WebException が投げられた場合に無条件でリトライしていますが、一般的には 40x 系でリトライを行うのは不適切なので 503 の場合だけリトライを行うように条件を追加します。

Polly では Handle<TException> にデリゲートを指定出来るので、そこでサクッと条件を書きます。

var client = new WebClient();

// WebException に含まれるステータスコードが 503 の場合には 3 回までリトライする
// リトライしても失敗した場合には例外が再スローされる
Policy.Handle<WebException>(ex => ((HttpWebResponse)ex.Response).StatusCode == HttpStatusCode.ServiceUnavailable)
      .Retry(3)
      .Execute(() => client.DownloadString("https://blog.azure.moe/"));

基本はこんな感じですが、他にも Async を使っていると例外が AggregateException として投げられることがありますが、そういった場合には HandleInner<TException> を使うとハンドリングが可能です。

条件を複数付けたい場合には Or<TException>OrInner<TException>を使います。

例外だけではなく、実行結果に対しても HandleResult<TResult> を使うと条件を指定出来るので、この辺りは要件に合わせて上手く使い分けると良いと思います。

var httpClient = new HttpClient();

// GetAsync はステータスコードが 20x / 30x 以外でも例外は投げないので、HandleResult を使って条件を指定する
await Policy.HandleResult<HttpResponseMessage>(x => x.StatusCode == HttpStatusCode.ServiceUnavailable)
            .RetryAsync(3)
            .ExecuteAsync(() => httpClient.GetAsync("https://blog.azure.moe/"));

勿論 OrResult<TException> も用意されているので、この辺りの条件組み合わせは自由に行えます。

ExecuteAndCapture

アクションの実行に Execute を使うと Policy の実行に失敗した場合は例外が再スローされますが、再スローを避けたい場合には ExecuteAndCapture を使います。

これを使うと例外や実行中の情報は PolicyResult に保存されて返されます。メソッド名の通りですね。

var client = new WebClient();

// WebException が投げられた場合には 3 回までリトライする
// リトライしても失敗した場合でも例外は再スローされない (PolicyResult に投げられた例外が保存されている)
var policyResult = Policy.Handle<WebException>()
                         .Retry(3)
                         .ExecuteAndCapture(() => client.DownloadString("https://blogs.azure.moe/"));

// Policy が成功したかは Outcome で判断する
if (policyResult.Outcome == OutcomeType.Failure)
{
    // 実行に失敗した

    if (policyResult.FinalException is WebException)
    {
        // FinalException に例外が格納されている
    }
}

実行に成功したかどうかは FinalException だけでは判断できないので注意が必要です。ちゃんと Outcome の値をチェックして判断するようにします。

Async 対応

Polly は Async にフル対応していますが、注意点として ExecuteAsyncExecuteAndCaptureAsync を使う場合には Policy 自体も Async なバージョンを使う必要があります。

var httpClient = new HttpClient();

// ExecuteAsync を使う場合は Policy も Async なバージョンを使う必要がある
// 間違った組み合わせの場合は例外が投げられる
await Policy.Handle<HttpRequestException>()
            .RetryAsync(3)
            .ExecuteAsync(() => httpClient.GetStringAsync("https://blog.azure.moe/"));

もし片方だけ Async なバージョンを使っていた場合には実行時エラーになります。

共通した部分のおさらいはこれくらいにして、実際にそれぞれの Policy を使って挙動を確認していきます。

Retry

もはや説明は不要かと思いますが、Azure や AWS などでリトライは重要です。理由はスロットリングなどで一瞬失敗しても、時間を空けて再実行すると大体は成功するようになっているからです。

これからの SDK とかでは既にリトライが組み込まれているので、何も考えなくても大体はいい感じに使えるようになってますが、それ以外では適切に実装する必要があります。この辺りはクラウドデザインパターンにも書いてあります。

思っているよりもリトライをちゃんと実装するのは難しいものです。

Polly では HttpClientFactory 向けのライブラリが用意されているので、それを使うと透過的にリトライを組み込めるので便利です。

既に単純なリトライのサンプルは紹介してきたので、別パターンを紹介します。

サービス側の一時的な問題の場合、余計な負荷をかけないためにも少し待ち時間を入れて再実行するのが適切なので、Polly でも専用に WaitAndRetry メソッドが用意されています。

var httpClient = new HttpClient();

// 失敗した場合には 2 秒 * リトライ回数分ずつ待ち時間を増やして 5 回まで試す
await Policy.Handle<HttpRequestException>()
            .WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromSeconds(retryAttempt * 2))
            .ExecuteAsync(() => httpClient.GetStringAsync("https://blog.azure.moe/"));

これだけでいい感じにウェイトを入れつつリトライを行ってくれますが、待ち時間を返すデリゲートは自分で用意する必要があるのが少し面倒です。

なので適当に以下のようなクラスを作っておけば、適切な SleepDurationProvider を指定出来るはずです。

public static class SleepDurationProviders
{
    private static readonly Random _random = new Random();

    public static Func<int, TimeSpan> Simple(int seconds)
    {
        return retryAttempt => TimeSpan.FromSeconds(retryAttempt * seconds);
    }

    public static Func<int, TimeSpan> Fixed(int seconds)
    {
        return _ => TimeSpan.FromSeconds(seconds);
    }

    public static Func<int, TimeSpan> ExponentialBackoff(int maximumBackoff = 64)
    {
        return retryAttempt => TimeSpan.FromSeconds(Math.Min(Math.Pow(2, retryAttempt - 1), maximumBackoff) + _random.NextDouble());
    }
}

待ち時間も単純にリトライ回数で線型的に増やすのではなく、指数的に増やす Exponential Backoff を使うことが多くのパブリッククラウドでは推奨されています。更に分散環境での実行も考慮して、待ち時間にランダム性を持たせて負荷が集中しないような工夫も重要になります。

リトライしても失敗する場合には別の方法でサービスを可用性を担保します。

Circuit Breaker

一時的なエラーの場合は大抵はリトライで問題なくサービスを継続することは出来ますが、サーバー側の問題でリクエストを処理できない場合が続くと、リトライをしても意味がないケースがあります。

意味がないどころか、リトライを行い続けることによってサーバーに更に負荷をかけてしまうことがあるので、そういったケースにサーキットブレーカーを使います。設定で指定した回数のエラーが連続して発生すると、指定した時間内は処理を実行することなく即座にエラーを返します。

サーキットブレーカーの説明はクラウドデザインパターンに任せますが、エラーが発生しているコンポーネントに負荷をかけることはなく、復旧した場合には元通り処理を実行するようになります。

Docs にあるサンプルは別コンポーネントの障害時にサーキットブレーカーを使って、ページの一部が使えないことをユーザーに返すようになっています。

ユーザー視点では一部の機能は使えなくなっていますが、それ以外の機能は問題なく扱えますし、サーバー側では即座にエラーを返すので、無駄な待ち時間が発生してリソースを食い潰すことも防げます。

Polly では CircuitBreakerAdvancedCircuitBreaker の 2 種類が用意されていて、名前からわかるように後者の方が高度な条件を指定することが出来ますが、まずはシンプルな方で試せばよいと思います。

var httpClient = new HttpClient();

// CircuitBrekaer Policy は予め作成しておく (状態を持っているので注意)
// 5 回連続して失敗した場合には 10 秒間ブレーカーを Open 状態にする
var circuitBreaker = Policy.Handle<HttpRequestException>()
                           .CircuitBreakerAsync(5, TimeSpan.FromSeconds(10));

// 実際に処理を行う
try
{
    circuitBreaker.ExecuteAsync(() => httpClient.GetStringAsync("https://blog.azure.moe/"));
}
catch (BrokenCircuitException)
{
    // サーキットブレーカーによって処理が行われなかった
}
catch (HttpRequestException)
{
    // 通常のエラー
}

上の例では 5 回連続して失敗した場合に、10 秒間サーキットブレーカーが動くようになっています。この状態で処理を実行しようとすると BrokenCircuitException が投げられるので、ユーザーには予め用意しておいた適切なページを返せばよいです。

わざわざ実行せずとも CircuitState を確認すれば例外のコストを回避できます。

手動でサーキットブレーカーを操作したい場合には Isolate メソッドを使います、Isolate メソッドを呼び出せば、それ以降の処理は実行されずに IsolatedCircuitException が投げられます。戻す場合には明示的に Reset メソッドを呼び出す必要があります。

Timeout

特に説明も不要かと思われるタイムアウトです。指定した時間が経過しても処理が終わらない場合には TimeoutRejectedException が投げられるという分かりやすい挙動です。

挙動は分かりやすいですが、タイムアウトの処理方法として楽観的タイムアウトと悲観的タイムアウトがあるので、その 2 つは気を付けて選ぶ必要があります。

TimeoutStrategy.Optimistic

楽観的タイムアウトは CancellationToken を使ってキャンセルリクエストを投げ、その後は処理が終わるのを待つという分かりやすい実装です。

従って CancellationToken を適切にハンドリングしていない処理が含まれていると、指定した時間を過ぎてもタイムアウトになりません。勘違いしないようにしましょう。

try
{
    // 楽観的タイムアウトを行う
    // タイムアウト時には CancellationToken を使ったキャンセルのリクエストが行われる
    await Policy.TimeoutAsync(5, TimeoutStrategy.Optimistic)
                .ExecuteAsync(ct => Task.Delay(TimeSpan.FromSeconds(10), ct), CancellationToken.None);
}
catch (TimeoutRejectedException)
{
    // タイムアウト発生
}

楽観的タイムアウトと呼ばれる理由が分かりやすいと思います。

BCL が提供している Async メソッドを使う場合は CancellationToken を受け取るようになっているはずなので、引き渡していけば良いので簡単ですが、独自のメソッドの場合はちゃんとキャンセルを実装します。

TimeoutStrategy.Pessimistic

もう一つの悲観的タイムアウトですが、指定した時間が経過すれば問答無用で TimeoutRejectedException が投げられるので CancellationToken を使っていなくてもタイムアウトになります。

try
{
    // 悲観的タイムアウトを行う
    // タイムアウトしても処理自体が途中で打ち切られるわけではないので注意
    await Policy.TimeoutAsync(5, TimeoutStrategy.Pessimistic)
                .ExecuteAsync(() => Task.Delay(TimeSpan.FromSeconds(10)));
}
catch (TimeoutRejectedException)
{
    // タイムアウト発生
}

使いやすそうな雰囲気もありますが、Polly は一定時間後に処理が終わってなければ例外を投げて処理を戻すだけで、何もしなければ Execute などで指定した処理は裏で継続されるという挙動になります。

なので以下のようなコードを書くと、コンソールには両方共のメッセージが出力されます。

try
{
    await Policy.TimeoutAsync(5, TimeoutStrategy.Pessimistic)
                .ExecuteAsync(async () =>
                {
                    await Task.Delay(TimeSpan.FromSeconds(10));

                    // TimeoutRejectedException が投げられた後にちゃんと実行される罠
                    Console.WriteLine("Completed");
                });
}
catch (TimeoutRejectedException)
{
    // 例外が投げられたら ExecuteAsync の実行が止まるというわけではない
    Console.WriteLine("Timeout");
}

Console.ReadKey();

スレッドを強制的に止めるというような危険な挙動ではないので安全と言えば安全ですが、注意しておかないとタイムアウトで終わったと思っていた処理が裏で続いていて、別の問題を引き起こす可能性もあります。

Bulkhead

アプリケーションで複数のサービスに対してリクエストを行う場合には、当然ながらそれぞれで接続や CPU 時間などのリソースを消費することになります。これが正常時ならば問題ないのですが、障害などで大量のリクエストが特定のサービスに偏ってしまうと、サーバーのリソースを食い潰してしまう可能性があります。

その場合は正常な別のサービスに対するリクエストにも影響が出るため、バルクヘッドを用意して並列に実行可能なリクエスト数を制限すると、こういった際の問題を緩和できます。このような場合はコンシューマーバルクヘッドとも呼ぶようです。

クラウドデザインパターンにはクライアント単位でサービスのインスタンスを分けるという方法も記載されていますが、これはまあまあ難しいというかコストもかかりますね。結局のところマルチデータセンターでの運用に近いので、それなりの規模じゃないと難しいでしょう。

Polly の実装ではセマフォを使って Policy 単位での同時実行可能数を制限しています。内部でキューを持っていて、常に指定した数だけが同時に実行されるため、過度にリソースを消費せずに処理を行えます。

var httpClient = new HttpClient();

// 並列実行数は 2、キューの深さは 10 の Bulkhead Policy を作成
var bulkhead = Policy.BulkheadAsync(2, 10);

try
{
    // ブチザッキには同時に 2 リクエストしか投げない
    // 溢れた分は 10 まではキューに積まれて順次処理される
    await bulkhead.ExecuteAsync(() => httpClient.GetStringAsync("https://blog.azure.moe/"));
}
catch (BulkheadRejectedException)
{
    // キューからも溢れた時に例外が投げられる
}

キューの上限を超えた場合には BulkheadRejectedException が投げられるので、その際には少し時間を空けてリトライを行うといった処理が考えられます。

後述する PolicyWrap を使うと、簡単にそういった Policy を作れるのでとても楽です。

Cache

Cache に関しては一般的すぎるので特に説明は要らないと思います。Polly の Cache を使うメリットは他の Policy と組み合わせて柔軟に処理に組み込めるという点です。

データストアは拡張可能となっていて、公式で以下の 2 つが提供されています。名前から想像がつくように .NET Core で追加された IMemoryCache / IDistributedCache を使うようになっているので、ASP.NET Core アプリケーションへの組み込みが簡単に行えます。

その他の特徴としては TTL の設定が柔軟というところでしょうか。よくある相対時間や絶対時間だけではなく、Sliding する TTL も用意されています。もちろん拡張可能なので、どんなパターンにでも対応出来ます。

サンプルでは分かりやすさを優先して TimeSpan を使って TTL を指定しています。

// IMemoryCache を作るには DI が一番楽だっただけ
var provider = new ServiceCollection()
               .AddMemoryCache()
               .BuildServiceProvider();

var memoryCache = provider.GetService<IMemoryCache>();

// 5 秒間メモリにキャッシュする
var cachePolicy = Policy.Cache(new MemoryCacheProvider(memoryCache), TimeSpan.FromSeconds(5));

// 分かりやすいように現在の日付を返す
// Context を渡してキャッシュキーを指定しないとキャッシュされないので注意
var now = cachePolicy.Execute(context => DateTime.Now, new Context("Default"));

単体だとキャッシュするだけという単機能しかないので使いどころが微妙ですが、他の Policy を組み合わせると非常に便利に使える Policy です。

Fallback

リトライで失敗した場合やサーキットブレーカーでエラーになった場合、どのようにクライアントに見せるかという点はケースバイケースなので何とも言えないですが、予め決めておいた固定のコンテンツを返すという方法が考えられます。

Polly ではエラーが発生した場合に、最終的に返したい値は Fallback を使って指定できます。

// Execute に失敗した場合には例外は投げられずに Fallback で指定した値が返る
var result = Policy<string>.Handle<Exception>()
                           .Fallback("Default Value")
                           .Execute(() => throw new Exception());

単体では使いどころが無さそうに見える Fallback Policy ですが、Cache と同様に他の Policy と組み合わせることで力を発揮してくれます。

PolicyWrap

ここまでいろんな機能の Policy について書いてきましたが、単体ではなく組み合わせて使いたいものばかりだったと思います。Cache や Fallback はその典型例です。

例えば 5 回までリトライをしてもエラーだった場合には、予め用意しておいた別の値を返すといった処理は Fallback Policy と Retry Policy を作成して、その二つを PolicyWrap で組み合わせるだけで作れます。

var httpClient = new HttpClient();

var fallback = Policy<string>.Handle<HttpRequestException>()
                             .FallbackAsync("ブチザッキがエラーだった");

var retry = Policy<string>.Handle<HttpRequestException>()
                          .RetryAsync(5);

var policy = Policy.WrapAsync(fallback, retry);

var result = await policy.ExecuteAsync(() => httpClient.GetStringAsync("https://blog.azure.moe/"));

Fluent Interface の途中で Wrap を呼ぶことも出来ますが、個人的にはこっちの書き方のが分かりやすくて好きです。最終的に Async を使う場合には、全て Async な Policy で統一しないといけないので注意。

PolicyWrap でのそれぞれの Policy に対する適切な組み合わせは、Polly の Wiki に書いてあるのでとても参考になります。当然ながら上の例でも逆にすると何の意味もないので、組み合わせる順番がとても重要です。

他にも Polly には機能がありますが、とりあえずは各 Policy とその組み合わせを知っておくことが重要なので、メモはここまでにしておきます。思った以上に長くなったので書くのに疲れました。