
Привет, Хабр! Я Владислав Кислый, разработчик отказоустойчивых нагруженных сервисов в Т-Банке. Расскажу страшную сказку о том, как в одной компании взялись разрабатывать сервис.
В качестве протокола взаимодействия выбрали gRPC. Что из этого вышло, с какими сетевыми проблемами пришлось столкнуться и как мы их решили — читайте в статье. Описанные проблемы можно потрогать руками с помощью тестового проекта, докера и темной магии Toxiproxy, который будет портить нам жизнь.
Знакомство с сервисом
gRPC — протокол молодой, но прочно закрепившийся как один из механизмов для межсервисного взаимодействия. Он разработан компанией Google на базе HTTP/2 и Protobuf. Основной целью было снижение количества сетевого трафика и строгая спецификация. Это была попытка закрыть два основных недостатка REST — протокола популярного, но хаотичного и многословного. Подробнее можно почитать в статье.
С протоколом разобрались. А что же будет делать наш чудесный сервис? Он должен выполнять важнейшую функцию — адресно приветствовать клиентов, которые присылают ему запросы с именем или ID. Это действие повышает удовлетворенность жизнью и мировосприятие клиентов, ведь им часто не хватает вежливого приветствия.
Важно учесть, что у нас серьезный сервис и клиентов будет много, а значит, у сервиса высокие требования к времени ответа и высокий RPS.
У нас есть репозиторий с сервисом. В нем на Java реализован сервис Greeter и простейший клиент, которые общаются между собой запросами-приветствиями. В Git код специально разбросан по веткам (P1/.../P7), чтобы можно было двигаться по решениям итерационно, с возможностью поэкспериментировать на каждом этапе.
Сложную логику в сервисе и протоколе накручивать особо смысла не было, так как разговор больше пойдет об обеспечении надежности самого взаимодействия по gRPC.
Выходим в прод
Открываем ветку Р1. Запускаем сервис:
mvn exec:java -Dexec.mainClass="ru.karlsoft.grpc.helloworld.GreeterServer"
Запускаем клиент:
mvn exec:java -Dexec.mainClass="ru.karlsoft.grpc.helloworld.GreeterClient"
Чтобы было удобнее наблюдать за сервисом, можно настроить дополнительный тулинг Grafana с Prometheus.

Считаем, что на этом шаге мы отрелизились в прод. Клиенты подключаются к шлюзу, который занимается маршрутизацией соединений и их балансировкой между подами сервиса.
Схема заведомо упрощена, чтобы не нырять в дебри балансировки k8s. В реальности же там все сильно сложнее и под гейтвеем лежат HAProxy + Cilium. Хочу сосредоточиться на том, как подключение выглядит для клиента, для которого шлюз — это «черный ящик», который как бы наш сервис.
Спустя неделю после релиза стали поступать тревожные сигналы о том, что иногда резко просаживается RPS и вырастает лейтенси, соединения после таймаута обрываются с ошибкой UNAVAILABLE. Клиенты негодуют, уровень тревожности растет.
Разберемся, в чем дело, и попробуем это исправить. Первым делом проверим сервис и клиент, достаточно быстро убедимся, что дело не в них. Исследование показало, что единичные соединения время от времени как будто зависают. Взаимодействие по ним останавливается до тех пор, пока клиент сам не разорвет его.
На сервисе картина выглядит как спад нагрузки — будто клиенты просто перестали ходить. То есть все указывает, что проблемы где-то за нашим шлюзом, а возможно, и в нем самом. Траблшутинг таких проблем занимает недели, а клиенты страдают прямо сейчас. Поэтому нет времени объяснять — давайте что-то делать на стороне клиента!
Тюним таймауты, настраиваем ретраи
Переходим к ветке Р2. Попробуем воспроизвести ошибки на стенде. Для этого нам понадобится помощник — Toxiproxy. Эта утилита нужна для создания сетевых проблем. Качаем и настраиваем. Дополнительный скрипты по настройке правил лежат в каталоге Сonfig/Еoxiproxy.
Меняем схему подключения, чтобы между клиентом и сервером вместо привычного нам шлюза расположился Еoxiproxy. Он перебрасывает все запросы клиента через себя на сервер, но может ломать их по нашей команде.

Главное, что для клиента существует только одна точка входа, а за ней расположено несколько нод. Для тестового стенда нам хватит и одной ноды.
Включаем схему, собираем метрики, все вроде бы красиво. Примерно так будет выглядеть трафик по gRPC-протоколу:

Теперь включаем проблемы на Toxiproxy. В текущей симуляции Toxiproxy все запросы проваливаются в «черную дыру».
Буквально сразу видим, что резко падает весь RPS и также резко подпрыгивает лейтенси. Ситуация очень похожа на проблемы, происходящие на проде. Кроме того, если разбираться на уровне TCP-пакетов, видно, что весь трафик идет в рамках одного соединения, а вот попыток создать новые соединения не наблюдается.
Такое поведение связано с фичей мультиплексирования, когда в рамках одного TCP-соединения существует множество внутренних соединений. Это сокращает издержки на открытие и поддержание открытых соединений. К слову, полезную фичу мультиплексирования предоставляет протокол HTTP/2.
Получается, мы можем гонять все наши тысячи RPS в рамках одного соединения и не тратить время на установку новых TCP-соединений. Круто! Но, как говорится, наши недостатки — это логическое продолжение наших достоинств, и, если что-то происходит с этим TCP-соединением, разрыв соединения равен увеличенному лагу. Страдают сразу все запросы, им обслуживаемые.
Рассуждая, что сеть в целом штука ненадежная и никому нельзя верить, настраиваем ретраи. Внезапно оказывается, что все это поддерживается gRPC сразу из коробки. Тогда добавим ретраи и посмотрим, насколько это улучшит ситуацию.
Читаем доку: ретраи уже работают по умолчанию (transparent retries). Все равно добавляем и пробуем ?

На сетевом уровне видно, что клиент соединение не закрывает, а упорно шлет keep-alive-пакеты. И только когда выходит время дедлайна, заданного в Toxiproxy, идут попытки создать новое соединение. Если же выставить таймаут на 0, что значит не сбрасывать соединение, попытки использовать «мертвое» соединение будут идти бесконечно.

Взаимодействие в дампе выше и идет в обе стороны. Toxiproxy не настолько низкоуровневая утилита, чтобы блокировать взаимодействие на уровне пакетов, она управляет запросами выше транспортного уровня модели OSI. Становится понятным такое поведение протокола gRPC и почему нет попыток пересоздать соединение, так как фактически оно живо, но нам от этого не легче, ведь клиентские запросы не обрабатываются.
Добавляем дедлайны
Переходим на ветку Р3. Читаем еще доку, получается, что Read Timeouts в их обычном понимании нет. Вернее, они есть, но работают только на фазе установки соединения. А после надо пользоваться дедлайнами. Прикручиваем дедлайны, пробуем.

Стало не сильно лучше. Все запросы ушли в DEADLINE_EXCEEDED, лейтенси равно выставленному дедлайну, и сетевой трафик выглядит так.

Теперь трафик завален попытками запросов и их отменами RST_STREAM. Попыток открыть новое соединение опять не наблюдается, и это уже начинает напрягать. Попробуем покрутить настройки Keep-alive.
Keep-alive уровня gRPC
Переходим на ветку Р4. Включаем настройку Keep-alive на уровне GRPC. Пока пробуем без дедлайнов, так как они засоряют вывод, а поведение с ними идентичное.

Наконец-то поведение меняется. Видно, как отрабатывает Keep-alive(PING) и, после того как ответ не был получен, открывается новое соединение, а старое закрывается.


Совсем другое дело: клиент начинает реагировать на проблемы со стороны сервиса. Если после отправки пинга сервер не пришлет ответ, клиент переоткрывает соединениe.
Но есть большая ложка дегтя ? Согласно доке, если сервер должным образом не настроен, он будет закрывать соединения часто пингующих клиентов. Часто, с точки зрения архитекторов gRPC, — 5 минут. Их логику можно понять: нечего отъедать бесполезными запросами канал обмена.
C другой стороны, 5 минут — достаточно большой таймаут, и обнаружение мертвых соединений будет занимать минуты, что катастрофически скажется на всех SLO/SLA. Но главное, клиент уже стал реагировать — и мы будем развивать успех!
Уменьшаем влияние обрывов соединения
Переходим к ветке Р5. Мы нашли механизм, который помогает обнаруживать мертвые соединения, — Keepalive. Включаем его, уменьшаем период между пингами до 10 с, можно быстрее реагировать на проблемы плохой сети.
Но все равно остается проблема одного соединения, через которое течет множество запросов в параллель. Как ни крути, оно только одно — и, если с ним хоть что-то случится, весь клиентский трафик пострадает. Ведь мы потратим время на обнаружение проблем, а потом — на открытие нового соединения. И все это время мы не сможем обрабатывать запросы.
Было бы здорово иметь пул соединений, ведь у нас за шлюзом несколько узлов, к которым можно подключиться. Так почему они должны простаивать? А если с основным соединением что-то случится, мы сразу будем использовать другое. Так хотя бы сэкономим время на открытие нового.

