🐥

Kubernetes クラスタ内の別ノード再起動によるTCP接続タイムアウト

2020/12/07に公開

はじめに

GKE クラスタを運用中に、WebSocket の接続が定期的に同時に 4000 近く送信タイムアウトが発生して切断される、という障害に遭遇しました。クライアントからみると WebSocket の再接続をすれば済むと思うかもしれませんが、この切断は TCP 経路中のネットワーク遮断によるものなので、これを通信の両端が検知するまでにはどうやっても時間がかかるため、その間の WebSocket 経由のメッセージのやり取りに支障が発生することは避けられません。また、WebSocket 接続の終了処理リクエストとその後の再接続に伴う確立処理リクエストのスパイクは予期せぬバックエンドの負荷につながります。

障害の原因は特定して コンテナ ネイティブの負荷分散に切り替えることで解消されました。このエントリーでは、障害の発生条件・原因・対応策を簡単にまとめます。GKE に限定して書きますがそれ以外の Kubernetes 環境でも参考になるところはあると思います。また、kube-proxy のモードは現在デフォルトの iptables で確認しています。

障害の発生条件

必須の発生条件

発生頻度が増える条件

  • 同じクラスタ内でプリエンプティブノードを利用している
    • これによってノード再起動(= ターミネート)の頻度がかなり高まるため
    • 同じノードプール内である必要はない点に注意。例えば、preemptive-api-node-pool と websocket-node-pool を同じクラスタで運用している場合、この条件を満たす。

被害が大きくなる条件

  • クラスタ内で対象の Pod がごく少数のインスタンスにしか配置されていない
    • 例えば、上記の例で言うと websocket-node-pool は 1 インスタンスしかなくここに WebSocket サーバー Pod が固まっている場合、この WebSocket Pod が保持している TCP 接続が api Pod のそれよりも大きな影響を被る

障害の原因

従来の Ingress 経由の GKE Pod への負荷分散は LB がノードまでの負荷分散を担当して、ノードから Pod までの負荷分散を Kubernetes(の kube-proxy)が担当しています。

LB は Pod がどのノードにいるかを把握していないので、例えば WebSocket service 向けのリクエストであってもバックエンドとして登録されているインスタンスグループに preemptive-api-node-pool が含まれていれば、まずそこにリクエストを振り分けることがあります。このリクエストを受け取ったノードに常駐している kube-proxy は、Service 設定に基づいた iptables ルールを使用して目的の Pod にリクエストを転送します。

iptablesconntrack(netfliter connection tracking system) database に転送した接続情報を保存しています。接続の両端にいる peer、つまり LB と Pod で TCP 接続が確立されると、それ以後の通信は conntrack database を保持するこのノードを経由して行われるようになります。

WebSocket サーバー Pod 内の TCP 接続相手 IP 一覧の出力結果。この WebSocket サーバーは外部ネットワークから WebSocket 接続を待ち受ける役割と、内部ネットワークから HTTP POST で特定の WebSocket 接続にメッセージをプッシュする役割の二つを担っています。10.146.0.0/20 はこのクラスタのノードに割り当てられた ip range で前者、10.48.0.0/14 はこのクラスタの Pod に割り当てられた ip range で後者に対応しています。LB と確立すべき TCP 接続の相手先が GCLB の Private ip range ではなく、ノードのそれになっているのがここからわかります。

❯❯❯ gcloud beta compute ssh --zone "asia-northeast1-a" "gke-cluster-websocket-pool-3d7d55f0-h2fz" --project "zenn"

yoheimuta@gke-cluster-websocket-pool-3d7d55f0-h2fz ~ $ sudo nsenter -t 243187 -n bash
gke-cluster-websocket-pool-3d7d55f0-h2fz /home/yoheimuta # netstat -ant | grep 8080 | awk '{print $5}' | cut -d: -f1 | sort | uniq -c
      1
    833 10.146.0.10
    399 10.146.0.11
   4224 10.146.0.3
   4225 10.146.0.60
    449 10.146.0.98
   4259 10.48.12.1
    194 10.48.204.24
    228 10.48.204.46
   3903 10.48.207.248
...

