Привет! Я Михаил Абраш — старший Go-разработчик, работаю в команде Evolution AI Factory в Cloud.ru. Недавно мы начали активно внедрять новые сервисы, переходя с Python на Go, и заметили, что нагрузка между репликами распределяется неравномерно. У нас в команде не было единого подхода к тому, как правильно делать балансировку, поэтому решили провести небольшое R&D-исследование. Что из этого вышло, к чему мы в итоге пришли и как работает балансировка gRPC в целом, рассказал в статье.

Почему нас не устраивала балансировка на L4
Балансировка на L4 работает на базе информации, которая доступна на транспортном уровне — обычно это порт или IP-адрес. По сути Куберовский сервис типа ClusterIP является именно таким балансировщиком. Обычно каждое новое tcp-соединение он отправляет на свой под по очереди или рандомно — в зависимости от настроек kube-proxy. При использовании http для каждого нового запроса устанавливается новая tcp-сессия. Соответственно, для обычных rest-сервисов вся балансировка работает «из коробки» — каждый новый http-запрос уходит на новый под.
gRPC же в качестве транспорта использует HTTP/2, который в свою очередь использует одно долгоживущее tcp-соединение. Что происходит в этом случае? Клиент создает соединение до одного из подов и держит его как можно дольше пока оно не разорвется. Так было и у нас: клиент подключался к одному из подов, все запросы шли на него, а другой под оставался без нагрузки.
Вот график RPS для одного из наших сервисов по подам: на одном из них 150, а на другом 13,5 RPS. Пару месяцев спустя у сервиса уже было несколько тысяч RPS, и если бы мы не наладили балансировку, то ситуация была бы еще хуже.

Такое распределение запросов ведет к:
неэффективному использованию ресурсов;
неработающему горизонтальному масштабированию;
неравномерной нагрузке, из-за которой возникают неочевидные ошибки на одной из реплик.
Этот расклад нас не устраивал, ведь мы хотели балансировать на уровне gRPC, чтобы каждый запрос улетал на свою реплику. Для этого нужно отделять один запрос от другого, а значит, нам нужна информация, доступная на более высоком уровне — L7.
Балансировка на уровне gRPC: варианты реализации
Было несколько вариантов, как реализовать gRPC-балансировку. В итоге мы выбрали третий способ.
Вариант 1. Балансировка на стороне сервера.
Можно было поднять реверс-прокси (например, Nginx) для каждого сервера и он бы балансировал между всеми репликами. Но сложность в том, что динамическими конфигурациями нужно как-то управлять, обратный прокси должен узнавать о новых репликах, их IP-адресах, на какую реплику и как направлять запрос и т. д. Несмотря на то, что есть решения, которые закрывают эти проблемы (например, Envoy), отдельный прокси нужно поднимать для каждого сервиса, настраивать и поддерживать его, что требует дополнительных рук. Согласитесь, сложно?
Вариант 2. Балансировка с помощью service mesh.
В этом случае Service Mesh добавляет к каждому поду sidecar, который выступает в качестве прокси — сам понимает, что и куда отправить, и практически сам балансирует. Вроде, вариант неплохой, но сложность в том, что не все у нас пользуются Service Mesh, затаскивать его ради одной балансировки слишком дорого.
Вариант 3. Балансировка на стороне клиента.
В этом случае клиент сам узнает обо всех репликах сервера, адресах и сам решает, какой какой запрос и куда отправить. Это вариант нам подошел лучше всего — мы смогли реализовать его с минимальными изменениями в коде и инфраструктуре, без оверхеда и дополнительного прокси.
Как устроен gRPC-клиент в Go
gRPC-библиотека для Go поддерживает балансировку на стороне клиента. Вот как это работает:
При создании клиента ему передается URL до сервера. По схеме, указанной в этом URL, клиент выбирает, какой резолвер будет использоваться.
Этот резолвер должен вернуть список IP-адресов до всех реплик этого сервера и конфиг.
Затем на основании этого конфига создается политика балансировки, которая отвечает за создание и поддержание в рабочем состоянии подканалов (subchannel) для этого канала — это абстракция над реальным tcp-соединением. После того как все подканалы созданы, политика возвращает state и picker. State — это объект, в котором хранятся все эти каналы. Picker — это компонент, который отвечает за выбор подканала для каждого RPC. В нем сосредоточена вся логика балансировки.
При каждом RPC-вызове вызывается Picker, который возвращает один из подканалов и этот канал используется для отправки RPC в нужную реплику.
Если схема не указана или указана как dns://, то будет использоваться DNS-резолвер. DNS-резолвер использует IP-адреса, которые вернул DNS-запрос.
В клиенте нам ничего не пришлось менять. Единственное, что нас не устраивало, это тот самый ClusterIP-сервис Kubernetes, который из коробки делает L4-балансировку. Почему? Потому что вместо подов он возвращает IP-адрес самого сервиса. Вместо стандартного ClusterIP-сервиса нам нужно было использовать headless-сервис, который возвращает список IP-адресов всех подов под этим сервисом.