Управлять количеством соединений в gRPC можно через связку системы разрешения имен и балансировку нагрузкой (Loadbalancing Policy).

Сделаю акцент на прямой связи балансировки с разрешением имен. Все политики балансировки опираются на то, что вернет DNS. Стандартных политик балансировки всего две: Pick_first и Round_robin. Есть еще устаревшая gRPCLB, но ее рассматривать не будем.
Pick_first — политика по умолчанию, никакого отдельного конфигурирования не требует. Получает список IP-адресов от резолвера и пробует подключаться к ним по порядку. Первый, к которому удалось успешно подключиться, в дальнейшем и используется для всех запросов. Так как соединение только одно, нам не подходит.
Round_robin конфигурируется при создании канала (channel). Как и в первом случае, получается список IP-адресов от DNS. Но клиентский код устанавливает отдельное TCP-соединение к каждому адресу, соединение будет жить своей жизнью, и для запросов они будут использоваться по очереди. Получается, что, если DNS вернет три адреса и пострадает одно соединение, остальные два останутся работать и катастрофического эффекта на клиентский трафик удастся избежать.
Такое дефолтное поведение — вполне рабочий вариант, и для многих компаний его более чем достаточно. Правда, не всегда DNS нами управляется, а главное, не всегда DNS публикует реальное состояние сети. Часто там присутствует только один IP — адрес шлюза, который будет заниматься маршрутизацией. Это и есть наш случай.
На этом этапе есть один из вариантов решения на уровне кубера — через Linkerd. Но хочется не зависеть от сторонних систем и самим управлять количеством соединений.
А раз так, копаем дальше.

Если мы хотим много соединений, нужно крутить LB policy. А еще ретраи расположены сразу же за балансировкой, и, если захочется организовать какие-то свои ретраи, следует располагать их там, а не за Channe.
Создадим кастомный Name Resolver, который обманет балансировщик. Он будет возвращать список одинаковых IP-адресов в нужном нам количестве. В таком случае балансировщик Round-robin создаст отдельные соединения к шлюзу и мы сможем получить все преимущества от round-robin-балансировки, несмотря на то что хосты скрыты за шлюзом. Запускаем, пробуем.

Изменений в коде намного больше, и стоит посмотреть в пакет ru.karlsoft.grpc.helloworld.resolver: основные изменения там.
Сейчас используется пять соединений, поэтому чуть меняем правила для Toxiproxy, чтобы затрагивало только часть работающих соединений (параметр Toxiciy):
./toxiproxy-cli toxic add --downstream -t timeout --toxicName="timeout_ms" -a timeout=0 --toxicity=0.2 grpc_test_hello
У меня зацепило два соединения в такой конфигурации.

В итоге трафик просел, но не до 0, как было до того.

Пробуем спекулятивные ретраи: путь для минимизации влияния
Идем дальше — на ветку Р6. Попробуем путь минимизации влияния — хеджирующие или спекулятивные ретраи. Это такой специфичный тип ретраев, когда клиент ждет ответа фиксированный период времени. Если по истечении указанного интервала результата нет, клиент отправляет еще один запрос, а потом еще один — в результате победит быстрейший. Главное отличие в том, что он не завязан на ошибки от сервера, как сделано в обычных ретраях.