この状況で、例えば 10.146.0.3 が Preemptive Node で 24 時間以内の再起動(回収処理)が行われると、4224 接続を管理する conntrack database が失われることになるため、同時に大量のクライアントと通信ができなくなります。さらに TCP は一度確立されれば中間の通信経路が失われただけでは即座にクローズ処理は行わないので、アプリケーションレイヤーで特別なことをしない場合は、TCP レイヤーのタイムアウトが発生するまでは peer は切断に気づくことすらできません。どのタイムアウトの仕組みが適用されるかは TCP 接続ステートと通信状況によります。その間、write(2) 呼び出しはエラーを吐かずに成功して、代わりに read(2) 呼び出しがブロックされ続けた末にタイムアウトエラーが返ってくることで、ようやく接続をクローズできます。

原因特定の方法

障害時の WebSocket サーバーは read: connection timed out エラーを大量に出力していたので、TCP のタイムアウトであることがわかりました。

今回はデフォルトで TCP Keepalive のタイムアウト時間を短く設定している Go が使われていたので、切断前最後の通信処理から、tcp_keepalive_time(15) + tcp_keepalive_intvl(15) * tcp_keepalive_probes(9) = 150 秒以内には切断されます。この挙動が観測していた時系列に合っていたことから、TCP 接続の中間経路が突然不通になっているとあたりをつけることができました。

他への影響範囲

TCP 接続の中間経路が不通になる影響は、WebSocket 接続のような長時間維持することを前提にした L7 プロトコルでは顕著ですが、デフォルトで Keep-Alive が有効な HTTP でも タイムアウトエラーの形で現れるはずです。実際にトラフィックの多い運用環境では、HTTP GCLB で 502 がこれを起因に発生していました。ノードターミネイト時にバックエンドの負荷が高くないにもかかわらず 502 が一定数集中して発生していたらそれの可能性があります。

障害の対応策

GKE のコンテナ ネイティブの負荷分散に切り替えることで解消します。これは2019 年にリリースされた機能なので、すでに運用している GKE クラスタはこの分散方式になっていないことが多いと思います。これは LB から直接 Pod にトラフィックが配信されるようになるため、そもそも kube-proxy が管理する iptables を経由しなくなります。結果、同一クラスタに存在する関係ないノードの再起動によって TCP 接続が切断されることはなくなります。イメージの左が従来の分散方式で、右が コンテナ ネイティブの負荷分散です。

切り替え後の、WebSocket サーバー Pod 内の TCP 接続相手 IP 一覧の出力結果。ノードに割り当てられた 10.146.0.0/20 は消え、代わりに ロードバランサー(GFE)の Private IP アドレスである 35.191.0.0/16, 130.211.0.0/22と直接接続するようになったのがわかります。

gke-cluster-n1-standard-8-we-edc530af-klv4 /home/yoheimuta # netstat -ant | grep 12345 | awk '{print $5}' | cut -d: -f1 | sort | uniq -c
      1
      7 10.8.0.1
   1694 10.8.1.17
    189 10.8.1.18
...
    302 130.211.0.111
    421 130.211.2.229
    389 130.211.2.232
...
    283 35.191.1.160
    345 35.191.1.161
    262 35.191.1.162
...

コンテナ ネイティブの負荷分散に切り替えた後、目新しい別の問題などは発生していません。新規 Pod の追加(ex. Deployment の Rolling Update や HPA による ScaleUp など) 時に負荷がその Pod にすぐに割り当てられない問題(これは別エントリーにまとめるかもしれません)も付随して解消しました。また、全体的に nf_conntrack_count も大幅に下げることができたのでしばらく nf_conntrack_max の調整が不要になりました。

ここまで書くと、以前の環境で作成されたクラスタを運用している人は コンテナ ネイティブの負荷分散に早速切り替えることを検討するかもしれませんが、1点注意が必要です。切り替えるにはクラスタが VPC ネイティブである必要があるため、もし運用中のクラスタがそうでなければ新規クラスタごと入れ替える作業が発生します。これはなかなか大変で、私の環境では起票から検証込みでだいたい 4 ヶ月かけて(途中に新規開発が入って中断しつつ)多くの関係者の協力のもと完了しました。

まとめ

従来の GKE の負荷分散方式は kube-proxy に Pod までのルーティングを頼っていたために、別ノードがターミネイトされると、関係ない Pod のネットワークが不通になる問題があります。コンテナ ネイティブの負荷分散に切り替えると解決します。

Discussion