読者です 読者をやめる 読者になる 読者になる

相手がいないのに ESTABLISHED になってる TCP ポート

最近 ParallelServer というライブラリを作ったのですが、その最中に奇妙な状態になってる TCP ポートを見つけたので、メモっておきます。

Ruby では TCP サーバーは次のような感じで作ることができます。お手軽ですね。

require 'socket'

Socket.tcp_server_loop(12345) do |socket, client_addr|
  socket.puts "Your IP address: #{client_addr.ip_address}"
  name = socket.gets
  socket.puts "Hello, #{name}"
  socket.close
end

これは 12345 ポートでクライアントからの接続を待ち、接続されたらクライアントのIPアドレスとクライアントからの入力をクライアントに送信して切断するだけの簡単なプログラムです。

~% nc -v localhost 12345
Connection to localhost 12345 port [tcp/*] succeeded!
Your IP address: 127.0.0.1
hogehoge
Hello, hogehoge
~% 

サーバープログラム起動直後の 12345 ポートの状態は次のようになります。

~% netstat -an | grep 12345
tcp        0      0 0.0.0.0:12345           0.0.0.0:*               LISTEN     
tcp6       0      0 :::12345                :::*                    LISTEN     

IPv4 と IPv6 の両方で LISTEN 状態です。

クライアントから接続すると次のように ESTABLISHED になります。

~% netstat -an | grep 12345
tcp        0      0 0.0.0.0:12345           0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:12345         127.0.0.1:56863         ESTABLISHED
tcp        0      0 127.0.0.1:56863         127.0.0.1:12345         ESTABLISHED
tcp6       0      0 :::12345                :::*                    LISTEN     

サーバーの 12345 とクライアントの 56863 が接続されている状態です。 同じサーバー内で接続しているため、1つの接続について、サーバーからみた接続とクライアントから見た接続の2行出力されています。

このように普通は接続が確立したらサーバーとクライアントの両方が ESTABLISHED になります。

ここで次のクライアントプログラムを動かしてみます。

require 'socket'

sockets = []
200.times do |i|
  p i
  sockets.push TCPSocket.new("localhost", 12345)
end
sleep

サーバーに 200接続したまま何もしないプログラムです。

サーバーは最初の接続からのクライアントの入力を待つので、2個め以降の接続を処理できません。

普通のサーバープログラムは、そんなことにならないように、クライアントからの接続を受け付けた時に fork したりスレッドを作ったりするのですが、ここでは意図的にこのようにしてます。

この状態で netstat を見ると、サーバー側にいくつか SYN_RECV 状態のポートがあります。

tcp        0      0 127.0.0.1:12345         127.0.0.1:36807         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:36930         SYN_RECV       ← これ
tcp        0      0 127.0.0.1:12345         127.0.0.1:36953         SYN_RECV       ← これ
tcp        0      0 127.0.0.1:36807         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:36930         127.0.0.1:12345         ESTABLISHED    ← これ
tcp        0      0 127.0.0.1:36953         127.0.0.1:12345         ESTABLISHED    ← これ

クライアントポート 36807 はちゃんと対向するサーバーが ESTABLISHED になっているのですが、36930, 36953 ポートは SYN_RECV になっています。

しばらくすると、この SYN_RECV 状態のポートはなくなります。といっても ESTABLISHED になったわけではなく、消えてなくなってしまいます。

tcp        0      0 127.0.0.1:12345         127.0.0.1:36807         ESTABLISHED
tcp        0      0 127.0.0.1:36807         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:36930         127.0.0.1:12345         ESTABLISHED    ← これ
tcp        0      0 127.0.0.1:36953         127.0.0.1:12345         ESTABLISHED    ← これ

クライアントは ESTABLISHED だと思っているのに接続相手のサーバーはいません。

サーバープログラムを停止しても状態は変わりません。 クライアントプログラムを停止しない限り残ったままです。

tcp        0      0 127.0.0.1:36930         127.0.0.1:12345         ESTABLISHED    ← これ
tcp        0      0 127.0.0.1:36953         127.0.0.1:12345         ESTABLISHED    ← これ

このように 200 くらい接続すると SYN_RECV が確認できるのですけど、どうやらこれは listen のバックログの値に関連してるようです。

Socket.tcp_server_loop は内部で listen(Socket::SOMAXCONN) しています。手元の Linux では SOMAXCONN は 128 になっていました。

もっと単純に次のようなサーバープログラムで試してみました。

require 'socket'

TCPServer.new(12345).listen(5)
sleep

このようにバックログを 5 にすると、クライアント接続が 10 くらいでも再現できます。

なお、listen(Socket::SOMAXCONN) しているのは Ruby 2.0 以降です。Ruby 1.9.3 では listen(5) としているので、すぐに発生します。

で、結局こんなことになってしまう理由はわかりませんでした。

クライアントが ESTABLISHED だと思ってるのに SYN_RECV になってるのもわからないし、相手がいないのに ESTABLISHED になってるソケットが存在してるのもわかりません。 ネットワークの向こうの別のサーバであればパケット落ちとかでこのような状況になるのもわかるのですけど。

バックログを超えたら SYN_RECV にならなくてもいいと思うんですけど…。 TCP/IP はそういうもんなんですかね。

[追記]

Facebook の方で色々教えてもらいました。

OS X では発生しないようです。うすうす感じていたのですがやはり Linux の実装の問題のようです。

教えてもらったブログ http://veithen.blogspot.jp/2014/01/how-tcp-backlog-works-in-linux.html に答えがありました。

BSD では接続用のキューが1つで、キューがいっぱいの場合はクライアントからの SYN を破棄するだけなので、クライアントが ESTABLISHED になることはありません。私の知ってる TCP の動きです。

Linux では SYN キューと accept キューの2つがあるようで、SYN キューに入った時に SYN ACK を返し SYN_RECV 状態になり、accept キューに入った時に ESTABLISHED になるようです。

サーバープログラムが accept しなければ、accept キューがいっぱいになり SYN キューも掃けません。SYN キューにたまったものは一定時間たつと削除されるようです。

SYN キューの大きさは、sysctl net.ipv4.tcp_max_syn_backlog で、accept キューの大きさはプログラムから指定する listen のバックログです。

sysctl -w net.ipv4.tcp_max_syn_backlog=0 にして試してみたら BSD の動きに近くなりました。ただし 0 に設定しても1個は SYN_RECV になってしまうようです。

また sysctl -w net.ipv4.tcp_abort_on_overflow=1 にすると accept キューがあふれた場合、クライアントからの SYN に RST を返すようです。この場合はクライアントプログラムは connect(2) に対して ECONNRESET エラーが返ります。

RST じゃなくて SYN を無視するようになれば BSD と同じになるんじゃないかと思ったのですが、そういうパラメータは無さそうでした。

まあ別に BSD と同じ動きにしたかったわけではなく、ただ疑問に思っただけだったので、それは解決したのでもういいです。