概要
Idle connectionをプールするkeep-aliveの仕組みですが、golangで適切に使用するためにはいくつか注意があります。
環境
- golang 1.13.1
TCP Keep-Aliveの挙動をパケットキャプチャで確認する
例えば以下のようにDefaultTransportの一部の設定(①、②)をイジってリクエストを送ると
client = &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 10 * time.Second, // ① DualStack: true, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 60 * time.Second, // ② TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, Timeout: 20 * time.Second, }
以下のようにレスポンスが返ってからもIdle connectionを保持し続ける挙動がwiresharkのパケット解析で分かります。
設定したとおりに、
- TCP Keep-Aliveのprobeを10秒ごとに
- Idle状態は60秒まで
と確認できますね。
気をつける点
このKeep-Aliveですが、golangで使う時にいくつか気をつけておく点があります。
デフォルトだと同一ホストに対し2つしかidle connectionsを保持しない
デフォルトのTransportでは同一ホスト(※マシン毎ではなくFQDN)に対して2つしかidle connectionsを保持しません。
// DefaultMaxIdleConnsPerHost is the default value of Transport's
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2
ref: https://golang.org/src/net/http/transport.go?#L56
そのため外部APIをコールしたりするユースケースのように、同一ホストに大量のリクエストを投げる場合にはやや不向きです。
なので以下のように
client = &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 500, // ココ MaxIdleConnsPerHost: 100, // ココ IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, Timeout: 60 * time.Second, }
- MaxIdleConns
- MaxIdleConnsPerHost
を調整したclientを使用すると良いです。
response bodyを全てreadしないとダメ
ドキュメントにもありますが、
The default HTTP client's Transport may not reuse HTTP/1.x "keep-alive" TCP connections if the Body is not read to completion and closed.
ref: https://golang.org/src/net/http/response.go?#L59
keep-aliveでコネクションを再利用する条件として
- response bodyを読み切ること
- response bodyをcloseすること
の2点があります。
後者はリークしないようにということでよく言われていますが、前者はあまり知られていません。
なので一部のユースケースではresponse body使わないから〜とreadせずに処理を終了させてしまうとconnectionは再利用されません。
対応方法
bodyを必ず読み切ればいいので、ざっと以下の対応方法があります。
a) ioutil.ReadAll()を使う
b, err := ioutil.ReadAll(resp.Body)
のように一度読み切ってメモリに載せてしまえばコネクション再利用の条件を満たすことができます。
一方でメモリに毎回載せる分メモリを使用しますし、
で話していたstreamのメリットがなくなります。
b) ioutil.Discardに吐き出す
defer resp.Body.Close()
だけでなく、
defer func() { io.Copy(ioutil.Discard, resp.Body) resp.Body.Close() }()
と、読みきらなかったbodyを/dev/null
に吐き出させます。
個人的にはこれが漏れも出ずオススメです。
c) jsonであるならばjson.Decoderで必ず読み切る
Go 1.7以前はjson.Decoderでは全てreadされず、最後の\n
が残るという問題が存在しました。
なので先程のioutil.Discardに吐き出す手法で4倍高速化する、というのも話題に上がりました。
現在は修正されているので、この再利用の条件を意識してユースケース的にbodyを使用しないとしても必ず読み切るようにすれば条件を満たします。
動作確認
では実際に動作確認をしてみます。
- bodyを全ては読み込まない(=keep-aliveなし)
- body全て読みきる(MaxIdleConnsPerHostいじらない)
- body全て読みきる(MaxIdleConnsPerHostいじる)
の3通りで検証します。
検証環境
- ローカルにサーバとクライアントを用意
- ngrokを使って外部公開しインターネットを経由
- 500リクエスト投げる
計測にはgo-httpstatを利用します。
a) bodyを全ては読み込まない(=keep-aliveなし)
初回のリクエスト
DNS lookupやTCP connectionに大きく時間がかかっていることが分かります。
DNS lookup: 44 ms TCP connection: 163 ms TLS handshake: 0 ms Server processing: 405 ms Content transfer: 0 ms Name Lookup: 44 ms Connect: 208 ms Pre Transfer: 208 ms Start Transfer: 614 ms Total: 614 ms
以降のリクエスト
再利用されないため、毎回TCP connectionの時間がかかってます。
DNS lookupについてはOSのcacheがあるのか縮んでます。
DNS lookup: 1 ms TCP connection: 163 ms TLS handshake: 0 ms Server processing: 521 ms Content transfer: 0 ms Name Lookup: 1 ms Connect: 164 ms Pre Transfer: 164 ms Start Transfer: 686 ms Total: 686 ms
TIME_WAITは1000になりました。
おそらくサーバ側とクライアント側それぞれで500ずつコネクションが張られたのでしょう。
~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 1 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 2 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 20 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 52 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 69 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 129 ... ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 1000
b) body全て読みきる(MaxIdleConnsPerHostいじらない)
bodyを全て読んでkeep-aliveが有効になるようにします。ただし
- MaxIdleConnsはデフォルト値(=100)
- MaxIdleConnsPerHostもデフォルト値(=2)
でやってみます。
初回のリクエスト
a)と変わらないので割愛
以降のリクエスト
再利用されてDNS lookupやTCP connectionに時間がかからなくなってます。
DNS lookup: 0 ms TCP connection: 0 ms TLS handshake: 0 ms Server processing: 661 ms Content transfer: 0 ms Name Lookup: 0 ms Connect: 0 ms Pre Transfer: 0 ms Start Transfer: 661 ms Total: 661 ms
ただしidle connectionsが少ないためか、いくつかのリクエストで再利用できず新規コネクションを張ってます。
DNS lookup: 0 ms TCP connection: 110 ms TLS handshake: 0 ms Server processing: 687 ms Content transfer: 0 ms Name Lookup: 0 ms Connect: 110 ms Pre Transfer: 0 ms Start Transfer: 687 ms Total: 687 ms
再利用できてないコネクションの数が多いため、TIME_WAITもたくさん出てきます。
~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 1 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 24 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 48 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 71 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 89 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 115 ... ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 742 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 748
c) body全て読みきる(MaxIdleConnsPerHostいじる)
- MaxIdleConnsを無制限に
- MaxIdleConnsPerHostをループ回数と同じに
として検証してみます。
初回のリクエスト
a)、b)と変わらないので割愛
以降のリクエスト
b)と同様に再利用されるのでオーバーヘッドがなくなってます。
DNS lookup: 0 ms TCP connection: 0 ms TLS handshake: 0 ms Server processing: 395 ms Content transfer: 0 ms Name Lookup: 0 ms Connect: 0 ms Pre Transfer: 0 ms Start Transfer: 395 ms Total: 396 ms
TIME_WAITに関してはリクエストループの頻度とレスポンスのレイテンシとの関係によって、過剰な新規コネクションが発生→closeとなりいくつか発生していますが、あるタイミングで常に一定になりました。
以降は常にIdle connectionsでさばけるようになったと考えられます。
~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 1 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 36 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 45 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 51 ... ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 372 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 372 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 372 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 372 ~ $ sysctl net.inet.tcp.tw_pcbcount net.inet.tcp.tw_pcbcount: 372
その他
その他検証中に気づいたこといくつか
ローカルのmacOSだとサーバからのTCP keep-aliveが15秒間隔?
Dialer.KeepAlive
をデフォルト値(=30sec)のまま使用したところ、以下のようになりました。
- 15秒間隔でkeep-alive probeが発生
- サーバから送られている
これはOS側のカーネルを利用しているのでしょうか?
でも調べる感じだと75秒設定で謎でした。
$ sysctl net.inet.tcp | grep keepintvl net.inet.tcp.keepintvl: 75000
サンプルコード
今回の検証で使用したコードはこちらです。