Содержание урока · 14 разделов
- grpc load balancing: конвейер от target-строки до SubConn
- loadBalancingConfig против loadBalancingPolicy: как канал выбирает политику
- pickfirst: дефолт, который держит одно соединение
- round_robin: регистрация и балансировка по RPC
- Пикер: три исхода Pick и DROP без ретраев
- dns-резолвер: порт, authority, TXT и тридцать секунд
- Резолвер под свой каталог: discovery-стиль
- Клиентский LB против серверного
- Sticky sessions: почему в gRPC нет «приклейки» из коробки
- Чек-лист: load balancing
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
grpc load balancing: конвейер от target-строки до SubConn#
Клиентская балансировка grpc-вызовов начинается не с алгоритма «распределить по бэкендам», а с разбора строки цели. grpc.NewClient из пятого урока принимает target — URI со схемой резолвера; пакет resolver держит реестр «схема → Builder» (resolver.Register заполняет его в init(), resolver.Get ищет по имени), а Target — это структура с единственным полем URL url.URL, разобранным по RFC 3986. Док-комментарий Target обещает поведение на всякий случай: если схемы нет или она не зарегистрирована, клиент подставит схему по умолчанию и перепарсит цель заново. Дальше резолвер отдаёт resolver.State — и это не «список строк»: там лежат Addresses и Endpoints, а также ServiceConfig; документация service_config формулирует контракт целиком: name resolver, когда его просят разрешить имя сервера, «will return both the resolved addresses and the service config». Третий этаж — балансер: «Package balancer defines APIs for load balancing in gRPC». Builder.Build получает ClientConn-фасад и создаёт SubConn'ы: «A SubConn represents a single connection to a gRPC backend service» — внутри это HTTP/2-соединение, на котором мультиплексируются все вызовы. Важная деталь из док-комментария SubConn: «All SubConns start in IDLE, and will not try to connect. To trigger a connection attempt, Balancers must call Connect» — соединяться балансер заставляет сам. Четвёртый этаж — пикер: «Picker is used by gRPC to pick a SubConn to send an RPC», и вызывается он на каждый RPC. Балансер собирает новый пикер из текущего снимка состояния и отдаёт каналу через UpdateState(State{ConnectivityState, Picker}). Итого: канал — диспетчер, резолвер — источник данных, балансер — стратегия, пикер — мгновенное решение на конкретный вызов. Ниже пройдём по этажам с исходниками в руках.
Клиент: round_robin через service config
GO// cmd/client/main.go — включаем round_robin строкой service config.
const sc = `{
"loadBalancingConfig": [
{ "round_robin": {} } // первая зарегистрированная политика из списка
],
"methodConfig": [
{
// Дефолт для всех методов сервиса: ждать Ready и иметь свой срок.
"name": [{"service": "shop.v1.OrderService"}],
"waitForReady": true,
"timeout": "2s"
}
]
}`
// dns-резолвер вернёт все адреса orders.internal:50051, round_robin
// откроет по соединению на каждый и будет чередовать вызовы.
conn, err := grpc.NewClient("dns:///orders.internal:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(sc)) // конфиг-дефолт канала
if err != nil {
log.Fatal(err)
}
client := pb.NewOrderServiceClient(conn)
// Шесть вызовов подряд: посмотрите в логах серверов, как они распределятся.
for i := 0; i < 6; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
log.Printf("вызов %d: код %v", i, status.Code(err))
cancel()
}Три вещи, на которые стоит смотреть. Первая: round_robin включается полем loadBalancingConfig — массивом объектов, где ключ — имя политики, значение — её конфиг; у round_robin конфиг пустой. Вторая: конфиг, как и в пятнадцатом уроке, — обычная строка JSON, которую канал передаёт в политику; сюда же просится waitForReady из пятого урока — при старте клиента, пока резолвер ещё не ответил, вызовы подождут готовности вместо мгновенного Unavailable. Третья: сам вызов не меняется — балансировка происходит ниже вашего кода, между каналом и транспортом; клиентская логика видит только статус из тринадцатого урока.
loadBalancingConfig против loadBalancingPolicy: как канал выбирает политику#
В service config живёт пара полей, и разница между ними — источник тихих сюрпризов. Устаревшая форма — строка: «loadBalancingPolicy»: «round_robin»; каноническая — массив: «loadBalancingConfig»: [{ «round_robin»: {} }]. Механика разбора в service_config.go: если массива нет, берётся имя из строкового поля, а если и его нет — name := pickfirst.Name, то есть дефолтная политика называется pick_first. Дальше самое интересное: «if balancer.Get(name) == nil { name = pickfirst.Name }» — незнакомое имя в строковом поле НЕ роняет конфиг: канал молча подставит pick_first и поедет дальше. Вы написали «round_robn» с опечаткой? Ошибки не будет — просто балансировка не включится, и искать это придётся по поведению, а не по логам. С массивом правила другие: gracefulswitch.ParseConfig проходит по списку объектов, требует, чтобы в каждом был ровно один ключ («expected a JSON struct with one entry»), и для незнакомых имён говорит «Skip unregistered balancer names» — пропустить и смотреть следующего. Массив [{ «fancy_lb»: {} }, { «round_robin»: {} }] возьмёт round_robin: первый кандидат не зарегистрирован, второй — да. Но если НЕ зарегистрирован никто, разбор падает уже с ошибкой: «no supported policies found in config». Сам реестр политик — тот же паттерн, что у резолверов: balancer.Register в init(), и док-комментарий предупреждает: «If multiple Balancers are registered with the same name, the one registered last will take effect» — последний побеждает. В базовой поставке grpc зарегистрированы ровно две политики: pick_first и round_robin — второй импортируется в clientconn.go с честным комментарием «// To register roundrobin». Остальные — least_request_experimental, ring_hash_experimental, weighted_target и компания — лежат в репозитории, но регистрируются при импорте своих пакетов и в обычный канал без этого импорта не попадают; большинство из них рассчитано на xDS-инфраструктуру.
Две формы записи политики в service config
GO// Устаревшая форма: строка без настроек политики.
const legacy = `{
"loadBalancingPolicy": "round_robin"
}`
// Каноническая форма: массив политик по порядку предпочтения.
const modern = `{
"loadBalancingConfig": [
{ "fancy_lb": {} }, // не зарегистрирована — молча пропущена
{ "round_robin": {} } // первая зарегистрированная — и победит
]
}`
// Если в массиве не зарегистрирован НИКТО, конфиг отвергается с ошибкой
// «no supported policies found in config»; строка с опечаткой просто
// тихо вернётся к pick_first — разница, о которой стоит помнить.Обе строки — валидные service config'и, и разница между ними не в синтаксисе, а в поведении при ошибке: массив отдаёт разбор политику наилучшим из доступных вариантов и ругается только когда доступных нет вовсе, а строка с незнакомым именем молча деградирует до дефолта. Практическое правило: новые конфиги пишите массивом — он же единственный способ передать политике настройки (у pickfirst, например, есть shuffleAddressList).
Проверка: незнакомое имя политики
В service config указано только «loadBalancingPolicy»: «ring_hash», при этом пакет balancer/ringhash не импортировался. Что сделает канал?
pickfirst: дефолт, который держит одно соединение#
Что происходит, когда вы вообще не думали о балансировке? Работает pick_first — политика из balancer/pickfirst с const Name = «pick_first». Её стратегия — не распределение нагрузки, а живучесть: соединение устанавливается с первым доступным адресом и используется для всех вызовов. Правило порядка адресов сформулировано в док-комментарии SubConn: «gRPC will try to connect to the addresses in sequence, and stop trying the remainder once the first connection is successful» — адреса пробуются по очереди, и на первом успешном попытки прекращаются. Под этим комментарием стоит приписка «However, this behavior is deprecated. SubConns should only use a single address» — в современной модели каждый адрес получает собственный SubConn, а не один общий со списком; смысл остался тем же: один активный канал связи, остальные адреса — запасные. Детали из исходников pickfirst. Первый проход по списку — «happy eyeballs»: между попытками держится пауза connectionDelayInterval = 250 * time.Millisecond, чтобы не долбить все адреса разом; неудача первого прохода запоминается в connectionFailedInFirstPass. Список можно перемешать конфигом: pfConfig с полем ShuffleAddressList, json-ключ «shuffleAddressList» — полезно, когда резолвер возвращает адреса в одинаковом порядке для всех клиентов и первый адрес собрал бы всю нагрузку. Провал соединения вводит SubConn в TransientFailure с «липким» поведением из gRFC A62 — канал ждёт, пока политика не решит переподключиться. Для ваших вызовов это означает: упал единственный активный бэкенд — канал уходит в TransientFailure, и дальше включается знакомый по пятому уроку расклад: fail fast вернёт Unavailable сразу, WaitForReady(true) — подождёт восстановления. Смысл дефолта честный: одной SubConn хватает подавляющему большинству приложений, а «балансировать» начинают, когда бэкендов становится несколько и нагрузка перестаёт помещаться в одно соединение.
Три адреса и pick_first
Прочитайте код и выберите, что он выведет:
// service config политики НЕ задаёт — работает дефолт pick_first.
// dns-резолвер вернул три адреса в порядке A, B, C:
// A — сервер выключен, соединение отбивается;
// B и C — живые.
// Клиент делает один вызов GetOrder.round_robin: регистрация и балансировка по RPC#
Вторая политика из коробки — round_robin из balancer/roundrobin. Регистрация классическая: const Name = «round_robin» и balancer.Register(builder{}) в init(); док-комментарий пакета объясняет, почему импортировать его руками не нужно: «Roundrobin balancer is installed as one of the default balancers in gRPC, users don't need to explicitly install this balancer». За словом «roundrobin» в исходниках стоит двухэтажная конструкция. Внешний этаж — endpointsharding: пакет, который держит по дочернему балансеру на каждый endpoint из resolver.State. Внутренний этаж — pickfirst-лист на каждый endpoint: dns-резолвер отдаёт адреса без Endpoints, и сработает правило из resolver.go — «If a resolver sets Addresses but does not set Endpoints, one Endpoint will be created for each Address», то есть один адрес = один endpoint = один pickfirst-лист = одно соединение. Итог: round_robin по dns-адресам держит по одному соединению на каждый бэкенд. Само чередование делает пикер endpointsharding — два этажа механики в его исходнике: старт со случайного индекса next: uint32(randIntN(len(pickers))) и шаг на каждый вызов: «nextIndex := atomic.AddUint32(&p.next, 1)», затем «p.pickers[nextIndex%uint32(len(p.pickers))]» — атомарный счётчик и деление по модулю на число готовых детей. Дети делятся по состояниям: если есть хоть один Ready, пикер собирается только из готовых; нет готовых — из Connecting, затем Idle, затем TransientFailure. Два следствия, которые стоит осознать. Первое: распределение идёт по RPC, а не по соединениям — один вызов за другим честно обходят все живые бэкенды, включая HTTP/2-мультиплексирование внутри каждого соединения. Второе: старт со случайного индекса нужен, чтобы десять клиентов не начали с одного и того же первого бэкенда — та же идея, что у shuffleAddressList, только встроенная.
Проверка: механика чередования
round_robin обслуживает канал с тремя готовыми бэкендами. Как пикер выбирает SubConn для очередного вызова?
Пикер: три исхода Pick и DROP без ретраев#
Пикер — самая короткая функция в конвейере и самая важная: док-комментарий Picker требует «Pick should not block» — блокироваться в нём нельзя. Что может вернуть Pick? Три исхода. Первый — SubConn: вызов уезжает на выбранное соединение. Второй — balancer.ErrNoSubConnAvailable, и его док-контракт проговаривает ожидание: «ErrNoSubConnAvailable indicates no SubConn is available for pick(). gRPC will block the RPC until a new picker is available via UpdateState()» — вызов замирает до появления нового пикера; тот же эффект даёт SubConn, который ещё не Ready. Третий — ошибка: статусная ошибка завершает RPC немедленно с этим кодом, прочие ошибки при fail fast дают Unavailable, а с WaitForReady — ожидание; док-комментарий pick в picker_wrapper.go перечисляет случаи, когда вызов блокируется, и заканчивает фразой «pick blocks until the picker gets updated». Теперь то, ради чего стоило читать про пикер в уроке про ретраи. Статусная ошибка из пикера оборачивается в dropError — «dropError is a wrapper error that indicates the LB policy wishes to drop the RPC and not retry it», и getTransport в stream.go ставит попытке флаг a.drop = true. Этот флаг попадает в самую первую ветку shouldRetry, которую пятнадцатый урок цитировал как «… || a.drop { // RPC is finished or committed or was dropped by the picker; cannot retry» — пикер отбросил вызов, ретрая нет. Спека A6 фиксирует правило для обеих пикер-ошибок: если результат пикера DROP — «the RPC will fail immediately, without any retries», если TRANSIENT_FAILURE — «the RPC will be handled by the configured retry policy». Практический источник DROP — gRPCLB, lookaside-балансировщик: его пикер отдаёт статус Unavailable с сообщением «request dropped by grpclb» по drop-токенам из списка серверов. Вывод для дизайна: политика балансировки — единственное место, где вызов может умереть ДО ретрай-механики и умышленно, без второй попытки.
DROP против retryPolicy
Прочитайте код и выберите, что он выведет:
// service config канала:
// "loadBalancingConfig": [{ "grpclb": {} }],
// retryPolicy для GetOrder: maxAttempts 3,
// retryableStatusCodes: ["unavailable"]
//
// Пикер балансировщика отработал drop-токен и вернул:
// status.Errorf(codes.Unavailable, "request dropped by grpclb")
//
// Что получит клиент?dns-резолвер: порт, authority, TXT и тридцать секунд#
Пятый урок зафиксировал развилку: NewClient использует dns-резолвер по умолчанию, Dial — passthrough, «It sends the target name without scheme back to gRPC as resolved address». Док-комментарий GetDefaultScheme фиксирует дефолт дословно: если SetDefaultScheme не вызывался, «the default scheme used by grpc.NewClient is "dns" instead». Теперь внутрь dns. Регистрация — в init(): resolver.Register(NewBuilder()), Scheme возвращает «dns», а док-комментарий пакета честно зовёт его «to be installed as the default resolver». Порт: const defaultPort = "443" — цель без порта получит 443, а не ваш 50051, поэтому dns:///orders.internal означает orders.internal:443; всегда пишите порт явно. IP-цель обрабатывается без DNS-запроса: formatIP распознаёт литеральный адрес, резолвер сразу отдаёт его в State — и на этом заканчивается: для IP-цели возвращается deadResolver, который ничего не делает, watcher не запускается, переразрешений не бывает. Authority из цели работает: «dns://8.8.8.8/foo.googleapis.com» — часть между вторым и третьим слэшем выбирает DNS-сервер (newNetResolver строит net.Resolver с PreferGo и Dial на выбранную пару host:port; порт самого DNS — defaultDNSSvrPort "53"). Дальше — то, что делает dns-резолвер союзником балансировки: он не «разрешил и забыл», а живёт в горутине watcher и переразрешает имя по подсказкам ResolveNow — канал дёргает их, когда соединения в беде; повторное разрешение ограничено снизу MinResolutionInterval = 30 time.Second — «the minimum interval at which re-resolutions are allowed. This helps to prevent excessive re-resolution», на сам запрос отведено ResolvingTimeout = 30 time.Second, а после неудачи watcher уходит в экспоненциальный бэкофф из backoff.DefaultExponential. И главное для service config: резолвер запрашивает TXT-запись с префиксом txtPrefix = "_grpc_config." и ожидает атрибут «grpc_config=» — «In DNS, service config is encoded in a TXT record via the mechanism described in RFC-1464», схема из gRFC A2. Так конфиг с retryPolicy и loadBalancingConfig доезжает до клиента без единой строки кода; TXT-поиски включены по умолчанию — переменная окружения GRPC_ENABLE_TXT_SERVICE_CONFIG со значением false их отключает, как и WithDisableServiceConfig. Не хотите конфиг из сети — WithDisableServiceConfig: «causes gRPC to ignore any service config provided by the resolver and provides a hint to the resolver to not fetch service configs», причём дефолтный конфиг из WithDefaultServiceConfig продолжит работать.
Соберите канал с round_robin
Впишите ключ service config, схему резолвера, опцию канала и имя дефолтной политики.
Свой резолвер: manual в тесте
GO// Тест: ручной резолвер подсовывает каналу адреса, которых нет в DNS.
r := manual.NewBuilderWithScheme("lab") // своя схема
conn, err := grpc.NewClient("lab:///orders",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithResolvers(r)) // добавить builder в реестр канала
if err != nil {
log.Fatal(err)
}
// Публикуем первое состояние: два «бэкенда» на локалхосте.
r.InitialState(resolver.State{
Addresses: []resolver.Address{
{Addr: "127.0.0.1:50051"},
{Addr: "127.0.0.1:50052"},
},
})
client := pb.NewOrderServiceClient(conn)
// Позже список можно заменить целиком — канал узнает из UpdateState:
// r.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: "127.0.0.1:50053"}}})manual.Resolver — резолвер-заглушка для тестов и примеров: NewBuilderWithScheme создаёт Builder с вашей схемой, Build возвращает сам резолвер, InitialState и UpdateState публикуют resolver.State вручную, а колбэки BuildCallback/UpdateStateCallback позволяют тесту реагировать на действия канала. Опция WithResolvers добавляет builder в поиск канала. Механика ровно та же, что у dns: State с адресами едет в политику балансировки, дальше работают pickfirst или round_robin. В проде на место manual встаёт настоящий резолвер — о нём следующий блок.
Резолвер под свой каталог: discovery-стиль#
Интерфейс резолвера намеренно крошечный: Builder с методами Build(target Target, cc ClientConn, opts BuildOptions) и Scheme(), плюс Resolver с ResolveNow и Close. Реализация подписывается на источник правды и пушит resolver.State через cc.UpdateState — ошибка возврата просит повторить позже с бэкоффом, ReportError сообщает о проблеме разрешения. На этом строится discovery-стиль эксплуатации: резолвер опрашивает каталог сервиса — health-чеки Consul, etcd, Kubernetes Endpoints — и превращает список ЗДОРОВЫХ инстансов в Addresses. Клиент при этом ничего не знает о каталоге: для него «dns» и «consul» — просто схемы в target; док-пример NewClient даже показывает чужую схему «zookeeper://zk.example.com:9900/example_service» как легальную форму цели. Полезно посмотреть, что вы получаете бесплатно: Target.URL — обычный url.URL, значит query-параметры цели доступны резолверу (для опций вида consul://catalog?dc=dc1), метод Endpoint() отдаёт путь без ведущего слэша, а String() собирает каноническую строку scheme://host/endpoint. Ровно на этом стыке живёт тизер: в отдельном курсе по go-kit есть урок про discovery, где каталог и health-check'и становятся главным героем — там эта схема разбирается с Consul по-настоящему, от регистрации сервиса до фильтрации нездоровых инстансов; здесь же достаточно понимать: ваш резолвер — это сорок строк Go между каталогом и каналом. Отдельно запомните ограничение из док-комментария manual: каждый экземпляр резолвера работает только с одним каналом — «Every instance of the manual resolver may only ever be used with a single grpc.ClientConn». Забавная кухня напоследок: внутри resolver/map.go endpoint'ы сравниваются как множества, для чего адреса кодируются в base64 — «base64 encoding the address strings restricts the characters present within the strings», чтобы разделитель не требовал экранирования.
Проверка: незарегистрированная схема
grpc.NewClient("consul://localhost:8500/orders") вызван в программе, где резолвер со схемой consul НЕ зарегистрирован. Что произойдёт?
Клиентский LB против серверного#
| Аспект | Клиентский LB | Серверный LB |
|---|---|---|
| Где живёт решение | Резолвер и политика внутри процесса клиента; бэкенды известны каждому клиенту | Отдельный узел — L4/L7-балансировщик или lookaside-сервис; клиент видит один адрес |
| Что балансирует | Каждый RPC: пикер выбирает SubConn на вызов, соединений — по числу бэкендов | L4 — соединения, а не RPC: HTTP/2 упаковывает все вызовы в один TCP; L7-proxy видит :path и может делить по RPC |
| Отказ бэкенда | Замечен сразу соединением; пикер перестаёт отдавать мёртвый SubConn | Обнаруживает балансировщик по health-чекам; клиент не в курсе |
| Sticky sessions | Только консистентным хешированием или своим пикером | L4 — по TCP-соединению; по RPC — нужен L7, понимающий HTTP/2 |
| Конфигурация | Едет с service config: DNS TXT или WithDefaultServiceConfig | Инфраструктура; gRPCLB-lookaside отдаёт клиенту список бэкендов и drop-токены |
Sticky sessions: почему в gRPC нет «приклейки» из коробки#
Классический вопрос после внедрения round_robin: «а как направлять сессию пользователя всегда на тот же инстанс?» Честный ответ: в gRPC это не бесплатная опция, и вот почему. Первый слой проблемы — сам транспорт: HTTP/2 мультиплексирует все вызовы в одно TCP-соединение, поэтому L4-балансировщик, привыкший в мире HTTP/1 распределять соединения и «липнуть» к ним, здесь видит один длинный поток на клиента — балансировать по соединениям бессмысленно, все вызовы уже внутри. Второй слой — балансировка grpc на клиенте по определению бессессионна: round_robin кладёт соседние вызовы одного пользователя на разные бэкенды атомарным счётчиком, и никакого «пользователь ↔ соединение» не существует. Третий слой — куки не работают: в HTTP-мире sticky реализуют cookie-инъекцией, но куки — механизм веб-приложений; gRPC-прокси читают метаданные и :path, а решать по ним умеют далеко не все балансировщики. Что делать. Первый путь — перестать хотеть: сделать сервис stateless, состояние вынести в общее хранилище; это главная рекомендация, и балансировка по RPC тогда безопасна. Второй путь — консистентное хеширование: одинаковый ключ уезжает на одинаковый бэкенд при живых адресах; в репозитории grpc-go есть ring_hash_experimental (balancer/ringhash), рассчитанный на xDS-конфигурацию, где хеш-ключи задаются политиками hash по заголовкам. Третий путь — свой пикер: кастомная политика, которая читает PickInfo.Ctx, достаёт ключ сессии из метаданных десятого урока и возвращает всегда один и тот же SubConn — API балансера для этого открыт (Builder, ClientConn, Picker), а балансер-обёртки вроде endpointsharding показывают, как строить составные политики. Просто помните цену: sticky возвращает проблему «умёр бэкенд с сессиями» — держите под ней репликацию состояния, иначе отказоустойчивость, которую давал round_robin, исчезнет вместе с привязкой.
Первая: цель без порта — dns подставит дефолтный 443 (defaultPort), и ваш сервер на 50051 не найдётся; порт пишите всегда. Вторая: опечатка в loadBalancingPolicy — не ошибка, а тихий откат к pick_first («if balancer.Get(name) == nil { name = pickfirst.Name }»); проверяйте, что политика вообще включилась. Третья: пустой список адресов — не ошибка резолвера: канал уйдёт в TransientFailure, и fail fast из пятого урока вернёт Unavailable на вызовах без WaitForReady. Четвёртая: DROP-пикер не проходит через retryPolicy — флаг a.drop выводит shouldRetry в ветку cannot retry; ретраи из пятнадцатого урока тут бессильны по замыслу. Пятая: round_robin выравнивает ЧИСЛО вызовов, а не нагрузку — тяжёлый метод на одном бэкенде перегреет его и при честных долях; для чувствительных сервисов смотрите в сторону least_request-политик и серверной отчетности о нагрузке.
Чек-лист: load balancing#
- Конвейер канала: target-строка → резолвер → LoadBalancer → SubConn → пикер; у каждого этажа свои API и свои ошибки.
- Резолвер возвращает и адреса, и service config — «will return both the resolved addresses and the service config».
- NewClient по умолчанию dns, Dial — passthrough; незарегистрированная схема не падает, а перепарсивается через схему по умолчанию.
- dns добавляет порт 443, если порт не указан; dns://8.8.8.8/host выбирает DNS-сервер из authority; повторное разрешение — не чаще раза в 30 секунд.
- Политика берётся из loadBalancingConfig (массив, побеждает первая зарегистрированная) или устаревшего loadBalancingPolicy (строка, незнакомое имя — тихо pick_first).
- pick_first — дефолт: адреса по очереди, одно активное соединение, пауза 250 мс между попытками первого прохода, перемешивание через shuffleAddressList.
- round_robin зарегистрирован из коробки: по SubConn на адрес, чередование готовых детей атомарным счётчиком с модулем, старт со случайного индекса, распределение по RPC.
- Пикер не блокируется: ErrNoSubConnAvailable ставит вызов в ожидание нового пикера, статус-ошибка завершает RPC немедленно — так работает DROP.
- DROP-пикер не ретраится: a.drop = true выводит shouldRetry в ветку cannot retry — политика повторов не запускается вовсе.
- Sticky sessions не бесплатны: stateless-сервисы, консистентное хеширование (ring_hash через xDS) или свой пикер с ключом из метаданных.
Путь RPC: от target-строки до SubConn
Расставьте этапы жизни вызова в порядке, в котором их проходит канал.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: load balancing
Что дальше по курсу#
Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять резолвер и три бэкенда в браузере нельзя, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за вечер на паре из четвёртого и пятого уроков. Три эксперимента. Первый: поднимите два инстанса сервера на разных портах, включите round_robin через service config и посчитайте в перехватчике из одиннадцатого урока, как распределились вызовы; затем выключите один инстанс и убедитесь, что вызовы перетекли без ошибок клиента. Второй: уберите порт из цели dns:///orders.internal и прочитайте ошибку соединения — там всплывёт 443; верните порт и сравните. Третий: возьмите manual-резолвер из этого урока, опубликуйте список из «мёртвого» первого адреса и живого второго, включите pick_first — по логам соединений увидите happy-eyeballs-проход и выбор второго адреса; затем поменяйте список через UpdateState и понаблюдайте переключение. Дальше по маршруту: семнадцатый урок — health checking protocol: grpc_health_v1, серверные health-сервисы и probes; связь с сегодняшним материалом прямая — балансировщики умеют слушать здоровье бэкендов через health listener (round_robin включает его для своих pickfirst-детей комментарием «Enable the health listener in pickfirst children for client side health checks»), и нездоровый инстанс исчезает из пикера, не дожидаясь обрыва соединения. Перед ним перечитайте урок про ретраи — DROP-ветку shouldRetry вы теперь видели с обеих сторон, и урок про клиент — target-синтаксис и состояния канала. Полный маршрут курса — на странице gRPC-Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Клиентский LB — конвейер из четырёх этажей: target-строка, резолвер, LoadBalancer, пикер; SubConn — соединение до конкретного бэкенда, а пикер выбирает SubConn для каждого RPC.
- Резолвер возвращает resolver.State: адреса, endpoints и service config; в DNS конфиг приезжает TXT-записью _grpc_config с атрибутом grpc_config= (RFC-1464, gRFC A2).
- NewClient по умолчанию dns, Dial — passthrough; незарегистрированная схема перепарсивается через дефолтную; dns подставляет порт 443, умеет authority с DNS-сервером и переразрешается не чаще раза в 30 секунд.
- Политика балансировки берётся из loadBalancingConfig (массив, побеждает первая зарегистрированная; все незнакомые — ошибка no supported policies) или из строкового loadBalancingPolicy (незнакомое имя тихо даёт pick_first).
- pick_first — дефолт: адреса по очереди, одно активное соединение, happy-eyeballs-пауза 250 мс, перемешивание списка конфигом shuffleAddressList.
- round_robin построен на endpointsharding с pickfirst-детьми; пикер чередует готовых детей атомарным счётчиком по модулю со случайным стартом — распределение по RPC, по соединению на бэкенд.
- Pick не блокируется: ErrNoSubConnAvailable — ждать новый пикер, статус-ошибка — умереть сразу; DROP-пикер (dropError, a.drop = true) обходит retryPolicy — «fail immediately, without any retries».
- Sticky sessions в gRPC не бесплатны: HTTP/2 сводит клиента к одному TCP-потоку, куки не работают; пути — stateless, консистентное хеширование (ring_hash через xDS) или свой пикер с ключом из метаданных.
Домашнее задание
Соберите лабораторию балансировки на паре сервер/клиент из четвёртого и пятого уроков. Первое: поднимите три инстанса OrderService на портах 50051-50053, включите round_robin через WithDefaultServiceConfig и прогоните двадцать вызовов GetOrder; в перехватчике из одиннадцатого урока логируйте peer-адрес и постройте распределение — убедитесь, что каждый инстанс получил примерно равную долю и что стартовый обход начался не с первого инстанса. Второе: выключите второй инстанс и повторите серию — ошибок клиент показать не должен, а доля вызовов должна перетечь на живых; затем поднимите инстанс обратно и проверьте, что он вернулся в ротацию. Третье: переключите политику на pick_first тем же конфигом и объясните по логам, почему теперь все вызовы идут на один инстанс; уроните его и убедитесь, что клиент переехал на следующий адрес списка, а не распределил нагрузку. Четвёртое: уберите порт из цели dns:///orders.internal и прочитайте ошибку соединения — найдите в ней 443; верните порт. Пятое: напишите тест с manual-резолвером (NewBuilderWithScheme, InitialState, WithResolvers), опубликуйте адрес «мёртвого» бэкенда и живого, включите pick_first и по логам соединений проследите happy-eyeballs-проход; затем через UpdateState замените список адресов и убедитесь, что канал перестроил соединения. Шестое: пропишите в конфиге loadBalancingPolicy с опечаткой (например, «round_robn») и докажите по поведению, что канал молча работает на pick_first; затем перенесите имя в loadBalancingConfig с несуществующей политикой и round_robin по соседству — проверьте, что сработал второй вариант, а после удаления round_robin из массива конфиг отвергается с «no supported policies found in config».
Личные заметкиПусто
Вопросы и ответы по уроку
Как включить round_robin и почему после включения ничего не меняется?
Политика задаётся в service config: loadBalancingConfig: [{ "round_robin": {} }] — через WithDefaultServiceConfig или через DNS TXT (grpc_config=). Если эффекта нет, проверьте три вещи. Первая: конфиг вообще доезжает — строковое поле loadBalancingPolicy с опечаткой молча даёт pick_first («if balancer.Get(name) == nil { name = pickfirst.Name }»), ошибок не будет. Вторая: у резолвера несколько адресов — если dns возвращает один адрес, чередовать нечего, round_robin вырождается в одно соединение; посмотрите, что за записи в DNS и сколько бэкендов за именем. Третья: распределение видно только по бэкендам — логируйте peer-адрес в перехватчике или stats handler, а не результат вызова. И помните: у round_robin конфиг пустой объект — настраивать в нём нечего, весь тюнинг живёт в methodConfig рядом.
Клиентская балансировка или серверный балансировщик — что выбрать?
Клиентская (pick_first, round_robin, свой резолвер) убирает лишний хоп: клиент соединяется с каждым бэкендом напрямую, знает об их отказах немедленно и делит вызовы по RPC; цена — каждый клиент должен уметь резолвить и держать несколько соединений, а правила едут service config'ом. Серверный балансировщик (L4-типа NLB или L7-типа Envoy) проще эксплуатировать: один адрес, клиенты наивные, health-чеки централизованы; но L4 в мире HTTP/2 балансирует соединения, а не вызовы — все RPC клиента упакованы в один TCP, и перекосы неизбежны, поэтому для gRPC L4-хоп часто бессмыслен, а L7-proxy обязан понимать HTTP/2. Lookaside-вариант gRPCLB — гибрид: отдельный сервис отдаёт клиенту список бэкендов и drop-токены, а соединения клиент держит сам; его drop-пикер вы уже видели — «request dropped by grpclb», и ретраи такие вызовы не повторяют. Практика: небольшой кластер и свой каталог — клиентская схема; многоязычные клиенты и жёсткий контроль — L7-proxy; крупная инфраструктура Google-стиля — lookaside.
Почему sticky sessions в gRPC не работают «из коробки» и что делать?
Потому что в gRPC нет объекта, к которому можно приклеиться. Соединения — TCP — обрабатывают много вызовов сразу (HTTP/2 мультиплексирование), так что sticky по соединению либо ничего не даёт (клиентская балансировка вообще не знает про ваши сессии), либо вреден (L4 видит один поток на клиента и не может делить по RPC). Куки — механизм веб-приложений: gRPC-прокси оперируют метаданными и :path, а cookie-инъекция в бинарный протокол не пролезает. Решения три. Первое — правильное: сделать сервис stateless, вынеся состояние в общее хранилище; тогда привязка не нужна, а round_robin безопасен. Второе — консистентное хеширование: в репозитории есть ring_hash_experimental, обычно включаемый через xDS, где ключи хеширования задаются политиками по заголовкам вызова. Третье — своя политика балансировки: открытый API (balancer.Builder, ClientConn, Picker) позволяет прочитать PickInfo.Ctx, взять ключ сессии из метаданных и всегда возвращать один SubConn. Любой sticky возвращает классическую беду — отказ бэкенда вместе с его сессиями, так что под привязкой обязана лежать репликация состояния.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки