Goはスクレイピングに自然に適している。並行処理が安価で、標準ライブラリが充実しており、デプロイも単一の静的バイナリで済む。residential proxyをnet/httpに組み込むのは本当に3行で済む。1日を無駄にするのはその3行より後の部分だ。GoのHTTPクライアントには接続の再利用を静かに破壊する鋭利な落とし穴がいくつかあり、プロキシ経由になるとそれがレイテンシとタイムアウトに変わり、原因を説明するのに苦労することになる。
これはPythonでのレジデンシャルプロキシ利用やPlaywrightでの利用と同じシリーズのGo編であり、動作するコードと、Goで重要になる具体的な落とし穴を扱う。
以下はすべてShifterのresidential gatewayを使用する。エンドポイントは1つ、p.shifter.io:443で、ターゲティングはすべてユーザー名にエンコードされる。ホストと認証情報を別のプロバイダーに置き換えても、形は同じだ。
ゲートウェイモデルを一段落で
プロキシのユーザー名には認証情報とターゲティングの両方が含まれる。国やセッションを変更するのにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変更する。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usは米国をターゲットにし、sidはスティッキーセッションを固定し、ttlはそのIPをN秒間保持する。sid/ttlを省略すると、新しい接続ごとにローテーションする。パスワードは固定だ。
Chromium(インラインのプロキシ認証情報を無視する)とは異なり、Goのnet/httpはプロキシURL内の認証情報を正しく処理し、Proxy-Authorizationを自動で送信する。そのためhttp://user:pass@host:portはそのまま動作する。
最小構成版
package main
import ( "fmt" "io" "net/http" "net/url" "os" "time")
func main() { user, pass := os.Getenv("SHIFTER_USER"), os.Getenv("SHIFTER_PASS") proxyURL, err := url.Parse(fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass)) if err != nil { panic(err) }
client := &http.Client{ Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: 30 * time.Second, }
resp, err := client.Get("https://api.ipify.org") if err != nil { panic(err) } defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) // a US residential IP}これで統合は完了だ。ここからは、パフォーマンスを左右する部分になる。
落とし穴1: クライアントは再利用する、リクエストごとに新規作成しない
これはGoで最もよくある、最もコストの高い間違いであり、プロキシがそれをさらに悪化させる。
http.Clientとhttp.Transportは長期間存続し、共有されることを想定して設計されている。複数のgoroutineによる並行利用に対して安全であり、接続プールはTransportに保持される。リクエストごとに新しいクライアントを作成すると、すべてのリクエストが新規のTCPハンドシェイクに加えてプロキシ経由のTLSハンドシェイクの代償を払うことになる。これはまさにレイテンシガイドが排除すべきと述べているオーバーヘッドだ。さらに悪いことに、アイドル接続がリークする。
// BAD: a new client per request. Zero connection reuse, leaks connections.func fetch(u string) (*http.Response, error) { client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} return client.Get(u)}
// GOOD: one client, package-level or injected, shared across goroutines.var client = &http.Client{ /* configured once, see below */ }起動時に一度だけ構築し、それを渡し回すこと。
落とし穴2: ボディを読み切って閉じないと再利用されない
Goは、レスポンスボディが完全に読み切られて閉じられた場合にのみ、接続をプールに戻す。読まずに閉じると、接続は破棄され、クライアントを共有していても静かにプーリングを失うことになる。
resp, err := client.Get(u)if err != nil { return err}defer resp.Body.Close()
// If you don't need the body, still drain it so the connection can be reused.// Deferred calls run last-in-first-out, so this drain runs *before* the Close// above, which is the order you want.defer io.Copy(io.Discard, resp.Body)通常の方法(io.ReadAll、EOFまで読み込むJSONデコーダー)でボディを解析しているなら問題ない。落とし穴は早期リターンだ。非200ステータスでボディを読み切らずに処理を打ち切ると、接続は使用不能になり、多くのブロックに遭遇するスクレイパーではこれがトラフィックの大半を占めることになる。
落とし穴3: MaxIdleConnsPerHostのデフォルトは2
これはあらゆるGoスクレイパーを苦しめる。http.TransportのデフォルトのMaxIdleConnsPerHostは2だ。1つのホストに対して50個のgoroutineを実行すると、そのうち48個が新しい接続を開いては破棄し続け、それぞれがプロキシ経由のフルハンドシェイクの代償を払うことになる。
少なくともホストごとの並行数に合わせて設定すること。
transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), MaxIdleConns: 200, MaxIdleConnsPerHost: 50, // >= your per-host concurrency IdleConnTimeout: 90 * time.Second,
// Granular timeouts beat one blunt Client.Timeout. TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, ExpectContinueTimeout: 1 * time.Second,}
client := &http.Client{Transport: transport}ここでClient.Timeoutを意図的に設定していないことに注意してほしい。Client.Timeoutは、ダイアル、TLS、ヘッダー、ボディをカバーする単一の総予算だ。細かいトランスポートタイムアウトを使うことで、遅い接続と遅いレスポンスを区別できる。これはまさにタイムアウトを診断可能にする、接続対読み込みの区別だ。リクエストごとの全体的な期限にはcontext.WithTimeoutを使うこと。
地域とセッションのローテーション
ターゲティングがユーザー名に存在するため、異なるアイデンティティは異なるプロキシURLを意味する。2つのアプローチがある。
リクエストごと、Proxy関数を経由する方法。Transport.Proxyはリクエストごとに呼び出されるため、そこでアイデンティティを変えることができる。Goはプロキシのurlごとに接続をプールするため、これは効率的なままだ。
func proxyFor(country, sid string) (*url.URL, error) { u := fmt.Sprintf("%s-country-%s", os.Getenv("SHIFTER_USER"), country) if sid != "" { u += fmt.Sprintf("-sid-%s-ttl-600", sid) } return url.Parse(fmt.Sprintf("http://%s:%s@p.shifter.io:443", u, os.Getenv("SHIFTER_PASS")))}
// Read targeting off the request context so each job picks its own identity.type ctxKey stringconst identityKey ctxKey = "identity"
type identity struct{ Country, SID string }
transport := &http.Transport{ Proxy: func(r *http.Request) (*url.URL, error) { if id, ok := r.Context().Value(identityKey).(identity); ok { return proxyFor(id.Country, id.SID) } return proxyFor("us", "") }, MaxIdleConnsPerHost: 50,}そして、各ジョブにアイデンティティを紐付ける。
ctx := context.WithValue(req.Context(), identityKey, identity{Country: "de", SID: "job-42"})resp, err := client.Do(req.WithContext(ctx))または、アイデンティティごとのトランスポートをマップにキャッシュする方法もあり、小規模で固定的なセット(たとえば国ごとに1つ)がある場合に適している。どちらも機能するが、アイデンティティが作業項目ごとに決まる場合はcontextによるアプローチのほうがスケールしやすい。
論理的な作業単位ごとに独自のsidを持たせ、フローの途中ではなく作業単位間でローテーションすること(sticky vs rotatingがそれぞれの適用場面を扱い、load balancing postが作業とアイデンティティのマッピングを扱う)。
並行処理、ホストごとに
Goでは10,000個のgoroutineを起動するのは簡単であり、同じくらい簡単に自分自身をブロックされる状況に陥ることができる。並行処理はグローバルではなく、ターゲットホストごとに制限し、脆弱なターゲットが叩き潰されず、許容度の高いターゲットが不必要にスロットルされないようにすること。
import "golang.org/x/sync/semaphore"
var limits = map[string]*semaphore.Weighted{ "tough-site.example": semaphore.NewWeighted(4), "open-site.example": semaphore.NewWeighted(32),}
func fetch(ctx context.Context, host, u string) error { sem := limits[host] if err := sem.Acquire(ctx, 1); err != nil { return err } defer sem.Release(1) // ... do the request return nil}ターゲットの許容度を超えると、並列度を上げてもブロックが増えるだけで、スループットは増えない(ブロックを避ける方法)。
Colly
CollyはGoの標準的なスクレイピングフレームワークで、プロキシを1行で設定できる。
c := colly.NewCollector(colly.AllowedDomains("example.com"))
proxyURL := fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass)if err := c.SetProxy(proxyURL); err != nil { log.Fatal(err)}リクエストごとの地域やスティッキーセッションにはSetProxyFuncを使う。これは前述のTransport.Proxy関数と同じ考え方だ。
c.SetProxyFunc(func(r *http.Request) (*url.URL, error) { // Pick country/session per request, e.g. from the URL or a job map. return proxyFor("de", "job-42")})Collyには独自のドメインごとのリミッターがあるので、自作するのではなくそれを使うこと。
c.Limit(&colly.LimitRule{ DomainGlob: "*", Parallelism: 8, Delay: 200 * time.Millisecond, RandomDelay: 200 * time.Millisecond, // jitter, so requests aren't a metronome})そして、前述のプーリングとタイムアウト設定を使いたい場合は、カスタムトランスポートを設定する。
c.WithTransport(transport)CollyのOnErrorがリトライロジックを置く場所だ。リトライする前に分類し、同じ出口でリトライするのではなくブロック時にはアイデンティティを変えること。load balancing postの分類表がそのまま当てはまる。
実際にプロキシ経由になっているか確認する
ベンチマークやその他のデバッグを行う前に、出口IPを確認すること。
resp, _ := client.Get("http://ip-api.com/json")defer resp.Body.Close()b, _ := io.ReadAll(resp.Body)fmt.Println(string(b)) // expect a residential IP in the targeted country自分自身のIPが返ってきたら、クライアントがプロキシを使用していないことを意味する。ハングする場合はローカルの出口がブロックされている。両方ともタイムアウト診断ガイドで扱われている。
FAQ
GoはURL内のプロキシ認証情報をサポートしていますか?
はい。Chromiumベースのブラウザとは異なり、Goのnet/httpはhttp://user:pass@host:portを正しく処理し、Proxy-Authorizationを自動で送信する。ゲートウェイはターゲティングをユーザー名にエンコードするため、それだけで十分だ。
クライアントを1つにしているのに、プロキシ経由でGoのスクレイパーが遅いのはなぜですか?
最も可能性が高いのは、レスポンスボディを読み切っていない(そのため接続がプールに戻らない)か、多数のgoroutineを実行しているのにMaxIdleConnsPerHostがデフォルトの2のままになっていることだ。両方を修正すれば、リクエストごとのハンドシェイクのオーバーヘッドはほぼ解消される。
GoでリクエストごとにIPをローテーションするにはどうすればよいですか?
プロキシのユーザー名を変える。Transport.Proxyから異なるURLを返す(リクエストコンテキストからアイデンティティを読み取る)か、アイデンティティごとにトランスポートを保持するかのいずれかだ。Goはプロキシのurlごとに接続をプールするため、リクエストごとの変化でも効率的なままだ。
Client.Timeoutとトランスポートのタイムアウト、どちらを使うべきですか?
両方とも、異なる用途で使う。細かいトランスポートタイムアウト(TLSHandshakeTimeout、ResponseHeaderTimeout)を使うことで、遅い接続と遅いレスポンスを区別できる。リクエストごとの全体的な期限にはcontext.WithTimeoutを使うこと。単一の粗いClient.Timeoutだけでは、どのフェーズが失敗したのかが隠れてしまう。
http.ProxyFromEnvironmentを代わりに使えますか?
はい、HTTP_PROXY/HTTPS_PROXY/NO_PROXYを読み取るため、コンテナ環境では便利だ。すべてのリクエストが同じアイデンティティを使う場合には良いデフォルトだが、その方法ではリクエストごとに地域やセッションを変えることはできないため、ターゲティングが必要な場合は明示的なProxy関数を使うこと。
結論
Goとレジデンシャルプロキシの組み合わせは、標準ライブラリのルールを尊重すれば強力な組み合わせになる。長期間存続する1つのクライアントを共有し、必ずボディを読み切って閉じ、MaxIdleConnsPerHostを並行数に合わせて上げ、単一の粗いタイムアウトよりも細かいトランスポートタイムアウトを優先すること。プロキシのユーザー名を変えて地域やセッションを変更し、ホストごとに並行処理を制限し、CollyのリミッターとSetProxyFuncにフレームワークレベルで同じ作業を任せること。
これらを正しく行えば、3行の統合はGoにふさわしい性能を発揮する。residential gatewayを指定し、プールの品質がそもそも何回リトライすることになるかを左右することを忘れないこと(IP reputation)。pricing pageには、独自のターゲットに対してテストするためのGBあたりのプランが掲載されている。