Как стать автором
Обновить

Одна опция TCP-стека спасет приложение от даунтайма

Уровень сложностиСложный
Время на прочтение15 мин
Количество просмотров17K
Всего голосов 62: ↑62 и ↓0+71
Комментарии16

Комментарии 16

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

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

каким образом научились вызывать 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 похоже опечатка

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

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

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

Все же решение на уровне транспортного протокола не сильно отличается от решения на уровне ядра. Все те же минусы: нужно углубляться в детали реализации, проверять какие могут быть 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 еще больше.

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

Чаще всего 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

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

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

Но все равно звучит, что нужно двигаться в эту сторону, иначе часть кейсов “подвисших запросов” так и останется не покрыта. Т.е. потушили пожар наиболее быстрым и безопасным способом, а потом уже в спокойном режиме с более низким приоритетом можно плавно двигаться к "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 должен обязательно вернуться.

можно плавно двигаться к "long-term" решению

Согласен, мы в спокойном для себя темпе идем к этому. Предстоит еще много работы, что бы процесс таймаутов для запросов был введен и был прозрачен для разработчиков. Возможно об этом когда-нибудь напишем статью и поделимся :)

как-будто фиксить поведение нужно именно тут

Что Linkerd, что Envoy убирают из пула балансировки такие реплики. Здесь сыграло то, что соединения уже установлены и Linkerd, видимо, не может по своей инициативе разрывать соединения. Либо ждет какого-то другого ивента от k8s.

Так это все еще аппликейшны

Согласен - мысль свою выразил не так :)
Под апликейшеном подразумевал, что linkerd может быть использован как protocol-aware прокси и для grpc реализует мультиплексирование соединений.
Под простым tcp - подразумевал проксирование application протокола на уровне L4 без доп фичей самого протокола.

который гоняет ack’и в отдельном коннекте или переодически новые tcp сессии создает

Это хорошее решение, плюсую за него!
По такому же алгоритму работает в Go пулинг соединений до БД database/sql/sql.go - соединения периодически обновляются.

Но в Go-реализации gRPC (да и в Rust) не предоставляется конфигурация менеджмента соединений, те такую логику по периодическому обновлению соединений не реализовать. При этом ее нет только со стороны клиента, с стороны сервера она есть keepalive/keepalive.go

Почему сделано так - без понятия ¯_(ツ)_/¯. Причин не нашел

В общем основной мой поинт в том

Согласен с вами! По возможности лучше обходится силами applicaiton-протоколов. Но в случае gRPC, у нас это не получилось - не хватает опций для управления соединений и частой отправки пингов со стороны соединений.

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

Спасибо за детальную инфу!
Мы с коллегами изучим, может действительно что-то такое там заложено и вернусь с ответом, если что найдем :)

Добрый вечер!

Мы с коллегами посмотрели детальнее настройки сети меж нодами кластера. И вы верно предположили )

Наш сетевой менеджер для kubernetes установил правила для conntrack-а: что все пакеты с любых адресов, помеченные внутри самого conntrack как Invalid (те как раз пакеты с другим seq id, к примеру) должны дропаться.

При том мы еще раз с коллегами убедились, что если бы этих правил на conntrack не было, то пакеты долетели бы до нод и их системы отстрелились бы с RST.
Тк таблица с mac-адресами персистентна в кластере между падениями и восстановлением нод.

Странно, что раньше не заметили и внимания не обратили. Скорее всего не думали в сторону сетевого менеджера.

При том, польза от этого правила и его выставление сетевым менеджером по-дефолту понятны: отсеивать кривые пакеты еще до их захода на уровень операционки, тем самым разгрузив ее.

Добавлю эту секцией UPD в статью.
Спасибо за годный комментарий!

Дополнительно tcp_user_timeout добавляет еще и доп. tcp опцию (28ую) в хедер пакета, а ее иногда “неофициально” используют для проброса клиентского IP (раз, два).

Чесгря, прифигел от того, что это не просто опция, а RFC 5482 аж из категории Standards Track, даже не Experimental. Ну и/или с того, что опцию таким образом абьюзят (кстати, по ссылкам забавно, примеры скриптов на Tcl).

Поэтому лучше такие проблемы решать на уровне аппликейшна

Совершенно верно. Как это всегда в правильных протоколах и делалось (хороший маркер, что Jabber неправильный, ггг).

По timeout’ам на application запросы у вас немного странные аргументы.

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

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

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

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

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

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

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

Десятилетия идут, а неграмотные разработчики всё вляпываются и вляпываются всё в ту же проблему. Статья местами ну очень неграмотная.

