Всем привет, меня зовут Вадим Макеров, я работаю в iSpring бэкенд-разработчиком.

Мы разрабатываем систему управления обучением (LMS - learning management system) iSpring Learn. Внутри система представляет из себя модульный монолит на PHP с почти сотней микросервисов на Go. Мы используем Kubernetes, Service Mesh, gRPC и прочие модные технологии :) Сейчас я работаю во внутренней команде Core, которая занимается внутренними улучшениями нашей системы.

Однажды у нас в продукте был инцидент, который привел к даунтайму LMS и происходил несколько раз, в течении нескольких дней. Причина оказалась нетривиальной и находилась на уровне сетевых настроек подключений между сервисами.

В ходе расследования пришлось погрузиться в особенности TCP-стека и его работе с gRPC.

Чтобы статья читалась легче, описание TCP-стека упрощено без потери содержательности.
Статья может быть полезна бэкенд-разработчикам, которые хотят узнать о работе сетевой системы более подробно.

План

  1. Деградация приложения и зависание на 15 минут

  2. Технические причины проблемы

    • Полуоткрытое соединение 

    • 15 ретрансмитов

  3. 3 проверенных решения проблемы

    • Тонко настроить ядро

    • gRPC Keepalive

    • TCP_USER_TIMEOUT

  4. Как применять решения на практике

    • Настроить оптимальные параметры

    • Внедрить интеграцию с Service Mesh

    • Проставить конфигурацию вручную

  5. Заключение и выводы

Деградация приложения и зависание на 15 минут

Рассмотрим кейс, который был в нашем приложении. Выделим для рассмотрения часть с 4-мя сервисами:

Сервисы B,C,D обращаются к компоненту A по gRPC. Каждый имеет несколько реплик (для упрощения иллюстраций они будут опущены до момента их необходимости)

Из общей схемы системы становится понятно, что A — это некий критичный узел. В реальности это может быть брокер, база данных, сервис — любое подключение по keep-alive или долгоживущему соединению. В нашем случае важна критичность компонента A: B,C,D не могут корректно работать без A.

При отказе одной из реплик A мы ожидаем, что остальные компоненты переключат трафик с отказавшей реплики на другую живую реплику.

Ожидаем получить что-то такое
Ожидаем получить что-то такое

Но в реальности наблюдаем следующее:

Сервисы B,C,D не переключили gRPC-соединение с реплики A , соединения остались и новые RPC по этим соединениям вставали в очереди.
B,C,D перестали отвечать на некоторые клиентские запросы, тк трафик с реплики A1 не был переключен.

Далее будем рассматривать только сервис B для простоты — однако B, C, D ждёт одинаковая судьба — просто в разные периоды времени.

B не переключил часть трафика с реплики A, текущие RPC по gRPC зависли.
На продолжительный промежуток времени запросы начинают зависать больше чем, на 2 секунды.

На графике: зависание запросов
На графике: зависание запросов

Одному из сервисов повезло ещё меньше: его время зависания достигает 50 секунд (в реальности это другой кейс):

На графике: малые столбы это те кейсы, где запросу удалось пробиться в живую реплику компонента A
На графике: малые столбы это те кейсы, где запросу удалось пробиться в живую реплику компонента A

gRPC фреймворк так же не понял, что что-то произошло с репликой A и продолжает отправлять ей новые запросы по зависшим (или имеющимся) соединениям.

При большом потоке запросов на сервисы B,C,D очередь запросов в мертвую реплику заставит B,C,D истощаться по ресурсам. К примеру, это может быть ограниченное количество воркеров, оперативная память. Истощение ресурсов может привести к двум исходам.

Первый:
Из-за повышенного потребления ресурсов обработка остальных запросов сервисами B,C,D значительно замедлится, а запросы, которые попали в очередь запросов на мёртвую реплику, упадут по таймауту

Второй:

  1. Постепенное истощение ресурсов компонентов

  1. Полный отказ B,C,D

    При некорректно настроенной балансировке gRPC запросов (или вообще ее полном отсутствии) приближение такой ситуации можно кратно ускорить.
    Т.к. в данном случае gRPC трафик  между репликами может идти в совершенно рандомном распределении или вообще идти на одну реплику.

Попробую ответить на очевидно возникающий сейчас вопрос в форме диалога с читателем:
Читатель: "Почему такое не происходит, когда я делаю редеплой приложения в кубере или допускаю segmentation fault в программе и происходит экстренное завершение?"
Автор: "За вас отправляют по соединению FIN-пакет"

Технические причины

Полуоткрытое соединение

Особенность gRPC в использовании долгоживущих соединений и мультиплексирование запросов по одному соединению, т.е. по одному соединению может проходить последовательно несколько RPC.
Из-за чего, если отправка по соединению останавливается — gRPC продолжает копить RPC на это соединение. Тем самым в приложении истощаются ресурсы и воркеры.

