My External Storage

Sep 13, 2021 - 5 minute read - Comments - go

[Go] 前方互換性を保ちながらhttp.DefaultTransportからチューニングしたhttp.Transportをつくる

@dice_zuさんからhttp.DefaultTransportの正しい(?)コピーのやり方を教えてもらったのでメモしておく。
結論から言うとhttp.DefaultTransport変数にたいしてnet/http#Transport.Cloneメソッドを使うと良い。 これなら新しいGoのバージョンでhttp.Transportに新しいフィールドが追加されても問題ない。

https://pkg.go.dev/net/http#Transport.Clone

TL;DR

  • *http.Clientオブジェクトは再利用したほうがよい
  • http.DefaultClientはタイムアウトの設定がされていないので独自定義するのが一般的
  • http.Transportオブジェクトも独自定義する
  • http.DefaultTransportから少しだけ設定値を変更したオブジェクトを作るにはCloneメソッドを使う。

サンプルコードは次の通り。

https://play.golang.org/p/niRAgxrIv8V

package main

import (
  "net"
  "net/http"
  "time"
)

func main() {
  t := defaultTransport()
  t.MaxIdleConnsPerHost = 100
  cli := &http.Client{
    Transport: t,
    Timeout:   3 * time.Second,
  }
  _, _ = cli.Get("https://example.com")
}

func defaultTransport() *http.Transport {
  dt := http.DefaultTransport
  if t, ok := dt.(*http.Transport); ok {
    return t.Clone()
  }
  // 何らか悪されてていた時。
  return &http.Transport{
    Proxy: http.ProxyFromEnvironment,
    DialContext: (&net.Dialer{
      Timeout:   30 * time.Second,
      KeepAlive: 30 * time.Second,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    MaxIdleConnsPerHost:   100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
  }
}

*http.Clientオブジェクトは再利用したほうがよい

Goで何かしらのHTTPリクエストを送信したいならば、*http.Clientオブジェクトを使う。 Webアプリケーションのサーバを書いているときでも外部サービスや他のバックエンドサービスへ通信をするために多用する。

このとき、*http.Clientオブジェクトはリクエストのたびに生成してはいけない。可能な限り再利用する必要がある。 その理由はhttp.Clientの定義に仕様として記載がある通り、*http.Clientオブジェクトは内部でTCPコネクションのキャッシュを持つからである。

The Client’s Transport typically has internal state (cached TCP connections), so Clients should be reused instead of created as needed. Clients are safe for concurrent use by multiple goroutines.

http.DefaultClientはタイムアウトの設定がされていないので独自定義するのが一般的

他方、*http.Clientオブジェクトはnet/httpパッケージに宣言済みのhttp.DefaultClient変数が存在する。これを使うことは基本的に避ける。 なぜならば、このhttp.DefaultClientはタイムアウトの設定がされていないためだ。そのため、*http.Clientオブジェクトを用意するときは次のように宣言したりする。

cli := &http.Client{
    // Transport: 未初期化の場合はhttp.DefaultTransportが呼ばれる。
    Timeout:   3 * time.Second,
}

ここで、*http.Client.Transportフィールドも独自定義の設定に変えたい時がある。 *http.Client.Transportフィールドは未初期化状態だった場合、http.DefaultTransportが呼ばれる。

*http.Transportオブジェクトも独自定義する

http.Transport型にはMaxIdleConnsPerHostというフィールドがある。名前の通り、ホストごとのアイドルするコネクションの最大値を制御する設定だ。 未初期化だとhttp.DefaultMaxIdleConnsPerHostが利用される。Go1.17時点でhttp.DefaultMaxIdleConnsPerHost2だ。 マイクロサービス構成のバックエンドサーバなどで*http.Clientオブジェクトを利用するシーンでは外部サービスや別のマイクロサービスサーバなど接続先ホストがいくつかに限定されることが多いだろう。限定された接続先にスループットの数だけ並列リクエストが飛ぶことを考慮するとMaxIdleConnsPerHost2以上に設定したくなる。

transport := &http.Transport{
        DefaultMaxIdleConnsPerHost: 100,
  }

しかし上記のような宣言ではMaxIdleConnsPerHostフィールド以外のフィールドがゼロ値になってしまうため、よくない宣言だ。 ではどのように*http.Transportオブジェクトを初期化すればよいのだろうか?一番イージーなのはhttp.DefaultTransport変数の宣言をコピペだろう。 Go1.17時点のhttp.DefaultTransport変数の宣言は次のような設定値で初期化されている。

https://github.com/golang/go/blob/go1.17.1/src/net/http/transport.go#L38-L54

transport  := &http.Transport{
  Proxy: ProxyFromEnvironment,
  DialContext: (&net.Dialer{
    Timeout:   30 * time.Second,
    KeepAlive: 30 * time.Second,
  }).DialContext,
  ForceAttemptHTTP2:     true,
  MaxIdleConns:          100,
  IdleConnTimeout:       90 * time.Second,
  TLSHandshakeTimeout:   10 * time.Second,
  ExpectContinueTimeout: 1 * time.Second,
}

しかしこれをコピペして利用するのはリスクがある。Go1.18以降では設定値が変更されるかもしれないし、http.Transport型に新たにフィールドが増えた場合未初期化になってしまう。

http.DefaultTransportから少しだけ設定値を変更したオブジェクトを作るにはCloneメソッドを使う。

http.Transport型にはCloneメソッドというオブジェクトをディープコピーしてくれるメソッドが存在する。 Cloneメソッドを利用するのが正しい設定のコピーの仕方だろう。この方法ならば、中身が変わったときでもメンテ無しで追従できる。 この手法を利用しているのが、Google APIのGoクライアントだ。 こちらも接続先がGoogle API用のホストに限定されるからかMaxIdleConnsPerHostを100に設定している。

https://github.com/googleapis/google-api-go-client/blob/v0.56.0/transport/http/default_transport_go113.go#L12-L21

// clonedTransport returns the given RoundTripper as a cloned *http.Transport.
// It returns nil if the RoundTripper can't be cloned or coerced to
// *http.Transport.
func clonedTransport(rt http.RoundTripper) *http.Transport {
  t, ok := rt.(*http.Transport)
  if !ok {
    return nil
  }
  return t.Clone()
}

https://github.com/googleapis/google-api-go-client/blob/v0.56.0/transport/http/dial.go#L163-L171

  // Copy http.DefaultTransport except for MaxIdleConnsPerHost setting,
  // which is increased due to reported performance issues under load in the GCS
  // client. Transport.Clone is only available in Go 1.13 and up.
  trans := clonedTransport(http.DefaultTransport)
  if trans == nil {
    trans = fallbackBaseTransport()
  }
  trans.MaxIdleConnsPerHost = 100

終わりに

github.com/googleapis/google-api-go-client の例はGoコードリーディングパーティ@dice_zuさんから教えてもらった。
普段からいろいろなOSSを見ているであろう@dice_zuさん流石だと思った。見習わなければ。

参考

関連記事