gRPC — это Application level протокол, он никак не проверяет соединение на транспортном уровне, надеясь на надежность TCP.

Собственно, дальше можно было бы не читать - это и есть та ошибка, которую делают те, кто не читал хотя бы классическую книжку У.Р.Стивенса еще 90-х годов. Правильное решение, которое применялось еще в том же IRC - пинги на L7, т.е. на уровне L7-протокола. Потому что TCP работает на другом уровне и никак не может отследить, в порядке ли ПРОЦЕСС приложения - допустим, сервер подвис и просто перестал читать из сокета, но сама машина в порядке, т.е. ядро работает - соответственно, на уровне TCP доставка прекрасно будет осуществляться, никаких таймаутов не будет.

Но почитаем дальше...

способны определить жесткое отключение пира и отправить клиенту FIN самостоятельно

Тут и далее по тексту регулярно путаются кейсы FIN и RST. Современное применение термина "полуоткрытое соединение" тоже не соответствует исходному.

Таблица роста RTO описывает случай, когда RTO считается равным 200 мс. Это совершенно необязательно так - RTO рассчитывается исходя из замеренного RTT, т.е. реального "пинга" между узлами. А 200 просто значение по умолчанию для округления, рассчитанное на "большой" Интернет. Скорее всего, в этих ваших линуксах его тоже можно понизить, если машина работает только в условиях ДЦ, где всё быстро - тогда экспоненциально уменьшится и полный таймаут.

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

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

Данная опция gRPC клиента и сервера позволяет им обмениваться HTTP/2 пингами для проверки стабильности соединения

А вот тут уже пахнет проблемой дизайна этого gRPC - что, если по дороге балансер, который сам ответит на пинг, а не конечный процесс?..

По поводу опасности и неправильного применения gRPC Keepalive есть issue на гитхабе в gRPC-go  — в этом посте автор критикует то, как работает Keepalive и даже предложил убрать эту фичу с клиентской стороны (чего не сделали).

Посмотрел в issue, он предлагает SO_KEEPALIVE. То есть еще один неграмотный. Хорошо, что ему не вняли. Вообще забавно, там упоминают, что Go ставит её в 15 секунд - если это про тот же TCP KEEPALIVE, то еще и авторы Go неграмотные.

Пояснение: TCP keepalive предназначен для обнаружения ситуаций, когда ХОСТ на том конце упал (перезагрузился, выключился) бесследно, и не для ситуаций нормального - т.е. быстрого - обмена. Поэтому значение его таймаута проверки должно быть выше полного RTO обычных ретрансмитов (читай, 10 минут). Иное - абьюз механизма, по сути костыль (всё равно не решающий L7-проблему).

TCP_USER_TIMEOUT

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

Резюмируя: вот и выросло поколение (с) которое не читает документацию, а "просто так не гуглится" становится проблемой. Книжки читать надо, ребята.

Спасибо за комментарий!

порядке ли ПРОЦЕСС приложения

Как таковой цели проверить, что именно процесс приложения работает корректно - не было. В рамках статьи рассматривается только TCP-доступность хоста. Нужны другие инструменты, чтобы гарантировать, что само приложение работает корректно. К примеру, тут хватило бы классических liveness проб от k8s. Которые убьют зависшую реплику, не говоря уж о соединении (убив реплику).
Так что, при всем уважении, вы тут немного про другое.

Но если в целом раскрыть тему про сравнение проверки доступности хоста по L4 или L7.
Проверка на уровне TCP позволит гарантировать, что хост все еще доступен, для протоколов, которые требуют строгой последовательности. Протокол MySQL, к примеру. Пока выполняется SQL запрос по соединению нельзя выполнить ping.

его тоже можно понизить, если машина работает только в условиях ДЦ

Согласен с вами, но исходя из внутренних тестов мы сошлись на значении в 30 секунд. Возможно действительно можно было меньше, исходя из условий работы внутри ДЦ.
Мы еще хотели перенести эту настройку в Linkerd, он уже может проксировать запросы в дикий интернет. Поэтому сошлись на 30 секундах.

Ну вот, я битый час объяснял, почему правильно делать именно на L7, а мне говорят, что про другое... Нет, про то же самое. Нет, я могу согласиться, что **в качестве костыля** для криво спроектированных протоколов, типа приведенного в пример MySQL, можно проверять TCP-доступность хоста, т.к. это лучше чем ничего. Но если есть выбор, а тем более если это и вовсе собственное приложение/протокол, надо делать на L7.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий