In diesem Artikel gehe ich auf einen kritischen Aspekt der TCP-Kommunikation ein: die effektive Verwaltung von Szenarien, in denen der Server nicht antwortet. Ich konzentriere mich auf ein bestimmtes Szenario, in dem die Anwendung nur Daten über TCP sendet, ohne vom Server eine Antwort auf Anwendungsebene zu erhalten.
Diese Untersuchung befasst sich mit der TCP-Kommunikation aus der Perspektive der Anwendung und beleuchtet sowohl die Anwendungsschicht als auch die zugrunde liegenden Betriebssystemvorgänge. Sie erfahren, wie Sie effektive Zeitüberschreitungen festlegen, um Datenverluste bei nicht reagierenden Serverinstanzen zu vermeiden. Ich werde Codebeispiele in Ruby bereitstellen, aber die Idee bleibt für jede Sprache gleich.
Stellen Sie sich vor, Sie arbeiten mit einer Anwendung, die Daten konsistent über einen TCP-Socket überträgt. Während TCP darauf ausgelegt ist, die Paketzustellung auf der Transportebene innerhalb der definierten TCP-Stack-Konfigurationen sicherzustellen, ist es interessant zu überlegen, was dies auf der Anwendungsebene bedeutet.
Um dies besser zu verstehen, erstellen wir einen Beispiel-TCP-Server und -Client mit Ruby. Dadurch können wir den Kommunikationsprozess in Aktion beobachten.
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
Und der 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
Und kapseln wir dieses Setup mithilfe dieser Dockerfile
in Container:
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 . .
und 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:
Jetzt können wir dies ganz einfach mit docker compose up
ausführen und in Protokollen sehen, wie der Client die Nachricht sendet und der Server sie empfängt:
$ 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
Bisher ziemlich einfach, oder?
Interessanter wird die Situation jedoch, wenn wir einen Serverausfall für eine aktive Verbindung simulieren.
Wir machen das mit 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
Wir beobachten, dass der Server jetzt offline ist, der Client sich jedoch so verhält, als ob die Verbindung noch aktiv wäre, und ohne zu zögern weiterhin Daten an den Socket sendet.
Dies führt mich zu der Frage, warum dies geschieht. Logischerweise sollte der Client die Ausfallzeit des Servers innerhalb einer kurzen Zeitspanne, möglicherweise Sekunden, erkennen, da TCP keine Bestätigungen für seine Pakete erhält, was zu einer Verbindungsschließung führt.
Das tatsächliche Ergebnis weicht jedoch von dieser Erwartung ab:
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
In der Realität kann es sein, dass der Kunde die unterbrochene Verbindung bis zu 15 Minuten lang nicht bemerkt!
Was verursacht diese Verzögerung bei der Erkennung? Lassen Sie uns tiefer eintauchen, um die Gründe zu verstehen.
Um diesen Fall vollständig abzudecken, werfen wir zunächst einen Blick auf die Grundprinzipien und untersuchen anschließend, wie der Client Daten über TCP überträgt.
Hier ist das grundlegende Diagramm, das den TCP-Fluss veranschaulicht:
Sobald eine Verbindung hergestellt ist, umfasst die Übertragung jeder Nachricht normalerweise zwei wichtige Schritte:
Der Client sendet die Nachricht, markiert mit dem PSH-Flag (Push).
Der Server bestätigt den Empfang, indem er eine ACK-Antwort (Acknowledgement) zurücksendet.
Nachfolgend finden Sie ein vereinfachtes Sequenzdiagramm, das das Öffnen eines TCP-Sockets durch eine Anwendung und die anschließende Datenübertragung darüber veranschaulicht:
Die Anwendung führt zwei Vorgänge aus:
Öffnen eines TCP-Sockets
Senden von Daten an den offenen Socket
Wenn Sie beispielsweise einen TCP-Socket öffnen, wie dies mit dem Ruby-Befehl Socket.tcp(host, port)
geschieht, erstellt das System synchron einen Socket mithilfe des Systemaufrufs socket(2)
und stellt dann eine Verbindung über den Systemaufruf connect(2)
her .
Was das Senden von Daten angeht, platziert die Verwendung eines Befehls wie socket.write('foo')
in einer Anwendung die Nachricht hauptsächlich im Sendepuffer des Sockets. Anschließend wird die Anzahl der Bytes zurückgegeben, die erfolgreich in die Warteschlange gestellt wurden. Die eigentliche Übertragung dieser Daten über das Netzwerk zum Zielhost wird asynchron vom TCP/IP-Stack verwaltet.
Das bedeutet, dass eine Anwendung, wenn sie in den Socket schreibt, nicht direkt an den Netzwerkvorgängen beteiligt ist und möglicherweise nicht in Echtzeit weiß, ob die Verbindung noch aktiv ist. Die einzige Bestätigung, die es erhält, ist, dass die Nachricht erfolgreich zum TCP-Sendepuffer hinzugefügt wurde.
Da der Server nicht mit einem ACK-Flag antwortet, initiiert unser TCP-Stack eine erneute Übertragung des letzten nicht bestätigten Pakets:
Das Interessante dabei ist, dass TCP standardmäßig 15 Neuübertragungen mit exponentiellem Backoff durchführt, was zu fast 15 Minuten Wiederholungsversuchen führt!
Sie können überprüfen, wie viele Wiederholungsversuche auf Ihrem Host eingestellt sind:
$ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15
Nach dem Eintauchen in die Dokumentation wird es klar; In der Dokumentation zu ip-sysctl.txt heißt es:
Der Standardwert 15 ergibt ein hypothetisches Timeout von 924,6 Sekunden und stellt eine Untergrenze für das effektive Timeout dar. Beim ersten RTO, das das hypothetische Timeout überschreitet, kommt es bei TCP effektiv zu einer Zeitüberschreitung.
Während dieser Zeit ist der lokale TCP-Socket aktiv und akzeptiert Daten. Wenn alle Wiederholungsversuche durchgeführt wurden, wird der Socket geschlossen und die Anwendung erhält bei dem Versuch, etwas an den Socket zu senden, eine Fehlermeldung.
Das Szenario, dass der TCP-Server unerwartet ausfällt, ohne FIN- oder RST-TCP-Flags zu senden, oder wenn Verbindungsprobleme auftreten, kommt recht häufig vor. Warum bleiben solche Situationen oft unbemerkt?
Denn in den meisten Fällen antwortet der Server mit einer Antwort auf Anwendungsebene. Beispielsweise erfordert das HTTP-Protokoll, dass der Server auf jede Anfrage antwortet. Wenn Sie Code wie connection.get
haben, werden im Grunde zwei Hauptoperationen ausgeführt:
Schreibt Ihre Nutzdaten in den Sendepuffer des TCP-Sockets.
Ab diesem Zeitpunkt übernimmt der TCP-Stack des Betriebssystems die Verantwortung für die zuverlässige Zustellung dieser Pakete an den Remote-Server mit TCP-Garantien.
Warten auf eine Antwort in einem TCP-Empfangspuffer vom Remote-Server
Normalerweise verwenden Anwendungen nicht blockierende Lesevorgänge aus dem Empfangspuffer desselben TCP-Sockets.
Dieser Ansatz vereinfacht die Sache erheblich, da wir in solchen Fällen problemlos auf Anwendungsebene einen Timeout setzen und den Socket schließen können, wenn innerhalb eines definierten Zeitraums keine Antwort vom Server erfolgt.
Wenn wir jedoch keine Antwort vom Server erwarten (außer TCP-Bestätigungen), wird es schwieriger, den Status der Verbindung auf Anwendungsebene zu bestimmen
Bisher haben wir Folgendes festgestellt:
Die Anwendung öffnet einen TCP-Socket und schreibt regelmäßig Daten darauf.
Irgendwann fällt der TCP-Server aus, ohne überhaupt ein RST-Paket zu senden, und der TCP-Stack des Absenders beginnt mit der erneuten Übertragung des letzten unbestätigten Pakets.
Alle anderen an diesen Socket geschriebenen Pakete werden im Sendepuffer des Sockets in die Warteschlange gestellt.
Standardmäßig versucht der TCP-Stack, das unbestätigte Paket 15 Mal erneut zu übertragen, wobei er einen exponentiellen Backoff verwendet, was zu einer Dauer von etwa 924,6 Sekunden (etwa 15 Minuten) führt.
Während dieses 15-minütigen Zeitraums bleibt der lokale TCP-Socket geöffnet und die Anwendung schreibt weiterhin Daten darauf, bis der Sendepuffer voll ist (der normalerweise eine begrenzte Kapazität hat, oft nur wenige Megabyte). Wenn der Socket nach allen erneuten Übertragungen schließlich als geschlossen markiert wird, gehen alle Daten im Sendepuffer verloren .
Dies liegt daran, dass die Anwendung nach dem Schreiben in den Sendepuffer nicht mehr dafür verantwortlich ist und das Betriebssystem diese Daten einfach verwirft.
Die Anwendung kann erst erkennen, dass die Verbindung unterbrochen ist, wenn der Sendepuffer des TCP-Sockets voll ist. In solchen Fällen blockiert der Versuch, in den Socket zu schreiben, den Hauptthread der Anwendung, sodass diese die Situation bewältigen kann.
Die Wirksamkeit dieser Erkennungsmethode hängt jedoch von der Größe der gesendeten Daten ab.
Wenn die Anwendung beispielsweise nur wenige Bytes sendet, beispielsweise Metriken, kann es sein, dass der Sendepuffer innerhalb dieses 15-Minuten-Zeitrahmens nicht vollständig gefüllt wird.
Wie kann man also einen Mechanismus implementieren, um eine Verbindung mit einem explizit festgelegten Timeout zu schließen, wenn der TCP-Server ausfällt, um 15 Minuten erneute Übertragungen und Datenverluste in diesem Zeitraum zu vermeiden?
In privaten Netzwerken sind in der Regel keine umfangreichen Neuübertragungen erforderlich, und es ist möglich, den TCP-Stack so zu konfigurieren, dass er nur eine begrenzte Anzahl von Wiederholungsversuchen durchführt. Diese Konfiguration gilt jedoch global für den gesamten Knoten. Da häufig mehrere Anwendungen auf demselben Knoten ausgeführt werden, kann eine Änderung dieses Standardwerts zu unerwarteten Nebenwirkungen führen.
Ein genauerer Ansatz besteht darin, mithilfe der Socket-Option TCP_USER_TIMEOUT ein Zeitlimit für die erneute Übertragung ausschließlich für unseren Socket festzulegen. Durch die Verwendung dieser Option schließt der TCP-Stack automatisch den Socket, wenn Neuübertragungen innerhalb des angegebenen Zeitlimits nicht erfolgreich sind, unabhängig von der global festgelegten maximalen Anzahl von TCP-Neuübertragungen.
Auf Anwendungsebene führt dies dazu, dass beim Versuch, Daten in einen geschlossenen Socket zu schreiben, ein Fehler empfangen wird, was eine ordnungsgemäße Handhabung zur Verhinderung von Datenverlust ermöglicht.
Lassen Sie uns diese Socket-Option in client.rb
festlegen:
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
Außerdem ist nach meinen Beobachtungen die Socket-Option TCP_USER_TIMEOUT unter macOS nicht verfügbar.
Starten Sie nun alles erneut mit docket compose up
und stoppen Sie irgendwann den Server erneut mit 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
Um ~12:37:45 stoppte ich den Server und wir sahen, dass der Client Errno::ETIMEDOUT
fast innerhalb von 5 Sekunden erhielt, großartig.
Lassen Sie uns einen TCP-Dump mit docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234
erfassen:
Es ist erwähnenswert, dass die Zeitüberschreitung tatsächlich nach etwas mehr als 5 Sekunden auftritt. Dies liegt daran, dass die Überprüfung auf Überschreitung von TCP_USER_TIMEOUT beim nächsten Wiederholungsversuch erfolgt. Wenn der TCP/IP-Stack erkennt, dass das Zeitlimit überschritten wurde, markiert er den Socket als geschlossen und unsere Anwendung erhält den Fehler Errno::ETIMEDOUT
Wenn Sie TCP-Keepalives verwenden, empfehle ich außerdem, diesen Artikel von Cloudflare zu lesen. Es behandelt die Nuancen der Verwendung von TCP_USER_TIMEOUT in Verbindung mit TCP-Keepalives.