Carpe Diem

備忘録

Goのnet/httpのkeep-aliveで気をつけること

概要

Idle connectionをプールするkeep-aliveの仕組みですが、golangで適切に使用するためにはいくつか注意があります。

環境

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のパケット解析で分かります。

f:id:quoll00:20191008210218p:plain

設定したとおりに、

  • 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でコネクションを再利用する条件として

  1. response bodyを読み切ること
  2. response bodyをcloseすること

の2点があります。

後者はリークしないようにということでよく言われていますが、前者はあまり知られていません。
なので一部のユースケースではresponse body使わないから〜とreadせずに処理を終了させてしまうとconnectionは再利用されません

対応方法

bodyを必ず読み切ればいいので、ざっと以下の対応方法があります。

a) ioutil.ReadAll()を使う

b, err := ioutil.ReadAll(resp.Body)

のように一度読み切ってメモリに載せてしまえばコネクション再利用の条件を満たすことができます。
一方でメモリに毎回載せる分メモリを使用しますし、

christina04.hatenablog.com

で話していた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倍高速化する、というのも話題に上がりました。

github.com

現在は修正されているので、この再利用の条件を意識してユースケース的にbodyを使用しないとしても必ず読み切るようにすれば条件を満たします。

動作確認

では実際に動作確認をしてみます。

  1. bodyを全ては読み込まない(=keep-aliveなし)
  2. body全て読みきる(MaxIdleConnsPerHostいじらない)
  3. body全て読みきる(MaxIdleConnsPerHostいじる)

の3通りで検証します。

検証環境

  • ローカルにサーバとクライアントを用意
  • ngrokを使って外部公開しインターネットを経由
    • ローカル間通信だとDNS lookupやTCP connectionのオーバーヘッドが分かりにくいので
  • 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毎でした。

f:id:quoll00:20210115170915p:plain

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())

f:id:quoll00:20191010112743p:plain

このようにリクエスト後すぐに切断されました。

有効な時との違いは、HTTPのレスポンスヘッダーに

f:id:quoll00:20191010113025p:plain

Connection: closeが含まれていました。

HTTP/1.1 では、 Connection ヘッダーで close の値が送信された場合に限り、短命なコネクションモデルを使用します。

ref: HTTP/1.x のコネクション管理 - HTTP | MDN

HTTP/1.1の仕様通りですね。

クライアント側で無効にする場合

Transport.DisableKeepAlivestrueにします。

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リクエストヘッダーに

f:id:quoll00:20191015161745p:plain

Connection: closeが含まれ、それによってレスポンスヘッダーにもConnection: closeが含まれていました。

f:id:quoll00:20191015162051p:plain

これによってすぐに切断されました。

片方が途中で切断したらどうなるか

Keep-Aliveでidle connectionが存在する状態で、一方が落ちたらどうなるでしょうか

サーバを落とした場合

サーバから即座にFINパケットが送られて、TCP connectionがcloseされます。

f:id:quoll00:20191010113557p:plain

Ctrl-Cで中断させたり、panicでプロセスKillした場合も同様でした。

クライアントを落とした場合

クライアントから即座にFINパケットが送られて、TCP connectionがcloseされます。

f:id:quoll00:20191010113646p:plain

ネットワーク障害が起きたらどうなるか

先程はサーバやクライアントが落ちたケースでしたが、それとは別でネットワーク自体に障害が起きたケースを考えてみます。

途中まで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

f:id:quoll00:20191011104919p:plain

このようにカーネルの最大試行回数(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)のまま使用したところ、以下のようになりました。

f:id:quoll00:20191009080940p:plain

  • 15秒間隔でkeep-alive probeが発生
  • サーバから送られている

これはOS側のカーネルを利用しているのでしょうか?
でも調べる感じだと75秒設定で謎でした。

$ sysctl net.inet.tcp | grep keepintvl
net.inet.tcp.keepintvl: 75000

追記

サーバのListener側のKeepaliveのデフォルト値が15秒でした。

github.com

サンプルコード

今回の検証で使用したコードはこちらです。

github.com

ソース