この記事では、TCP 通信の重要な側面、つまりサーバーが応答しないシナリオを効果的に管理する方法について説明します。ここでは、アプリケーションがサーバーからアプリケーション レベルの応答を受信せずに、TCP 経由でデータのみを送信する特定のシナリオに焦点を当てます。
この調査では、アプリケーションの観点から TCP 通信を取り上げ、アプリケーション層と基盤となる OS 操作の両方に焦点を当てます。サーバー インスタンスが応答しない場合のデータ損失を回避するために、効果的なタイムアウトを設定する方法を学習します。 Ruby のコード例を紹介しますが、考え方はどの言語でも同じです。
TCP ソケット経由でデータを一貫して送信するアプリケーションを操作していると想像してください。 TCP は、定義された TCP スタック構成内のトランスポート レベルでパケット配信を保証するように設計されていますが、これがアプリケーション レベルで何を意味するかを考えるのは興味深いことです。
これをよりよく理解するために、Ruby を使用してサンプル TCP サーバーとクライアントを構築してみましょう。これにより、実際の通信プロセスを観察できるようになります。
server.rb
:
# server.rb require 'socket' require 'time' $stdout.sync = true puts 'starting tcp server...' server = TCPServer.new(1234) puts 'started tcp server on port 1234' loop do Thread.start(server.accept) do |client| puts 'new client' while (message = client.gets) puts "#{Time.now}]: #{message.chomp}" end client.close end end
そしてclient.rb
:
require 'socket' require 'time' $stdout.sync = true socket = Socket.tcp('server', 1234) loop do puts "sending message to the socket at #{Time.now}" socket.write "Hello from client\n" sleep 1 end
そして、このDockerfile
を使用して、このセットアップをコンテナーにカプセル化しましょう。
FROM ruby:2.7 RUN apt-get update && apt-get install -y tcpdump # Set the working directory in the container WORKDIR /usr/src/app # Copy the current directory contents into the container at /usr/src/app COPY . .
およびdocker-compose.yml
:
version: '3' services: server: build: context: . dockerfile: Dockerfile command: ruby server.rb volumes: - .:/usr/src/app ports: - "1234:1234" healthcheck: test: ["CMD", "sh", "-c", "nc -z localhost 1234"] interval: 1s timeout: 1s retries: 2 networks: - net client: build: context: . dockerfile: Dockerfile command: ruby client.rb volumes: - .:/usr/src/app - ./data:/data depends_on: - server networks: - net networks: net:
これで、 docker compose up
を使用してこれを簡単に実行し、クライアントがメッセージを送信し、サーバーがそれを受信する方法をログで確認できます。
$ docker compose up [+] Running 2/0 ⠿ Container tcp_tests-server-1 Created 0.0s ⠿ Container tcp_tests-client-1 Created 0.0s Attaching to tcp_tests-client-1, tcp_tests-server-1 tcp_tests-server-1 | starting tcp server... tcp_tests-server-1 | started tcp server on port 1234 tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:08 +0000 tcp_tests-server-1 | new client tcp_tests-server-1 | 2024-01-14 08:59:08 +0000]: Hello from client tcp_tests-server-1 | 2024-01-14 08:59:09 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:09 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:10 +0000 tcp_tests-server-1 | 2024-01-14 08:59:10 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:11 +0000 tcp_tests-server-1 | 2024-01-14 08:59:11 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:12 +0000 tcp_tests-server-1 | 2024-01-14 08:59:12 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:13 +0000
ここまではかなり簡単ですね。
ただし、アクティブな接続のサーバー障害をシミュレートすると、状況はさらに興味深いものになります。
これはdocker compose stop server
を使用して行います。
tcp_tests-server-1 | 2024-01-14 09:04:23 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:24 +0000 tcp_tests-server-1 | 2024-01-14 09:04:24 +0000]: Hello from client tcp_tests-server-1 exited with code 1 tcp_tests-server-1 exited with code 0 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:25 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:26 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:27 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:28 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:29 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:30 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:31 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:32 +0000
サーバーは現在オフラインですが、クライアントは接続がまだアクティブであるかのように動作し、ためらうことなくソケットにデータを送信し続けていることがわかります。
このことから、なぜこのようなことが起こるのかという疑問が生じます。論理的には、TCP がパケットの確認応答を受信できず、接続の終了を促すため、クライアントはサーバーのダウンタイムを短期間 (おそらく数秒) で検出する必要があります。
ただし、実際の結果はこの予想とは異なりました。
tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:11 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:12 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:13 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:14 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:15 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:16 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:17 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:18 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:19 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:20 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:21 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:22 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:23 +0000 tcp_tests-client-1 | client.rb:11:in `write': No route to host (Errno::EHOSTUNREACH) tcp_tests-client-1 | from client.rb:11:in `block in <main>' tcp_tests-client-1 | from client.rb:9:in `loop' tcp_tests-client-1 | from client.rb:9:in `<main>' tcp_tests-client-1 exited with code 1
実際には、クライアントは15 分間も接続の中断に気づかない可能性があります。
この検出の遅れの原因は何でしょうか?理由を理解するためにさらに深く掘り下げてみましょう。
このケースを完全に説明するために、まず基本原則を再確認し、次にクライアントが TCP 経由でデータを送信する方法を調べてみましょう。
TCP フローを示す基本的な図は次のとおりです。
接続が確立されると、通常、各メッセージの送信には 2 つの重要な手順が含まれます。
クライアントは、PSH (プッシュ) フラグを付けてメッセージを送信します。
サーバーは ACK (確認応答) 応答を送り返すことで受信を確認します。
以下は、アプリケーションによる TCP ソケットのオープンと、それを介したその後のデータ送信を示す簡略化されたシーケンス図です。
アプリケーションは次の 2 つの操作を行います。
TCPソケットを開く
オープンソケットにデータを送信する
たとえば、Ruby のSocket.tcp(host, port)
コマンドで行われるように、TCP ソケットを開くと、システムは、 socket(2)
システム コールを使用してソケットを同期的に作成し、 connect(2)
システム コールを介して接続を確立します。 。
データの送信に関しては、アプリケーションでsocket.write('foo')
のようなコマンドを使用すると、主にメッセージがソケットの送信バッファに配置されます。次に、正常にキューに入れられたバイト数を返します。ネットワークを介した宛先ホストへのこのデータの実際の送信は、TCP/IP スタックによって非同期的に管理されます。
これは、アプリケーションがソケットに書き込むとき、アプリケーションはネットワーク操作に直接関与せず、接続がまだアクティブであるかどうかをリアルタイムで認識できない可能性があることを意味します。受信する唯一の確認は、メッセージが TCP 送信バッファに正常に追加されたことです。
サーバーが ACK フラグで応答しないため、TCP スタックは最後の未確認パケットの再送信を開始します。
ここで興味深いのは、デフォルトで TCP が指数バックオフを使用して 15 回の再送信を行うため、再試行に 15 分近くかかることです。
ホストに設定されている再試行回数を確認できます。
$ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15
ドキュメントを詳しく調べてみると、そのことが明らかになってきました。 ip-sysctl.txt のドキュメントには次のように書かれています。
デフォルト値の 15 は、924.6 秒の仮想タイムアウトを生成し、これが実効タイムアウトの下限です。 TCP は、仮想タイムアウトを超える最初の RTO で事実上タイムアウトになります。
この期間中、ローカル TCP ソケットは動作しており、データを受け入れます。すべての再試行が完了すると、ソケットは閉じられ、アプリケーションはソケットに何かを送信しようとするとエラーを受け取ります。
FIN または RST TCP フラグを送信せずに TCP サーバーが予期せずダウンするシナリオ、または接続の問題が発生するシナリオは非常に一般的です。では、なぜそのような状況が見過ごされることが多いのでしょうか?
ほとんどの場合、サーバーはアプリケーション レベルで何らかの応答を返すからです。たとえば、HTTP プロトコルでは、サーバーがすべてのリクエストに応答する必要があります。基本的に、 connection.get
のようなコードがある場合、次の 2 つの主要な操作が行われます。
ペイロードを TCP ソケットの送信バッファに書き込みます。
この時点から、オペレーティング システムの TCP スタックは、TCP 保証を使用してこれらのパケットをリモート サーバーに確実に配信する責任を負います。
TCP受信バッファでリモートサーバーからの応答を待っています
通常、アプリケーションは同じ TCP ソケットの受信バッファからのノンブロッキング読み取りを使用します。
このような場合、アプリケーション レベルでタイムアウトを簡単に設定し、定義された時間枠内にサーバーからの応答がない場合はソケットを閉じることができるため、このアプローチでは問題が大幅に簡素化されます。
ただし、サーバーからの応答 (TCP 確認応答を除く) が期待されない場合、アプリケーション レベルから接続のステータスを判断するのは難しくなります。
これまでのところ、次のことが確立されています。
アプリケーションは TCP ソケットを開き、定期的にそれにデータを書き込みます。
ある時点で、TCP サーバーは RST パケットさえ送信せずにダウンし、送信側の TCP スタックは最後の未確認パケットの再送信を開始します。
そのソケットに書き込まれる他のすべてのパケットは、ソケットの送信バッファーのキューに入れられます。
デフォルトでは、TCP スタックは指数バックオフを使用して未確認パケットの再送信を 15 回試行し、その結果、所要時間は約 924.6 秒 (約 15 分) になります。
この 15 分間、ローカル TCP ソケットは開いたままになり、アプリケーションは送信バッファー (通常、容量には制限があり、わずか数メガバイト) がいっぱいになるまでデータを書き込み続けます。すべての再送信後に最終的にソケットがクローズ済みとしてマークされると、送信バッファ内のすべてのデータが失われます。
これは、アプリケーションが送信バッファに書き込んだ後はその責任を負わず、オペレーティング システムが単にこのデータを破棄するためです。
アプリケーションは、TCP ソケットの送信バッファがいっぱいになった場合にのみ、接続が切断されたことを検出できます。このような場合、ソケットに書き込もうとするとアプリケーションのメインスレッドがブロックされ、アプリケーションが状況を処理できるようになります。
ただし、この検出方法の有効性は、送信されるデータのサイズによって異なります。
たとえば、アプリケーションがメトリクスなどの数バイトのみを送信する場合、この 15 分の時間枠内で送信バッファが完全に満たされない可能性があります。
では、TCP サーバーがダウンしたときに、明示的に設定されたタイムアウトで接続を閉じるメカニズムを実装して、この期間中の 15 分間の再送信とデータの損失を回避するにはどうすればよいでしょうか?
プライベート ネットワークでは、通常、大規模な再送信は必要なく、制限された回数だけ再試行するように TCP スタックを構成することができます。ただし、この構成はノード全体にグローバルに適用されます。多くの場合、複数のアプリケーションが同じノード上で実行されるため、このデフォルト値を変更すると予期しない副作用が発生する可能性があります。
より正確な方法は、 TCP_USER_TIMEOUTソケット オプションを使用して、ソケット専用に再送信タイムアウトを設定することです。このオプションを使用すると、グローバルに設定された TCP 再送信の最大数に関係なく、指定されたタイムアウト内に再送信が成功しなかった場合、TCP スタックは自動的にソケットを閉じます。
アプリケーション レベルでは、これにより、閉じられたソケットにデータを書き込もうとしたときにエラーが受信され、適切なデータ損失防止処理が可能になります。
このソケット オプションをclient.rb
に設定しましょう。
require 'socket' require 'time' $stdout.sync = true socket = Socket.tcp('server', 1234) # set 5 seconds restransmissions timeout socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_USER_TIMEOUT, 5000) loop do puts "sending message to the socket at #{Time.now}" socket.write "Hello from client\n" sleep 1 end
また、私の観察によると、TCP_USER_TIMEOUT ソケット オプションは macOS では使用できません。
ここで、 docket compose up
を使用してすべてを再度開始し、ある時点で、 docker compose stop server
を使用してサーバーを再度停止しましょう。
$ docker compose up [+] Running 2/0 ⠿ Container tcp_tests-server-1 Created 0.0s ⠿ Container tcp_tests-client-1 Created 0.0s Attaching to tcp_tests-client-1, tcp_tests-server-1 tcp_tests-server-1 | starting tcp server... tcp_tests-server-1 | started tcp server on port 1234 tcp_tests-server-1 | new client tcp_tests-server-1 | 2024-01-20 12:37:38 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:38 +0000 tcp_tests-server-1 | 2024-01-20 12:37:39 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:39 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:40 +0000 tcp_tests-server-1 | 2024-01-20 12:37:40 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:41 +0000 tcp_tests-server-1 | 2024-01-20 12:37:41 +0000]: Hello from client tcp_tests-server-1 | 2024-01-20 12:37:42 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:42 +0000 tcp_tests-server-1 | 2024-01-20 12:37:43 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:43 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:44 +0000 tcp_tests-server-1 | 2024-01-20 12:37:44 +0000]: Hello from client tcp_tests-server-1 exited with code 1 tcp_tests-server-1 exited with code 0 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:45 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:46 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:47 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:48 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:49 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:50 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:51 +0000 tcp_tests-client-1 | client.rb:11:in `write': Connection timed out (Errno::ETIMEDOUT) tcp_tests-client-1 | from client.rb:11:in `block in <main>' tcp_tests-client-1 | from client.rb:9:in `loop' tcp_tests-client-1 | from client.rb:9:in `<main>' tcp_tests-client-1 exited with code 1
~12:37:45 にサーバーを停止すると、クライアントがほぼ 5 秒以内にErrno::ETIMEDOUT
を取得したことがわかりました。
docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234
使用して tcpdump をキャプチャしましょう。
実際には 5 秒強でタイムアウトが発生することに注意してください。これは、TCP_USER_TIMEOUT を超過したかどうかのチェックが次回の再試行時に行われるためです。 TCP/IP スタックがタイムアウトの超過を検出すると、ソケットが閉じられたものとしてマークされ、アプリケーションはErrno::ETIMEDOUT
エラーを受け取ります
また、TCP キープアライブを使用している場合は、 Cloudflare のこの記事をチェックすることをお勧めします。 TCP_USER_TIMEOUT を TCP キープアライブと組み合わせて使用する場合の微妙な違いについて説明します。