設計者の失敗、バグ、正しくないドキュメントが組み合わさって、.NETのHttpClientを正しく使うのは驚くほど難しくなっている。その結果、プロダクションで正しく動くであろうアプリケーションが性能問題や高負荷時の障害に苦しんでいる。
この事実は、ASP.NET MonstersのSimon Timms氏が書いたYou're using HttpClient wrong and it is destabilizing your softwareと題した記事で明らかになった。
この記事への反応はさまざまだが、多くは失望と不満を表している。
... こんな記事を読んで怒りを覚えているのは私だけでしょうか。こんなコードを書いてリリースしたら一体何が起こるか。笑い者になるだけですよ。でも、重要なのは、この事実を受け入れて回避策を実行して、同じような記事をいくつも書くことでしょうね。
驚き最小の原則を思い切り捻じ曲げていますね。
これが、HttpClientをバグがあるようにしているか、悪い設計にしている原因のように思います。どちらかはわかりませんが。もし、悪い設計にしているのであれば、おかしな話です。他の方法でhttpリクエストするように置き換える必要があります。
-- Eirenarch
C#の開発者はどのように学んできたか
状況を理解するために、まず、コネクション志向の別のクラスを見てみよう。SqlConnectionだ。IDisposableとusingの使い方を教わるとき、ほとんどの開発者は次のような例を示される。
using (var con = new SqlConnection(connectionString)) {
con.open();
//use the connection here
} //this closes the connection
この例の説明は不完全だが、このパターン自体は正しく何年もの間開発者に問題なく使われてきた。しかし、HttpClientにこのパターンを適用すると、意図しない問題に巻き込まれてしまう。
具体的にいうと、必要以上にたくさんのソケットをオープンし、サーバに負荷をかけてしまうのだ。さらに、これらのソケットはusingでクローズされない。アプリケーションがコネクションを使うのをやめたあと、数分してからクローズする。
コネクションプーリング
SqlConnectionの例に戻ると、ほとんどのコネクション志向のリソースはプールされる。データベースのコネクションを"オープン"するとき、まず、プールをチェックして使われていないコネクションがあるかどうかを調べ、もし見つかったら、新しいコネクションを作らずに、再利用する。
同じように、SqlConnectionを"クローズ"する場合、コネクションをプールに戻すことで解放する。別のプロセスが長い間使われていなかったコネクションをクローズするかもしれないが、一般的にはこれは正しい挙動だとされている。性能とサーバの負荷のバランスを考慮した結果だ。
しかし、HttpClientはこのような挙動をしない。破棄をすると、制御していたソケットを閉じる処理を発動する。これによって次にリクエストをするときに新しいコネクションを作成しなければならない。これはレイテンシが高いネットワークやコネクションがセキュアな場合、特に大きな問題になる。セキュアな場合はSSL/TLSのネゴシエーションが必要になるからだ。
ソケットを閉じるのにかかる時間は4分
上述の通り、コネクションのクローズは速い処理ではない。ソケットを"クローズ"するとき、ソケットがTIME_WAITという状態になる。Windowsはこの状態で一定時間ソケットを放置する。この時間は構成できる。デフォルトでは4分だ。残りのパケットが送信中の場合は考慮しているのだ。
この挙動によってソケットの枯渇につながりやすくなる。次のようなランタイムエラーを引き起こすのだ。"リモートサーバーに接続できません。System.Net.Sockets.SocketException: それぞれのソケットアドレス(protocol/network address/port)はひとつの用途だけに限られています。".
Simon Timms氏によれば、"Googleで検索してみると、コネクションタイムアウトの時間を短くするという恐ろしいアドバイスが見つかります。タイムアウトを短くすれば、HttpClientを適切に利用している他のアプリケーションにも悪影響が生まれます。“適切”の意味を理解し、マシンレベルの変数を変更するのではなく、根本的な問題を解決する必要があります。
NET Coreのパフォーマンス・ヒット
.NET Frameworkの完全なバージョンを使っているほとんどの開発者はこの問題に気づかない。しかし、NET Coreを使っている場合は、さらに別の問題があり、これによってすべての問題があらわになりやすい。
.NET CoreはRC1とRC2の間でバグが混入し、HttpClient.Disposeを呼び出したときに1010ミリ秒から1030ミリ秒遅延が生まれるようになった。この遅延は、.NET Coreの1.2で修正される予定だ。
解決策としてのブローカークラス
HttpClientのドキュメントには一切言及されていないが、Microsoft Patterns & PracticesのGitHubではあるパターンが説明されている。ここでは、HttpClientを"ブローカークラス"と説明し、次のように解説している。
これらのブローカークラスを作成するのは高価です。代わりに、一度インスタンス化したら、アプリケーション実行中は再利用するように意図されています。しかし、これらのクラスの利用の仕方については誤解されています。必要に応じて確保し、素早く解放するべきリソースとして扱われてしまっているのです[…]
HttpClientは必要に応じて、作成、破棄するのではなく、一度インスタンスを作成したら、静的フィールドに保持し、アプリケーションの生存期間中は共有して使う、というのがMicrosoft P&Pの推奨だ。
紛らわしいドキュメント
この問題は紛らわしいドキュメントに行き着く。公式ドキュメントのバージョン118 (現時点でGoogleとBingの検索で見つかるバージョン)には、HttpClientをスレッド間で共有するのはサポートしていないと書いてある。
この型の public static (Visual Basic では Shared) のメンバーはすべて、スレッド セーフです。インスタンス メンバーの場合は、スレッド セーフであるとは限りません。
これだけしか書いていない。バージョン110の公式ドキュメントには、有用なことが書いてある。
HttpClientは一度インスタンス化し、アプリケーションの有効期間にわたって再利用します。それぞれのリクエストでHttpClientクラスをインスタンス化するのは、高負荷時にソケットを消耗します。これはSocketExceptionを引き起こす可能性があります。
このドキュメントでは、次のメソッドはスレッドセーフであると書いてある。
- CancelPendingRequests
- DeleteAsync
- GetAsync
- GetByteArrayAsync
- GetStreamAsync
- GetStringAsync
- PostAsync
- PutAsync
- SendAsync
現在も続いているMSDNの問題だ。クラスのすべての説明を読むにはそれぞれのバージョンをチェックし、重要な記述が追加、削除されていないかを確認する必要がある。
DNSのバグ
上述のアドバイスに従った場合、別の問題が生まれる。Ali Kheyrollahi氏が言うには、
しかし、深刻な問題があるのがわかりました。DNSの変更が反映されず、HttpClientは(HttpClientHandlerを通じて)ソケットがクローズするまでコネクションを使い続けます。無制限にです。では、DNSはいつ変更されるか。ブルーグリーン配置(Azureでは、ステージングスロットに配置したとき、プロダクション/ステージングをスワップしたとき)をしたとき、Azure Traffic Managerの設定変更をしたとき、フェールオーバーしたとき、そして、PaaSの内部の無数の挙動に応じて変更されます。
これが報告もされずに2年以上も続いていたなんて... .NETでどんなアプリケーションが開発されてるのか不思議なくらいです。
フェールオーバなら、コネクションは切断され、新しいサーバ向けに開かれます。しかし、ブルーブラック配置の場合は、ステージングからプロダクションへスワップしても、ステージングを呼び出してしまいます。これが私がぶつかった問題であり、Azureのおかしな挙動が原因だと考え依存しれいるサーバをバウンスさせることで解決したのでした。バカだったのは私です。コードに原因があったなんて。誰のコードでしょうか。それは議論の余地がありそうですが。
これは直せない問題ではない。理論的にはHttpClientはDNS TTLを尊重する。この値はデフォルトでは1時間だ。期限切れになれば、HttpClientはDNSのエントリが有効であることを検証し、必要に応じて更新されたIPアドレスにたいsて新しいコネクションを作成する。
しかし、このシナリオ通りにはならないかもしれないので、Kheyrollahi氏はシンプルは回避策を定時している。ServicePointManagerを使うのだ。これを使えば、HttpClientにコネクションを自動的にリサイクルするように指定できる。
var sp = ServicePointManager.FindServicePoint(new Uri("http://foo.bar"));
sp.ConnectionLeaseTimeout = 60*1000; // 1 minute
これはアプリケーションの起動に、アプリケーションで接続するすべてのエンドポイント向けに、一度だけしたい処理です(エンドポイントが実行時に決まるなら、決まった時にする必要があります)。パスはクエリ文字列は虫されます。ホスト、ポート、スキーマが重要です。1分から5分に設定するのがよいでしょう。
Rate this Article
- Editor Review
- Chief Editor Action