Goのnet/httpクライアントで大量のリクエストを高速に行う方法

こんにちは、門多です。

BoltzEngineはv3.0で、APNsがサポートするHTTP/2に対応したプロバイダ認証トークン方式(JSON Web Token形式)のプッシュ通知に対応しました。また、BoltzEngine v3.1からはFCM HTTP v1 APIにも対応します。 これらのプロトコルはどちらもHTTP/2に対応していますが、net/httpをデフォルトのまま使っても、想定していたほどの通知速度を出すことができません。 これはnet/httpが遅いというわけではなく、短時間で何万リクエストも発行するプッシュ通知での利用が特殊なケースだからと思いますが、 この製品は速度をひとつの特徴としているので、HTTP/2でも一定の速度を出す必要がありました。 まだ改善の余地はあると思いますが、なぜデフォルトだと速度が出ないのか、と、速度を出すために行った実装を紹介します。
(※ この記事はGo 1.11の頃に書かれたものです。)

まずは、HTTP/2とnet/httpの動作について。

net/httpでHTTP/2を使う

Goの net/http は、接続先のサーバとプロトコルネゴシエーションを行なって、 サーバがHTTP/2に対応していれば優先的にHTTP/2を使って通信します。

net/httpは、環境変数やその他いくつかの方法でHTTP/2を無効化できます。 環境変数の場合は、GODEBUG=http2client=0 とすればサーバが対応していてもHTTP/2を使いません。

HTTP/2プロトコルネゴシエーション

上の文章で「サーバがHTTP/2に対応していれば」と書きましたが、HTTP/1.xと同様に、HTTP/2も80または443ポートが使われます。 そのためクライアントは、通信前にはHTTP/2またはHTTP/1.xのどちらの方法で通信すれば良いか判断ができません。 プロトコルを判断するため、通信の最初に行うことがプロトコルネゴシエーションです。接続先のサーバでどのプロトコルが使えるのかは、以下のどれかで検出することが可能です。

  1. TLSのALPN(Application-Layer Protocol Negotiation)
  2. TLSのNPN(Next Protocol Negotiation)
  3. HTTPのConnectionとUpgradeヘッダ
  4. ネゴシエーションなし(ダイレクト)

TLSが使える場合はALPNまたはNPNが使われます。これらTLSのしくみを使う場合は総称して h2 (HTTP/2 over TLS) と呼ばれます。 TLSが使えない場合はConnectionやUpgradeヘッダが使われます。こちらは h2c (HTTP/2 over TCP) です。 ただし、Go 1.11時点の net/http は、h2c に対応していないので、HTTPSが必須です。

ALPNまたはNPNは、ALPNの方が効率がよく、他の慣習に沿っているので良いそうです。

HTTP/2クライアントは、通信の最初にConnection Prefaceというリクエストを送信します。 これは誤ってHTTP/1.1サーバに接続してしまった場合であっても、間違ったまま通信をさせずエラーを引き起こさせるために送信しています。

PRI * HTTP/2.0\r\n
\r\n
SM\r\n
\r\n

net/httpで大量のリクエストを実行するとどうなるか

APNsのHTTP/2プロトコルでは、1回のHTTP/2リクエストで1つの端末へ通知を行います。1回のリクエストで複数の端末に送る手段はないので、高速な配信を行うためには短時間で大量のリクエストを処理する必要があります。

net/httpのコネクション管理

再接続のコストを抑えるため、HTTP/1.1にはコネクション再利用(Keep-Alive)のための仕様が含まれます。net/httpでは、Keep-Alive状態のコネクションをアイドルコネクションと呼んでいて、コネクションの管理はhttp.Transportが行います。 http.Transportは内部をうまく隠蔽しているため、開発者はそれほど意識することなくKeep-Aliveの恩恵を受けることができます。

しかし逆に、隠蔽されていることにより、コネクションを個別に解放する方法がありません。 そのため、http.Transportはグローバルに1つ用意して使いまわすことが推奨されます。 特に理由がなければ、通常はhttp.DefaultTransportを使うのが良いでしょう。

