TCPのhalf-open connectionsが発生したので紐解いてみた

はじめに

この記事は DMMグループ Advent Calendar 2024 6日目の記事です。

こんにちは、プラットフォーム開発本部 第三開発部 基盤開発グループの登石です。今回問題が発生したのはGen2-GWというAPI Gatewayで、私の所属する基盤開発グループが開発・運用をしています。 このシステムは複数のコンポーネントで成り立っています。

この記事では、これらのコンポーネントで発生したTCPのhalf-open connections問題について説明します。 原因の解説がメインで、解決方法についてはおまけ程度の記述になっています。

主な登場人物はIstio・Cloud NAT・Nginxです。

環境

問題説明の前に、まず問題の発生した環境を簡単に説明します。

HTTPクライアントはGoで実装されており、Google CloudのGKE上で動作しています。
HTTPサーバーはNginxです。オンプレ上で動作しています。

GKEからオンプレへの通信はインターネットを経由します。 GKEからインターネットに出るときは、Cloud NATによるNAT変換をします。 またGKEではIstioが動作しています。HTTPクライアントの通信がインターネットに出る時はこのIstioを経由します。

図に起こすと以下のような構成です。

今回の問題は、これら複数コンポーネントの設定が相互に関係していた結果発生しました。

今回発生した問題

実際にどういう問題が発生していたのかについて説明します。

今回の問題は、コンポーネント間がHTTP通信をする際に発生しました。 具体的にはHTTPクライアントが下記のようなエラーログを出すようになりました。

dial tcp 192.0.2.0:443: i/o timeout

なんらかの理由でTCPのコネクションを貼るときにタイムアウトしていると考えられます。

このエラーログだけでは詳細が不明だったので、tcpdumpでパケットキャプチャをしました。 以下の画像はサーバー側でパケットキャプチャした結果です。

16392ポートから送信されたSYNパケットに対して、443ポート(サーバー側)がACKパケットで応答しています。 TCPの再送によってSYNパケットの受信とACKパケットの送信が繰り返されている様子もわかります。

クライアント側でもパケットキャプチャをしました。 以下が結果の画像です。 ACKは受信できておらず、SYNパケットを再送している様子がわかります。

サーバー側でssコマンドを使ってソケットの状態も確認しました。 結果、このポートとIPのペアはすでにESTABLISHEDな状態になっていました。
つまりすでにESTABLISHEDなソケットに3ウェイハンドシェイクを試みて失敗しているという状態でした。 dial tcp timeoutエラーは、3ウェイハンドシェイクの試行を繰り返して時間が経った結果です。

片方にだけESTABLISHEDなコネクションが残っている状態を、RFC9293では「half-open」と呼んでいます。 またこのような状態のコネクションをhalf-open connectionsと表現しています。

half-open connectionsができる状況はいくつか考えられます。 例えば、クライアント側がOSごとクラッシュした場合や、ネットワークの障害などです。 FINやRSTパケットが送信されないあるいは到達しない状態になることによってhalf-open connectionsができます。

half-open connectionsに関する余談

3ウェイハンドシェイクが失敗していたと書きましたが、あらためてRFC9293を確認します。 RFCには、half-open connectionsに対してSYNパケットを送信した時の挙動が書かれています。 SYNパケットを受け取ったピアはACKパケットを返し、それを受け取ったピアはRSTパケットを送信してhalf-open connectionsを解消します。

今回問題が発生した環境ではクライアントにACKが到達しなかった結果、クライアントはSYNパケットの送信しかせずRSTパケットは送信しませんでした。 ACKが到達していればおそらく今回の問題は発生せず、half-open connectionsはエラーになることなく解決していたと考えられます。
実際適当な環境でhalf-open connectionsを作り実験してみると、RSTパケットが送信されhalf-open connectionsは解消されました。

なぜ今回の環境ではACKが到達しなかったのか気になるところですが、調査の結果原因を突き止めることはできませんでした。 (本当は解決したかったのですが自身の調査能力が限界でした。)

half-open connectionsが発生した原因と解消方法

ここまでで、half-open connectionsによって3ウェイハンドシェイクが失敗していたことがわかりました。 次はTCPのhalf-open connectionsが発生した原因です。

これもパケットキャプチャの結果、クライアント側が1時間後にFINパケットを送信していることがわかりました。 サーバー側でFINパケットを受信できなかった理由と、FINパケットの送信が1時間後だった理由を説明します。 またどのようにすれば問題を解消できるかについても説明します。

cloud nat

結論から書くと、サーバー側でFINパケットを受信できなかった直接的な原因はCloud NATのマッピングが削除されていたことでした。
GKEからインターネットに出るときには、Cloud NATがsource ipとportを変換します。 マッピングは変換元と変換先のペアです。 FINパケットの送信前にマッピングが削除されたため、FINパケット送信時にあらためてマッピングが作られます。 この時sourceのportかipは変わる可能性があります。 実際の通信とFINパケットでsourceが異なれば、適切なソケットにFINパケットが届かなくなります。

