その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

GoのHTTPクライアントがAWS NLB配下にあるコンポーネントと通信するときに5-tupleが分散しないので特定のインスタンスにしか負荷分散されないという話題

Microservicesのようなものを考えた際、Goで書かれたコンポーネントがHTTP(S)を使って他のコンポーネントと通信するという場合があると思います。
その「他のコンポーネント」がAWS NLBの配下にある時、GoのHTTPクライアントがTCPコネクションを使い回す場合があり、その状況においては特定のNLB配下のインスタンスにしかリクエストを割り振らない挙動をするという話題です。

NLB

プロトコル、ソースIP、ソースポート、宛先IP、宛先ポート、そしてTCPシーケンス番号に基いてフローハッシュアルゴリズムを用いて割り振り先のインスタンスを選択するようになっています。

ref:

For TCP traffic, the load balancer selects a target using a flow hash algorithm based on the protocol, source IP address, source port, destination IP address, destination port, and TCP sequence number. The TCP connections from a client have different source ports and sequence numbers, and can be routed to different targets. Each individual TCP connection is routed to a single target for the life of the connection.
https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html

つまり、同一のTCPコネクションが継続的に張られている状態 (すなわち5-tupleが同じ状況) では、その上を通るHTTPリクエストは常に同じインスタンス (ターゲット) に割り振られることになります。

GoのHTTPクライアント

GoのHTTPクライアントは基本的にHTTP Keep-Aliveするようになっています。デフォルトのHTTPクライアントのTransporthttp.Transportが用いられており、MaxIdleConnsあるいはMaxIdleConnsPerHostによってKeep-Aliveして使い回すコネクションの数をコントロールしています。

ref: https://engineers.fenrir-inc.com/entry/2018/11/12/153859

デフォルトではMaxIdleConnsは無制限 *1、かつMaxIdleConnsPerHost2 *2 *3 となっています。
つまりデフォルトの状況では同一ホストに対して2並列コネクションまではKeep-Aliveが有効となり、それ以上の並列リクエストについては都度コネクションを張り切りするという挙動となります。

GoのHTTPクライアントからNLB配下のコンポーネントへリクエストを送る時

以上から、並列リクエスト数が少ない時 (すなわち MaxIdleConns あるいは MaxIdleConnsPerHost 以下の時) にTCPのコネクションがHTTP Keep-Aliveによりpersistentに保たれるため、固定のインスタンスにしかリクエストが割り振られないこととなります。たとえば高々1リクエストしか同時に捌かないような時 (つまり総リクエスト数が少ない時)、そのリクエストは常に一意なNLB配下のインスタンスに振り分けられることとなります。

なので並列リクエスト数が少ない時にNLBの配下にたくさんターゲットをぶら下げていても、大半のターゲットにはリクエストが振られず遊んでいる状況となるためまったくの無駄となります。なのでうまいこと並列リクエスト数に応じてスケールアウト・スケールインできるようになっていると良さそうですね。
(ただこれはリクエスト元のGoで書かれたコンポーネントのプロセス数にも依存するとは思っており、もし大量のプロセスがいる場合はプロセスあたりの並列リクエスト数が少なくてもうまいこと5-tupleが分散する (あるいはプロセス内のコネクションが暇すぎてKeep-Aliveが切れて都度ハンドシェイクする) のでNLB配下のターゲットについては分散するとは思いますが、そもそもリクエスト数が少ない時の大量のプロセスを上げているのはリソース過剰であるのでそこが無駄では、という見方もできる気がします。)
一方で突然リクエスト数が増えた時にスケールアウトをトリガーしたとして、果たして間に合うかどうかという話題はありますが......そうなってくると遊ぶのを織り込んで余剰なターゲットをあらかじめ上げておくしかないような気もしているところです。


それはそうとして、低並列の場合でもTCPコネクションを良い感じでラウンドロビンして5-Tupleを散らす (ついでに良い感じでTCPコネクションをプールしておく)、みたいな方法は無いものですかね。自分でTransportレイヤーを書くしか無いのでしょうか? とはいえ並列リクエスト数が少ないということは総リクエスト数も少ないというわけで、その少ないリクエストをNLB配下のターゲットにまんべんなく割り振っても特に意味はないような気はしますね......ウーン。


[追記]
ありがたい助言:

なるほど、TCPレベルでのpoolをTransportレイヤで持つことを当初考えていましたが、HTTP Clientレベルでpoolすれば良いという気付きをいただきました。
[追記ここまで]

*1:MaxIdleConns controls the maximum number of idle (keep-alive) connections across all hosts. Zero means no limit. https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=192

*2:MaxIdleConnsPerHost, if non-zero, controls the maximum idle (keep-alive) connections to keep per-host. If zero, DefaultMaxIdleConnsPerHost is used.

*3:https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=58;bpv=1;bpt=1