paint-brush
Controle las retransmisiones TCP: detección temprana de problemas para evitar la pérdida de datospor@koilas
876 lecturas
876 lecturas

Controle las retransmisiones TCP: detección temprana de problemas para evitar la pérdida de datos

por Oleg Tolmashov14m2024/01/23
Read on Terminal Reader

Demasiado Largo; Para Leer

En este artículo, abordaré un aspecto crítico de la comunicación TCP: gestionar eficazmente escenarios en los que el servidor no responde. Me concentro en un escenario específico donde la aplicación solo envía datos a través de TCP sin recibir ninguna respuesta a nivel de aplicación del servidor. Esta exploración cubre la comunicación TCP desde la perspectiva de la aplicación, destacando tanto la capa de aplicación como las operaciones subyacentes del sistema operativo. Aprenderá cómo establecer tiempos de espera efectivos para evitar la pérdida de datos durante instancias de servidor que no responden.
featured image - Controle las retransmisiones TCP: detección temprana de problemas para evitar la pérdida de datos
Oleg Tolmashov HackerNoon profile picture
0-item
1-item

Introducción

En este artículo, abordaré un aspecto crítico de la comunicación TCP: gestionar eficazmente escenarios en los que el servidor no responde. Me concentro en un escenario específico donde la aplicación solo envía datos a través de TCP sin recibir ninguna respuesta a nivel de aplicación del servidor.


Esta exploración cubre la comunicación TCP desde la perspectiva de la aplicación, destacando tanto la capa de aplicación como las operaciones subyacentes del sistema operativo. Aprenderá cómo establecer tiempos de espera efectivos para evitar la pérdida de datos durante instancias de servidor que no responden. Proporcionaré ejemplos de código en Ruby, pero la idea sigue siendo la misma para cualquier idioma.

El desafío de los servidores TCP silenciosos

Imagine que está trabajando con una aplicación que transmite datos de manera consistente a través de un socket TCP. Si bien TCP está diseñado para garantizar la entrega de paquetes a nivel de transporte dentro de las configuraciones de pila TCP definidas, es interesante considerar lo que esto implica a nivel de aplicación.


Para comprender mejor esto, construyamos un servidor y un cliente TCP de muestra usando Ruby. Esto nos permitirá observar el proceso de comunicación en acción.


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


Y el 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


Y encapsulemos esta configuración en contenedores usando este 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 . .


y 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:


Ahora, podemos ejecutar esto fácilmente con docker compose up y ver en los registros cómo el cliente envía el mensaje y el servidor lo recibe:


 $ 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

Bastante fácil hasta ahora, ¿eh?


Sin embargo, la situación se vuelve más interesante cuando simulamos una falla del servidor para una conexión activa.


Hacemos esto usando 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


Observamos que el servidor ahora está fuera de línea, pero el cliente se comporta como si la conexión todavía estuviera activa y continúa enviando datos al socket sin dudarlo.


Esto me lleva a preguntarme por qué ocurre esto. Lógicamente, el cliente debería detectar el tiempo de inactividad del servidor en un lapso corto, posiblemente segundos, ya que TCP no puede recibir confirmaciones de sus paquetes, lo que provoca el cierre de la conexión.


Sin embargo, el resultado real divergió de esta expectativa:

 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 realidad, el cliente puede permanecer sin darse cuenta de la interrupción de la conexión durante hasta 15 minutos .


¿Qué causa este retraso en la detección? Profundicemos más para entender las razones.

En profundidad: Mecánica de comunicación TCP

Para cubrir completamente este caso, primero revisemos los principios básicos, seguido de un examen de cómo el cliente transmite datos a través de TCP.

Conceptos básicos de TCP

Aquí está el diagrama básico que ilustra el flujo de TCP:

flujo TCP