gRPC — это Application level протокол, он никак не проверяет соединение на транспортном уровне, надеясь на надежность TCP. Если на уровне TCP соединение зависло — gRPC никак на это не отреагирует.
В таком случае, стоит спуститься на уровень TCP-соединения, чтобы понять, что происходит, когда разрывается соединение, без уведомления другой стороны.

Рассмотрим два Linux хоста, соединенные TCP-соединением.

Два Linux хоста, соединенные TCP-соединением. Пунктирные линии обозначают границы TCP-соединения
Два Linux хоста, соединенные TCP-соединением. Пунктирные линии обозначают границы TCP-соединения

Внезапно второй хост умирает.

Второй хост умирает без уведомления первого. В TCP если хост хочет закрыть соединение, то сначала он должен уведомить другую сторону FIN-пакетом. Но в этом случае FIN-пакет не был отправлен.

В таком случае, что произойдёт с TCP-соединением у первого хоста ?
В TCP, когда соединение закрыто с одной из сторон и состояние соединения не синхронизировано между хостами, оно становится полуоткрытым соединением (Half-Opened)

Но что должно произойти с операционной системой, чтобы она не отправила FIN-пакет перед закрытием соединения?

"ауф"
"ауф"

В действительности нет гарантий, что другой хост отправит FIN перед закрытием соединения или пакет достигнет адресата. Провод между нодами кубер-кластера в дц просто перережут ножом или как-то ещё разорвут соединение на физическом уровне. Способов порвать соединение на уровне системы ещё больше — случаи могут быть разные.

Скрытый текст

Ремарка про NAT и AWS, Google Cloud proxy
Если соединение происходит за NAT или через Proxy облачных провайдеров, то эти системы способны определить жесткое отключение пира и отправить клиенту FIN самостоятельно. В нашем кейсе между системами не было облачного прокси или настроенного NAT, как обычном необлачном K8S кластере 

В нашем реальном кейсе — упала worker-нода из-за бага в ядре (произошёл segmentation fault и система упала) ...несколько раз.

Если с сетью что-то произошло, приложение должно уметь обрабатывать эти сценарии или быть сконфигурировано для этого.
Один из способ избежать Half-opened соединений — настроить TCP_KEEPALIVE.

Скрытый текст

TCP_KEEPALIVE работает за счет отправки по соединению специальных зонд-пакетов, на которые другой хост должен ответить ACK-пакетом.
TCP_KEEPALIVE имеет следующие настройки:

  • SO_KEEPALIVE = 1 — включить отправку keepalive зондов

  • TCP_KEEPIDLE = 5 — отправить первый зонд спустя 5 бездействия

  • TCP_KEEPINTVL = 3 —  отправить следующий зонд через 3 секунды

  • TCP_KEEPCNT = 5 — закрыть соединение после 4 неудачных попыток

Таким образом, в случае того, если соединение станет Half-opened, оно будет закрыто через:
TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT = 20 секунд.

Пока по TCP-соединению нет активности — TCP-стек с ним ничего не делает, соединение повиснет и утечет.
К примеру, если такие соединения образуются на сервере (к примеру, Websocket или gRPC сервер), они быстро может привести к утечке памяти и файловых дескрипторов.
Настройка является частым best-practice (рекомендации к redis-server ставить TCP_KEEPALIVE) и может быть  установлена по умолчанию, в том же redis-server TCP_KEEPALIVE предустановлен по умолчанию.

В таком случае, при установке TCP_KEEPALIVE в кейсе выше произойдет следующее:

По истечению таймаута TCP_KEEPALIVE TCP стек разорвёт соединение, клиентское приложение получит от ядра ошибку — ETIMEDOUT.
Однако одной настройки параметра TCP_KEEPALIVE недостаточно.

В том кейсе клиенты обращались по соединению в уже мертвый хост.
Со стороны TCP это выглядит так, что очередь на отправку постоянно растёт, а когда у соединения есть пакеты в очереди на отправку — TCP_KEEPALIVE перестаёт работать.

Заметки на полях: во время наших инцидентов у нас был выставлен TCP_KEEPALIVE и он действительно не помог :)

15 ретрансмитов

Чтобы решить проблему 15-ти минут, нужно выяснить её корни.

В кейсе выше у первого хоста есть очередь на отправку и он отправляет пакеты на ту сторону. В TCP на каждый отправленным пакет, должен прийти пакет подтверждения получения, ACK-пакет. В нашем случае, на отправленные пакеты не приходит подтверждение с той стороны - пакет "теряется".

