Dans cet article, j'aborderai un aspect critique de la communication TCP : gérer efficacement les scénarios dans lesquels le serveur ne répond pas. Je me concentre sur un scénario spécifique dans lequel l'application envoie uniquement des données via TCP sans recevoir de réponse du serveur au niveau de l'application.
Cette exploration couvre la communication TCP du point de vue de l'application, en mettant en évidence à la fois la couche application et les opérations sous-jacentes du système d'exploitation. Vous apprendrez à définir des délais d'attente efficaces pour éviter la perte de données lors d'instances de serveur qui ne répondent pas. Je vais fournir des exemples de code dans Ruby, mais l'idée reste la même pour n'importe quel langage.
Imaginez que vous travaillez avec une application qui transmet de manière cohérente des données via un socket TCP. Bien que TCP soit conçu pour garantir la livraison des paquets au niveau du transport dans les configurations de pile TCP définies, il est intéressant de considérer ce que cela implique au niveau de l'application.
Pour mieux comprendre cela, construisons un exemple de serveur et de client TCP à l'aide de Ruby. Cela nous permettra d’observer le processus de communication en action.
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
Et le 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
Et encapsulons cette configuration dans des conteneurs en utilisant ce 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 . .
et 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:
Maintenant, nous pouvons facilement exécuter cela avec docker compose up
et voir dans les journaux comment le client envoie le message et le serveur le reçoit :
$ 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
Assez facile jusqu’à présent, hein ?
Cependant, la situation devient plus intéressante lorsque l'on simule une panne de serveur pour une connexion active.
Nous faisons cela en utilisant 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
On observe que le serveur est désormais hors ligne, pourtant le client se comporte comme si la connexion était toujours active, continuant d'envoyer des données vers le socket sans hésitation.
Cela m'amène à me demander pourquoi cela se produit. Logiquement, le client devrait détecter le temps d'arrêt du serveur dans un court laps de temps, éventuellement quelques secondes, car TCP ne parvient pas à recevoir les accusés de réception de ses paquets, ce qui entraîne une fermeture de connexion.
Cependant, le résultat réel s’est écarté de cette attente :
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
En réalité, le client peut ignorer la connexion interrompue jusqu'à 15 minutes !
Quelle est la cause de ce retard de détection ? Approfondissons pour comprendre les raisons.
Pour couvrir entièrement ce cas, revenons d'abord sur les principes de base, suivis d'un examen de la manière dont le client transmet les données via TCP.
Voici le schéma de base illustrant le flux TCP :
Une fois la connexion établie, la transmission de chaque message implique généralement deux étapes clés :
Le client envoie le message, marqué du drapeau PSH (Push).
Le serveur accuse réception en renvoyant une réponse ACK (Acknowledgement).
Vous trouverez ci-dessous un diagramme de séquence simplifié illustrant l'ouverture d'un socket TCP par une application et la transmission ultérieure de données à travers celle-ci :
L'application effectue deux opérations :
Ouvrir un socket TCP
Envoi de données au socket ouvert
Par exemple, lors de l'ouverture d'un socket TCP, comme avec la commande Socket.tcp(host, port)
de Ruby, le système crée un socket de manière synchrone à l'aide de l'appel système socket(2)
, puis établit une connexion via l'appel système connect(2)
.
En ce qui concerne l'envoi de données, l'utilisation d'une commande telle que socket.write('foo')
dans une application place principalement le message dans le tampon d'envoi du socket. Il renvoie ensuite le nombre d'octets qui ont été mis en file d'attente avec succès. La transmission réelle de ces données sur le réseau vers l'hôte de destination est gérée de manière asynchrone par la pile TCP/IP.
Cela signifie que lorsqu'une application écrit sur le socket, elle n'est pas directement impliquée dans les opérations réseau et peut ne pas savoir en temps réel si la connexion est toujours active. La seule confirmation qu'il reçoit est que le message a été ajouté avec succès au tampon d'envoi TCP.
Comme le serveur ne répond pas avec un flag ACK, notre pile TCP va initier une retransmission du dernier paquet non acquitté :
Ce qui est intéressant ici, c'est que par défaut, TCP effectue 15 retransmissions avec une interruption exponentielle, ce qui entraîne près de 15 minutes de tentatives !
Vous pouvez vérifier combien de tentatives sont définies sur votre hôte :
$ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15
Après avoir plongé dans la documentation, cela devient clair ; La documentation ip-sysctl.txt dit :
La valeur par défaut de 15 donne un délai d'attente hypothétique de 924,6 secondes et constitue une limite inférieure pour le délai d'attente effectif. TCP expirera effectivement au premier RTO qui dépasse le délai d'expiration hypothétique.
Pendant cette période, le socket TCP local est actif et accepte les données. Lorsque toutes les tentatives sont effectuées, le socket est fermé et l'application reçoit une erreur lors d'une tentative d'envoi d'un élément au socket.
Le scénario dans lequel le serveur TCP tombe en panne de manière inattendue sans envoyer d'indicateurs TCP FIN ou RST, ou en cas de problèmes de connectivité, est assez courant. Alors pourquoi de telles situations passent-elles souvent inaperçues ?
Parce que, dans la plupart des cas, le serveur répond avec une réponse au niveau de l'application. Par exemple, le protocole HTTP exige que le serveur réponde à chaque requête. Fondamentalement, lorsque vous avez du code comme connection.get
, cela effectue deux opérations principales :
Écrit votre charge utile dans le tampon d'envoi du socket TCP.
À partir de ce moment, la pile TCP du système d'exploitation prend la responsabilité de transmettre de manière fiable ces paquets au serveur distant avec les garanties TCP.
En attente d'une réponse dans un tampon de réception TCP du serveur distant
En règle générale, les applications utilisent des lectures non bloquantes à partir du tampon de réception du même socket TCP.
Cette approche simplifie considérablement les choses car, dans de tels cas, nous pouvons facilement définir un délai d'attente au niveau de l'application et fermer le socket s'il n'y a pas de réponse du serveur dans un délai défini.
Cependant, lorsque l'on n'attend aucune réponse du serveur (sauf les accusés de réception TCP), il devient moins simple de déterminer l'état de la connexion au niveau de l'application.
Jusqu'à présent, nous avons établi ce qui suit :
L'application ouvre un socket TCP et y écrit régulièrement des données.
À un moment donné, le serveur TCP tombe en panne sans même envoyer de paquet RST et la pile TCP de l'expéditeur commence à retransmettre le dernier paquet non accusé de réception.
Tous les autres paquets écrits sur ce socket sont mis en file d'attente dans le tampon d'envoi du socket.
Par défaut, la pile TCP tente de retransmettre le paquet non accusé de réception 15 fois, en utilisant un intervalle exponentiel, ce qui entraîne une durée d'environ 924,6 secondes (environ 15 minutes).
Pendant cette période de 15 minutes, le socket TCP local reste ouvert et l'application continue d'y écrire des données jusqu'à ce que le tampon d'envoi soit plein (qui a généralement une capacité limitée, souvent quelques mégaoctets seulement). Lorsque le socket est finalement marqué comme fermé après toutes les retransmissions, toutes les données du tampon d'envoi sont perdues .
En effet, l'application n'en est plus responsable après avoir écrit dans le tampon d'envoi et le système d'exploitation supprime simplement ces données.
L'application ne peut détecter que la connexion est interrompue que lorsque le tampon d'envoi du socket TCP est plein. Dans de tels cas, tenter d’écrire sur le socket bloquera le thread principal de l’application, lui permettant ainsi de gérer la situation.
Cependant, l'efficacité de cette méthode de détection dépend de la taille des données envoyées.
Par exemple, si l'application n'envoie que quelques octets, tels que des métriques, elle risque de ne pas remplir complètement le tampon d'envoi dans ce délai de 15 minutes.
Alors, comment implémenter un mécanisme permettant de fermer une connexion avec un timeout explicitement défini lorsque le serveur TCP est en panne pour éviter 15 minutes de retransmissions et de perte de données pendant cette période ?
Dans les réseaux privés, de nombreuses retransmissions ne sont généralement pas nécessaires et il est possible de configurer la pile TCP pour tenter uniquement un nombre limité de tentatives. Cependant, cette configuration s'applique globalement à l'ensemble du nœud. Étant donné que plusieurs applications s'exécutent souvent sur le même nœud, la modification de cette valeur par défaut peut entraîner des effets secondaires inattendus.
Une approche plus précise consiste à définir un délai d'expiration de retransmission exclusivement pour notre socket à l'aide de l'option de socket TCP_USER_TIMEOUT . En utilisant cette option, la pile TCP fermera automatiquement le socket si les retransmissions échouent dans le délai spécifié, quel que soit le nombre maximum de retransmissions TCP défini globalement.
Au niveau de l'application, cela entraîne la réception d'une erreur lors de la tentative d'écriture de données sur un socket fermé, permettant une gestion appropriée de la prévention des pertes de données.
Définissons cette option de socket dans 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
De plus, d'après mes observations, l'option de socket TCP_USER_TIMEOUT n'est pas disponible sur macOS.
Maintenant, recommencez tout avec docket compose up
, et à un moment donné, arrêtons à nouveau le serveur avec 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, j'ai arrêté le serveur et nous avons vu que le client obtenait Errno::ETIMEDOUT
presque en 5 secondes, super.
Capturons un tcpdump avec docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234
:
Il convient de noter que le délai d'attente se produit en réalité en un peu plus de 5 secondes. En effet, la vérification du dépassement de TCP_USER_TIMEOUT a lieu lors de la prochaine tentative. Lorsque la pile TCP/IP détecte que le délai d'attente a été dépassé, elle marque le socket comme fermé et notre application reçoit l'erreur Errno::ETIMEDOUT
De plus, si vous utilisez TCP keepalives, je vous recommande de consulter cet article de Cloudflare . Il couvre les nuances de l'utilisation de TCP_USER_TIMEOUT en conjonction avec TCP keepalives.