Одна опция TCP-стека спасет приложение от даунтайма
Всем привет, меня зовут Вадим Макеров, я работаю в iSpring бэкенд-разработчиком.
Мы разрабатываем систему управления обучением (LMS — learning management system) iSpring Learn. Внутри система представляет из себя модульный монолит на PHP с почти сотней микросервисов на Go. Мы используем Kubernetes, Service Mesh, gRPC и прочие модные технологии :) Сейчас я работаю во внутренней команде Core, которая занимается внутренними улучшениями нашей системы.
Однажды у нас в продукте был инцидент, который привел к даунтайму LMS и происходил несколько раз, в течении нескольких дней. Причина оказалась нетривиальной и находилась на уровне сетевых настроек подключений между сервисами.
В ходе расследования пришлось погрузиться в особенности TCP-стека и его работе с gRPC.
Чтобы статья читалась легче, описание TCP-стека упрощено без потери содержательности.
Статья может быть полезна бэкенд-разработчикам, которые хотят узнать о работе сетевой системы более подробно.
План
Деградация приложения и зависание на 15 минут
Технические причины проблемы
3 проверенных решения проблемы
Как применять решения на практике
Настроить оптимальные параметры
Внедрить интеграцию с Service Mesh
Проставить конфигурацию вручную
Заключение и выводы
Деградация приложения и зависание на 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
gRPC фреймворк так же не понял, что что-то произошло с репликой A и продолжает отправлять ей новые запросы по зависшим (или имеющимся) соединениям.
При большом потоке запросов на сервисы B, C, D очередь запросов в мертвую реплкику заставит B, C, D истощаться по ресурсам. К примеру, это может быть ограниченное количество воркеров, оперативная память. Истощение ресурсов может привести к двум исходам.
Первый:
Из-за повышенного потребления ресурсов обработка остальных запросов сервисами B, C, D значительно замедлится, а запросы, которые попали в очередь запросов на мёртвую реплику, упадут по таймауту
Второй:
Постепенное истощение ресурсов компонентов
Полный отказ B, C, D
При некорректно настроенной балансировке gRPC запросов (или вообще ее полном отсутствии) приближение такой ситуации можно кратно ускорить.
Т.к. в данном случае gRPC трафик между репликами может идти в совершенно рандомном распределении или вообще идти на одну реплику.
Попробую ответить на очевидно возникающий сейчас вопрос в форме диалога с читателем:
Читатель: «Почему такое не происходит, когда я делаю редеплой приложения в кубере или допускаю segmentation fault в программе и происходит экстренное завершение?»
Автор: «За вас отправляют по соединению FIN-пакет»
Технические причины
Полуоткрытое соединение
Особенность gRPC в использовании долгоживущих соединений и мультиплексирование запросов по одному соединению, т.е. по одному соединению может проходить последовательно несколько RPC.
Из-за чего, если отправка по соединению останавливается — gRPC продолжает копить RPC на это соединение. Тем самым в приложении истощаются ресурсы и воркеры.
gRPC — это Application level протокол, он никак не проверяет соединение на транспортном уровне, надеясь на надежность TCP. Если на уровне TCP соединение зависло — gRPC никак на это не отреагирует.
В таком случае, стоит спуститься на уровень TCP-соединения, чтобы понять, что происходит, когда разрывается соединение, без уведомления другой стороны.
Рассмотрим два Linux хоста, соединенные 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_KEEPCNT + 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 просто разложен на два параметра:
Дальше по тексту я буду использовать термины из документации
По значениям по умолчанию в 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
Соединение закрыто спустя 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 показывали те же результаты.
Также хотелось бы здесь добавить какую-нибудь статистику о том, как нас спасает эта конфигурация:, но за время, что она стоит, никаких инцидентов не было и срабатывать ей было незачем :)