Основной плюс такого способа ретраев в том, что обычно лейтенси сервера известно и мы можем очень быстро реагировать на любые лаги сети, почти не отбрасывая входящие запросы.
Опасность такого вида ретраев заключается в том, что можно легко перегрузить принимающую сторону. Частичной защитой принимающей стороны выступает механизм retryThrottling, когда сервис слишком часто отвечает ошибками, часть ретраев не будет отправлена. И чем больше ошибок от сервера, тем больше ретраев будет отброшено без отправки.
Вторая опасность — использование хеджирующих ретраев для пишущих ендпоинтов. Без идемпотентности это может привести к печальным результатам — например, начислить лишние деньги на счет. Стоит взвесить за и против, прежде чем их использовать.
Кроме того, добавлен класс HeaderClientInterceptor.java для наблюдения за поведением спекулятивных ретраев, так как через логгер это сделать нельзя. Этот класс будет печатать передаваемые заголовки запросов. Нас интересует заголовок grpc-previous-rpc-attempts, его клиент будет обрабатывать особо и печатать значение, сколько ретраев пережил конкретный запрос.

Посмотрим, какие результаты даст использование спекулятивных ретраев:
Проба 1. Затронуло 3 соединения.
Проба 2. Затронуло 2 соединения.
Проба 3. Затронуло 2 соединения.
Проба 4. Затронуло только 1 соединение. С дополнительной конфигурацией дедлайнов и сокращением времени на хедж-запросы.

По графикам видно, что процент успешных запросов зависит от того, сколько соединений остается на ходу. Кроме того, на графике RPC stream ops есть какие-то флуктуации во взаимодействии, которые не имеют к экспериментам никакого отношения. Особенно отрадно, что при потере одного соединения просадка практически равняется этим флуктуациям.
Проводим последнюю проверку
Переходим на ветку Р7 — настройки такие же, как у Р6, но тесты на базе Iptables.
Хочется исключить фактор обмана gRPC, чтобы протокол не воспринимал, что соединение по-прежнему живо. Поэтому сценарий, который будем рассматривать, немного отличается от предыдущего.
Откроем пять портов (10051-55) через Toxiproxy (файл с настройкой Configure-5.sh), Toxiproxy останется перенаправлять запросы на тот же сервис.

Выбранный сценарий нужен для того, чтобы была возможность заблокировать один из пяти портов через Iptables (файл Config/iptables.sh), блокироваться будет порт 10053. Клиент же будет использовать пятое подключение, но уже на разные порты — по одному соединению на порт.
Проба 1. Блокировка трафика на 2 минуты и потом его возобновление.
Проба 2. Блокировка трафика на 5 минут и потом его возобновление.

Пятиминутная блокировка трафика очень быстро отыгралась и мало отличается по эффекту от двухминутной. Влияние коснулось только 20% трафика и на очень небольшой период. Часть запросов все-таки попали в Deadline, но их количество невелико.
Посмотрим, что происходило на сетевом уровне.

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

Сначала статус соединения порта 10053 прыгал между полузакрытым и полуоткрытым. В итоге попытки использовать порт 10053 вообще прекратились: соединение к этому порту просто было закрыто. И только спустя некоторое время порт опять начал использоваться заново. Даже на уровне пакетов достаточно долго не было попыток открыть новое соединение.