マッピングが削除されるタイミングは、Cloud NATのタイムアウト設定によって制御します。 Google Cloudのドキュメントに設定についての説明があります。
https://cloud.google.com/nat/docs/tune-nat-configuration#modify-nat-timeouts の"TCP Established Connection Idle Timeout"が今回関係する設定です。 この設定はデフォルトでは20分に設定されていて、この間TCP通信が発生しなければマッピングを削除します。 実際に今回の環境ではこの間TCP通信が発生していなかったため、マッピングが削除されたと考えられます。

なぜTCP通信が発生していなかったのかについて考えます。 まず一つは、当たり前ですがコンスタントにHTTP通信が発生する環境ではなかったということです。 Cloud NATのタイムアウトより短いスパンでHTTP通信が発生していれば、タイムアウトにはなりません。

今回の問題では以下の二点の影響によって、タイムアウトするまでの間TCP通信が発生しないようになっていました。

  • TCPのkeepaliveの設定
  • コネクションのタイムアウト設定

Istioの設定、nginxの設定の観点からこれらについて説明します。

Istio

今回の環境では、クライアント側の通信はIstio経由でインターネットに出ていきます。 TCPのkeepaliveについて確認するには、Istioの設定を見る必要があります。

IstioではConnectionPoolSettings.TCPSettings.TcpKeepaliveでTCPのkeepaliveを設定します。 https://istio.io/latest/docs/reference/config/networking/destination-rule/#ConnectionPoolSettings-TCPSettings-TcpKeepalive
注目すべきはtimeフィールドです。 このフィールドは、コネクションがアイドル状態になった後どれだけ経過したらTCP keepaliveを始めるかを制御するものです。 Descriptionにあるとおり、明示的に設定しなければ2時間後にTCP keepaliveのパケットが飛び始めます。 今回の環境では明示的に設定されていませんでした。 よってNATのマッピングが削除されるまでの間にTCP keepaliveが実行されません。

keepaliveが実行されるまでの時間を短くすれば、Cloud NATのタイムアウトを防ぐことができます。 注意点としては、無駄に必要のないコネクションを維持する可能性があることです。 実際今回の問題でこの設定は変更しませんでした。

次にTCPのidle connectionのタイムアウトの設定を確認します。 これはConnectionPoolSettings.TCPSettingsidleTimeoutフィールドで設定します。 https://istio.io/latest/docs/reference/config/networking/destination-rule/#ConnectionPoolSettings-TCPSettings
デフォルトは1時間です。 今回の環境では明示的に設定されていなかったのでデフォルトのままです。 この設定によって、1時間後にFINパケットが飛ぶことになります。 NATのマッピングは20分で削除されるので、FINパケットが送信される時にはすでに削除されています。 よってFINパケットを送信する際には再度マッピングを取得する必要があります。 この時同じポート/IPが割り当てられるとは限りません。 別のポート/IPが割り当てられた場合、本来CloseしたいコネクションにはFINパケットが届かないことになります。

この設定を変更してCloud NATがタイムアウトする前にidle connectionがタイムアウトするようにすれば、適切なコネクションに対してFINパケットが届くようになります。

まとめると、以下のようになります。

  • Istioは1時間後にFINパケットを送信する
  • Istioはkeepaliveを2時間後に開始する
  • Cloud NATは利用のないマッピングを20分後に削除する

これらの挙動により、20分間HTTPリクエストが発生しなかった場合にhalf-open connectionsが発生します。

Nginx

またコネクションのタイムアウトについては、Nginx側の設定にも問題がありました。

Nginxにはkeepalive_timeoutという設定があります。 https://nginx.org/en/docs/http/ngx_http_core_module.html#keepalive_timeout
この設定は、アイドル状態になったコネクションをタイムアウトにするまでの時間を制御します。 この設定がCloud NATのタイムアウトより短ければ、Cloud NATのマッピングが削除される前にNginxの側からコネクションを片付けることができます。

実は今回問題が起こった環境ではこの設定が9999999秒になっていました。 (なぜこのような設定になっていたのか、その背景はわかりませんでした。)
これによって約115日が経過するまで、アイドル状態のコネクションは片付けられず、half-open connectionsが残り続ける状態でした。

実際にこの設定に対して、Cloud NATのタイムアウト以下の値を設定することで、問題は再現しなくなりました。

最後に

Istio・Cloud NAT・Nginxの設定の関係によるTCPのhalf-open connections問題について説明しました。 最終的には各設定の関係を考慮して設定するという対応になりましたが、これは各設定の意味を理解しないといけないので大変でした。

またクライアント側のパケットキャプチャはKubernetesのEphemeral Containerでtcpdumpを動かして取っていました。 このとき誰かがコードをマージするとDeploymentが更新されロールアウトでEphemeral Containerごと無くなるという恐怖があったので、別の方法を考えようと思いました。

プラットフォーム開発本部では一緒に働く仲間を募集しています! speakerdeck.com