December 6, 2020

TCPコネクションにおけるTIME-WAIT状態とその改善

こんにちは。久しぶりの投稿です。

今回はTCPサーバーにおけるSO_LINGERオプションによるパフォーマンス・チューニングをまとめたいと思っています。また、実装サンプルはGo言語で書いています。

本投稿での、TCP(L4)や、HTTP(L7)について詳細は説明は割愛させていただきます。

HTTP/1.1のConnectionとKeep Aliveヘッダー

まず、はじめにHTTP/1.1の標準機能であるKeep Aliveについて軽く紹介したいと思います。ここでは特にHTTP Keep Aliveを取り上げ、単にKeep Aliveと記述しています。

Connectionヘッダーは、Keep Aliveの設定をサーバーとクライアントの間でやりとりをするために使われます。ブラウザ、およびサーバーがKeep Aliveに対応していることを双方に伝えるために使われます。 また、Keep AliveレスポンスヘッダーはConnectionヘッダーと同時に使われ、timeoutディレクティブで接続を切るまでのタイムアウト時間を設定したり、接続の残りリクエスト数をmaxディレクティブで伝えたりすることができます。

HTTP/1.1ではデフォルトでKeep Aliveが有効になっているのですが、これが有効になっているおかげでHTTPリクエスト・レスポンスが行われたあともTCP接続が切れることがありません。 「TCP接続を使い回す」という表現がされ、TCP接続のためのThree way handshackが必要でなくなるため4Round Time Trip(RTT)を1RTTに減らすことができます。そのため、頻繁にアクセスをする、または短期間に集中してアクセスをするときにレイテンシなどの改善が見込めます。TCP接続を切るまでの時間、タイムアウト時間はブラウザなどクライアントに依存する他、サーバー側(アクティブクローズにおけるイニシエータ)でも設定することができます。

Go言語では*http.Server構造体にWriteTimeoutReadTimeoutがあり、それぞれ書き込み、および読み込みのタイムアウト時間を設定することができます。

http.Server{
    WriteTimeout time.Duration
    ReadTimeout  time.Duration
}

また、TCPコネクションを閉じたいときはKeep Alive: closeというヘッダーをサーバーがクライアントに送ることで閉じることができます。

TCPコネクションのクローズとコネクションの状態遷移

TCPで規定されているように、コネクションが確立(Establish)した状態からTCPコネクションを閉じるまでにいくつかのやり取りがクライアントとサーバーの間で行われます。 TCPコネクションの開始がThree way handshackで行われるように、終了処理はFour way handshackで終わります。TCPコネクションを終了することはモバイル端末のようなクライアントでも、一般的なサーバーでもどちらからでも行うことができます。

まず最初に、クライアントはFIN/ACKパケットをサーバーに送信します。最初にFINパケットを送るという挙動は実装によっては異なるケースもありますので注意してください。この状態でクライアントはFIN-WAIT1状態になります。 そして、これを受信したサーバーはACKパケットを送り返し、アプリケーションが実際に接続をクローズするのを待ちます。これのサーバーの状態のことをCLOSE-WAIT状態と呼びます。次に、ACKパケットを受け取ったクライアントはサーバーからFIN/ACKパケットが送られてくるのを待ちます。 FIN-WAIT2状態です。サーバーは、アプリケーションからクローズの処理要求があると、FIN/ACKパケットを送信し、ACKパケットが送られてくるのをまつLAST-WAIT状態に遷移します。 そして、サーバーからFIN/ACKパケットを受け取ったクライアントは、それに対するACKを送ることによってTIME-WAIT状態に遷移します。このACKパケットを受け取ったサーバーはCLOSE状態になり、コネクションを消します。 同時にTIME-WAIT状態にあるクライアントは設定された時間をまってからCLOSED状態に遷移します。これによって、クライアントが保持していたリソースを開放します。

つまり、クライアント、およびサーバーの(ESTABLISH後の)状態遷移は以下のようになっています。

  • クライアント: ESTABLISHEDFIN-WAIT1FIN-WAIT2TIME-WAITCLOSED
  • サーバー: ESTABLISEDCLOSE-WAITLAST-WAITCLOSED

TIME-WAIT状態のパフォーマンス的問題

先程説明した通り、TIME-WAIT状態はCLOSED状態に遷移するまで、タイムアウト時間分だけクライアントが保持しているTCPソケットなどのリソースがアイドリングすることになります。 参考文献[2]にもあるように、TIME-WAIT状態によってTCPソケットが使えない状態になるとアプリケーションが正常にハンドリングできない、といったような問題が発生します。

TIME-WAIT状態の存在意義

なぜ、リソースがアイドリング状態になるにも関わらず、このような状態があるのでしょうか。