В TCP получение пакета другой стороной должно быть подтверждено специальным ACK-пакетом. Когда пакет не подтверждается второй стороной, происходит повторная передача неподтвержденных пакетов или  Retransmission (далее по статье процесс буду называть ретрансмит). Повторная отправка происходит по RTO (retransmission timeout).

Т.к. второй хост мёртв, первый будет непрерывно повторять отправку пакетов. Количество повторных отправок пакетов в Linux задаётся настройкой net.ipv4.tcp_retries2 и по умолчанию равно 15. По их истечении будет закрыто соединение.

При этом RTO динамический и экспоненциально увеличивается после каждого ретрансмита.

15 ретрансмитов при постоянно увеличивающемся RTO дают примерный таймаут в ~15 минут.
Таким образом и получаются 15 минут ожидания по соединению до его закрытия.

Наглядная сводная таблица роста RTO в зависимости от номера ретрансмита:

Взято со статьи
Взято со статьи

Также в статье есть описание алгоритма подсчёта RTO

Способы решения

Таймауты на уровне приложения

Очевидный и понятный способ ограничить ожидание приложением какой-то сетевой задержки могут быть  application timeouts. Суть в том, что устанавливаются HTTP таймауты на запрос или Deadline в случае gRCP на RPC. В таком случае, возникают следующие проблемы:

Универсального таймаута со стороны приложения не существует
Для каждого RPC или запроса в приложении допустимы разные значения задержек:
К примеру, RPC  на авторизацию операции в системе недопустимо не отвечать более 30 секунд. А вот некоторым сложным SQL-запросам может быть дозволительно работать более 30 секунд, если это происходит в фоне.
Таким образом нужно всем запросам индивидуально выставлять таймауты, чтобы корректно срабатывать при сетевых проблемах. В крупной системе подобрать и выставить всем запросам корректный application таймаут может быть очень дорого.

Потеря медленных клиентов 
При наличии сетевых задержек, но при этом общей работоспособности сети, медленные клиенты начнут чаще отваливаться, хоть и сеть до них стабильна.

(прим. Application таймауты применимы в том случае, если мы хотим форсировать, чтобы система укладывалась в соблюдение задержки на RPC в определенный сервис или ограничивать медленные запросы по API свыше определенного порога)

Нужно другое, более специфичное решение, которое будет учитывать только сетевые задержки и не использовать таймаут на стороне приложения.

Тонко настроить ядро

Первая идея, которая приходит в голову, при понимании откуда берутся 15 минут ожидания - уменьшить количество повторов до с 15 до 7.

Меняется значение через sysctl

sysctl net.ipv4.tcp_retries2=7 

В таком случае значение ретрансмитов станет 7 для всей системы (если применить в docker,cri контейнерах, то изменения будут применены в рамках только контейнера)

Но у данного решения есть несколько минусов:

System-wide
Если приложение ходит во внешнюю сеть и активно обращается внутри кластера - количество ретрансмитов будет одинаково в любом направлении. Хотелось бы для соединений внутри кластера иметь меньшее количество повторов и быстрее реагировать на сетевые изменения, а во внешний интернет — иметь большее количество ретрансмитов, чтобы не терять менее стабильных клиентов.

Зависимость от sysctl
Необходимо доставить sysctl в pod/контейнер приложения. Что заставляет запускать контейнер от root пользователя. К тому же, добавляет ещё один cli инструмент или вообще невозможно реализовать при использовании distroless образов.
Правда даже такое ограничение можно обойти: при деплое приложения в кубернетес можно воспользоваться init-контейнером для конфигурирования сети приложения - тогда нет необходимости в sysctl в основном контейнере.

Мы решили не идти в эту сторону, т.к. конфигурирование через sysctl могло иметь множество особенностей, про которые мы могли не знать.

gRPC Keepalive

В поисках причин зависания и решения мы нашли настройку gRPC Keepalive (grpc.io) - мы же столкнулись с такой проблемой при использовании gRPC, значит возможно можно найти решение через gRPC. Данная опция gRPC клиента и сервера позволяет им обмениваться HTTP/2 пингами для проверки стабильности соединения (подробнее про Keepalive рассказывали коллеги из ozon в своей статье )

Мы решили опробовать Keepalive, чтобы защитить межсервисные соединения.

По умолчанию gRPC keepalive клиент не отправляет пинги, отправку делает только сервер раз в 2 часа.
Такие настройки нам не походили: мы хотели, чтобы обе стороны проверяли статус соединения, а сервер чаще отправлял пинги.

В рамках тестирования Keepalive выставили клиенту и серверу следующие значения:

  • KEEPALIVE_TIME=30s — отправляем пинги каждые 30 секунд

  • KEEPALIVE_TIMEOUT=10s — ждём ответный пинг 10 секунд

  • PERMIT_KEEPALIVE_WITHOUT_CALLS=1 — соединение проверяется даже без активных RPC