Una vez que se establece una conexión, la transmisión de cada mensaje normalmente implica dos pasos clave:


  1. El cliente envía el mensaje, marcado con el indicador PSH (Push).


  2. El servidor acusa recibo enviando una respuesta ACK (Acknowledgement).

Comunicación entre la aplicación y el socket

A continuación se muestra un diagrama de secuencia simplificado que ilustra la apertura de un socket TCP por parte de una aplicación y la posterior transmisión de datos a través de él:


Comunicación con el socket TCP


La aplicación realiza dos operaciones:

  1. Abrir un socket TCP


  2. Envío de datos al socket abierto


Por ejemplo, al abrir un socket TCP, como se hace con el comando Socket.tcp(host, port) de Ruby, el sistema crea sincrónicamente un socket usando la llamada al sistema socket(2) y luego establece una conexión a través de la llamada al sistema connect(2) .


En cuanto al envío de datos, el uso de un comando como socket.write('foo') en una aplicación coloca principalmente el mensaje en el búfer de envío del socket. Luego devuelve el recuento de bytes que se pusieron en cola con éxito. La transmisión real de estos datos a través de la red al host de destino se gestiona de forma asincrónica mediante la pila TCP/IP.


Esto significa que cuando una aplicación escribe en el socket, no participa directamente en las operaciones de la red y es posible que no sepa en tiempo real si la conexión aún está activa. La única confirmación que recibe es que el mensaje se ha agregado correctamente al búfer de envío TCP.

¿Qué sucede cuando el servidor TCP deja de funcionar?

Como el servidor no responde con un indicador ACK, nuestra pila TCP iniciará una retransmisión del último paquete no reconocido:


¿Qué pasa cuando el servidor se cae?


Lo interesante aquí es que, de forma predeterminada, TCP realiza 15 retransmisiones con retroceso exponencial, lo que resulta en casi 15 minutos de reintentos.


Puede verificar cuántos reintentos están configurados en su host:

 $ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15


Después de profundizar en los documentos, queda claro; La documentación de ip-sysctl.txt dice:


El valor predeterminado de 15 produce un tiempo de espera hipotético de 924,6 segundos y es un límite inferior para el tiempo de espera efectivo. TCP efectivamente expirará en el primer RTO que exceda el tiempo de espera hipotético.


Durante este período, el socket TCP local está activo y acepta datos. Cuando se realizan todos los reintentos, el socket se cierra y la aplicación recibe un error al intentar enviar algo al socket.

¿Por qué no suele ser un problema?

El escenario en el que el servidor TCP deja de funcionar inesperadamente sin enviar indicadores TCP FIN o RST, o cuando hay problemas de conectividad, es bastante común. Entonces, ¿por qué estas situaciones suelen pasar desapercibidas?


Porque, en la mayoría de los casos, el servidor responde con alguna respuesta a nivel de aplicación. Por ejemplo, el protocolo HTTP requiere que el servidor responda a cada solicitud. Básicamente, cuando tienes un código como connection.get , realiza dos operaciones principales:

  1. Escribe su carga útil en el búfer de envío del socket TCP.

    A partir de este momento, la pila TCP del sistema operativo asume la responsabilidad de entregar de manera confiable estos paquetes al servidor remoto con garantías TCP.


  2. Esperando una respuesta en un búfer de recepción TCP del servidor remoto


    Normalmente, las aplicaciones utilizan lecturas sin bloqueo del búfer de recepción del mismo socket TCP.


Este enfoque simplifica considerablemente las cosas porque, en tales casos, podemos establecer fácilmente un tiempo de espera en el nivel de la aplicación y cerrar el socket si no hay respuesta del servidor dentro de un período de tiempo definido.


Sin embargo, cuando no esperamos ninguna respuesta del servidor (excepto los acuses de recibo TCP), resulta menos sencillo determinar el estado de la conexión desde el nivel de la aplicación.

El impacto de las retransmisiones TCP largas

