概要
Idle connectionをプールするkeep-aliveの仕組みですが、Goで適切に使用するためにはいくつか注意があります。
環境
- golang/go 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ですが、Goで使う時にいくつか気をつけておく点があります。
デフォルトだと同一ホストに対し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
その他
その他検証中に気づいたこといくつか
MaxIdleConnsPerHostはFQDNの数
初めはPerHostは接続するマシンのIP毎かな?と思ってFQDNに複数IPが紐付いているものに対して検証してみたところ、マシン毎にMaxIdleConnsPerHost
の数コネクションが張られるわけではなく、FQDN毎でした。
HTTP Keep-Aliveを無効にした場合
サーバ側で無効にする場合
サーバでSetKeepAlivesEnabled()
を指定することでHTTP Keep-Aliveを無効にするとどうなるでしょうか。
mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(hello)) svr := &http.Server{Addr: ":8080", Handler: mux} svr.SetKeepAlivesEnabled(false) // ココ log.Fatal(svr.ListenAndServe())
このようにリクエスト後すぐに切断されました。
有効な時との違いは、HTTPのレスポンスヘッダーに
Connection: close
が含まれていました。
HTTP/1.1 では、 Connection ヘッダーで close の値が送信された場合に限り、短命なコネクションモデルを使用します。
ref: HTTP/1.x のコネクション管理 - HTTP | MDN
HTTP/1.1の仕様通りですね。
クライアント側で無効にする場合
Transport.DisableKeepAlives
をtrue
にします。
client = &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: true, // ココ }, Timeout: 60 * time.Second, }
するとクライアントのHTTPリクエストヘッダーに
Connection: close
が含まれ、それによってレスポンスヘッダーにもConnection: close
が含まれていました。
これによってすぐに切断されました。
片方が途中で切断したらどうなるか
Keep-Aliveでidle connectionが存在する状態で、一方が落ちたらどうなるでしょうか
サーバを落とした場合
サーバから即座にFINパケットが送られて、TCP connectionがcloseされます。
Ctrl-Cで中断させたり、panicでプロセスKillした場合も同様でした。
クライアントを落とした場合
クライアントから即座にFINパケットが送られて、TCP connectionがcloseされます。
ネットワーク障害が起きたらどうなるか
先程はサーバやクライアントが落ちたケースでしたが、それとは別でネットワーク自体に障害が起きたケースを考えてみます。
途中までKeep-Aliveが使えていた状態で、サーバ側でiptablesを使ってパケットを破棄させます。
※受信・送信両方
$ sudo iptables -A INPUT -p tcp -d 192.168.33.10 --dport 8080 -j DROP $ sudo iptables -A OUTPUT -p tcp -d 192.168.33.1 --sport 8080 -j DROP
このようにカーネルの最大試行回数(macOSだと8)までKeep-Alive probeを送りますが、それも失敗するとクライアントからRSTパケットを送って強制切断します。
# Keep-Alive中 $ sudo lsof -nP -iTCP | grep 192.168.33.10 main 68008 jun06t 3u IPv4 0x12f8d5c198eafc1f 0t0 TCP 192.168.33.1:56109->192.168.33.10:8080 (ESTABLISHED) main 68008 jun06t 5u IPv4 0x12f8d5c19570ec1f 0t0 TCP 192.168.33.1:56108->192.168.33.10:8080 (ESTABLISHED) # RST後 $ sudo lsof -nP -iTCP | grep 192.168.33.10 $
終わったらiptablesの後片付けをしておきます。
$ sudo iptables --flush
ローカルの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
追記
サーバのListener側のKeepaliveのデフォルト値が15秒でした。
サンプルコード
今回の検証で使用したコードはこちらです。