.NETはバックエンドのデータ収集における主力である。高速な非同期I/O、強力な型付け、そして高い並行性を余裕を持って処理できるランタイムを備えている。レジデンシャルプロキシをHttpClientに組み込むのは数行で済むが、C#にはプロキシとは無関係の、HttpClientの想定される使い方に関する独自の落とし穴がある。クライアントのライフタイムを正しく扱えば、プロキシ部分は簡単だ。
これはPythonでのレジデンシャルプロキシ、Goでの利用、Node.jsでの利用と同じシリーズのC#編であり、動作するコードに加えて.NET特有の落とし穴を扱う。
以下すべてShifterのレジデンシャルゲートウェイを使用する。エンドポイントはp.shifter.io:443の一つだけで、すべてのターゲティングはユーザー名にエンコードされている。ホストと認証情報を他のプロバイダのものに置き換えれば、構造は同じだ。
ゲートウェイモデルを一段落で
プロキシのユーザー名には認証情報とターゲティング情報の両方が含まれる。国やセッションを変えるためにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変更する。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usは米国をターゲットにし、sidはスティッキーセッションを固定し、ttlはそのIPをN秒間保持する。sid/ttlを省略すると、新しい接続ごとにローテーションする。パスワードは一定のままだ。.NETでは、そのユーザー名はプロキシURLではなくNetworkCredentialに渡す。
基本セットアップ: WebProxyとハンドラ
プロキシはメッセージハンドラ上で設定し、それをHttpClientに渡す。認証情報はURIにインラインで書くのではなく、NetworkCredentialを介してWebProxyに渡す。
using System.Net;
var user = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us";var pass = Environment.GetEnvironmentVariable("SHIFTER_PASS");
var proxy = new WebProxy("http://p.shifter.io:443"){ Credentials = new NetworkCredential(user, pass) // not user:pass@ in the URL};
var handler = new SocketsHttpHandler{ Proxy = proxy, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) // see gotcha below};
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var ip = await client.GetStringAsync("https://api.ipify.org");Console.WriteLine(ip); // a US residential IP理解しておくべき点が二つある。user文字列にはターゲティングのフラグ(-country-us)が含まれる。ジオ情報はそこに存在するからだ。そして認証情報はWebProxy上にNetworkCredentialとして渡す。HttpClientは、ブラウザがするようにプロキシURIからuser:pass@hostを読み取ることはない。
古いHttpClientHandlerよりもSocketsHttpHandler(最新の.NETでのデフォルトハンドラ)を優先すること。これはモダンでクロスプラットフォームな実装であり、後述の理由で必要になるPooledConnectionLifetimeを公開している。
落とし穴1: HttpClientのライフタイム問題
これは.NET特有の落とし穴で、正反対の二方向から噛みついてくる。
リクエストごとにnew HttpClient()を作成すると、ソケットがリークする。各クライアントは独自の接続プールを保持し、破棄されたクライアントはTIME_WAIT状態のままソケットを残してしまう。負荷がかかると利用可能なポートが枯渇し、アプリがSocketExceptionを投げ始める。そのため、単一のHttpClientを再利用すべきというのがよく知られたアドバイスだ。
しかし、単一のクライアントを永久に保持すると逆の問題が生じる。接続プールの寿命の間DNS解決をキャッシュし続け、エンドポイントのIPが変わっても気づかない。解決策は二択ではなく、SocketsHttpHandlerのPooledConnectionLifetimeだ。これはプールされた接続を定期的にリサイクルし、古いDNSに固定されることなく接続の再利用を維持する。これを数分程度に設定し、クライアントを再利用すること。
クライアントを再利用することは、プロキシを介した接続をウォームな状態に保つことにもつながる。これはまさにレイテンシガイドが排除しようとしているオーバーヘッドである。
ASP.NET Core流のやり方: IHttpClientFactory
DIベースのアプリであれば、HttpClientを手動で管理することは一切すべきでない。IHttpClientFactoryはハンドラのライフタイムと接続のリサイクルを代わりに処理し、名前付きクライアント上でプロキシを一度だけ設定できる。
services.AddHttpClient("shifter") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential( Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us", Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true });
// then inject IHttpClientFactory and call factory.CreateClient("shifter")ファクトリは独自のスケジュールで基盤となるハンドラをローテーションするため、自分でPooledConnectionLifetimeを操作せずにプーリングと新鮮なDNSの両方を得られる。長時間稼働するサービスにはこれが推奨される方法だ。
落とし穴2: プロキシはハンドラ上にあるため、アイデンティティごとにクライアントでローテーションする
プロキシとその認証情報がハンドラに焼き込まれているため、単一のHttpClient上でリクエストごとにプロキシのユーザー名を変えることはできない。異なるアイデンティティは異なるハンドラ、つまり異なるクライアントを意味する。効率的なパターンは、アイデンティティごとに一つのクライアントをキャッシュし、各セッションが独自の接続プールを保持するようにすることだ。これは他のスタックにおけるアイデンティティごとのエージェントマップと同じ考え方である。
using System.Collections.Concurrent;
static readonly ConcurrentDictionary<string, HttpClient> Clients = new();
static HttpClient ClientFor(string country, string sid){ var key = $"{country}:{sid ?? "rotate"}"; return Clients.GetOrAdd(key, _ => { var u = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-" + country + (sid != null ? $"-sid-{sid}-ttl-600" : ""); var handler = new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential(u, Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }; return new HttpClient(handler); });}論理的な作業単位ごとに独自のsidを与え、ストリームの途中ではなく作業単位間でローテーションすること(スティッキー対ローテーティングがこの区別を扱っている)。また、ロードバランシングの記事が説明する方法で作業をアイデンティティに割り当てる。これらのクライアントはキャッシュして再利用し、リクエストごとに新しく作成しないこと。
落とし穴3: 並行性に上限を設ける
.NETではTask.WhenAllを使って一度に数千のリクエストを発射することが簡単にできてしまい、すべての接続を同時に開くことを何も止めてくれない。それは自分側のソケットを枯渇させ、ターゲットにとっては攻撃のように見える。SemaphoreSlimで処理中のリクエストに上限を設けること。
var gate = new SemaphoreSlim(8); // at most 8 concurrent requests
async Task<string> Fetch(HttpClient client, string url){ await gate.WaitAsync(); try { return await client.GetStringAsync(url); } finally { gate.Release(); }}全体だけでなくターゲットホストごとに並行性を制限し、脆弱な一つのサイトが叩かれ続ける一方で許容度の高いサイトが飢餓状態になることを避けること。ターゲットの許容範囲を超えた並行性は、スループットではなくブロックを招く(ブロックを避ける方法)。
実際にプロキシを経由しているか確認する
ベンチマークやその他のデバッグを行う前に、出口IPを確認すること。
var json = await client.GetStringAsync("http://ip-api.com/json");Console.WriteLine(json); // expect a residential IP in the targeted country自分のIPが表示される場合、ハンドラが適用されていない(通常はUseProxyがfalseのままか、認証情報が間違ったオブジェクトに設定されている)。ハングする場合はローカルの発信がブロックされている。両方ともタイムアウト診断ガイドで扱われている。
FAQ
プロキシの認証情報をURLに入れても動作しないのはなぜですか?
HttpClientはプロキシURIからuser:pass@hostを読み取らない。認証情報はNetworkCredentialとしてWebProxyオブジェクトに設定すること。ゲートウェイはターゲティングをユーザー名にエンコードするため、その完全なユーザー名(-country-...のフラグ付き)がNetworkCredentialのユーザー名となり、パスワードは一定である。
HttpClientHandlerとSocketsHttpHandler、どちらを使うべきですか?
最新の.NETではSocketsHttpHandlerを優先すること。これはデフォルトのマネージドハンドラであり、クロスプラットフォームであり、長寿命のクライアントで古いDNSを避けるために必要なPooledConnectionLifetimeを公開している。HttpClientHandlerは今でも動作し、内部でこれに委譲している。
私の.NETスクレイパーが負荷下でSocketExceptionを投げます。なぜですか?
おそらくリクエストごとにnew HttpClient()を作成し、ソケットを枯渇させている。単一のクライアントを再利用するか(またはIHttpClientFactoryを使う)、再利用が古いDNSに固定されないようにPooledConnectionLifetimeを設定すること。
C#でリクエストごとにIPをローテーションするにはどうすればよいですか?
プロキシのユーザー名を変える、つまり異なるハンドラ、そして異なるクライアントを意味する。ConcurrentDictionaryにアイデンティティごとに一つのHttpClientをキャッシュして、各セッションが独自のプールを保持できるようにし、作業単位ごとにクライアントを選択する。新しい接続ごとにローテーションするには、ユーザー名のsidを省略すること。
IHttpClientFactoryはプロキシで動作しますか?
はい。ConfigurePrimaryHttpMessageHandlerを介してプライマリハンドラ上でプロキシを設定する。ファクトリはハンドラのライフタイムとDNSの新鮮さを代わりに管理するため、DIベースのサービスでは最もクリーンな選択肢である。
結論
C#とレジデンシャルプロキシの組み合わせは、二つのことを守れば素早く実現できる。認証情報はURLではなくNetworkCredentialとしてWebProxyに設定すること、そしてHttpClientのライフタイムを正しく扱うことだ。PooledConnectionLifetime付きのSocketsHttpHandlerを使うか、DIアプリではIHttpClientFactoryを使うことで、古いDNSに固定されることなく、またソケットをリークすることなく接続を再利用できる。ジオとセッションのローテーションは、アイデンティティごとに一つのクライアントをキャッシュして行い、並行性はSemaphoreSlimで制限する。
これを正しく行えば、.NETは並行データ収集を他の何とも同様に扱える。レジデンシャルゲートウェイを利用し、プールの品質がそもそも何回リトライすることになるかを決めるということを忘れないでほしい(IPレピュテーション)。料金ページにはGB単位のプランがあり、自分のターゲットに対してテストできる。