В первом тестировании такое решение помогло (прим. мы научились вызывать segmentation fault внутри ядра, чтобы симулировать инцидент). 

Спустя 30 секунд после падения сервера клиент отправлял пинг и, не получив на него ответ, закрывал соединение.

Но есть ложка дёгтя:
При первом же тестировании получали значительное количество ошибок от Go реализации (прим., мы преимущественно используем Go, поэтому будут референсы оттуда), когда были обращения в сервисы, чьи gRPC сервера не были сконфигурированы. Получали следующее:

code: ErrCodeEnhanceYourCalm 
debugData: too_many_pings

Причина оказалась в настройке gRPC сервера: у него есть своя политика защиты сервера от чрезмерных пингов или EnforcementPolicy.

Скрытый текст

Про EnforcementPolicy 

Такой термин присутствует только в Go реализации (в C реализации, нет на момент 12.2024), так что термин неофициальный.
Go реализация содержит отдельную структуру конфигурации для сервера:

type EnforcementPolicy struct {  
//...   
MinTime time.Duration // The current default value is 5 minutes.
//...  
PermitWithoutStream bool // false by default.  
}  

В остальных реализациях EnforcementPolicy просто разложен на два параметра:

  • PERMIT_KEEPALIVE_TIME - MinTime в Go

  • PERMIT_KEEPALIVE_WITHOUT_CALLS - PermitWithoutStream в Go

Дальше по тексту я буду использовать термины из документации

По значениям по умолчанию в EnforcementPolicy сервер может принимать пинги только раз в 5 минут. (прим. Технически, там дозволительно два, но это уже скорее как некая опция, позволяющая клиенту отправить дополнительный пинг из-за разницы во времени между сервисами)
Если любой клиент нарушает эту политику, сервер отправляет ему ErrCodeEnhanceYourCalm и сразу закрывает соединение, прерывая все текущие RPC.

Проблема решается достаточно просто — выставляется PERMIT_KEEPALIVE_TIME на стороне сервера в 30s, равный KEEPALIVE_TIME для клиента.

В этом случае возникает несколько нюансов:

Необходимо каскадно обновить все сервисы
Нужно применить политику PERMIT_KEEPALIVE_TIME ко всем сервисам сразу.
Конфигурирование одного сервиса ведёт за собой конфигурирование его зависимостей и т.д. Как-то хитро конфигурировать сервис, чтобы при подключении по gRPC к одним сервисам он применял одни параметры, а к другим нет. Так как возрастает сложность настройки.

Гарантированный сбой при изменении PERMIT_KEEPALIVE_TIME
Т.к. политику формирует сервер, при деплое клиента раньше сервера - клиент будет получать ErrCodeEnhanceYourCalm .
В Go реализации есть корректировка, если клиент получил too_many_pings, он удваивает свой KEEPALIVE_TIME. В таком случае, чтобы прийти к одинаковому значению KEEPALIVE_TIME между клиентской и серверной стороной, нужно допустить несколько сбоев. К примеру, для KEEPALIVE_TIME в 30s  нужно 4 сбоя.
Таким образом, при выставлении gRPC Keepalive в системе, сервер должен деплоиться первым.
У gRPC Keepalive нет API или доп протокола (как xDS для балансировки и Service Discovery), чтобы сервер и клиент могли согласовать свои параметры PERMIT_KEEPALIVE_TIME и KEEPALIVE_TIME.

Некорректное использование Keepalive
По поводу опасности и неправильного применения gRPC Keepalive есть issue на гитхабе в gRPC-go  — в этом посте автор критикует то, как работает Keepalive и даже предложил убрать эту фичу с клиентской стороны (чего не сделали). Он даёт ссылки на proposal-ы gPRC по Keepalive(A8-client-side-keepalive.md и A9-server-side-conn-mgt)  и наталкивает на мысль:
gRPC Keepalive  это про поддержку соединения напрямую с клиентом на уровне L7, игнорируя промежуточные сервера (прокси, load-balancers и т.п.), где TCP проверки не сработают или невозможны.

Такие нюансы gRPC Keepalive заставили отойти от него в пользу другого решения.

TCP_USER_TIMEOUT

Рассмотрим еще раз проблему на уровне TCP:
В кейсе выше первый хост уходит в 15-ти минутные ретрансмиты, и если ограничение повторов для всей системы не подходит — возможно есть какая-то опция сокета, позволяющая ограничить повторы пакетов.

Помимо опции TCP_KEEPALIVE tcp стек может реагировать на полуоткрытое соединение через опцию TCP_USER_TIMEOUT. 
Рассмотрим поведение в кейсе выше при конфигурации TCP_USER_TIMEOUT=30s:

