PHPは今でもサーバー間トラフィックの膨大な部分を担っており、そのほとんどは2つの経路のいずれかを通る。生のcURL、またはcURLの上に乗るGuzzleだ。residential proxyをどちらに組み込むにしても、必要なのはいくつかのオプション設定であり、書き直しではない。詰まるのはそれぞれが異なる方法で扱う細部だ。プロキシ認証の渡し方、HTTPSがプロキシ経由で機能するかどうかを左右する1つの設定、そして高速なスクレイパーと毎回フルハンドシェイクを払うスクレイパーを分ける接続再利用の挙動である。
これはPythonでのresidential proxies、Playwrightでの、Goでのと同じシリーズのPHP編であり、cURLとGuzzleの両方で機能するコードに加え、PHP特有の落とし穴を扱う。
以下すべてはShifterのresidential gatewayを使用する。エンドポイントはp.shifter.io:443の1つのみで、すべてのターゲティングはユーザー名にエンコードされる。ホストと認証情報を別のプロバイダーのものに入れ替えても、形は同じだ。
ゲートウェイモデルを一段落で
プロキシのユーザー名は認証情報とターゲティングの両方を運ぶ。国やセッションを変えるためにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変える。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usはアメリカ合衆国をターゲットにし、sidはスティッキーセッションを固定し、ttlはそのIPをN秒間保持する。sid/ttlを省略すると、新しい接続のたびにローテーションする。パスワードは一定のままだ。この文字列全体が、cURLやGuzzleに渡すユーザー名になる。
生のcURL
cURLはプロキシホストをCURLOPT_PROXYで、認証情報をCURLOPT_PROXYUSERPWDで受け取る。プロキシ文字列にuser:pass@hostをインラインで書くこともできるが、認証情報を別のオプションに保つ方がすっきりしており、長いユーザー名でのURLエンコードの面倒も避けられる。
<?php$user = getenv('SHIFTER_USER') . '-country-us';$pass = getenv('SHIFTER_PASS');
$ch = curl_init('https://api.ipify.org');curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_PROXY => 'p.shifter.io:443', CURLOPT_PROXYUSERPWD => "$user:$pass", CURLOPT_HTTPPROXYTUNNEL => true, // CONNECT tunnel for HTTPS through the proxy CURLOPT_CONNECTTIMEOUT => 10, // connect phase only CURLOPT_TIMEOUT => 30, // whole transfer]);
$body = curl_exec($ch);if ($body === false) { fwrite(STDERR, 'curl error: ' . curl_error($ch) . "\n");} else { echo $body, "\n"; // a US residential IP}curl_close($ch);理解すべきことが2つある。$user文字列にはターゲティングフラグ(-country-us)が含まれる。地域情報はそこに存在するからだ。そしてCURLOPT_HTTPPROXYTUNNELが、プロキシ経由のHTTPSを機能させるものだ。これはcURLにCONNECTトンネルを開くよう指示し、TLSがプロキシとではなくターゲットとエンドツーエンドでネゴシエートされるようにする。これを設定しないと、HTTPプロキシ経由のHTTPSリクエストは失敗するか、おかしな挙動になる。これはPHPプロキシに関する最もよくある間違いだ。
Guzzle
Guzzleはproxyリクエスト(またはクライアント)オプションでプロキシを受け取り、認証情報はURLにインラインで含める。GuzzleはcURLのラッパーなので、内部では同じトンネル挙動が適用されるが、Guzzleはhttps://ターゲットに対するCONNECTを自動的に処理する。
<?phprequire 'vendor/autoload.php';
use GuzzleHttp\Client;
$user = getenv('SHIFTER_USER') . '-country-us';$pass = getenv('SHIFTER_PASS');$proxy = "http://$user:$pass@p.shifter.io:443";
// Build the client ONCE and reuse it (see the connection-reuse note below).$client = new Client([ 'proxy' => $proxy, 'connect_timeout' => 10, // connect phase 'timeout' => 30, // whole request]);
$res = $client->get('https://api.ipify.org');echo $res->getBody(), "\n"; // a US residential IPconnect_timeoutとtimeoutが分かれていることに注目してほしい。この接続時間と合計時間の分離こそが、何かが停滞したときにタイムアウトを診断可能にするものだ。接続が遅い場合は自分側か、一致するIPが不足していることを示し、合計が遅い場合はターゲット側を示す。
Guzzleはまた、proxyオプションをリクエストごとに、またスキームをキーとした配列としても受け付ける。これにより、httpsだけをプロキシ経由にルーティングしたり、noバイパスリストを設定したりできる。
$res = $client->get('https://example.com', [ 'proxy' => [ 'http' => $proxy, 'https' => $proxy, 'no' => ['localhost', '127.0.0.1'], ],]);落とし穴1: Guzzleクライアント(とcURLハンドル)を再利用する
リクエストごとに新しいGuzzleHttp\Clientを作る、またはリクエストごとに新しいcurl_init()を呼ぶと、毎回プロキシ経由でフルのTCP + TLSハンドシェイクを払うことになる。これはレイテンシガイドがなくそうとしているオーバーヘッドそのものだ。Guzzleは内部でcURLハンドルのプールを保持し、クライアントを再利用すれば接続も再利用する。起動時に1つクライアントを構築し、それを注入して保持しておくこと。
生のcURLでは、リクエスト間でハンドルを再利用し、呼び出しごとにURLだけを変えることで、接続を温かい状態に保つ。
$ch = curl_init();curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_PROXY => 'p.shifter.io:443', CURLOPT_PROXYUSERPWD => "$user:$pass", CURLOPT_HTTPPROXYTUNNEL => true,]);foreach ($urls as $url) { curl_setopt($ch, CURLOPT_URL, $url); // reuse the handle, keep the connection $body = curl_exec($ch); // ... handle $body}curl_close($ch);落とし穴2: PHPのデフォルトは一度に1つのURLしか取得しない
PHPのリクエストハンドラーはデフォルトで同期的だ。curl_execをループで回すスクレイパーは厳密に一度に1つのURLしか取得せず、これは小規模なジョブなら問題ないが、規模が大きくなると痛いほど遅い。上に行く方法は2つある。
上限を設けたプールを使ったGuzzleの非同期処理。これにより、N個のリクエストが同時進行するが、それを超えることはない。
use GuzzleHttp\Pool;use GuzzleHttp\Psr7\Request;
$requests = function ($urls) { foreach ($urls as $u) { yield new Request('GET', $u); }};
$pool = new Pool($client, $requests($urls), [ 'concurrency' => 8, // cap in-flight requests 'fulfilled' => function ($response, $i) { /* handle */ }, 'rejected' => function ($reason, $i) { /* log + retry */ },]);$pool->promise()->wait();あるいは、生のcURLを使っているならcurl_multi_*だ。いずれの場合も、並行数はグローバルにではなくターゲットホストごとに上限を設け、1つの壊れやすいサイトが叩かれ続ける一方で寛容なサイトが飢餓状態になることがないようにする。ターゲットの許容量を超えた並列化は、スループットではなくブロックを買うだけだ(スクレイピングでブロックされないための方法)。
落とし穴3: レスポンスを消費し、ステータスだけでなくトランスポートエラーも確認する
cURLはトランスポート障害(プロキシが拒否、トンネル失敗、タイムアウト)ではfalseを返し、HTTPレスポンス(407や502であっても)ではボディの文字列を返す。curl_execの戻り値をfalseと比較して確認し、ステータスコードを信頼する前にcurl_error/curl_errnoを読むこと。Guzzleでは、接続失敗はConnectExceptionをスローし、4xx/5xxはhttp_errorsがオンの場合(デフォルトでオンだ)のみRequestExceptionをスローする。両方をハンドリングし、ハンドルを再利用可能にするために常にボディを読み切ること。
use GuzzleHttp\Exception\ConnectException;use GuzzleHttp\Exception\RequestException;
try { $res = $client->get($url); $body = (string) $res->getBody(); // drain the body} catch (ConnectException $e) { // transport: proxy/tunnel/timeout — retry with a fresh identity} catch (RequestException $e) { // HTTP status — inspect $e->getResponse()->getStatusCode()}地域とセッションのローテーション
ターゲティングがユーザー名に存在するため、異なるアイデンティティは異なる認証情報文字列になる。
生のcURL: 呼び出し前にCURLOPT_PROXYUSERPWDを新しいユーザー名に設定する。同じハンドルが反復ごとに異なるアイデンティティを運べる。
Guzzle: proxyオプションをリクエストごとに渡してクライアントのデフォルトを上書きすることで、1つのクライアントが再構築なしに多数のアイデンティティを扱える。
function userFor(string $country, ?string $sid = null): string { $u = getenv('SHIFTER_USER') . '-country-' . $country; if ($sid !== null) { $u .= '-sid-' . $sid . '-ttl-600'; } return $u;}
$pass = getenv('SHIFTER_PASS');$proxy = 'http://' . userFor('de', 'job-42') . ":$pass@p.shifter.io:443";$res = $client->get('https://example.com', ['proxy' => $proxy]);論理的な作業単位それぞれに専用のsidを与え、ストリームの途中ではなく単位間でローテーションすること(sticky vs rotatingがこの違いを扱っている)。作業とアイデンティティのマッピングはロードバランシングの投稿が説明する方法で行う。
実際にプロキシ経由になっているか検証する
ベンチマークやデバッグを始める前に、出口IPを確認すること。
$res = $client->get('http://ip-api.com/json');echo $res->getBody(), "\n"; // expect a residential IP in the targeted country自分自身のIPが表示されるなら、プロキシオプションが適用されていない。ハングするならローカルの発信がブロックされている。両方ともタイムアウト診断ガイドで扱われている。
FAQ
生のcURLでHTTPSリクエストがプロキシ経由で失敗するのはなぜですか?
ほぼ確実にCURLOPT_HTTPPROXYTUNNELが抜けている。HTTPプロキシ経由のHTTPSにはCONNECTトンネルが必要で、これによりTLSがプロキシではなくターゲットとネゴシエートされる。CURLOPT_HTTPPROXYTUNNEL => trueを設定すること。Guzzleはhttps://ターゲットに対してこれを自動的に行うため、この落とし穴は生のcURLだけを噛む。
プロキシの認証情報はURLに入れるべきですか、それとも別のオプションにすべきですか?
どちらでも機能する。生のcURLでは、CURLOPT_PROXYUSERPWDにより長いゲートウェイユーザー名をURLから外に出せ、URLエンコードの問題を避けられる。Guzzleでは、proxy文字列にuser:pass@hostをインラインで書くのが通常の方法だ。どちらかを選び、一貫させること。
プロキシが速いのに私のPHPスクレイパーが遅いのはなぜですか?
おそらくリクエストごとに新しいGuzzleクライアント(またはcurl_init)を作成しており、毎回新しいハンドシェイクを払っているか、あるいは厳密に一度に1つのURLしか取得していない。1つのクライアント/ハンドルを再利用し、上限を設けたGuzzleのPoolかcurl_multiを使って複数のリクエストを並行実行すること。
PHPでリクエストごとにIPをローテーションするにはどうすればよいですか?
プロキシのユーザー名を変えること。生のcURLでは、呼び出しごとにCURLOPT_PROXYUSERPWDを新しいユーザー名に設定する。Guzzleでは、proxyオプションをリクエストごとに渡す。どちらも1つの共有クライアントまたはハンドルが多数のアイデンティティを扱えるようにする。
スクレイピングにはcURLとGuzzleどちらがよいですか? Guzzleは小さな依存関係で非同期プール、ミドルウェア、リトライ、PSR-7を提供する。生のcURLは依存関係なしで、呼び出しごとにわずかに高速だ。並行性と構造が欲しいならGuzzle、タイトで最小限のスクリプトなら生のcURLを使うこと。上記のプロキシ設定と落とし穴はGuzzleがcURLの上で動くため、両方に当てはまる。
結論
PHPとresidential proxiesの組み合わせは、それぞれのクライアントのルールを尊重すれば安定する。生のcURLでは、プロキシホストとCURLOPT_PROXYUSERPWDを設定し、HTTPSのためにCURLOPT_HTTPPROXYTUNNELを決して忘れないこと。Guzzleでは、proxyオプションを渡してトンネリングを任せること。1つのクライアントまたはハンドルを再利用して接続を温かく保ち、一度に1つずつではなく上限を設けたプールでリクエストを並行実行し、並行数はターゲットホストごとに上限を設け、ステータスコードだけでなくトランスポートエラーも確認すること。地域やセッションを変えるにはプロキシのユーザー名を変えること。
これを正しく行えば、両方の経路とも規模を拡大してもPHPが本来出すべき性能を発揮する。residential gatewayに向けて実行し、プールの品質がそもそもリトライの頻度を決めることを忘れないこと(IP reputation)。価格ページにはGB単位のプランがあり、自分のターゲットに対してテストできる。