В этой статье я рассмотрю критический аспект TCP-связи: эффективное управление сценариями, когда сервер не отвечает. Я концентрируюсь на конкретном сценарии, в котором приложение отправляет данные только по TCP, не получая от сервера ответа на уровне приложения.
Это исследование охватывает TCP-коммуникацию с точки зрения приложения, подчеркивая как уровень приложения, так и базовые операции ОС. Вы узнаете, как установить эффективные тайм-ауты, чтобы избежать потери данных во время не отвечающих экземпляров сервера. Я приведу примеры кода на Ruby, но идея останется одинаковой для любого языка.
Представьте, что вы работаете с приложением, которое последовательно передает данные через сокет TCP. Хотя протокол TCP предназначен для обеспечения доставки пакетов на транспортном уровне в рамках определенных конфигураций стека TCP, интересно рассмотреть, что это означает на уровне приложения.
Чтобы лучше это понять, давайте создадим пример TCP-сервера и клиента с использованием Ruby. Это позволит нам наблюдать процесс общения в действии.
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:
После установления соединения передача каждого сообщения обычно включает в себя два ключевых этапа:
Клиент отправляет сообщение, помеченное флагом PSH (Push).
Сервер подтверждает получение, отправляя ответ ACK (подтверждение).
Ниже приведена упрощенная диаграмма последовательности, иллюстрирующая открытие TCP-сокета приложением и последующую передачу данных через него:
Приложение выполняет две операции:
Открытие TCP-сокета
Отправка данных в открытый сокет
Например, при открытии TCP-сокета, как это делается с помощью команды Ruby Socket.tcp(host, port)
, система синхронно создает сокет с помощью системного вызова 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 активен и принимает данные. После выполнения всех повторных попыток сокет закрывается, и приложение получает ошибку при попытке отправить что-либо в сокет.
Сценарий, когда TCP-сервер неожиданно отключается без отправки TCP-флагов FIN или RST, или при возникновении проблем с подключением, довольно распространен. Так почему же такие ситуации часто остаются незамеченными?
Потому что в большинстве случаев сервер отвечает каким-то ответом на уровне приложения. Например, протокол HTTP требует, чтобы сервер отвечал на каждый запрос. По сути, когда у вас есть такой код, как connection.get
, он выполняет две основные операции:
Записывает полезную нагрузку в буфер отправки 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 я остановил сервер, и мы увидели, что клиент получил Errno::ETIMEDOUT
почти через 5 секунд, отлично.
Давайте запишем tcpdump с помощью docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234
:
Стоит отметить, что таймаут на самом деле происходит чуть больше, чем через 5 секунд. Это связано с тем, что проверка превышения TCP_USER_TIMEOUT происходит при следующей повторной попытке. Когда стек TCP/IP обнаруживает, что тайм-аут превышен, он помечает сокет как закрытый, и наше приложение получает ошибку Errno::ETIMEDOUT
Кроме того, если вы используете пакеты поддержки активности TCP, я рекомендую ознакомиться с этой статьей от Cloudflare . В нем рассматриваются нюансы использования TCP_USER_TIMEOUT в сочетании с проверками активности TCP.