もしあなたが SocketException: Address already in use をキャッチしたことがあるか、または.NETアプリケーションがプロキシを使用して数時間後に突然利用可能なソケットがなくなるのを見たことがあるなら、問題はほぼ常に HttpClient の誤った使用にあります。この記事では、SocketsHttpHandler を介してプロキシを設定し、SOCKS5を接続し、接続プールがボトルネックにならないようにIPのローテーションを行う方法を解説します。
なぜHttpClientはソケットを「消費する」のか
クラシックな誤りは、using内で各リクエストごとに新しいHttpClientのインスタンスを作成することです。プロキシを使用する場合、この問題は悪化します: 各プロキシサーバーを介した接続には別々のTCPハンドシェイクが必要であり、さらにプロキシを動的に変更する場合、接続プールは再利用されず、ソケットがTIME_WAITの状態に留まります。
Windowsでは特に重要です — デフォルトでは動的ポートの数が制限されています(通常16384)、そしてアプリケーションがプロキシを介して高頻度でデータを解析している場合、すべてのポートが数分で使い果たされる可能性があります。Linuxではカーネルの設定のおかげで状況は少し緩和されますが、接続を再利用しない限り問題は残ります。
古いHttpClientHandlerは、接続のライフサイクルを柔軟に制御することができません。そのため、.NET Core 2.1以降はSocketsHttpHandlerを使用することが推奨されています — これは完全にマネージコードで書かれており、接続プール、DNSのタイムアウト、およびプロキシ設定に直接アクセスできます。
SocketsHttpHandler: それが必要な理由
SocketsHttpHandlerは、.NET Core 2.1以降でLinuxとmacOSのデフォルトのHttpClientが構築されるトランスポート層です。 .NET 5以降はWindowsでも使用されます。古いWinHttpHandlerとは異なり、以下の点が特徴です:
PooledConnectionLifetimeを設定できる — プール内の接続のライフタイムで、ローテーションプロキシを使用する際に重要です;IWebProxyにアクセスできる、プログラムで動的にオーバーライドできるProxyデリゲートを介して;ConnectCallbackをサポート — これはSOCKS5に必要なトランスポートを置き換えるための低レベルのフックです;PooledConnectionIdleTimeoutが経過した後にソケットを正しく解放し、システムにゴミを残しません。
基本的な設定は次のようになります:
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 50,
AutomaticDecompression = DecompressionMethods.All
};
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
重要なルール: このハンドラーを持つHttpClientはシングルトンであるか、IHttpClientFactoryを介して作成されるべきです — 各リクエストごとに再生成しないでください、さもないと接続プールの意味が失われます。
SocketsHttpHandlerを介したプロキシの設定
HTTP/HTTPSプロキシの場合、ハンドラーのレベルでProxyプロパティを設定するだけで済みます。これは、HTTP CONNECTプロトコルを介してアクセスを提供するデータセンターのプロキシやレジデンシャルプロキシに対して機能します:
var proxy = new WebProxy("http://proxy-host:8000")
{
Credentials = new NetworkCredential("login", "password")
};
var handler = new SocketsHttpHandler
{
Proxy = proxy,
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(3)
};
var client = new HttpClient(handler);
var response = await client.GetAsync("https://example.com");
Console.WriteLine(await response.Content.ReadAsStringAsync());
異なるホストに対して異なるプロキシを接続する必要がある場合(例えば、あるドメインへの認証が直接行われ、スクレイピングがプロキシを介して行われる場合)、静的オブジェクトの代わりにデリゲートを使用してください:
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(),
UseProxy = true
};
handler.Proxy = new CustomProxy(uri =>
{
return uri.Host.Contains("target-site")
? new Uri("http://proxy-host:8000")
: null;
});
マーケットプレイスの解析タスク、例えばWildberriesやOzonの価格監視などでは、両方のアプローチを組み合わせることがよくあります: 主なトラフィックはデータセンターのプロキシを介して流れ、CAPTCHAサービスや認証へのリクエストは直接行われます。
.NETにおけるSOCKS5のサポート
.NET 6以降、SocketsHttpHandlerは、プロキシのURIにスキームを指定することでSOCKS4/SOCKS4a/SOCKS5のネイティブサポートを受けました — サードパーティのライブラリは必要ありません:
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
{
Credentials = new NetworkCredential("login", "password")
},
UseProxy = true
};
var client = new HttpClient(handler);
var response = await client.GetAsync("https://httpbin.org/ip");
重要な点: .NET 6以前にはこの機能はなく、開発者は手動でSOCKS5ハンドシェイクを行う独自のConnectCallbackを書く必要がありました。もしあなたのプロジェクトがまだ.NET Core 3.1または.NET 5の場合、この方法は依然として有効です:
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
await socket.ConnectAsync("proxy-host", 1080, cancellationToken);
await PerformSocks5Handshake(socket, context.DnsEndPoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
};
実際には、プロダクションプロジェクトでは.NET 6+にアップグレードして組み込みの実装を使用する方が簡単です — これはランタイムでテストされており、ハンドシェイクプロトコルの独自のサポートを必要としません。
HttpClientを再生成せずにプロキシをローテーションする
IPローテーションを実装する際の一般的な誤りは、プロキシを変更するたびに新しいHttpClient(つまり新しいハンドラーと新しい接続プール)を作成することです。これは未解放のソケットの蓄積を確実に引き起こします。正しいアプローチは、各呼び出しごとに異なるプロキシアドレスを返す独自のIWebProxyを実装することです:
public class RotatingProxy : IWebProxy
{
private readonly string[] _proxies;
private int _index = -1;
public RotatingProxy(string[] proxies) => _proxies = proxies;
public ICredentials Credentials { get; set; }
public Uri GetProxy(Uri destination)
{
var next = Interlocked.Increment(ref _index) % _proxies.Length;
return new Uri(_proxies[next]);
}
public bool IsBypassed(Uri host) => false;
}
このクラスは、同じ唯一のSocketsHttpHandlerに接続されます:
var handler = new SocketsHttpHandler
{
Proxy = new RotatingProxy(new[]
{
"http://proxy1:8000",
"http://proxy2:8000",
"http://proxy3:8000"
}),
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromSeconds(60)
};
var client = new HttpClient(handler);
このスキームは内部のソケットプールを再利用しますが、各リクエストは実際に新しいIPを介して送信されます。もしあなたが各プロキシに対して並列接続プールを構築するつもりなら(速度のために)、合理的なPooledConnectionLifetimeを設定してください — 通常は30〜90秒で、接続がプロキシプロバイダー側でIPが古くなるよりも早く期限切れにならないようにします。Avitoの解析やInstagram APIからのデータ収集のようなタスクには、プロバイダー側でローテーションされるレジデンシャルプロキシを使用する方が便利です — その場合、コード内でアドレスを変更する必要はなく、出力IPの自動交換を持つ「エントリーポイント」を1つ保持するだけで済みます。
PooledConnectionLifetimeとDNSローテーション
プロキシプロバイダーがDNSローテーションを使用している場合(1つのホスト名が各リクエストで異なるIPに解決される)、制限されたPooledConnectionLifetimeを設定することが重要です。デフォルトではInfiniteに設定されており、.NETは古いIPへの接続を無限に保持し、DNSの更新を無視します。
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromSeconds(30), // 接続を再生成
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
MaxConnectionsPerServer = 20
};
これは特にモバイルプロキシにとって重要で、プロバイダーによるIPの変更が携帯通信事業者のローテーションの範囲内で1〜10分ごとに発生する可能性があります。接続を長時間保持すると、リクエストはすでに解放されたIPを介して送信され続け、時にはタイムアウトエラーを受けることさえあります。このようなシナリオには、モバイルプロキシが適しています — これらはアンチフロードシステムからの信頼性が高いですが、クライアント側での接続TTLの設定が必要です。
短いPooledConnectionLifetimeは無料ではありません — 各接続の再生成は新しいTCP+TLSハンドシェイクを意味し、50〜200ミリ秒の遅延を追加します。IPの新鮮さとパフォーマンスのバランスは経験的に調整されます: 価格監視のタスクには60〜120秒で十分ですが、高頻度のアカウントファーミングには時には10〜20秒が必要です。
.NETアプリケーションのためのプロキシの種類の比較
| プロキシの種類 | プロトコル | 推奨PooledConnectionLifetime | タスク |
|---|---|---|---|
| データセンターのプロキシ | HTTP/HTTPS, SOCKS5 | 5-10分 | 解析、API統合、高速 |
| レジデンシャルプロキシ | HTTP/HTTPS, SOCKS5 | 30-90秒 | 価格監視、ジオブロックの回避 |
| モバイルプロキシ | HTTP/HTTPS, SOCKS5 | 10-30秒 | ソーシャルメディアの自動化、アンチフロードに敏感なAPI |
IHttpClientFactoryとプロキシを持つ型付きクライアント
ASP.NET Coreプロジェクトでは、HttpClientをIHttpClientFactoryを介して登録することが推奨されます — これにより、開発者はハンドラーのライフサイクル管理の責任を負う必要がなく、内部のSocketsHttpHandlerが自動的に再利用されます。
builder.Services.AddHttpClient("ProxyClient")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
{
Credentials = new NetworkCredential("login", "password")
},
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
SetHandlerLifetimeに注意してください — これはファクトリー内でのハンドラー自体のローテーションのための別のメカニズムです(デフォルトは2分)で、個別の接続のPooledConnectionLifetimeと混同しないでください。ファクトリーは定期的に新しいハンドラーを作成しますが、古い接続はアクティブなリクエストが完了するまで生存し続けます — ガベージコレクターが自動的にそれらをクリーンアップします。
複数のプロキシプールを持つシナリオでは、複数の名前付きクライアントを登録するのが便利です — 例えば、米国と欧州のために別々に、Google AdsやFacebook Adsの異なる地域からのテストを行う場合:
builder.Services.AddHttpClient("US-Proxy")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy("http://us-proxy-host:8000"),
UseProxy = true
});
builder.Services.AddHttpClient("EU-Proxy")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy("http://eu-proxy-host:8000"),
UseProxy = true
});
ソケットリークの診断
ソケットの蓄積が疑われる場合は、推測せずに確認してください。Linuxでは、プロセスのオープン接続の数を確認する簡単な方法があります:
# TIME_WAITの接続数
ss -tan state time-wait | wc -l
# 特定のプロセス(PID)のすべての接続
ls -la /proc/{PID}/fd | grep socket | wc -l
Windowsでは、netstat -anoコマンドを使用してPIDプロセスでフィルタリングされた同様の統計を取得できます。より深い診断のために.NETレベルで、dotnet-countersという組み込みツールを使用して、リアルタイムでSystem.Net.Httpのメトリクスを表示できます:
dotnet-counters monitor -p {PID} --counters System.Net.Http
# 主要なカウンター:
# current-connections
# connections-duration
# requests-failed-rate
current-connectionsが単調に増加し、決して減少しない場合 — これはハンドラーが再生成されすぎているか、PooledConnectionIdleTimeoutがInfiniteに設定されており、リクエストが正しく完了しないことを示す直接的な兆候です(例えば、レスポンスのボディが最後まで読み取られず、接続がプールに戻らない)。
もう一つの一般的なリークの原因は、読み取られなかったり、破棄されなかったresponse.Contentです。常にusing var response = ...またはレスポンスの処理が完了した後にDispose()を明示的に呼び出してください。
結論
.NETでのプロキシの正しい使用は、3つの原則に基づいています: 古い実装の代わりにSocketsHttpHandlerを使用し、HttpClientをシングルトンとして保持する(またはIHttpClientFactoryを介して)、そしてプロキシのローテーションは全体のクライアントを再生成するのではなく、カスタムIWebProxyを介して実装します。.NET 6以降はSOCKS5がネイティブにサポートされており、独自のハンドシェイクプロトコルを書く必要がなくなります。
あなたのアプリケーションがマーケットプレイスを解析したり、高頻度のリクエストでAPIからデータを収集している場合は、データセンターのプロキシに注目してください — これは接続プールの適切な設定で最大の速度を提供します。匿名性と通常のユーザートラフィックとの類似性が重要なタスクには、レジデンシャルプロキシが適しており、モバイルAPIやソーシャルプラットフォームでの作業には、モバイルプロキシが接続の短いライフタイムで便利です。