После первого ретрансмита общее время попыток ретрансмитов ограничивается TCP_USER_TIMEOUT.
После этого, если подтверждения не поступило, соединение закрывается с ETIMEDOUT.

Соединение закрыто спустя TCP_USER_TIMEOUT и ресурсы первого пира освобождены от half-opened соединения.
(прим. Значение в 30 секунд примерно соответствует времени ожидания при net.ipv4.tcp_retries2=7)

Эта настройка решает проблему с длительным подвисанием соединения. Теперь соединение будет закрыто, если подтверждение о доставке пакета не придёт в течение 30 секунд.
Поскольку эта настройка задаётся для каждого сокета отдельно, для разных соединений можно задать свои значения таймаутов.

Применение

В этой секции расскажу как мы посчитали оптимальный TCP_USER_TIMEOUT и применили его в нашей системе.

Оптимальное значение

Ранее по статье для примеров и моделирования использовались 7 ретрансмитов и 30s таймаута.

Почему 7 и 30s ?

Исходя из таблицы по подсчету RTO и количества повторенных пакетов, оптимальными значениями являются 7 повторов и 30 таймаута.

  • 7 повторов: чуть меньше половины ретраев при стандартных настройках. По сравнению с поведением по умолчанию, количество ретраев уменьшено всего в 2 раза.

  • 30 секунд: этого времени достаточно на проведение 7 повторов и оптимально, чтобы не породить сильные зависания в системе на таймаутах.
    Вот так всё просто :)

Таймаут в 30 секунд может не подходить в следующих случаях: когда идет взаимодействие с внешним клиентом напрямую, а не через ingress. В таком случае стоит рассмотреть вариант поднять значение в TCP_USER_TIMEOUT.

Service Mesh

Service Mesh — инструмент, который может гарантировать единые настройки сетевого стека для всех сервисов в системе.

Linkerd 

Мы используем Service Mesh Linkerd - по умолчанию в нём настроены только TCP_KEEPALIVE, и нет настроек для TCP_USER_TIMEOUT (именно поэтому у нас инцидент и произошёл :) ). Пришлось самим добавлять проставление TCP_USER_TIMEOUT: issue в проекте Linkerd. Спустя месяц апрува и ревью — pull request-ы #13024 и #3174  приняли и теперь Linkerd также поддерживает TCP_USER_TIMEOUT в новом релизе.

Теперь новая версия Linkerd защищает наш продукт от падения воркер-нод :)

ENVOY

В envoy же не нужно контрибьютить, достаточно настроить socket option и выставить номер опции TCP_USER_TIMEOUT. 

Кстати, при исследовании мы так же наткнулись на issues #28865 и #33466, где пользователи envoy столкнулись с тем же инцидентом (часть графиков для демонстрации я взял из этих issue)

Ручная конфигурация

Если в системе отсутствует ServiceMesh или он не сконфигурирован до критичных узлов системы, опцию TCP_USER_TIMEOUT можно выставить на сокет вручную:

func SetTCPUserTimeout(conn net.Conn, timeout time.Duration) error {  
    tcpconn, ok := conn.(*net.TCPConn)  
    if !ok {  
       // not a TCP connection. exit early  
       return nil  
    }  
    rawConn, err := tcpconn.SyscallConn()  
    if err != nil {  
       return fmt.Errorf("error getting raw connection: %v", err)  
    }  
    err = rawConn.Control(func(fd uintptr) {  
       err = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout/time.Millisecond))  
    })  
    if err != nil {  
       return fmt.Errorf("error setting option on socket: %v", err)  
    }  
    return nil  
}

Данный код взят с grpc-go internal/syscall/syscall_linux.go

Завершение

Исследование и исправление причин этого инцидента, стало настоящим челленджем. Кейс зависания специфичный и просто так не гуглится. Даже сами issue на github для envoy, уже были найдены после того, как в проблеме разобрались. Предложенные решения в этих issue только подкрепляли уверенность, что все делали правильно.

Понять корень проблемы нам помогла статья Cloudflare (перевод на хабре с дожившими до нашего времени картинками). В ней автор описывает не только кейс-ситуацию, подобную нашей, но и рассказывает важность использования TCP_KEEPALIVE. Приводит примеры работы протокола TCP с разными настройками. В этой статье намеренно опущены демонстрации экспериментов с TCP настройками, т.к. наши внутренние эксперименты на TCP_USER_TIMEOUT показывали те же результаты.

Также хотелось бы здесь добавить какую-нибудь статистику о том, как нас спасает эта конфигурация: но за время, что она стоит, никаких инцидентов не было и срабатывать ей было незачем :)

Комментарии (10)


  1. SLASH_CyberPunk
    25.12.2024 19:01

    Спасибо за статью, было очень интересно читать. Если вас не затруднит, может поделитесь, каким образом научились вызывать segmentation faul в ядре?

    P. S. в формуле для расчета таймаута tcp_keepalive похоже опечатка, там складываться должен idle


    1. VadimMakerov Автор
      25.12.2024 19:01

      каким образом научились вызывать segmentation faul в ядре

      Это налайфхачил мой коллега: мы создали модуль ядра linux на C и разыменовали там nil указатель :)

      Исходник:

      #include <linux/module.h>
      #include <linux/printk.h>
      
      int init_module(void)
      {
          pr_info("Hello world init.\n");
          return 0;
      }
      
      void cleanup_module(void)
      {
          int *p;
          p = NULL;
      
          pr_info("Goodbye world.\n");
          pr_info("Goodbye kernel, %d", *p);
      }
      
      MODULE_LICENSE("GPL");

      tcp_keepalive похоже опечатка

      Опечатку поправил, спасибо :)


      1. morijndael
        25.12.2024 19:01

        Емнип, есть sysrq команда чтобы вызвать kernel panic, но предварительно надо подкрутить систему чтобы эту возможность включить


  1. nv13
    25.12.2024 19:01

    У нас на совсем другой платформе возникала проблема с tcp, причём на давно работавшем коде, на долгих запросах к бд. Неожиданно они стали зависать и отваливаться. Как всегда, никто ничего не делал) Проблема оказалась в изменении полиси роутеров, которые без трафика стали прихлопывать соединение через час, а дефолтный кипэлайв был 2 часа. И для сервера и для клиента сам разрыв соединения был незаметен.
    Вообще контроллировать доступность сервиса по таймаутам прикладного протокола конечно можно, но не достаточно, имхо


  1. bfuvx
    25.12.2024 19:01

    Все же решение на уровне транспортного протокола не сильно отличается от решения на уровне ядра. Все те же минусы: нужно углубляться в детали реализации, проверять какие могут быть edge кейсы, можем ли что-то поломать, добавляется ли оверхед в сетевом стеке ядра (в случае user_timeout не должен, так как проверка таймера происходит при следующем ретрае, но все же), могут ли другие нестандартные sysctl’и поменять ожидаемое поведение и так далее. Дополнительно tcp_user_timeout добавляет еще и доп. tcp опцию (28ую) в хедер пакета, а ее иногда “неофициально” используют для проброса клиентского IP (раз, два).

    Ну и самое главное: это решает только тот вид проблем, с которыми уже столкнулись, и знаем, что для них такое решение подходит. А при какой-то другой комбинации обстоятельств не поможет (условно: ack’и проходят, но push пакеты теряются/тротлятся где-то в ядре на пути к апплику, потому что подвисла какая-нибудь прослойка, которая инспектит пакеты через nfqueue).

    Поэтому лучше такие проблемы решать на уровне аппликейшна/mesh_сети (в общем на том уровне, где client LB реализован). Тогда неважно, что будет происходить/меняться на инфрастуктуре, в ОС или других местах “под нами”.

    В большинстве случаев должно хватать хелсчеков + желательно timeout на application запросы.

    Зафейлились хелсчек пробы - выкинули из пула проблемный инстанс (нам не важно, как именно он упал и закрылись ли коннекшны. Хелс эндпоинт не отработал - удаляем инстанс из балансировки).

    По timeout’ам на application запросы у вас немного странные аргументы. Чаще всего 99% запросов в inter-service коммуникации подпадают под какую-то адекватную верхнюю границу (допустим, не больше 2-3 секунд), которая засетана где-нибудь в общих дефолтах для всех сервисов. А для остальных ситуаций уже поднимается дефолт (но это должно быть что-то редкое, иначе странно, если есть много синхронных запросов, где ок висеть 30 секунд - что-то тут не так тогда).

    С таким подходом нам не важно, что именно случилось с проблемным инстансом - следующий аппликативный ретрай уже пойдет на здоровый инстанс.

    В случае cloudflare’а подход с tcp_user_timeout и другие low-level тюнинги на уровне ядра/сети имеют смысл, потому что:

    a. У них другая задача: вовремя освобождать ресурсы на балансере не имея контроля над upstream/downstream (они не могут знать, какой аппликативный таймаут валиден для того или иного пользовательского реквеста). Поэтому да, им нужно спускаться на уровень ниже.

    b. У них есть ресурсы глубоко лезть в сетевой стэк ядра, разбираться во всех edge кейсах, и патчить ядро или писать кастомные eBPF программы, если всплывают какие-нибудь нюансы.

    c. На их масштабах окупается тюнинг на всех уровнях (hw, OS, network, application). Раз и так все кастомное, то чего бы и таймауты на уровне tcp не подтюнить (как и кучу других параметров заодно).

    Ну и еще пару копеек:

    • По-хорошему "плохой инстанс" должен перезапускаться по liveness пробе или чему-то аналогичному. Тем более в самом простом кейсе, когда процесс убился по сегфолту. Допустим в случае k8s перезапустился контейнер, но он будет с тем же IP - все "зависшие" коннекшны получат RST пакет на следующем ретрае и закроются даже без всего того, что было расписано выше. Т.е. вышеописанные проблемы должны происходить только, когда "плохой" инстанс" на каждом перезапуске получает новый IP (зачем?) или по какой-то причине инстанс не может стартануть обратно (должно быть что-то редкое).

    • Если все-таки что-то пошло совсем не по плану, и сервис "A" работает с проблемами, то тут должны помогать circuit breaker'ы, лимиты на очередь запросов и все такое. Так хотя бы ситуация не будет ухудшаться и продолжит работать остальной функционал (если он есть на сервисах B,C,D без сервиса A). Ну или как минимум не заддосим сервис A еще больше.


    1. VadimMakerov Автор
      25.12.2024 19:01

      Спасибо за столь развернутый комментарий! )

      Чаще всего 99% запросов в inter-service коммуникации подпадают под какую-то адекватную верхнюю границу

      Согласен с вами, что большинство запросов имеют какую-то адекватную верхнюю границу, но я бы обратил внимание на критичность этих запросов. Если рассматривать введение таймаутов с точки зрения ущерба приложению, который они могут принести на этапе их обкатки - то они становятся очень дороги в примении. В этот 1% запросов, что можно на мониторинге и не разглядеть при определении верхней границы, могут входить какие-то редкие, но критичные пользовательские сценарии. В нашем случае это особенно критично, т.к. у нас есть монолит, который при обработке бОльших данных в более крупном аккаунте (кейс нашей LMS) потреблять больше времени на обработку, чем в остальных - т.к. в монолите случается крайне не оптимизированный код местами :)
      Мы отказались от таймаутов на стороне приложения именно из-за высокого риска иметь отстреливаемые бизнес-сценарии на продолжительном промежутке времени.
      Наверное, если бы не решили решения через TCP_USER_TIMEOUT, то пришли бы к таймаутам приложения.

      следующий аппликативный ретрай уже пойдет на здоровый инстанс

      Не в случае gRPC: следующий ретрай для него как раз таки не обязательно пойдет на здоровый инстанс. Об этом кейсе статья :)

      В случае cloudflare’а подход с tcp_user_timeout и другие low-level тюнинги на уровне ядра/сети имеют смысл

      В нашем случае они так же имеют смысл, ведь мы подключили TCP_USER_TIMEOUT не напрямую в сервисы, а в используемый нами ServiceMesh Linkerd. Мы доработали Linkerd, чтобы системно на уровне ниже от приложения решить проблему. Мейнтенерам Linkerd нужно решать все те же проблемы, что и cloudflare lb. Я сейчас про:

      вовремя освобождать ресурсы на балансере не имея контроля над upstream/downstream

      Тк Linkerd может быть использован не только как прокси для application протоколов, но и general tcp прокси, для соединений до баз данных к примеру. Так что TCP_USER_TIMEOUT и низкоуровневый fine-tuning необходимы :)

      Тем более в самом простом кейсе, когда процесс убился по сегфолту

      Было не совсем это - по сегфолту упала сама worker-нода. И при перезапуске ноды, самим подам действительно выданы были те же ip-адреса.
      Но при этом после запуска ноды и появления подов, зависшие соединения все еще наблюдались и при анализе с TCP dump и wireshark - RST-пакетов не было. Если бы они были, то проблемы и кейса не было бы и статьи тоже :)

      все "зависшие" коннекшны получат RST

      Может у вас какая-то дока или кейс чтобы почитать про такое поведение. Потому что при своем исследовании, я такого не нашел.


      1. bfuvx
        25.12.2024 19:01

        Мы отказались от таймаутов на стороне приложения именно из-за высокого риска иметь отстреливаемые бизнес-сценарии на продолжительном промежутке времени.

        Но все равно звучит, что нужно двигаться в эту сторону, иначе часть кейсов “подвисших запросов” так и останется не покрыта. Т.е. потушили пожар наиболее быстрым и безопасным способом, а потом уже в спокойном режиме с более низким приоритетом можно плавно двигаться к "long-term" решению. Начать, допустим, только с новых фичей/сервисов и тех, где по мониторингу на 99.99 перцентилях все хорошо. А какое-нибудь легаси пускай себе остается только с tcp_user_timeout, пока до него руки не дойдут. Ну или как вариант, сделать retry полиси, когда на следующий ретрай увеличивается таймаут до абсурдно больших значений (все еще лучше, чем не иметь таймаута вообще + позволит потом по логам/мониторингу изучить, где ошиблись с инишиал таймаутом) - аналог exponential backoff, только для таймаута, а не паузы между ретраями.

        Не в случае gRPC: следующий ретрай для него как раз таки не обязательно пойдет на здоровый инстанс. Об этом кейсе статья :)

        Конкретно про реализацию gRPC не скажу, но если при наличии хелсчеков менеджер пула не убирает зафейленные (по хелсчеку) инстансы из балансировки, то как-будто фиксить поведение нужно именно тут. Не было бы вопросов, если бы так происходило при отсутствии хелсчеков или, если бы это касалось только тех запросов, что отправились до хелсчека, но после падения инстанса (при отсутствии апплик таймаута).

        Мейнтенерам Linkerd нужно решать все те же проблемы, что и cloudflare lb. 

        Мейнтейнерам Linkerd - да, так как потенциально кто-то их решение может использовать для внешних кастомеров. В вашем же случае корневое отличие в том, что клиенты и бэкенды Linkerd тоже находятся под вашим контролем, то чего бы не пользоваться этим преимуществом. Плюс, в вашем случае скорее всего речь идет только про inter-service трафик в пределах одного дц (или какого-то другого закрытого контура), т.е. сильно меньше кейсов, где можно ожидать неожиданное поведение сети. 

        Тк Linkerd может быть использован не только как прокси для application протоколов, но и general tcp прокси, для соединений до баз данных к примеру. 

        Так это все еще аппликейшны, и хорошо бы чтобы у них также были таймауты/хелсчеки. Просто голый tcp никому не нужен, поверх него все равно будет какой-то протокол. Т.е. остаются узкие кейсы, где почему-то нужно обойтись без “protocol-aware” хелсчеков. Но и для таких кейсов все равно все еще можем использовать примитивный хелсчек, который гоняет ack’и в отдельном коннекте или переодически новые tcp сессии создает - зафейлилось - перестаем туда отправлять данные новых коннектов и, по-хорошему, прибиваем старые коннекты по таймауту, если сами не закрылись.

        В общем основной мой поинт в том, что это хорошо, конечно, что можно подстраховаться тюнингом таймаутов на уровне TCP, но чаще будет правильней решать такие проблемы на уровне L7 (особенно, когда и клиенты и бэкенды под контролем). При этом tcp таймауты все равно можно оставлять для подстраховки.

        Было не совсем это - по сегфолту упала сама worker-нода.

        Ага, но принцип все равно такой же, пока сохраняется IP (даже не обязательно, чтобы сервис поднялся, главное, чтобы пакеты могли дойти до хоста, где в данный момент старый IP).

        Может у вас какая-то дока или кейс чтобы почитать про такое поведение. Потому что при своем исследовании, я такого не нашел.

        https://datatracker.ietf.org/doc/html/rfc9293#name-reset-generation

        > As a general rule, reset (RST) is sent whenever a segment arrives that apparently is not intended for the current connection.

        > If the connection does not exist (CLOSED), then a reset is sent in response to any incoming segment except another reset

         Могу предположить, что в вашем случае на воркере, или где-то в другом месте по пути был iptables рул (или что-то аналогичное), который чекал conntrack, и дропал пакеты, не принадлежащие текущим сессиям. В таком случае ядру не на что будет возвращать RST (src пакет не дошел до места, которое бы тригернуло RST).

        Если старый IP “routable”, и нет хитрых файрвол рулов, которые могут дропнуть пакеты со старых “зависших” сессий, то RST должен обязательно вернуться.


  1. bibmaster
    25.12.2024 19:01

    Опасности ddos через ping это когда на сервер идут прямые клиентские соединения и их много. Для внутрисерверных соединений ping вполне нормальный механизм. Таймаут можно делать маленький, всё равно под нагрузкой никаких пингов не будет, время считается от момента последней входящей активности.


    1. VadimMakerov Автор
      25.12.2024 19:01

      Согласен с вами.
      Только у нас наступала проблема не из-за значений таймаута, а впринципе при ping-ах клиента сервером чаще, чем дефолтные значения.

      У нас не все сервисы могут иметь постоянный трафик, что хотя бы раз в 5 или 30 секунд есть запросы к ним. Таким образом такие пинги будут отстреливать обращения в такие сервисы.
      Так что нам такой вариант не подошел


  1. sshade
    25.12.2024 19:01

    Не в тему всей статьи, но на случай, если кто-то наткнётся, исследую другую проблему. Ну, или если вам понадобится для чего-то другого.

    Необходимо доставить sysctl в pod/контейнер приложения. Что заставляет запускать контейнер от root пользователя.

    Не совсем так. https://kubernetes.io/docs/tasks/administer-cluster/sysctl-cluster/
    sysctl можно задавать через pod spec. net.ipv4.tcp_retries2 не в списке safe опций, придётся ещё добавить в опции kubelet.