1. Wandoring Duplication問題への対策

TCPコネクションでやり取りをされるデータグラムは、シーケンス番号で識別されます。 TIME-WAIT状態は保険のような役割があり、ACKパケットがサーバーに正常に届くまでの時間を保険にかけているようなものです。仮に、保険をかけない、すなわちTIME-WAIT状態を0にした場合、 前のコネクションで送信されたセグメントが、次に作成されたコネクションのセグメントとして受信される恐れがあります。そのようなことを避けるためにもTIME-WAIT状態が存在しています。セグメントがネットワーク内に滞留する時間はプロトコル上、2分と定められていて、TIME-WAIT状態のタイムアウトも2分にしとけば安心できそうです。

2. サーバー側のLAST-WAIT状態からCLOSED状態の安全な遷移

クライアント側がFIN-WAIT2からTIME-WAITへ状態遷移をするタイミングで送られるACKパケットが何かしらの理由で紛失したときに、サーバー側は再送をお願いすることになります(サーバーはクライアントにFIN/SYNを送り直します)。そのときに、クライアントにTIME-WAIT状態がなく、いきなりCLOSED状態だと再送をすることができません。 保険をかけるという意味でもTIME-WAIT状態が必要になるというわけです。

パフォーマンス・チューニング

1. カーネルパラメータを見直してみる

以下の2つが候補にあげられます。

  • tcp_fin_timeout: FIN-WAIT2状態を保つタイムアウト時間。特に有効そうではない。
  • tcp_tw_reuse: TIME-WAIT状態が大量発生するときに有効な、TIME-WAIT状態のソケットを再利用する方法。1秒でソケットの再利用が始まる。

2. カーネルをいじってみる

  • TCP_TIMEOUT_LEN: TIME_WAIT状態になったあときにCLOSE状態までに遷移するための時間で、デフォルトは60秒で設定されていることが多い。ちゃぴん先生の発言[3]にもあるように、tcp_tw_reuseパラメータを有効にしていると効果がないのかもしれないです。

3. Horizontal Scale(Scale out)して物理的にコネクションを増やす

コネクションは16ビット、つまり2バイトのポートを指定するため、それ分以上は作成することができません。しかし、この制約はマシン1台での話なので、複数台構成にしてロードバランシングなどをしてトラフィックをさばくことができます。 これによってスケールすることができ、クラウドコンピューティングの流れからもして容易に実施出来るのではないかなと思います。当然ながら、Vertical Scale(Scale up)してもポートの数は変わらないため、意味をなしません。

4. Lingerオプションを設定する

本記事の本題なのですが、Lingerオプションを設定することによって改善できるかもしれません。Lingerとは余韻という意味で、SO_LINGERを設定することでTIME-WAITからCLOSE状態までの時間を制御できます。 Lignerオプションを有効化するにはカーネルプロパティで有効化する必要があります。そしてソケットに対して、設定することができます(#TCPConn.SetLinger

conn, _ := net.Dial("tcp", hostPort)
tcpConn := conn.(*net.TCPConn) // TCPコネクションにアサーション
_ = tcpConn.SetLinger(0)

これによって、Lingerオプションを0にすることができました。

5. (アプリケーションの起動に関して) SO_REUSEADDRを設定する

あるアプリケーションでTCPソケットを使用して、再度アプリケーションを立ち上げたいとき、TIME-WAIT状態であるとリソースを開放しないので、そのソケットにバインドできないときがあります。 そのようなときでもsocket(7)SO_REUSEADDRを指定することによって、TIME-WAIT状態で残っている(条件を満たす)TCPソケットにバインドすることができます。

まとめ

TIME-WAIT状態は必要な理由は明確なものの、上記のような設定郡の設定を誤ると安全ではない、かつパフォーマンスに大きな影響を及ぼしそうですね。 単純なのはtcp_tw_reuseを設定することだと思い、LingerオプションやTCP_TIMEOUT_LEN0にする必要はないと思います。

今回はネットワークプログラミングについて少し潜りましたが、久しぶりにカーネルなどを考える機会があり、よいリハビリになりました。 最後までありがとうございます。

おまけ

RSTパケット

TCPプロトコル上でどうしても解決できないような問題をRSTパケットを送ることによって、強制的にTCP接続を切断することができます。 これは、シーケンス上で整合性や取れなくなったりしたりしたときに使われます。

TCPのデバッグに使えるnetstatコマンド

以下のようなオプションが便利です。

  • -a: すべてのポートを表示する
  • -t: TCPポートを表示する
  • p: PIDを表示する
  • -o: (macOSでは使えない) TIME_WAIT状態のポートを表示する

参考文献

© KeisukeYamashita 2021