paint-brush
Controle retransmissões TCP: detecção precoce de problemas para evitar perda de dadospor@koilas
853 leituras
853 leituras

Controle retransmissões TCP: detecção precoce de problemas para evitar perda de dados

por Oleg Tolmashov14m2024/01/23
Read on Terminal Reader

Muito longo; Para ler

Neste artigo, abordarei um aspecto crítico da comunicação TCP: gerenciar com eficácia cenários em que o servidor não responde. Eu me concentro em um cenário específico em que o aplicativo apenas envia dados por TCP sem receber nenhuma resposta do servidor em nível de aplicativo. Esta exploração abrange a comunicação TCP da perspectiva da aplicação, destacando tanto a camada da aplicação quanto as operações subjacentes do sistema operacional. Você aprenderá como definir tempos limite efetivos para evitar perda de dados durante instâncias de servidor que não respondem.
featured image - Controle retransmissões TCP: detecção precoce de problemas para evitar perda de dados
Oleg Tolmashov HackerNoon profile picture
0-item
1-item

Introdução

Neste artigo, abordarei um aspecto crítico da comunicação TCP: gerenciar com eficácia cenários em que o servidor não responde. Eu me concentro em um cenário específico em que o aplicativo apenas envia dados por TCP sem receber nenhuma resposta do servidor em nível de aplicativo.


Esta exploração abrange a comunicação TCP da perspectiva da aplicação, destacando tanto a camada da aplicação quanto as operações subjacentes do sistema operacional. Você aprenderá como definir tempos limite efetivos para evitar perda de dados durante instâncias de servidor que não respondem. Fornecerei exemplos de código em Ruby, mas a ideia permanece a mesma para qualquer linguagem.

O desafio dos servidores TCP silenciosos

Imagine que você está trabalhando com um aplicativo que transmite dados de forma consistente por meio de um soquete TCP. Embora o TCP seja projetado para garantir a entrega de pacotes no nível de transporte dentro das configurações de pilha TCP definidas, é interessante considerar o que isso implica no nível do aplicativo.


Para entender melhor isso, vamos construir um exemplo de servidor e cliente TCP usando Ruby. Isso nos permitirá observar o processo de comunicação em ação.


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


E o 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


E vamos encapsular essa configuração em contêineres 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 . .


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


Agora, podemos executar isso facilmente com docker compose up e ver nos logs como o cliente envia a mensagem e o servidor a recebe:


 $ 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

Muito fácil até agora, hein?


Porém, a situação fica mais interessante quando simulamos uma falha no servidor para uma conexão ativa.


Fazemos isso 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 o servidor agora está offline, mas o cliente se comporta como se a conexão ainda estivesse ativa, continuando a enviar dados para o soquete sem hesitação.


Isso me leva a questionar por que isso ocorre. Logicamente, o cliente deve detectar o tempo de inatividade do servidor em um curto espaço de tempo, possivelmente segundos, pois o TCP não consegue receber confirmações para seus pacotes, solicitando o fechamento da conexão.


No entanto, o resultado real divergiu desta 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


Na realidade, o cliente pode permanecer inconsciente da interrupção da conexão por até 15 minutos !


O que causa esse atraso na detecção? Vamos nos aprofundar para entender os motivos.

Aprofundado: Mecânica de Comunicação TCP

Para cobrir totalmente esse caso, vamos primeiro revisitar os princípios básicos, seguido de uma análise de como o cliente transmite dados por TCP.

Noções básicas de TCP

Aqui está o diagrama básico que ilustra o fluxo TCP:

Fluxo TCP

Depois que uma conexão é estabelecida, a transmissão de cada mensagem normalmente envolve duas etapas principais:


  1. O cliente envia a mensagem, marcada com o sinalizador PSH (Push).


  2. O servidor confirma o recebimento enviando de volta uma resposta ACK (Acknowledgment).

Comunicação entre o aplicativo e o soquete

Abaixo está um diagrama de sequência simplificado que ilustra a abertura de um soquete TCP por um aplicativo e a subsequente transmissão de dados através dele:


Comunicação com o soquete TCP


A aplicação realiza duas operações:

  1. Abrindo um soquete TCP


  2. Enviando dados para o soquete aberto


Por exemplo, ao abrir um soquete TCP, como feito com o comando Socket.tcp(host, port) do Ruby, o sistema cria um soquete de forma síncrona usando a chamada de sistema socket(2) e então estabelece uma conexão através da chamada de sistema connect(2) .


Quanto ao envio de dados, usar um comando como socket.write('foo') em um aplicativo coloca principalmente a mensagem no buffer de envio do soquete. Em seguida, ele retorna a contagem de bytes que foram enfileirados com sucesso. A transmissão real desses dados pela rede para o host de destino é gerenciada de forma assíncrona pela pilha TCP/IP.


Isso significa que quando um aplicativo grava no soquete, ele não está diretamente envolvido nas operações da rede e pode não saber em tempo real se a conexão ainda está ativa. A única confirmação que recebe é que a mensagem foi adicionada com sucesso ao buffer de envio TCP.

O que acontece quando o servidor TCP fica inativo?

Como o servidor não responde com um sinalizador ACK, nossa pilha TCP iniciará uma retransmissão do último pacote não reconhecido:


o que acontece quando o servidor cai


O interessante aqui é que por padrão o TCP faz 15 retransmissões com espera exponencial o que resulta em quase 15 minutos de novas tentativas!


Você pode verificar quantas tentativas estão definidas em seu host:

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


Depois de mergulhar nos documentos, fica claro; A documentação do ip-sysctl.txt diz:


O valor padrão de 15 produz um tempo limite hipotético de 924,6 segundos e é um limite inferior para o tempo limite efetivo. O TCP atingirá efetivamente o tempo limite no primeiro RTO que exceder o tempo limite hipotético.


Durante este período, o soquete TCP local está ativo e aceita dados. Quando todas as novas tentativas são feitas, o soquete é fechado e o aplicativo recebe um erro ao tentar enviar algo para o soquete.

Por que geralmente não é um problema?

O cenário em que o servidor TCP fica inativo inesperadamente sem enviar sinalizadores FIN ou RST TCP, ou quando há problemas de conectividade, é bastante comum. Então, por que tais situações muitas vezes passam despercebidas?


Porque, na maioria dos casos, o servidor responde com alguma resposta no nível do aplicativo. Por exemplo, o protocolo HTTP exige que o servidor responda a todas as solicitações. Basicamente, quando você tem um código como connection.get , ele realiza duas operações principais:

  1. Grava sua carga no buffer de envio do soquete TCP.

    Deste ponto em diante, a pilha TCP do sistema operacional assume a responsabilidade de entregar esses pacotes de forma confiável ao servidor remoto com garantias TCP.


  2. Aguardando uma resposta em um buffer de recebimento TCP do servidor remoto


    Normalmente, os aplicativos usam leituras sem bloqueio do buffer de recebimento do mesmo soquete TCP.


Esta abordagem simplifica consideravelmente as coisas porque, nesses casos, podemos facilmente definir um tempo limite no nível da aplicação e fechar o soquete se não houver resposta do servidor dentro de um período de tempo definido.


No entanto, quando não esperamos nenhuma resposta do servidor (exceto confirmações de TCP), torna-se menos simples determinar o status da conexão no nível do aplicativo.

O impacto das longas retransmissões TCP

Até agora, estabelecemos o seguinte:

  1. O aplicativo abre um soquete TCP e grava dados nele regularmente.


  2. Em algum momento, o servidor TCP fica inativo sem sequer enviar um pacote RST, e a pilha TCP do remetente começa a retransmitir o último pacote não confirmado.


  3. Todos os outros pacotes gravados nesse soquete são enfileirados no buffer de envio do soquete.


  4. Por padrão, a pilha TCP tenta retransmitir o pacote não confirmado 15 vezes, empregando espera exponencial, resultando em uma duração de aproximadamente 924,6 segundos (cerca de 15 minutos).


Durante esse período de 15 minutos, o soquete TCP local permanece aberto e o aplicativo continua a gravar dados nele até que o buffer de envio esteja cheio (que normalmente tem uma capacidade limitada, geralmente de apenas alguns megabytes). Quando o soquete é eventualmente marcado como fechado após todas as retransmissões, todos os dados no buffer de envio são perdidos .


Isso ocorre porque o aplicativo não é mais responsável por isso após gravar no buffer de envio e o sistema operacional simplesmente descarta esses dados.


O aplicativo só pode detectar que a conexão foi interrompida quando o buffer de envio do soquete TCP ficar cheio. Nesses casos, a tentativa de gravar no soquete bloqueará o thread principal do aplicativo, permitindo que ele lide com a situação.


No entanto, a eficácia deste método de detecção depende do tamanho dos dados enviados.


Por exemplo, se o aplicativo enviar apenas alguns bytes, como métricas, ele poderá não preencher totalmente o buffer de envio nesse período de 15 minutos.


Então, como implementar um mecanismo para fechar uma conexão com um timeout explicitamente definido quando o servidor TCP está inativo para evitar 15 minutos de retransmissões e perda de dados durante esse período?

Tempo limite de retransmissões TCP usando a opção Socket

Em redes privadas, normalmente não são necessárias retransmissões extensas e é possível configurar a pilha TCP para tentar apenas um número limitado de novas tentativas. No entanto, esta configuração se aplica globalmente a todo o nó. Como vários aplicativos geralmente são executados no mesmo nó, a alteração desse valor padrão pode causar efeitos colaterais inesperados.


Uma abordagem mais precisa é definir um tempo limite de retransmissão exclusivamente para nosso soquete usando a opção de soquete TCP_USER_TIMEOUT . Ao empregar esta opção, a pilha TCP fechará automaticamente o soquete se as retransmissões não forem bem-sucedidas dentro do tempo limite especificado, independentemente do número máximo de retransmissões TCP definido globalmente.


No nível do aplicativo, isso resulta no recebimento de um erro ao tentar gravar dados em um soquete fechado, permitindo o tratamento adequado da prevenção contra perda de dados.


Vamos definir esta opção de soquete em 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


Além disso, pelas minhas observações, a opção de soquete TCP_USER_TIMEOUT não está disponível no macOS.


Agora, comece tudo de novo com docket compose up e, em algum momento, vamos parar o servidor novamente com 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


Às ~12h37min45s, parei o servidor e vimos que o cliente obteve Errno::ETIMEDOUT quase em 5 segundos, ótimo.


Vamos capturar um tcpdump com docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234 :


tcpdump


É importante notar que o tempo limite ocorre em pouco mais de 5 segundos. Isso ocorre porque a verificação de excesso de TCP_USER_TIMEOUT acontece na próxima tentativa. Quando a pilha TCP/IP detecta que o tempo limite foi excedido, ela marca o soquete como fechado e nossa aplicação recebe o erro Errno::ETIMEDOUT


Além disso, se você estiver usando keepalives TCP, recomendo verificar este artigo da Cloudflare . Ele cobre as nuances do uso de TCP_USER_TIMEOUT em conjunto com manutenções de atividade TCP.