home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

技術/Socketプログラミング/Java (Groovy) の Socketプログラミングを strace と Wireshark で覗く

技術/Socketプログラミング/Java (Groovy) の Socketプログラミングを strace と Wireshark で覗く

技術 / Socketプログラミング / Java (Groovy) の Socketプログラミングを strace と Wireshark で覗く
id: 1268 所有者: msakamoto-sf    作成日: 2014-02-15 22:53:28
カテゴリ: Groovy Java UNIX ネットワーク プログラミング 

JavaのSocketプログラミングでTCP通信を勉強し始めてから10年近く、ずっと気になっていた点として、socket周りのシステムコールとTCPパケットレベルでの挙動観察をしてみようと思います。


実験環境とサンプルコード

サンプルコード:

  • https://gist.github.com/msakamoto-sf/9015555
    • t_tcp_echo_with_strace_1.groovy : サーバサイド
      • 1:1でのみ動作する、非常にシンプルなechoサーバです。
      • accept(), read()の後のwrite()の前後でconsole.readLine()を挟むことで、サーバの動きを一時停止させてます。
      • straceを別スレッドから起動して、サーバのスレッド自身のシステムコール呼び出しをトレースさせてます。
    • t_tcp_echo_client_1.groovy : クライアントサイド
      • 非常にシンプルなechoクライアントです。接続タイムアウトと、read()でのタイムアウトを設定できるようにしてます。

クライアントサイド実行環境:

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を入力)

SocketプログラミングとTCPフロー

上記の動かし方で動かした時の、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" からのメモ

上記説明は、動かしてみれば「ふーん、まぁその通りに動くよねぇ。で?」となる話でわざわざ記事を書くほどのもんでも無いっちゃー無いんですが、ここまで突っ込んだきっかけとしては、ずっと積ん読状態だった"UNIX Network Programming"(3rd Edition, Volume 1, 以下、"UNP")をパラパラめくってたら、長年の疑問がいくつか氷解したことでした。

listen(2)の第二引数の"int backlog"って何?

これ、単純なサンプル組んだだけでは、適当な整数値入れても動きが変わらなくてずっと「なんだろこれ」って思ってたんですが。
UNPの p104, "Elementary TCP Sockets", "4.5 listen Function" に、Figure 4.7でめっちゃ分かりやすく解説されてました。
もっと早く読んでおけばよかった。
accept()中のサーバに対してSYNパケットが殺到した時のキューなんですね。
そりゃ、サンプル程度の負荷じゃ影響しないわけだ・・・。

blockingモードのソケットを使ったconnect(2)は、POSIXレベルでは「接続タイムアウト」を指定できない。

POSIXレベルではconnect(2)では接続タイムアウトを指定できないみたいっすね。これはSE時代に仕事でconnect(2)弄ってた時に知りました。
ただ、解法としてSIGALRMによるタイムアウトの実装というのをきちんとした資料で確認できたのは今日が初めてかも?
UNPのp382, "Advanced I/O Functions" の "14.2 Socket Timeouts" 参照です。

アプリケーションレイヤー(ユーザーランド)でaccept(2)を使うとき、サーバ側のプログラムで「接続タイムアウト」を発生させることは可能か?

クライアント側の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などのファイアーウォールの仕組みを使うと一発でした。

UNPにはTCPの状態遷移のダイアグラムは載ってないの?

p40, "The Transport Layer: TCP, UDP, and SCTP" の "TCP State Transition Diagram", Figure 2.4 を参照。
(ホントは裏表紙とかすぐ参照できるところに載せておいて欲しいとこだった)

TCPの接続~read/write~切断までの、システムコールと実際のTCPフローの相関図が欲しい

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 参照。

accept(2) -> fork(2) する形式で、サーバ側が同じポートにbindした状態のソケットを複数扱えるのはなぜ?

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"として管理しているからのようです。

listen(2) -> fork(2) した後に複数の子プロセスからaccept(2)できるのはなぜ?(pre-forkスタイル)

UNP p817, "Client/Server Design Alternatives" に、fork(2)した後の複数の子プロセスから同じfiledescriptorに対してaccept(2)できる理由(="prefork")と、その際に注意する事柄が、いくつかの実装パターンに分けて丁寧に解説されてますので、そちらを参照。

shutdown(2)って何のためにあるの?

UNP p172, "I/O Multiplexing: The select and poll Functions" の "6.6 shutdown Function" 参照。

socket周りのシステムコールに使う定数で、"AF_"で始まるものと"PF_"で始まるものがあるのはなぜ?どう使い分ける?

UNP p98, "AF_xxx versus PF_xxx" 参照。

SO_LINGERっていうソケットオプションはどう使うのか?

UNP p202, "Socket Options", "SO_LINGER Socket Option" 参照。

接続時のタイムアウト、read()時のタイムアウト

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使わないと駄目かもと思って勉強しようとしては「いやちょっと待て・・・」と引いたりしてずるずるしてたんですが、そっちの方面まで持ち込む必要はなかったっぽくてまぁソレはソレで。)

listen()してないportにconnect() -> ConnectException

ホストには到達したが、接続先の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できそうです。

SocketのsetSoTimeout() -> read()時のタイムアウト

今回のサーバサイドのサンプルコードでは、クライアントからのデータを受信して、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)参考:



プレーンテキスト形式でダウンロード
現在のバージョン : 1
更新者: msakamoto-sf
更新日: 2014-02-15 23:04:50
md5:f5cccabe7a4349239ee62fc9af9164b2
sha1:fb7b857b1c9d804f7c9b4b830f715676ad6491f3
コメント
コメントを投稿するにはログインして下さい。