Mercari Advent Calendar 2019の13日目担当はメルカリSREチームの@catatsuyです。

社内ではGoを書いていることが多いので、今回はmercari/gaurunの事情を通して、GoのHTTP/2事情に関して紹介します。

忙しい人のためのまとめ

  • Goはhttp.TransportTLSClientConfigを上書きしている場合HTTP/2が使えません
  • HTTP/2を使いたい場合はGo1.12まではgolang.org/x/net/http2http2.ConfigureTransportを自分で呼び出す必要があります
  • Go1.13の新機能ForceAttemptHTTP2を使えば標準機能でHTTP/2を有効にできます
  • Go1.13の新機能を使うことでgaurunからgolang.org/x/net/の依存をなくすことができました

gaurunについて

以前から本ブログなどで紹介しているように、メルカリのプッシュ配信には自社でOSSとして開発しているgaurunを使用しています。

現在のgaurunでは以前に紹介したようにFCMAPNsに対してHTTP/2を使用して通信しています。

今回はAPNsとの通信で抱えた課題について注目していきます。

gaurunでAPNsを使用する場合、pem_cert_path, pem_key_pathというオプションでSSL証明書を渡す必要があります。現在のAPNsではトークン認証もサポートしていますが、gaurunではサポートしていません。なのでこの証明書はgaurunでのAPNsとの通信に必要です。Goの場合http.TransportTLSClientConfigに証明書を渡せば通信ができます。実際のgaurunで現在使われているコードは以下です。

config := &tls.Config{
Certificates: []tls.Certificate{cert},
}
config.BuildNameToCertificate()
transport := &http.Transport{
TLSClientConfig:     config,
MaxIdleConnsPerHost: ConfGaurun.Ios.KeepAliveConns,
Dial: (&net.Dialer{
Timeout:   time.Duration(ConfGaurun.Ios.Timeout) * time.Second,
KeepAlive: time.Duration(keepAliveInterval(ConfGaurun.Ios.KeepAliveTimeout)) * time.Second,
}).Dial,
IdleConnTimeout:   time.Duration(ConfGaurun.Ios.KeepAliveTimeout) * time.Second,
ForceAttemptHTTP2: true,
}

https://github.com/mercari/gaurun/blob/2822c213d1ab9bc83d4c892e3c71159b45c5a424/gaurun/apns_http2.go#L20-L34

このコードに至るまでの経緯を紹介していきます。

GoのHTTP/2対応

Go1.6からHTTP/2に標準で対応しました。GoでHTTP/2対応をするコードは以前からgolang.org/x/net/http2上で開発されていますが、こちらのコードをbundleというツールを使ってGoのソースコード内にいい感じにコピーすることで標準での対応を実現しています。これに関しては以下の記事が分かりやすいです。

Golangのbundleコマンド – Qiita

簡単にnet/httpでのHTTP/2対応について紹介します。

  • net/httpx/net/http2に依存しようとすると循環importが発生するので依存できない
  • bundleという独自コマンドでx/net/http2のコードをGoのソースコード内にh2_bundle.goという1つのファイルにまとめてコピーされている
  • テストはコピーされないので、開発自体はx/net/http2上で行われている

それに加えてx/net/http2の仕様などについても簡単に紹介します。

  • x/net/http2http2.ConfigureTransportを呼び出せばHTTP/2を使えるhttp.Transportになる
    • この関数はhttp2configureTransportという関数名でnet/http内で使える(先頭が小文字なのでunexportedな関数)
  • x/net/http2http2.Transportを直接使うとHTTP/2しか扱えないTransportになるので通常使用しない
  • 取り込まれているx/net/http2のバージョンはGitのbranch https://github.com/golang/net/branches で管理されている
  • 開発は2019/12/13現在、積極的に行われている

TLSClientConfigを上書きするとHTTP/2はデフォルトで使用できない

以上のことを踏まえて、2019/12/13現在の最新から1つ前のGo1.12のソースコードを確認してみます。

if t.TLSClientConfig != nil || t.Dial != nil || t.DialTLS != nil {
// Be conservative and don't automatically enable
// http2 if they've specified a custom TLS config or
// custom dialers. Let them opt-in themselves via
// http2.ConfigureTransport so we don't surprise them
// by modifying their tls.Config. Issue 14275.
return
}
t2, err := http2configureTransport(t)
if err != nil {
log.Printf("Error enabling Transport HTTP/2 support: %v", err)
return
}
t.h2transport = t2

https://github.com/golang/go/blob/release-branch.go1.12/src/net/http/transport.go#L299-L312

これまでの説明にもあるようにhttp2configureTransportを呼び出さなければHTTP/2を使用できません。しかしよく読むとhttp2configureTransportを呼び出されずにreturnされる条件があることが分かります。

今回関係があるのはt.TLSClientConfig != nilという条件です。TLSClientConfigに設定を渡すとHTTP/2を使用できません。既に紹介したようにAPNsとの通信を行うためにTLSClientConfigの設定を渡す必要がありますが、そうするとAPNsとの通信にHTTP/2を使用できなくなります。

通常ならHTTP/2が使用できない場合はHTTP/1.1を使用するだけなので困ることはあまりないかもしれません。しかしAPNsはHTTP/2しか提供しておらず、HTTP/1.1での通信が行えません。パフォーマンス面でもHTTP/2で使用する必要があるのでHTTP/2で通信する必要があります。

これを回避する方法はコメントにも書かれていますが、x/net/http2http2.ConfigureTransportを自分たちで呼び出すのが一番良い方法です。しかしこれには以下の問題がありました。

  • Goのコードと同じ挙動にするには使用しているGoのバージョンが使用しているgolang.org/x/net/を使用する必要があるので面倒
    • ブランチを指定すればよいが、バージョンアップ毎に必要
    • Goは互換性が高い言語なので、特にOSSの場合はあまりGoのバージョンを固定したくない
  • golang.org/x/net/はhttp2以外にも色々あり、かなり大きなリポジトリで更新も頻繁

これを解決する方法としてGo1.13の新機能を使うことがおすすめです。早速2019/12/13現在の最新のGo1.13のソースコードを確認します。

if !t.ForceAttemptHTTP2 && (t.TLSClientConfig != nil || t.Dial != nil || t.DialTLS != nil || t.DialContext != nil) {
// Be conservative and don't automatically enable
// http2 if they've specified a custom TLS config or
// custom dialers. Let them opt-in themselves via
// http2.ConfigureTransport so we don't surprise them
// by modifying their tls.Config. Issue 14275.
// However, if ForceAttemptHTTP2 is true, it overrides the above checks.
return
}
t2, err := http2configureTransport(t)
if err != nil {
log.Printf("Error enabling Transport HTTP/2 support: %v", err)
return
}
t.h2transport = t2

https://github.com/golang/go/blob/release-branch.go1.13/src/net/http/transport.go#L353-L367

ForceAttemptHTTP2というのが追加されています。Go1.13の新機能でここをtrueにすればhttp2configureTransportを必ず呼び出してくれるようになります。

欠点としてはx/net/http2の関数や定数は先頭を小文字にしてコピーされているので、例えばエラーがhttp2.GoAwayErrorか確認するみたいなことはできません。http2.GoAwayErrorhttp2GoAwayErrorという名前でコピーされているのでnet/http外で使用することができないためです。

github.com

しかしこれについては必要性が説明できればGoのコード側で取得する仕組みを用意してくれる可能性があります。本当に必要ならば開発チームに提案するのが正しい方法でしょう。

以上の理由からgaurunはForceAttemptHTTP2をtrueにして、golang.org/x/net/への依存をしないように変更しました。これによりGoのバージョンアップの追従も容易になりますし、特殊なコードも減りました。

今回の話題については以前に弊社で開催されたmercari.go #11での私の発表にて私が開発したISUCON9予選のベンチマーカーにおいても全く同じ対応が必要になったのでForceAttemptHTTP2を使用した話を紹介しています。それ以外の話題についても紹介していますので、興味のある方は是非ご覧下さい。

最後に

Goでミドルウェアに近いレイヤーを触っているとGo自体のソースコードも確認することが必要になることは多々あります。弊社メルカリではそういったことも臆さずに行えて、開発を行える方を歓迎します。興味のある方はご連絡ください

明日の執筆担当は、メルカリ Backend チームの@Peranikovさんです。それでは引き続きお楽しみください!