Node でプロキシを通して単一のリクエストを通すのは、10行で済む問題だ。チュートリアルはたいていそこで終わるが、そこからプロジェクトは成長していく。2つ目のHTTPクライアントが登場し、認証情報が3つのファイルにハードコードされ、誰かがアメリカではなくドイツを必要とし始め、リクエストが本当に想定した場所から出ているのかを誰も答えられなくなる。
これは構文ガイドではなく、セットアップガイドだ。各クライアントが期待する正確なエージェント設定、たとえばAxiosがエージェントに加えてproxy: falseを必要とする理由が知りたいなら、それはNode.jsでAxiosとGotを使ってレジデンシャルプロキシを使う方法で詳しく扱っている。以下では、プロジェクトが成長しても配線が正しく保たれるよう、プロジェクトをどう組み立てるかを説明する。
実際に対応するクライアントを決める
Nodeには一般的に使われる3つのHTTPクライアントがあり、それぞれ異なる仕組みでプロキシを扱う。ある程度の年数を経たコードベースで3つすべてに対応しているのは普通のことだが、それは偶然ではなく意思決定であるべきだ。
Axiosは明示的なエージェントを必要とする。これは、Axios自身のproxyオプションが人々の期待するようにHTTPSトンネリングを処理しないためだ。https-proxy-agentをインストールする必要がある。
Gotはエージェントをトップレベルのオプションではなくagent.httpsスロットで受け取る。
Native fetchはundiciを経由するため、ディスパッチャーとしてProxyAgentが必要になる。undiciはNodeに同梱されているが、自分でディスパッチャーを構築する場合は明示的な依存関係として扱いたいだろう。
実務的な結果として、依存関係リストはどのクライアントを維持するかによって決まる。つまり2つのクライアントを維持することは2つのプロキシ経路を維持することになる。ほとんどのコードベースは1つに統一して残りを変換する方がよく、本当に2つが必要な場合でも少なくともその理由を把握しておくべきだ。
認証情報はソースではなく環境に置く
ゲートウェイはターゲティングをユーザー名で受け取るため、認証情報の文字列とルーティング設定は同じ文字列になる。これは便利だが、まさにこうしてプロキシの認証情報がコミットされてしまう原因にもなる。
各要素は設定の中で分離しておく。
PROXY_HOST=p.shifter.ioPROXY_PORT=443PROXY_USER=customer-USERNAMEPROXY_PASS=your-passwordPROXY_COUNTRY=usそしてユーザー名は実行時に組み立てる。後で助けになるルールは、プロキシモジュール以外のファイルにリテラルなプロキシURLを含めないことだ。ターゲティングが埋め込まれた認証情報の文字列がスクレイパーにインラインで一度現れると、それはコピーされ、そのコピーは徐々にずれていく。
ログに記録するURLにパスワードを入れてはならない。プロキシURLは認証情報がログ収集システムに届く最も一般的な経路であり、自然なデバッグログはURL全体をそのまま出してしまうからだ。
1つのモジュールがエージェントを構築する
最も重要な構造上の決定は、設定をエージェントに変換する場所を1か所だけにすることだ。これは小さなモジュールだが、多くの種類の問題を防ぐ。
import { HttpsProxyAgent } from "https-proxy-agent";
const { PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS } = process.env;
export function proxyUrl({ country, session, ttl } = {}) { const parts = [PROXY_USER]; if (country) parts.push("country", country); if (session) parts.push("sid", session); if (session && ttl) parts.push("ttl", String(ttl)); const user = parts.join("-"); return `https://${user}:${PROXY_PASS}@${PROXY_HOST}:${PROXY_PORT}`;}
export function agentFor(opts) { return new HttpsProxyAgent(proxyUrl(opts));}このスニペットには明示すべき2つの詳細がある。ターゲティングのフラグは、各呼び出し箇所で文字列を連結するのではなく、名前付きオプションから組み立てられている。これにより、フラグ名のタイプミスが認証情報の問題に見える407エラーになることを防いでいる。そしてttlはセッションが存在する場合にのみ追加される。セッションIDがなければ、time to liveには維持すべき対象がないからだ。
呼び出し側はURLを一切見ることがない。
const agent = agentFor({ country: "de", session: "job-141", ttl: 600 });
// axiosawait axios.get(url, { httpsAgent: agent, proxy: false });
// gotawait got(url, { agent: { https: agent } });Native fetchはエージェントを受け取らない唯一のケースだ。undiciはディスパッチャーを要求する。
import { ProxyAgent, fetch } from "undici";import { proxyUrl } from "./proxy.js";
const dispatcher = new ProxyAgent(proxyUrl({ country: "de" }));await fetch(url, { dispatcher });proxyUrlを別途エクスポートしておくことで、fetchの経路も文字列組み立てを重複させずに他の2つと設定を共有できる。
エージェントを再利用し、セッションは事前に決めておく
エージェントはコネクションプールを保持する。リクエストごとに1つずつ構築すると、そのプールを毎回捨ててしまうことになり、これはネットワークの問題として誤診断されがちなレイテンシとして現れる。
使用する設定ごとにエージェントを1つだけ構築し、キャッシュする。5か国をローテーションしているなら、それはプロセスの生存期間中に保持される5つのエージェントであって、リクエストごとに1つではない。
セッションはこれに関連する決定だ。セッションIDがなければゲートウェイはローテーションする。これは独立したリクエストには望ましい動作だ。sidを使うと固定IPが得られ、指定したttlの間保持される。これはページネーションされた結果セット、マルチステップのフロー、2つ目のリクエストが1つ目と同じ場所から来る必要がある処理など、継続性を要するあらゆる処理に望ましい。デフォルトの固定時間は、設定しなければ120秒だ。
呼び出し箇所ごとではなくセットアップ時にこれを選ぶことが、一貫したローテーションポリシーと、半分のリクエストがたまたま固定になっているコードベースとの違いになる。この trade-off についてはローテーティングプロキシと静的プロキシの比較で説明している。
構築する前に検証する
スクレイパーを書く前に検証スクリプトを書く。5分で済み、将来の混乱の丸ごとひとつの分類を即座の答えに変えてくれる。
import { agentFor } from "./proxy.js";import axios from "axios";
const agent = agentFor({ country: "de" });const { data } = await axios.get("https://api.ipify.org?format=json", { httpsAgent: agent, proxy: false,});console.log(data);これで自分自身のアドレスが表示されるなら、エージェントが接続されておらず、プロジェクト内のすべてのリクエストが直接出ていっていることになる。これは実際に数日間続いてしまうことがある珍しくない状態だ。なぜなら何も失敗しないからだ。コードは動作する、ただプロキシを使っていないだけだ。国が実際に要求したものと一致しているかを確認するのがチェックの後半であり、それを正しく行う方法はプロキシの速度、成功率、位置情報の正確性をテストするに書かれている。
package.jsonのスクリプトにしておけば、何かおかしいと感じたときに誰でも実行できる。
エラーは一度だけマッピングする
プロキシのエラーは十分に具体的で自動的に診断できる。それを1か所で行う方が、深夜3時に何度も解釈し直すよりましだ。
- 407は認証情報が誤っているか、ターゲティングフラグの形式が不正であることを意味する。フラグ名のスペルミスもここに現れるため、パスワードより先にフラグ名を確認する価値がある。
- 502はフィルタに一致するexitがないことを意味する。ネットワークが落ちているのではなく、フィルタが狭すぎるということだ。
- 509は帯域割り当てが使い切られたことを意味する。
- Connection refusedは通常、古い設定から残った旧式のホストやポートを意味する。
一時的な失敗だけがリトライに値する。407をループでリトライすることは、決して自然には解決しない問題に対してレート制限を消耗するだけだ。509のリトライも何の効果もない。リトライは固定の遅延ではなく実際のバックオフでラップすること。これはレート制限とリクエストのスロットリングで扱っている。
並行度は意図的に制限する
Nodeは1万のリクエストを平然と開始し、3つのクライアントのどれもそれを止めない。プロキシの下ではこれは通常より悪い状況になる。それらのリクエストの1つ1つがトンネルを確立する必要があるからだ。
最初の事故が起きた後に追加するのではなく、最初から並行度リミッターを使う。防御機構を発動させてリトライに時間を費やす無制限のバーストよりも、安定したスループットを持つ適度な制限の方が優れている。
開発、CI、本番は異なる
Nodeプロジェクトにプロキシが組み込まれると必ず出てくる、3つの実務上の注意点がある。
レジデンシャルトラフィックは帯域幅で課金されるため、プロキシを通して実際のターゲットにアクセスするテストスイートは、利益なしに繰り返し発生する費用になる。単体テストではHTTP層をモックし、少数の実際のリクエストは別の手動でトリガーするチェックに保つ。
ローカル開発は本番と同じモジュール、同じ環境変数を使い、値だけを変える。本番にしか存在しない設定は、誰もテストしていない設定だ。
そしてコンテナイメージには認証情報を焼き込んではならない。他のシークレットと同様に、実行時に渡す。
FAQ
fetchしか使わない場合、https-proxy-agentは必要か?
不要だ。undiciのProxyAgentがその経路を扱う。AxiosとGotには別のエージェントパッケージが必要だ。
すでにエージェントを渡しているのに、なぜAxiosはproxy: falseを必要とするのか?
Axiosはそうしないと、エージェントの上に自分自身のプロキシ処理を適用しようとし、2つが組み合わない。falseに設定することで、処理を完全にエージェントに委ねる。
エージェントごとではなくリクエストごとにターゲティングを設定できるか?
できるが、設定が異なるごとに異なるエージェントになるため、リクエストごとに1つ構築するとコネクションプールを失うことになる。設定のキーでキャッシュすること。
国は設定値にするべきか、それとも呼び出しごとの引数にするべきか?
実際には両方だ。設定にデフォルト値を持たせ、特定のリージョンが必要なジョブでは呼び出しごとに上書きできるようにする。避けたいのは、国が個々のスクレイパーの中にリテラルとして現れることだ。
まとめ
Nodeにおけるプロキシの配線は小さく、十分に理解されている。プロジェクトが保守可能な状態を保てるかどうかを決めるのは、その周りの構成だ。設定からエージェントを構築する1つのモジュール、環境に置かれた認証情報、再構築ではなく再利用されるエージェント、意図的に選ばれたセッションポリシー、スクレイパーより前に存在する検証スクリプト、そしてフィルタが狭すぎるのかパスワードが間違っているのかを見分けるエラーハンドリング。
これを一度セットアップすれば、クライアントライブラリや新しいリージョンの追加は設定変更で済む。それを省略すると、新しい要件が出るたびにコードベースをハードコードされた文字列を求めて検索することになる。ゲートウェイの詳細はレジデンシャルプロキシのページ、帯域幅の料金は価格ページにある。