Unix Network Programming: Chap2

Sep 16, 2022 21:34 · 405 words · 2 minute read

(Chap.1 は こちら )

2. トランスポート層: TCP と UDP 🔗

2章の目標:

  • TCP/IP を理解し、実際の設計・実装に活用する方法を学ぶ。
  • なにがプロトコルで処理され、何がアプリケーションで処理される必要があるかを理解する。
  • 3way handshake、TCPコネクション終了シーケンス、TIME_WAIT状態、バッファリングといった仕組みを理解する。

TCP/IP 外観 🔗

L4 の代表的なプロトコル 🔗

  • IPv4: 32bit アドレスを用いて、後述の TCP/UDP/ICMP/IGMP にパケット配送サービスを提供する。
  • IPv6: ざっくりいうと、IPv4 のアドレス空間を 128bit に拡張したもの。
  • TCP: transmission control protocol
    • コネクション指向。
    • 応答、タイムアウト、再送などの機能を提供。
    • 信頼性のある全二重バイトストリーム。
    • (UDPと異なり)ストリームソケット。
  • UDP: User Datagram Protocol
    • コネクションレスプロトコル。
    • 意図した終点へ到達するかどうかが保証されない。
    • (TCPと異なり)データグラムソケット。
  • ICMP: Internet Control Message Protocol
    • ルータとホスト間の制御情報を取り扱う。
    • 通常はユーザープログラムではなく、TCP/IPネットワークングソフトウェアによって利用される。
    • ping は例外。
  • IGMP: Internet Group Management Protocol
    • 19章で解説
  • ARP: Address Resolution Protocol
    • IPv4 アドレスをハードウェアアドレス(Ethernetアドレス)に変換する。
    • Ethernet、トークンリング、FDDI などのブロードキャストネットワークで用いられる。
    • ポイントーポイント ネットワークでは用いられない。
  • RARP: Reverse Address Resolution Protocol
    • ハードウェアアドレスを IPv4 アドレスに変換する。
  • BPF: Berkley Packet Filter
    • プロセスに対して、データリンクへのアクセスを提供。
  • DLPI: Data Link Provider Interface
    • データリンクへのアクセスを提供。

IPv4とIPv6の両方をサポートするホストのことをデュアルスタックホストと呼ぶ。

UDP 🔗

  • RFC768
  • アプリケーションは UDP ソケットにデータグラムを書き込む。
  • データグラムが、IPv4(v6)のデータグラムにカプセル化され、終点に向けて送信される。
  • UDP を使って、データが終点に届いたかどうかを確認するためには、アプリケーション側で応答・再送・タイムアウトの制御が必要になる。
  • 各UDPデータグラムは、特有の長さを持っている。
    • バイトストリーム型のTCPとは異なる点。
  • 1つのデータグラムを「レコード」とみなすことができる。
  • UDPデータグラムを受信すると、受信したアプリケーションには、まずデータの長さが通知される。
  • コネクションレス
    • あるサーバーにデータグラムを送った直後に、同じソケットを使って別のサーバーにダイアグラムを送ることもできる。
    • サーバーは、単一のUDPソケットを使って複数の異なるクライアントから送られたデータグラムを受信することができる。

TCP 🔗

  • RFC793
  • TCPでは、まず初めに「コネクション」を確立する。
    • アプリケーションでは、コネクション上でデータを交換する。
  • TCPは信頼性を提供する。
    • データを送ったあと、受信側からの受け取り応答を期待する。
    • 受け取り応答が確認できない場合、一定時間が経過したら同じデータを再送する。
    • 一定の回数再送しても応答が確認できなければ、通信を諦める。
  • TCP では、クライアントとサーバー間の round trip time を動的に予測するアルゴリズムが内蔵されている。
    • どの程度の時間応答を待てば良いかが計算されている。
  • データの順序づけ(sequencing)
    • たとえばアプリケーションが2048バイトのデータをTCPソケットに書き込んだ場合。
      • 1バイトごとに「シーケンス番号」が振られる。
      • 2048バイトをいくつかの連続するシーケンス番号の塊(セグメント)に分割する。
      • セグメントごとにパケットに分割して送信する。
      • 欠落するセグメントがあれば、再送を要求する。
      • 重複するセグメントがあれば、破棄する。
  • 流量制御(flow control)
    • 相手に対して、自分が何バイトのデータを受け取る準備があるかを常時通知する。
    • これを「ウィンドウ」と呼ぶ。
    • どの瞬間を見ても、ウィンドウは受信側で利用可能な受信バッファの大きさを表している。
      • 送信側は、受信側のバッファを超えないようにデータを送る。
    • 送信側がデータを送ると、ウィンドウサイズは小さくなる。
    • 受信側がデータを読むと、ウィンドウサイズは大きくなる。
  • 全二重(full-duplex)
    • アプリケーションが同じコネクション上で、受信と送信が同時に行える。

※ パケットは IPv4 のデータ単位。TCPでは「セグメント」。

TCPコネクションの確立と終了 🔗

  • 確立
    • まず、サーバーでは到着するコネクションを受け付ける用意が整っている必要がある。
      • socket -> bind -> listen を呼び出すことで行われる。
      • この状態をパッシブオープンと呼ぶ。
    • 次に、クライアントがconnectを呼び出すことでコネクションの確立要求を行う。
      • このときのクライアント側のソケットはアクティブオープンという状態。
      • クライアントは、アクティブオープンなソケットから SYN セグメントを送る。
        • SYN セグメントには、データは付随せず、IPヘッダー、TCPヘッダー、TCPオプションのみで構成される。
    • サーバーは SYN セグメントを受信したら、それに応答する。
      • サーバーからの SYN + 応答の意味の ACK を単一のセグメントとして送信する。
    • クライアントはサーバーからの SYN を受け取ったら、それに ACK で応答する。
  • 電話を例に、コネクションの確立を例える。
    • socket 関数は、電話を用意すること。
    • bind 関数は、他の人が自分に電話をかけられるように電話番号を教えること。
    • listen 関数は、呼び出し音の発生を許可すること。
    • connect 関数は、電話番号を入力してダイアルすること。
    • accept 関数は、かかってきた電話の受話器を取ること。
  • 終了
    • 片方が close を呼び出す。
      • これをアクティブクローズとよぶ。
      • この時、FIN セグメントを送信する。
    • FIN を受信した側
      • 自動的に FIN に対する ACK が返る。
        • FIN を受信すると、アプリケーションには EOF が通知される。
      • その後、受信した側でも FIN を送る。
      • これをパッシブクローズと呼ぶ。
    • 最後に、最初に FIN を送った側が、受信した FIN に ACK して終了。
    • もし Unix プロセスが終了した場合、アプリケーションが利用していた TCP コネクションには FIN が書き込まれる。
    • アクティブクローズはどちらからでも実行できる。
      • 一般的には、クライアント側からクローズされることが多い。
      • HTTPはサーバー側がクローズすることになっている。
  • piggy back
    • サーバーの処理が、200ms 未満程度の場合、クライアントからのリクエストに対する ACK と、レスポンスの書き込みが同時に行われる挙動。
  • オーバーヘッド
    • 目的のセグメントの交換に加えて、SYN/ACK/FIN のために最低でも8セグメント分の通信が余分に発生する。
    • やり取りするデータのサイズが小さい場合には、UDP を使うとオーバーヘッドが抑えられる。
      • 反面、信頼性の担保をアプリケーションで実施する必要がある。

TIME_WAIT 状態 🔗

  • FIN_WAIT(相手からのFINを待つ状態)から、FINを受け取った後にACKを返す。
  • この時、ACKの送信と同時にコネクションをクローズするのではなく、MSL(maximum segment lifetime)の2倍の時間待機する。
  • この状態をTIME_WAITと呼ぶ。
  • 1つ目の目的
    • もしACKが相手まで届かなければ、サーバー側は再びFINを送ってくるので、それに応答する必要がある。
  • 2つ目の目的
    • 同一の IP:Port で別のコネクションが確立された時、ネットワーク内にセグメントが残っていた場合、古いコネクションのセグメントが新しいコネクションの中で誤って解釈される可能性がある。
    • これを防ぐために、セグメントのライフタイム以上待機することで、セグメントをタイムアウトさせてからIP:Portを解放する。

ソケットペア 🔗

  • コネクションの両方のエンドポイントを定義する (ローカルIP,ローカルPort,リモートIP,リモートPort) の組。

TCPポート番号と並行サーバー 🔗

  • 例:
    • 206.62.226.35206.62.226.66 の2つのIPが割り当てられているマルチホームホストを考える。
    • このホスト上で、21番ポートを使ってパッシブオープンする。
    • このとき、ホストのソケットペアは {*:21, *:*} となる。
      • {<ローカルIP>:<ローカルPort>, <リモートIP>:<リモートPort>}
      • これが「リスニングソケット」
    • 上記の指定では、どちらのIPアドレスのインターフェースに入ってきたコネクション要求も受け付ける。
    • ローカルIPに具体的なIPアドレスを指定することで、入ってくるインターフェースを絞ることもできる。
    • その後、クライアントが198.69.10.2で起動する。
    • クライアントが206.62.226.35:21に対してアクティブオープンを実行する。
      • この時クライアントが ephemeral port 1500 を割り当てられたとする。
      • クライアント側のソケットペアは{198.62.10.2:1500, 206.62.226.35:21}となる。
    • サーバーは、コネクション要求を受け付けると、プロセスを fork する。
      • この時点で、サーバーホスト上ではリスニングソケットと接続済みソケットが区別される。
      • ただし、子プロセスも親プロセスと同じポート(21)を使っている。
      • サーバー側のソケットペアにはリモートアドレスが書かれて{206.52.226.35:21, 198.62.10.2:1500}となる。
  • 重要なポイント
    • コネクションは{<ローカルIP>:<ローカルPort>, <リモートIP>:<リモートPort>}の4タプルで識別される。
    • 1つのポートには、複数のTCPコネクションが存在する。

バッファサイズの影響 🔗

  • 1セグメントのデータグラムの大きさの決定要因
    • IPv4 のデータグラムの最大長(ヘッダー込みで64434B; ヘッダー込みで16bit)
    • IPv6 のデータグラムの最大腸(ヘッダー込みで65575B; ヘッダー含まず16bit)
      • IPv6 には、ペイロード長を 32bit まで拡張するジャンボペイロードオプションがある。
      • ただし MTU > 65535 であるようなデータリンクの場合のみ。
    • ハードウェアの MTU
      • Ethernet は 1500B
    • パス MTU
      • 通信経路上の最小 MTU
      • インターネットの経路は非対称(A->B/B->Aの経路が異なる)でも良いので、行きと帰りのMTUが異なるケースもある。
      • もしデータグラムの長さが MTU を超えている場合、フラグメント化される。
        • ただし、IPv4 ヘッダには DF(don’t fragment) ビットが存在しており、この場合はフラグメント化せずに「終点到達不能」という ICMPv4 メッセージが生成される。
      • RFC1191 RFC1981 で、それぞれパスMTUの発見方法が定義されている。
    • バッファサイズの下限値
      • IPv4: 576B
      • IPv6: 1500B
      • このサイズ以上のバッファが実装されていることが必須。
    • TCPヘッダーのMMSオプション(SYNセグメント)
      • この値を通知された場合、これを超えるサイズのセグメントを相手に送信することはできない。
      • この値が通知されない場合、下限サイズ(576B)をセグメントサイズとみなす。

TCP 出力の挙動 🔗

  • アプリケーションが write を呼び出すと、カーネルはアプリケーションのバッファからソケットの送信バッファにすべてのデータをコピーする。
  • ソケットバッファよりもアプリケーションバッファが大きい場合、プロセスはスリープ状態に移行させられる。
    • アプリケーションバッファの最後の1バイトまでソケットの送信バッファに書かれないと、write を実行しているカーネルから制御が戻らない。
    • TCPソケットに対するwriteの成功は、単にアプリケーションバッファを再利用できることを意味しているだけであり、相手のTCPあるいはアプリケーションがデータを受け取ったかどうかまでは判定できない。
  • 送信元は、TCPの規則に基づきソケットの送信バッファからIP=>データリンクの順にデータを流していく。
  • 受信側は、データを受け取ったらACKを返す。
  • 送信元は、ACKを受け取ったらソケットバッファからデータを削除する。
    • ACK を受け取れるまでは、再送のためにソケットバッファにデータを残しておく必要がある。
  • 経路内の各データリンクは、出力キューをもち、キューがいっぱいならばパケットは破棄される。
  • TCPはエラーを検知すると再送を試みる。
    • 再送されているかどうかは、アプリケーションには通知されない。

UDP 出力の挙動 🔗

  • UDP は信頼性を提供しないため、バッファを持たない。
    • 再送がないので、再送のためにメッセージを保持しておく必要がない。

inetd で telnet/daytime を試す 🔗

  • apt install openbsd-inted telnetd
  • update-inetd --add 'telnet stream tcp nowait telnetd /usr/sbin/tcpd /usr/sbin/in.telnetd'
  • update-inetd --comment-chars '#' --enable daytime
    • daytime が有効になる
  • update-inetd --add 'echo stream tcp nowait root internal'
    • echo が有効になる。
  • telnet localhost echo