それぞれのコネクションは、次のようにアクティブとアイドル状態を遷移します。

  1. 最初はコネクションが無いので新しくコネクションを作成する
  2. コネクションはリクエストを送るためにアクティブ状態に遷移する
  3. レスポンスを読み終えて http.Response.Bodyをクローズするとアイドル状態へ遷移する
  4. ただしhttp.Transport.MaxIdleConnsまたはhttp.Transport.MaxIdleConnsPerHostを超えた場合はすぐにクローズする
  5. 一定時間(http.Transport.IdleConnTimeout)経過するまでアイドル状態を維持する
  6. 期間内に同一ホストへ次のリクエストが行われればコネクションを再利用する
  7. 期間内に使われなければコネクションをクローズする
  8. http.Transport.DisableKeepAlivetrueの場合はそもそもKeep-Aliveしない

HTTP/1.1で並列リクエスト

まずはHTTP/1.1での話です。HTTP/1.1は上に書いたようにKeep-Aliveを行うため、

  • スキーマ
  • ドメイン
  • ポート番号

が同じ場合はアイドル状態のコネクションを再利用します。 しかしHTTP/1.1では、リクエストを送ってからレスポンスを読み終わるまでの間、 同一のコネクションに並行して別のリクエストを行うことができません。 そのため、たとえ同一ホストへのコネクションがあったとしても、それがアクティブ状態であれば使えませんので、 アイドル状態のものがなければnet/httpは新しく接続を行います。

このとき、Go 1.10までは最大接続数の制限がありません。Go 1.11からはhttp.Transport.MaxConnsPerHostを設定すると、同一ホストへの同時接続上限を超えたらブロックするようになりました。

そのため、同時に大量のリクエストを並行させる場合、 発火時点ではラウンドトリップが完了していないことが多いので、 リクエスト数と同等のTCPまたはTLSコネクションが作られます。 当然、OSのリソースも消費します。

これらの動作は、文中にもいくつか挙げましたが、以下のパラメータで調整可能です。

  • http.Transport.DisableKeepAlive
  • http.Transport.IdleConnTimeout
  • http.Transport.MaxIdleConns
  • http.Transport.MaxIdleConnsPerHost
  • http.Transport.MaxConnsPerHost(Go 1.11以降)

HTTP/2で並列リクエスト

次にHTTP/2が使える場合です。アクセスするサーバがHTTP/2に対応しているかわからないので、net/httpでは、

  1. プロトコルネゴシエーションのため新しく接続する
  2. ALPNまたはNPNでHTTP/2が利用可能か調べる
  3. HTTP/2として接続しているコネクションから同じホストのものを探す
  4. 同じホストへのHTTP/2コネクションがあれば集約して片方を閉じる
  5. 該当するものがなければそのまま使う
  6. 以後HTTP/2として多重化

のように動作します。クライアント側だけでみると、ALPN等で確認する時点でTCPとTLSのハンドシェイクは終わっています。なので、集約したところで効率がどの程度速度が改善されるか分かりませんが、Goに限らずHTTP/2はそういうものらしいです。

net/httpでは、HTTP/1.1とHTTP/2のコネクションは分けて管理しています。なのでHTTP/1.1でアイドル状態のコネクションがあったとしても、HTTP/2では使われませんし、逆もありません。

大量のコネクションとリソース枯渇

上記でみたように、短時間で大量のリクエストを行う場合においては、HTTP/2でも多くのコネクションが発生します。OSのリソースも消費しますし、TCPとTLSハンドシェイクを接続のたびに行うため、当然遅くなってしまいます。

接続先のサーバが必ずHTTP/2に対応していると分かっているのであれば、接続の時点で確認を行う必要はありません。上で紹介したプロトコルネゴシエーションのうちネゴシエーションなしを利用すると、最初から既存のコネクションを探しに行くようになるため効率を上げることができます。このためには、golang.org/x/net/http2パッケージを使う必要があります。

client := http.Client{
    Transport: &http2.Transport{},
}

これで、OSのリソース消費量とハンドシェイクの遅さによる問題は改善します。しかしこのままでは、APNsの場合、秒間500リクエスト程度しか送ることができません。

HTTP/2のmaxConcurrentStreams

なぜかというと、HTTP/2にはmaxConcurrentStreamsというパラメータがあって、これはSETTINGSフレームでサーバからクライアント、またはクライアントからサーバへ送られます。HTTP/2では1つのコネクションで複数のストリーム(リクエスト+レスポンス)を同時に行えますが、実際は同時にmaxConcurrentStreamsで指定された個数までしかリクエストを送ることができません。この値を超えて送ろうとした場合、net/httpはレスポンスが返ってくるまでリクエストをブロックします。秒間500リクエスト程度しか送れないのは、APNsの場合はこの値が500前後だった、ということです。

HTTP/2で並列リクエストのところでみた通り、HTTP/2のコネクションは基本的に1本へ集約されるので、どう頑張っても1コネクションでは秒間500リクエスト前後を超えることができません。一般的にはhttp.Transportを使い回すほうが良いとされますが、しかし通知対象が数十万件以上になる場合は500/s程度では全然足りませんので、速度を上げたい場合は複数のコネクションを扱わなければなりません。

複数のコネクションを扱う場合は、単純に別のhttp2.Transportを使えば良いです。

client1 := http.Client{
    Transport: &http2.Transport{},
}
client2 := http.Client{
    Transport: &http2.Transport{},
}

Go 1.12から、ストリームの数がmaxConcurrentStreamsを超えた場合、ブロックせずに新しいコネクションを作成するようになりました。

The default behavior is now back to how it was in Go 1.9: each connection to a server can have up to MAX_CONCURRENT_STREAMS requests active and then new TCP connections are created as needed.
https://golang.org/doc/go1.12#net/http

リソース管理

http.Transport (以下http2.Transportも同じ)を複数扱うと決めた時点で、厄介なリソースの問題にも対処しなければならなくなります。http.Transportは内部でコネクションやゴルーチンを管理しており、これらのリソースはGCするだけでは回収されません。そのため、特に何も考慮せず使った場合はhttp.Transportを使うたびにリークします。

これらのリソースを確実に解放するため、

  • http.Response.Bodyを必ずクローズする
  • 全てのリクエストが終わったらhttp.Transport.CloseIdleConnectionsを必ず呼ぶ

の2点を忘れないようにしましょう。これを守ることでhttp.Transportの内部で管理していたリソースは解放されます。ただし名前の通り、CloseIdleConnectionsはアイドル状態のコネクションしか対象としません。使われているものは対象外なので必ず全てのリクエストが終わってから呼びましょう。

github.com

HTTP/2での単体テスト

少し本題からは外れますが、上記でネゴシエーションプロトコルを行わない代わりに必ずサーバはHTTP/2が必要、としました。現在のnet/httpでHTTP/2を使う場合は必ずHTTPSでの接続が必要です。

GoでHTTPクライアントを使った単体テストを行う場合はnet/http/httptestをよく使いますが、これをHTTP/2対応させるためにはいくつか手続きが必要です。具体的には、httptest.Serverhttp2.ConfigureServer(*http.Server, *http2.Server) errorに与えます。

s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
    fmt.Fprintf(w, "hello")
}))
if err := http2.ConfigureServer(s.Config, nil); err != nil {
    t.Fatal(err)
}
s.TLS = s.Config.TLSConfig
s.StartTLS()
defer s.Close()

また、このままではクライアント側で証明書の検証が失敗するので、http.Transport.TLSClientConfig.InsecureSkipVerifyをセットして証明書の検証を無視する必要があります。 しかしhttp.Transport.TLSConfigを変更した場合、net/httpはHTTP/2へのマイグレーションを無効化するため、クライアント側もhttp2.ConfigureTransport(*http.Transport) error で明示的にHTTP/2を有効化する必要があります。

t := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true,
    },
}
if err := http2.ConfigureTransport(t); err != nil {
    t.Fatal(err)
}
client := &http.Client{Transport: t}

終わりに

細かい工夫は他にもありますが、誰かの参考になりそうなものを紹介しました。BoltzEngineは他にも速度制限やKindle対応など、実際の問題に対応していますし、体験版も用意していますので気軽にお問い合わせください。

www.fenrir-inc.com

最近は、無作為に通知登録を促したりするアプリやサイトが増えたことで、スパム扱いされることも多く見かけるようになりました。個人的な意見ですが、何が送られてくるかわからない通知を購読しようとは思わないし、特にWebPushではサイトを開くだけで通知ダイアログが表示されてブラウジングが妨げられるので不快だと感じます。通知という機能は正しく使えば便利なものなので、可能であればサービス提供者の伝えたいことではなくユーザの望むことを通知するために使って欲しいと思っています。

APNsやFCM固有の話、例えばAPNs HTTP/2では1つのグローバルIPアドレスから1分以内に3つ以上のトークンを使うと弾かれるなど、細かい話は色々あるので、機会があれば書くかもしれません。