А еще на клиенте мы указали, что нужно использовать round-robin балансировку, ведь по умолчанию используются стратегия pick first, которая по сути выбирает одну из реплик, чтобы отправить на нее все запросы. Round-robin же перебирает все реплики.
conn, err := grpc.NewClient(
"headless-service",
grpc.WithDefaultServiceConfig({"loadBalancingPolicy":"round_robin"}),
)
Ну и на сервере можно поставить настройку MaxConnectionAge, которая отвечает за то, чтобы сервер периодически разрывал коннекты. Это нужно, поскольку стандартный DNS-резолвер не будет делать ререзолв, не будет заново получать список адресов, если все коннекты живые и всё хорошо работает. А это значит, что если сервер скейлится вверх, то клиент не узнает о новых подах, ведь он просто не будет делать новый DNS-запрос. Если сервер дополнительно прервет одну из сессий, клиент увидит, что она оборвалась, сделает ререзолв и уже тогда узнает о новых подах.
grpcServer := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionAge: time.Minute * 10,
}),
)
И вот еще пару моментов о том, как работает re-resolve:
Делается во время создания клиента, а затем только при ошибке соединения.
Стандартный резолвер не будет делать запросы к DNS-серверу чаще, чем раз в 30 секунд. Таймаут можно изменить, но обычно он спасает DNS-сервер от «DDoS» при больших нагрузках.
Нужно следить за стратегией выкатки сервера.
С чем мы столкнулись: пример downtime
Пример конкретный, но такое может произойти и на других конфигурациях сервера. Причем происходит он из-за того самого таймаута в 30 секунд между re-resolve.
Есть сервер, у него две реплики, есть клиент, есть headless-сервис, клиент знает про обе реплики и шлет на них запросы по очереди. Всё хорошо работает.

Потом мы решили обновить сервер, выкатываем новую версию.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
Что делает Kubernetes? Он убивает один из подов, поднимает два новых. Почему он это делает? Потому что у нас стоит стратегия выкатки RollingUpdate c maxUnavaiable=1 — в любой момент времени может быть недоступен один из подов, и maxSurge=1 — максимальное количество подов для деплоя может быть превышено на одну реплику.

Что происходит дальше? Клиент теряет коннект до этого пода и пытается сделать re-resolve. После этого headless-сервис возвращает ему адрес пода №1, поскольку остальные поды еще не поднялись.

Клиент остается на этом поде и шлет запрос ничего не подозревая.

Затем поды становятся ready. Допустим это происходит довольно быстро, и кубер убивает оставшийся под №1.

Что происходит? Клиент пытается сделать re-resolve, но не может — таймаут еще не прошел. Он потерял коннект до оставшегося пода. Ошибки сыпятся пока не пройдет 30 секунд.

Но вот клиент заново делает re-resolve:

Получается даунтайм на ровном месте. Ничего не ломалось, сервер просто выкатился, а клиент 30 секунд не мог сделать запрос:

Как мы это пофиксили
Как мы можем это исправить? На самом деле легко. Ставим maxUnavaiable=0 — говорим Kubernetes, что у нас не может быть недоступных реплик в любой момент времени.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
Вместо диаграмм на этот раз посмотрим на реальный кластер. В левом окне манифест сервера, в правом верхнем список всех наших подов, в правом нижнем логи клиента — он шлет запросы к headless-сервису и записывает, какая из реплик ему ответила.
Сначала проверяем, что maxUnavaiable=0:

Затем делаем рестарт деплоя:

Вместо того, чтобы убить один из подов, Кубер сначала поднимает один, ждет, когда он поднимется, а затем убивает один из старых:

Когда он поднимается, клиент сидит на старых репликах, а как только под поднялся, клиент делает re-resolve и получает IP-адрес одного нового пода и одного старого пода. Запросы идут на них по очереди.

Затем поднимается оставшийся новый под и Кубер убивает старый. Клиент остается на одном поде, который уже никуда не денется из новой версии:

Проходит 30 секунд, клиент делает re-resolve и узнает о втором поде. Так он может балансировать между ними без даунтайма.

Что важно для балансировки gRPS: выводы
Для использования балансировки на стороне клиента достаточно сделать две вещи:
1. Указать использование headless-сервиса на клиенте и политику round-robin по умолчанию для балансировки:

2. Создать этот headless-сервис на сервере, проследить, что стратегия выкатки правильная и не будет неожиданностей. Поставить MaxConnectionAge 10 минут — по желанию.

Мы внедрили балансировку gRPS с минимальными изменениями в коде и в инфраструктуре. А вы можете развить этот подход и дальше — например, использовать более сложные стратегии балансировки, или вместо DNS написать свой резолвер и получать динамическую конфигурацию из стороннего сервиса.
А еще можно почитать полезные материалы по теме:
Или посмотреть полную версию моего доклада — Балансировка gRPC в Kubernetes.