Hasta ahora, hemos establecido lo siguiente:

  1. La aplicación abre un socket TCP y escribe datos en él periódicamente.


  2. En algún momento, el servidor TCP deja de funcionar sin siquiera enviar un paquete RST y la pila TCP del remitente comienza a retransmitir el último paquete no reconocido.


  3. Todos los demás paquetes escritos en ese socket se ponen en cola en el búfer de envío del socket.


  4. De forma predeterminada, la pila TCP intenta retransmitir el paquete no reconocido 15 veces, empleando un retroceso exponencial, lo que da como resultado una duración de aproximadamente 924,6 segundos (aproximadamente 15 minutos).


Durante este período de 15 minutos, el socket TCP local permanece abierto y la aplicación continúa escribiendo datos en él hasta que el búfer de envío esté lleno (que generalmente tiene una capacidad limitada, a menudo solo unos pocos megabytes). Cuando el socket finalmente se marca como cerrado después de todas las retransmisiones, se pierden todos los datos en el búfer de envío .


Esto se debe a que la aplicación ya no es responsable de ello después de escribir en el búfer de envío y el sistema operativo simplemente descarta estos datos.


La aplicación solo puede detectar que la conexión se interrumpe cuando el búfer de envío del socket TCP se llena. En tales casos, intentar escribir en el socket bloqueará el hilo principal de la aplicación, permitiéndole manejar la situación.


Sin embargo, la eficacia de este método de detección depende del tamaño de los datos que se envían.


Por ejemplo, si la aplicación envía solo unos pocos bytes, como métricas, es posible que no llene completamente el búfer de envío dentro de este período de 15 minutos.


Entonces, ¿cómo se puede implementar un mecanismo para cerrar una conexión con un tiempo de espera establecido explícitamente cuando el servidor TCP está inactivo para evitar 15 minutos de retransmisiones y pérdida de datos durante este período?

Tiempo de espera de retransmisiones TCP utilizando la opción Socket

En las redes privadas, normalmente no son necesarias retransmisiones extensas y es posible configurar la pila TCP para intentar sólo un número limitado de reintentos. Sin embargo, esta configuración se aplica globalmente a todo el nodo. Dado que a menudo se ejecutan varias aplicaciones en el mismo nodo, alterar este valor predeterminado puede provocar efectos secundarios inesperados.


Un enfoque más preciso es establecer un tiempo de espera de retransmisión exclusivamente para nuestro socket utilizando la opción de socket TCP_USER_TIMEOUT . Al emplear esta opción, la pila TCP cerrará automáticamente el socket si las retransmisiones no se realizan correctamente dentro del tiempo de espera especificado, independientemente del número máximo de retransmisiones TCP establecidas globalmente.


A nivel de aplicación, esto da como resultado que se reciba un error al intentar escribir datos en un socket cerrado, lo que permite un manejo adecuado de prevención de pérdida de datos.


Configuremos esta opción de socket en 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


Además, según mis observaciones, la opción de socket TCP_USER_TIMEOUT no está disponible en macOS.


Ahora, comience todo nuevamente con docket compose up y, en algún momento, detengamos el servidor nuevamente con 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


A las 12:37:45, detuve el servidor y vimos que el cliente obtuvo Errno::ETIMEDOUT casi en 5 segundos, genial.


Capturemos un tcpdump con docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234 :


tcpdump


Vale la pena señalar que el tiempo de espera ocurre en un poco más de 5 segundos. Esto se debe a que la comprobación del exceso de TCP_USER_TIMEOUT se realiza en el siguiente reintento. Cuando la pila TCP/IP detecta que se ha excedido el tiempo de espera, marca el socket como cerrado y nuestra aplicación recibe el error Errno::ETIMEDOUT


Además, si está utilizando TCP keepalives, le recomiendo consultar este artículo de Cloudflare . Cubre los matices del uso de TCP_USER_TIMEOUT junto con los keepalives de TCP.