こんにちは。久しぶりの投稿です。
今回は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
構造体にIdelTimeout
があり、それぞれ書き込み、および読み込みのタイムアウト時間を設定することができます。
http.Server{
IdleTimeout time.Duration
}
また、TCPコネクションを閉じたいときはKeep Alive: close
というヘッダーをサーバーがクライアントに送ることで閉じることができます。
TCPコネクションのクローズとコネクションの状態遷移

引用: Wireshark Wiki
TCPで規定されているように、コネクションが確立(Establish)した状態からTCPコネクションを閉じるまでにいくつかのやり取りがクライアントとサーバーの間で行われます。 TCPコネクションの開始がThree way handshakeで行われるように、終了処理はFour way handshakeで終わります。TCPコネクションを終了することはモバイル端末のようなクライアントでも、一般的なサーバーでもどちらからでも行うことができます。
まず最初に、クライアントはFIN/ACK
パケットをサーバーに送信します。最初にFIN
パケットを送るという挙動は実装によっては異なるケースもありますので注意してください。この状態でクライアントはFIN-WAIT1
状態になります。
そして、これを受信したサーバーはACK
パケットを送り返し、アプリケーションが実際に接続をクローズするのを待ちます。これのサーバーの状態のことをCLOSE-WAIT
状態(閉じられるのを待っている状態)と呼びます。次に、ACK
パケットを受け取ったクライアントはサーバーからFIN/ACK
パケットが送られてくるのを待ちます。
最初のACK
パケットが送られてきたあとのクライアントの状態はFIN-WAIT2
状態です。サーバーは、アプリケーションからクローズの処理要求があると、FIN/ACK
パケットを送信し、ACK
パケットが送られてくるのをまつLAST-ACK
状態に遷移します。
そして、サーバーからFIN/ACK
パケットを受け取ったクライアントは、それに対するACK
を送ることによってTIME-WAIT
状態に遷移します。このACK
パケットを受け取ったサーバーはCLOSE
状態になり、コネクションを消します。
同時にTIME-WAIT
状態にあるクライアントは設定された時間をまってからCLOSED
状態に遷移します。これによって、クライアントが保持していたリソースを開放します。
つまり、クライアント、およびサーバーの(ESTABLISH
後の)状態遷移は以下のようになっています。
- クライアント:
ESTABLISHED
→FIN-WAIT1
→FIN-WAIT2
→TIME-WAIT
→CLOSED
- サーバー:
ESTABLISHED
→CLOSE-WAIT
→LAST-ACK
→CLOSED
TIME-WAIT状態のパフォーマンス的問題
先程説明した通り、TIME-WAIT
状態はCLOSED
状態に遷移するまで、タイムアウト時間分だけクライアントが保持しているTCPソケットなどのリソースがアイドリングすることになります。
参考文献[2]にもあるように、TIME-WAIT
状態によってTCPソケットが使えない状態になるとアプリケーションが正常にハンドリングできない、といったような問題が発生します。
TIME-WAIT状態の存在意義
なぜ、リソースがアイドリング状態になるにも関わらず、このような状態があるのでしょうか。
1. Wandering Duplication問題への対策
TCPコネクションでやり取りをされるデータグラムは、シーケンス番号で識別されます。
TIME-WAIT
状態は保険のような役割があり、ACK
パケットがサーバーに正常に届くまでの時間を保険にかけているようなものです。仮に、保険をかけない、すなわちTIME-WAIT
状態を0
にした場合、
前のコネクションで送信されたセグメントが、次に作成されたコネクションのセグメントとして受信される恐れがあります。そのようなことを避けるためにもTIME-WAIT
状態が存在しています。セグメントがネットワーク内に滞留する時間はプロトコル上、2分と定められていて、TIME-WAIT
状態のタイムアウトも2分にしとけば安心できそうです。
2. サーバー側のLAST-ACK状態から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
状態までの時間を制御できます。
Lingerオプションを有効化するにはカーネルプロパティで有効化する必要があります。そしてソケットに対して、設定することができます(#TCPConn.SetLinger。
conn, _ := net.Dial("tcp", hostPort)
tcpConn := conn.(*net.TCPConn)
_ = 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_LEN
を0
にする必要はないと思います。
今回はネットワークプログラミングについて少し潜りましたが、久しぶりにカーネルなどを考える機会があり、よいリハビリになりました。 最後までありがとうございます。
おまけ
RSTパケット
TCPプロトコル上でどうしても解決できないような問題をRSTパケットを送ることによって、強制的にTCP接続を切断することができます。 これは、シーケンス上で整合性や取れなくなったりしたりしたときに使われます。
TCPのデバッグに使えるnetstatコマンド
以下のようなオプションが便利です。
-a
: すべてのポートを表示する-t
: TCPポートを表示する-p
: PIDを表示する-o
: (macOSでは使えない)TIME_WAIT
状態のポートを表示する
参考文献
- 1: RFC:793 TRANSMISSION CONTROL PROTOCOL
- 2: ぜんぶTIME_WAITのせいだ!
- 3: Linuxカーネルの「TCP_TIMEWAIT_LEN」変更は無意味?
- 4: TIME_WAITに関する話
- 5: When is TCP option SO_LINGER (0) required?
- 6: TIME_WAIT状態のTCPコネクションを早く終了させるべくKernelをリビルド
- 7: How to reconnect to a socket gracefully
- 8: Bind: Address Already in Use
- 9: Resetting a TCP connection and SO_LINGER