Выводы
gRPC — мощный протокол с кучей очень крутых фич, которые не грех перенять для взаимодействия по обычному HTTP. Кроме того, это очень быстрый протокол, который дает быструю сериализацию и десериализацию на базе Protobuf и низкое лейтенси за счет мультиплексирования на уровне HTTP/2.
gRPC — не только языковой, но и сетевой агностик, он может использовать коммуникацию, отличную от привычного TCP/IP-соединения, из-за этого он базируется на большом количестве абстракций.
Не стоит ожидать в нем тонкой настройки TCP-соединений: это вне протокола и надо крутить настройки OS и Netty как транспорта. Из-за всех этих абстракций и механизмов управления коммуникацией настройка gRPC оказывается весьма запутанной, в ней легко можно заблудиться и ошибиться.
Retry целиком полагаются на ошибки, приходящие от нижних уровней модели OSI. Без настроек типа Keepalive практически бесполезны.
Deadlines — это не таймауты, это точка на временной шкале, когда запрос должен быть обработан. Их скорее надо использовать как механизм для предотвращения перегрузки сервиса, а не для обработки сетевых ошибок.
Keep-alive — важная часть обеспечения надежности, но сервер может сбрасывать часто пингующих клиентов.
Скажу банальность, но помним про сбор метрик, особенно если используем gRPC в условиях высокой нагрузки.
Несмотря на мощь, встречаются неохваченные моменты, например нет логирования для ретраев.
После долгого исследования (оно реально заняло полгода минимум) вышли на проблемы в инфре. Как оказалось, из-за баги в цилуме нарушалась маршрутизация за NAT и некоторые соединения терялись. Лечить такое на клиенте можно только путем сброса соединения и открытия нового. После чтения Git стало понятно, что багу если вообще исправят, то произойдет это очень не скоро.
Отмечу, что все трюки, описанные в статье, мы успешно использовали и для HTPPp-соединений, так как и они страдали— только в меньшей степени.
Полезные ссылки:
Комментарии (3)
yokotoka
29.08.2025 15:28О, может вы подскажете — почему в grpc до сих пор нет нормального способа пользоваться им из браузерного приложения? В теории он транспорт-агностик, а на практике grpc-web до сих пор кусок нерабочего кринжа. Что мешает им нормально уже сделать двухсторонние стримы поверх ws/http2/quic?
UPD: Я нашёл вот такое "объяснение", но это же какой-то стыд и позор для серьёзной технологии, которой grpc пытается казаться
https://github.com/grpc/grpc-web/blob/master/doc/streaming-roadmap.mdСамая важная функциональность -- "мы не будем делать потому что потому, у http2 лапки, поэтому у нас тоже", при том что технологических органичений никаких нет, можно было бы запилить с полдюжины вариантов без проблем
UPD2: Похоже RSocket выглядит лучшей потенциальной альтернативой gRPC
https://rsocket.io/powerman
29.08.2025 15:28почему в grpc до сих пор нет нормального способа пользоваться им из браузерного приложения?
Потому что gRPC активно использует низкоуровневые фичи HTTP/2, доступа к которым браузер просто не предоставляет. Поэтому тут проблема не столько gRPC или HTTP/2, сколько специфических ограничений песочницы браузеров для JS. Как следствие из браузера приходится вместо настоящего gRPC использовать другие протоколы, которые можно конвертировать в/из gRPC, но этим конвертированием во-первых кто-то должен заниматься, и во-вторых использование "другого протокола" автоматически лишает возможности использовать море готовых инструментов созданных для gRPC и обычно ограничивает доступные возможности самого gRPC (напр. лишая возможности использовать двухсторонний stream и т.п.).
Я нашёл вот такое "объяснение"
Это не сам gRPC, это gRPC-Web - один из вышеупомянутых конвертеров. И да, он малополезен. На Go обычно используют https://github.com/grpc-ecosystem/grpc-gateway, и он достаточно хорош (для не-Go можно на Go написать простейший прокси-сервер на grpc-gateway, который будет принимать из веба запросы и проксировать их в реальный бэк уже по gRPC). Ещё есть Buf Connect https://connectrpc.com/ (но этим я пока не пользовался).
Плюс такого подхода - кроме мелкого модуля на grpc-gateway весь остальной бэк может быть написан на gRPC, а для фронта/браузера будет автоматически генерироваться привычный им swagger.yml (ведущий в grpc-gateway прокси). При этом мобильное приложение при желании может либо использовать тот же swagger.yml либо подключаться мимо этого прокси по обычному gRPC. Решение рабочее, удобное, но не идеальное - определённые ограничения по функционалу относительно полноценного gRPC есть, как и потеря скорости на проксировании (на сериализации/десериализации JSON).
powerman
Вы бы блок TL;DR добавили в начало статьи:
На gRPC обязательно нужно включать KeepAlive (с цифрами порядка 5-15 сек. для min_time/timeout/interval), причём делать это стоит и на клиенте и на сервере по умолчанию, не дожидаясь проблем. Если сервер мешает использовать такие KeepAlive на клиенте - нужно бить по попе его авторов. В абсолютном большинстве случаев этого будет достаточно.
Если будут проблемы то стоит задействовать встроенный loadbalance для открытия нескольких одновременных соединений, причём от этого может быть польза даже если инстанс сервера у вас один.
Если и этого окажется недостаточно, то на помощь придёт отправка нескольких запросов вместо одного с использованием первого ответа - но это в разы увеличит нагрузку на сервер и делать такое надо очень-очень осторожно.