JavaのSocketプログラミングでTCP通信を勉強し始めてから10年近く、ずっと気になっていた点として、socket周りのシステムコールとTCPパケットレベルでの挙動観察をしてみようと思います。
サンプルコード:
クライアントサイド実行環境:
Windows7Pro SP1 64bit 日本語版 >java -version java version "1.7.0_25" Java(TM) SE Runtime Environment (build 1.7.0_25-b17) Java HotSpot(TM) 64-Bit Server VM (build 23.25-b01, mixed mode) >groovy -version Groovy Version: 2.2.1 JVM: 1.7.0_25 Vendor: Oracle Corporation OS: Windows 7 VirtualBox 4.3.6 -> サーバサイドのCentOS6をゲストOSとして稼働
サーバサイド実行環境:
CentOS6 x86_64版 クライアントサイド実行環境上のVirtualBox上で、GuestOSとして実行 Host Onlyのネットワークインターフェイスを設定し、192.168.56.101が割り振られている。 $ uname -a Linux dev2c6x64.lab.glamenv-septzen.net 2.6.32-431.5.1.el6.x86_64 #1 SMP \ Wed Feb 12 00:41:43 UTC 2014 x86_64 x86_64 x86_64 GNU/Linux $ java -version java version "1.7.0_51" Java(TM) SE Runtime Environment (build 1.7.0_51-b13) Java HotSpot(TM) 64-Bit Server VM (build 24.51-b03, mixed mode) $ groovy -version Groovy Version: 2.2.1 JVM: 1.7.0_51 Vendor: Oracle Corporation OS: Linux
※サーバサイドのCentOS6では、実験時はiptablesを停止しておきました。
# service iptables stop
今回はVirtualBox上のゲストOS(CentOS6)でサーバサイドを動作させ、ホストOS(Win7)からWiresharkを起動してEchoサーバ・クライアント間の通信をキャプチャ出来るようにして、クライアントサイドを動かしました。
まずサーバサイドで t_tcp_echo_with_strace_1.groovy を起動します。listenするポート番号は10007をハードコードしてますが、お好みに応じてカスタマイズして下さい。
$ groovy t_tcp_echo_with_strace_1.groovy log stdout to 2014-02-15_18-29-07_10565.sout log stderr to 2014-02-15_18-29-07_10565.serr start : strace -e trace=socket,readv,recvfrom,write,writev,sendto,listen,bind,accept,shutdown,close -p 10565 ready?(入力待ち→Enterを入力) echo server started, listening port: 10007 accpet?(入力待ち→Enterを入力すると、accept(2)が走ります)
続けてホストOS側でWiresharkを起動します。"Sun: \Device\NPF_{...(省略)...}" となっているインターフェイスを選択して、余計なパケットをキャプチャしないよう、以下のcapture filterを設定してキャプチャを開始します。
tcp port 10007
最後にホストOS側でクライアントサイドを実行します。
> groovy t_tcp_echo_client_1.groovy 192.168.56.101 10007 connect() start SO_REUSEADDR = false SO_LINGER = -1 SO_TIMEOUT = 10000 SO_KEEPALIVE = false OOBINLINE = false TCP_NODELAY = false connected to /192.168.56.101:10007 send1>(入力待ち)
ここでサーバ側を見てみると、accept()が接続を受付け、以下の様なメッセージが出力されます。
Remote(192.168.56.1:49878) -> Local(192.168.56.101:10007)
クライアント側で"hello1"+Enterと入力すると、サーバサイド側でread()します。
read 6 bytes. echo?(入力待ち)
→ここでサーバサイドを10秒以上放置すると、クライアント側ではread timeoutになります。ひとまずすぐにEnterを入力します。
クライアント側は、サーバからの読み込みはダミーで1バイト読み込んでおくだけにとどめていて、すぐに2つめの入力待ちに進みます。
send2>(入力待ち)
→"hello2"と入力すると、サーバサイド側でまたread()します。
read 6 bytes. echo?(入力待ち)
すぐにEnterを入力します。
クライアント側は、Socket#shutdownInput()前の入力待ちになります。
shutdownInput?(入力待ち)
→Enterを入力すると、shutdownInput()が実行され、後述しますがRST+ACKがサーバ側に送信されます。
サーバ側ではread()中にIOExceptionが発生し、リモートからの切断として処理を進めます。
closed from remote close?(入力待ち→Enterを入力) end : exitValue = 0
クライアント側はその後、shutdownOutput()とclose()がそれぞれ、入力待ちで実行されます。
shutdownOutput?(入力待ち→Enterを入力) close?(入力待ち→Enterを入力)
上記の動かし方で動かした時の、TCPの流れを簡単にまとめます。
1. クライアントからのSocket#connect() -> サーバでのaccept()
client --> SYN --> server clinet <-- SYN+ACK <-- server client --> ACK --> server
2. クライアントからのOutputStream#write("hello1")
client --> PUSH+ACK("hello1") --> server client <-- ACK <-- server
3. サーバからのechoによるwrite()
clinet <-- PUSH+ACK("hello1") <-- server client --> ACK --> server
4. クライアントからのOutputStream#write("hello2")
client --> PUSH+ACK("hello2") --> server client <-- ACK <-- server
5. サーバからのechoによるwrite()
clinet <-- PUSH+ACK("hello2") <-- server client --> ACK --> server
6. クライアントからのSocket#shutdownInput()
client --> RST + ACK --> server
※ちなみに、クライアント側の終了時の処理をclose()だけにすると、以下のようになりました。
clinet --> FIN+ACK --> server clinet --> RST+ACK --> server
サーバサイドのサンプルコードでは、straceを別スレッドで実行して、標準出力と標準エラー出力を分けてファイルに出力してます。で、どうもdettachしてJavaの方も終了しないと、バッファがフラッシュされないらしくて、内容を確認できるのはJavaの方が終了してからとなりました。標準出力は空っぽで、全部標準エラー出力の方に出てました。
socket(2)からのログをペタばりしておきます。見たまんまではあります。
socket(PF_INET6, SOCK_STREAM, IPPROTO_IP) = 67 bind(67, {sa_family=AF_INET6, sin6_port=htons(10007), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0 listen(67, 50) = 0 write(1, "echo server started, listening p"..., 42) = 42 write(1, "\n", 1) = 1 write(1, "accpet?", 7) = 7 accept(67, {sa_family=AF_INET6, sin6_port=htons(49878), inet_pton(AF_INET6, "::ffff:192.168.56.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 69 write(1, "Remote(192.168.56.1:49878) -> Lo"..., 57) = 57 write(1, "\n", 1) = 1 recvfrom(69, "hello1", 1024, 0, NULL, NULL) = 6 write(1, "read 6 bytes.", 13) = 13 write(1, "\n", 1) = 1 write(1, "echo?", 5) = 5 sendto(69, "hello1", 6, 0, NULL, 0) = 6 recvfrom(69, "hello2", 1024, 0, NULL, NULL) = 6 write(1, "read 6 bytes.", 13) = 13 write(1, "\n", 1) = 1 write(1, "echo?", 5) = 5 sendto(69, "hello2", 6, 0, NULL, 0) = 6 recvfrom(69, 0x7f5040540760, 1024, 0, 0, 0) = -1 ECONNRESET (Connection reset by peer) recvfrom(69, "", 1024, 0, NULL, NULL) = 0 close(69) = 0 write(1, "closed from remote", 18) = 18 write(1, "\n", 1) = 1 write(1, "close?", 6) = 6 close(67) = 0
驚いたのが、recvfrom(2)とsendto(2)を使っていた点でした。最初、てっきりread(2)/write(2)だろうと思ってtrace対象システムコールを絞ってたのですが、全く使われてる様子が見られず、試しに"-e"を外して全部ログさせるモードでstraceしてみたらrecvfrom(2)とsendto(2)を使っていたという・・・。
上記説明は、動かしてみれば「ふーん、まぁその通りに動くよねぇ。で?」となる話でわざわざ記事を書くほどのもんでも無いっちゃー無いんですが、ここまで突っ込んだきっかけとしては、ずっと積ん読状態だった"UNIX Network Programming"(3rd Edition, Volume 1, 以下、"UNP")をパラパラめくってたら、長年の疑問がいくつか氷解したことでした。
これ、単純なサンプル組んだだけでは、適当な整数値入れても動きが変わらなくてずっと「なんだろこれ」って思ってたんですが。
UNPの p104, "Elementary TCP Sockets", "4.5 listen Function" に、Figure 4.7でめっちゃ分かりやすく解説されてました。
もっと早く読んでおけばよかった。
accept()中のサーバに対してSYNパケットが殺到した時のキューなんですね。
そりゃ、サンプル程度の負荷じゃ影響しないわけだ・・・。
POSIXレベルではconnect(2)では接続タイムアウトを指定できないみたいっすね。これはSE時代に仕事でconnect(2)弄ってた時に知りました。
ただ、解法としてSIGALRMによるタイムアウトの実装というのをきちんとした資料で確認できたのは今日が初めてかも?
UNPのp382, "Advanced I/O Functions" の "14.2 Socket Timeouts" 参照です。
クライアント側のconnect(2)は、SYNを送った後、サーバからのSYN+ACKを受け取ってユーザーランドにreturnしてきます。
ここで、「接続タイムアウト」を実装したプログラムを単体テストしようとするとどうしても「接続タイムアウト」を発生させる必要が生じます。
そこで長年の疑問だったのが、accept(2)を使うケースでサーバからのSYN+ACKを送るタイミングを操作できるか?でした。
これさえ自由に使えれば、いくらでもSYN+ACKを遅らせることで「接続タイムアウト」を任意に発生させることが出来るのに・・・。
で、今回UNPをめくってみたところ、accept(2)ではカーネルレベルで自動的にSYN+ACKを送ることが判明したため、ようやくこのもやもやに決着を付けることが出来ました。
UNPのp37, "The Transport Layer: TCP, UDP and SCTP" の "2.6 TCP Connection Establishment and Termination" に、Figure 2.2 で分かりやすく、connect(2)とaccept(2)の裏側で、どういうタイミングでSYN -> SYN+ACK -> ACKのやりとりがされ、どこで各システムコールからreturnするのかが図示されてました。
結論としては、「接続タイムアウト」を発生させる = 新しく受信したSYNパケットの処理をDROPさせる、なので、iptablesなどのファイアーウォールの仕組みを使うと一発でした。
p40, "The Transport Layer: TCP, UDP, and SCTP" の "TCP State Transition Diagram", Figure 2.4 を参照。
(ホントは裏表紙とかすぐ参照できるところに載せておいて欲しいとこだった)
TCPの接続と切断については、UNPのp37, "The Transport Layer: TCP, UDP and SCTP" の "2.6 TCP Connection Establishment and Termination" 参照。
read/writeも含めた相関図は、p42, "The Transport Layer: TCP, UDP, and SCTP" の "Watching the Packets", Figure 2.5 参照。
UNP p52, "The Transport Layer: TCP, UDP, and SCTP" の "2.9 Port Numbers" の "Socket Pair" と "2.10 TCP Port Nubmers and Concurrent Servers" にありますが、リモート接続のホスト+ポート番号と、ローカルのホスト+ポート番号の4点をペアにして"Socket Pair"として管理しているからのようです。
UNP p817, "Client/Server Design Alternatives" に、fork(2)した後の複数の子プロセスから同じfiledescriptorに対してaccept(2)できる理由(="prefork")と、その際に注意する事柄が、いくつかの実装パターンに分けて丁寧に解説されてますので、そちらを参照。
UNP p172, "I/O Multiplexing: The select and poll Functions" の "6.6 shutdown Function" 参照。
UNP p98, "AF_xxx versus PF_xxx" 参照。
UNP p202, "Socket Options", "SO_LINGER Socket Option" 参照。
JavaのSocketプログラミングの「テスト」で、一番悩ましいのが「タイムアウト」の発生のさせ方であります。
今回のサンプルコードで実験してみましたのでメモ。
なお、サンプルコードを見れば明らかですがいずれもサーバサイドがblockingモードでSocket処理をしている場合になります。select()やpoll()などのnon-blockingモードの場合に、特に接続時のタイムアウトがどういう挙動になるのかは検証が必要かもしれません。
JDK 1.4で導入されたSocketのconnect()メソッドでは接続時のタイムアウトミリ秒を指定できます。
で、実際にどんなケースでタイムアウトになるかというと・・・原理的には、SYNを投げたあとのSYN+ACKが返されない場合なのですが・・・基本的に、ユーザーランドでこの辺を制御するのは非常に難しいと思います。RAW Socket + BPFかDLPI使うともしかしたら可能かもしれませんが。
ユーザーランドでは難しいのですが、サーバ側でやりたいことは結局のところ SYN が来たら、SYN+ACKを返さないということなので、iptablesなどのファイアーウォールの仕組みでSYN+ACKを返さないように設定できます。というか、カーネルモジュールとかBPF/DLPI/Raw Socket使いたくないのであればそれが一番お手軽かと思います。
次点で、クライアント側の接続タイムアウトのテスト時に、connect()で指定するタイムアウトミリ秒に "1" を指定することだと思います。
例えば今回のサーバサイドの実験環境はCentOS6で、"Minimal Desktop"でインストールした環境ではデフォルトでこんなiptablesが設定されてました。
/etc/sysconfig/iptables:
# Firewall configuration written by system-config-firewall # Manual customization of this file is not recommended. *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT -A INPUT -p icmp -j ACCEPT -A INPUT -i lo -j ACCEPT -A INPUT -m state --state NEW -m tcp -p tcp --dport 22 -j ACCEPT -A INPUT -j REJECT --reject-with icmp-host-prohibited -A FORWARD -j REJECT --reject-with icmp-host-prohibited COMMIT
これですと、最後から3行目の
-A INPUT -j REJECT --reject-with icmp-host-prohibited
これにより、デフォルトはREJECTになり、今回の例で動かしているTCPポート番号10007にconnect(2)しても、サーバ側はSYN+ACKを返さなくなります。
具体的にはWin7ProSP1上でクライアントプログラムを動かしたら、こんな動きになりました。
clinet --> SYN --> server (no reply) 3秒後: clinet --> SYN --> server (no reply) 6秒後: clinet --> SYN --> server (no reply) →clinet側でConnectException or SocketTimeoutException発生
なお、DROPポリシーを使えばRST+ACKを返す、つまりまるでそのポート番号でサービスが動いていないかのように動作する、という説明があるのですが、試しに
-A INPUT -j REJECT --reject-with icmp-host-prohibited
を
-A INPUT -j DROP
にしても、SYNパケットに対して無反応になるだけで同じでした・・・。
SYNパケットの送信間隔やリトライ回数は恐らくOSやkernelの設定に依存すると思いますので、他のプラットフォームで試すとまた異なる結果になるかもしれません。
とりあえず、単体テストでお手軽に「接続タイムアウト」を発生させたければ、サーバ側か途中のNW機器で、incommingのSYNパケットをdropするようなファイアーウォール設定をすればOKっぽいですね。
10年前に知っときたかったわー(血の涙)
iptables DROP/REJECT参考:
以下余談:
実は、今回の実験環境(Win7ProSP1)では「到達できないホストにconnect()」しようとしたらSocketTimeoutException例外が発生しました。ので、これをcatchすれば良いのではないか・・・と思ってLinux側でも試してみたのですが、そしたら普通にConnectExceptionになったり、java.net.NoRouteToHostExceptionになったりと、なかなか「このIPアドレスなら確実」というのが見つからなかったのです。
(Win7ProSP1で存在しないホストに接続) > groovy t_tcp_echo_client_1.groovy 192.168.56.102 10007 connect() start (10秒後) Caught: java.net.SocketTimeoutException: connect timed out java.net.SocketTimeoutException: connect timed out at java_net_Socket$connect$0.call(Unknown Source) at t_tcp_echo_client_1.run(t_tcp_echo_client_1.groovy:12)
で、少しtry and errorしてみた結果、多分 connect() で1ミリ秒にするのが一番早そうでした。これとても、サーバスペックが異常に良ければ1ミリ秒以内にaccept()されてしまう可能性があるのでなんとも言えないのですが・・・。
もうちょっと調査・検証の余地があるのかもしれません。
あるいは、そもそも java.net.SocketTimeoutException は IOException の派生クラスなので、後述の通りIOExceptionでまるめてcatchして処理していれば、別に、他のConnectExceptionとかでcatchできていても、ロジックを通すだけのテストであれば観点としては問題ないかもしれません。
※ちなみにSE時代、接続時に「タイムアウトした」のか、「タイムアウト以外のエラーが発生した」のか、通信処理の実装で分けて検出する必要があって、どういう条件なら SocketTimeoutException になるのか / SocketTimeoutException以外のIOException になるのかえらい苦労した記憶があります。その時は結局、単体テスト環境下で存在しないホスト名を指定することでなんとかSocketTimeoutExceptionになってくれて、それで単体テスト観点で実施した記憶が。
も~ほんと、今になってiptables使えば一瞬でテストできることに気づいて、一体何時間無駄な時間を過ごしてしまったのかあーもー、ってな感じです。
(この辺、Raw SocketやBPF/DLPI使わないと駄目かもと思って勉強しようとしては「いやちょっと待て・・・」と引いたりしてずるずるしてたんですが、そっちの方面まで持ち込む必要はなかったっぽくてまぁソレはソレで。)
ホストには到達したが、接続先のportがlisten()されてない場合は、即座に java.net.ConnectException (IOExceptionの派生) が発生しました。
> groovy t_tcp_echo_client_1.groovy 192.168.56.101 10008 connect() start Caught: java.net.ConnectException: Connection refused: connect java.net.ConnectException: Connection refused: connect at java_net_Socket$connect$0.call(Unknown Source) at t_tcp_echo_client_1.run(t_tcp_echo_client_1.groovy:12)
パケット上は、クライアントからのSYNに対してサーバから即座にRST+ACKが返されれる x 3 回で、ConnectExceptionになりました。
(他のOSやプラットフォーム上だと異なってくると思います)
ソケットやファイルの操作ではIOExceptionのcatchを多用することになりますが、ConnectExceptionはIOExceeptionの派生なので、そこでcatchできそうです。
今回のサーバサイドのサンプルコードでは、クライアントからのデータを受信して、echoする前に入力待ちになります。ここで10秒以上放置すると、クライアントサイドではread()での java.net.SocketTimeoutException が発生するように調整してます。
"UNIX Network Programming" (3rd Edition, Volume 1) : 英語ですが、Socket周りのシステムコールを呼ぶタイミングと、TCPのフローを重ねあわせた図を載せてくれてますので大変分かりやすいです。会社に一冊!おうちに一冊!
Kindle版はまだ無いようですが、ハードカバーで1000P近くありますので、面白そうなchapter/sectionをパラパラめくってあっちこっちのページを行ったり来たりしたい、今回の記事作成のようなシーンでは、紙媒体のほうが読んでて楽しかったですね。
JVMのスレッドは、Linuxのスレッドとどう対応するのか?参考:
JVMでLinuxのNative Thread IDを取得してstraceするには:
JNA(Java Native Access)参考: