gRPC и protobuf: скомпилированный контракт, транспорт HTTP/2 и балансировщик, не заметивший новые поды
Файл .proto компилируется в заглушки клиента и сервера; контракт провода — теги полей, а не имена, и теги 1-15 кодируются одним байтом. HTTP/2 мультиплексирует все RPC по одному соединению — L4-балансировщик прижимает нагрузку к одному бэкенду; дедлайны распространяются нативно.
На пике флеш-распродажи команда чекаута отмасштабировала сервис инвентаря с трёх подов до десяти — и ничего не изменилось: три пода упёрлись в 95% CPU, семь новых простаивали на 2%. Перед ними стоял обычный Kubernetes ClusterIP — балансировщик четвёртого уровня, который распределяет TCP-соединения. Каждый gRPC-клиент открыл ровно одно HTTP/2-соединение при старте и мультиплексировал по нему все RPC, так что балансировщик принял своё единственное решение по каждому клиенту ещё несколько дней назад; новые поды не увидели ни одного стрима. Кто-то перезапустил клиентский под, чтобы «починить», — его свежее соединение приземлилось на и без того горячий бэкенд, стало хуже. Настоящий фикс занял две строки: headless-Service, чтобы DNS возвращал IP всех подов, и round_robin в service config клиента, чтобы сам gRPC-канал балансировал каждый вызов. Фраза из постмортема, которая запомнилась: мы расширили пул, но протокол уже решил, кто будет работать.
Контракт компилируется в обе стороны
К концу этого раздела вы будете точно знать, почему переименование поля бесплатно, а смена тега — нет, и почему это различие определяет, что видит балансировщик.
REST-контракты живут в документации и расползаются; gRPC-контракт — это файл, который компилируется в обе стороны провода:
syntax = "proto3";
package inventory.v1;
service Inventory {
rpc GetStock(GetStockRequest) returns (StockReply);
rpc WatchStock(WatchRequest) returns (stream StockReply); // серверный стриминг
}
message GetStockRequest {
string sku = 1; // тег 1 — именно число и ЕСТЬ идентичность поля
string warehouse = 2;
}Запустите buf generate (или голый protoc с Go-плагинами) — и получите два артефакта: типизированную клиентскую заглушку, чьи методы сериализуют, отправляют и декодируют за вас, и серверный интерфейс — InventoryServer, — который обязан реализовать ваш код. Согласие двух команд о форме данных обеспечивает компилятор, а не страница в вики.
Деталь, которая меняет само мышление о protobuf: имена полей никогда не попадают на провод. Каждое закодированное поле — это ключ (номер тега, сдвинутый на три бита влево и объединённый по OR с wire type — типом кодирования, однозначно задающим размер значения), за которым идёт значение. Переименуйте sku в stock_unit — байты идентичны; поменяйте тег с 1 на 9 — вы молча создали другое поле. Ключ — varint, и поэтому значения тегов важны: теги с 1 по 15 укладывают ключ в один байт, с 16 по 2047 — в два. В сообщении, которое логируется миллионы раз в минуту, горячее repeated-поле с тегом 16 вместо тега 3 — это измеримый налог на хранилище. Планируйте пространство тегов как API: маленькие теги — горячим полям, и оставляйте зазоры на будущее.
Честные числа: protobuf-нагрузка обычно выходит в 2–5 раз меньше эквивалентного JSON и парсится примерно в 5–10 раз быстрее, потому что декодирование — арифметика над тегированными байтами, а не сканирование текста. Но gzip съедает большую часть выигрыша по размеру на крупных пейлоадах — сжатый JSON часто оказывается в пределах 1,5–2 раз от сжатого protobuf. Что переживает компрессию — стоимость парсинга, поведение аллокаций и сгенерированные типы: против gRPC-API никто не пишет археологию на json.RawMessage.
Коллега переименовывает protobuf-поле sku в stock_unit, сохранив тег 1, перегенерирует Go-код и деплоит только сервер. Что произойдёт со старыми клиентами, которые шлют старое поле?
Одно соединение, мультиплексирование — и что видит балансировщик
gRPC работает поверх HTTP/2: клиент открывает одно TCP-соединение, и каждый RPC становится на нём стримом, кадры конкурентных вызовов свободно перемежаются. Ни рукопожатия на каждый вызов, ни тюнинга пула соединений, и запрос 2 не ждёт запроса 1 на уровне HTTP (потеря пакета всё ещё стопорит все стримы на уровне TCP — HTTP/2 починил HTTP-шный head-of-line blocking, но не TCP-шный).
У этой эффективности есть продакшен-следствие, за которое заплатили в крючке. Балансировщик четвёртого уровня — Kubernetes ClusterIP, AWS NLB — балансирует в момент приёма соединения. gRPC-клиент предъявляет ровно одно долгоживущее соединение (headless-Service — сервис без кластерного IP, чей DNS возвращает IP каждого пода), поэтому все его RPC прижимаются к бэкенду, который его принял. Масштабирование добавляет поды, с которыми ни один существующий клиент никогда не заговорит. Реальных опций три:
- Клиентская балансировка: headless-Service плюс
"loadBalancingConfig": [{"round_robin":{}}]— канал держит под-соединение на каждый бэкенд и раскладывает вызовы. Нюанс: пере-резолв DNS ленивый; добавьте серверныйMaxConnectionAge, чтобы соединения перерождались и находили новые поды. - L7-прокси (Envoy, Linkerd): прокси говорит на HTTP/2 с обеих сторон и балансирует по стримам, а не по соединениям. Это ответ сервис-меша, ценой лишнего хопа.
- Не делать ничего — тоже решение: нормально для двух подов и фатально для автоскейлинга.
Все три варианта покрывают спектр от нулевых изменений инфраструктуры до полного сервис-меша. Когда вы масштабируете gRPC-сервис, а трафик не перераспределяется — почти всегда причина в том, что третий вариант был выбран неосознанно.
Четыре типа вызовов, путешествующие дедлайны и коды, которые врут дашбордам
Ключевое слово stream обобщает RPC до четырёх форм: unary (один запрос, один ответ — большинство вызовов), серверный стриминг (один запрос, много ответов: вотчи, фиды, выдача огромного результата без буферизации), клиентский стриминг (много запросов, один ответ: загрузки, батчинг телеметрии) и двунаправленный (оба сразу: чат, протоколы синхронизации, живая транскрипция). Сгенерированный Go-код даёт стримам методы Send и Recv поверх уже знакомой машинерии контекстов.
Дедлайны — то место, где gRPC тихо обыгрывает самодельные HTTP-конвенции. Поставьте дедлайн на клиентский контекст — и он передастся заголовком grpc-timeout в вызове, а также в каждом вызове ниже по цепочке из этого контекста, с оставшимся бюджетом. Дедлайн 500 мс на краю доезжает до третьего хопа тем, что от него осталось, и все сервисы падают быстро и вместе с DeadlineExceeded. С обычным HTTP вы переизобретаете это кастомными заголовками и командной дисциплиной; в gRPC это встроено в рантайм.
Ещё две вещи, на которых обжигаются сениоры. Первое: статус-коды gRPC — не HTTP-коды; HTTP-слой почти всегда говорит 200, а настоящий вердикт — OK, Unavailable, DeadlineExceeded, NotFound — едет в трейлере grpc-status. Дашборд, считающий HTTP 200 у gRPC-сервиса, не измеряет ничего. Второе: сквозные задачи живут в интерсепторах — это middleware gRPC, клиентские и серверные, unary- и stream-варианты:
func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
next grpc.UnaryHandler) (any, error) {
if err := verifyToken(ctx); err != nil {
return nil, status.Error(codes.Unauthenticated, "bad token")
}
return next(ctx, req) // цепочка продолжается — та же форма, что у http-middleware
}▸Почему это работает
Почему wire-формат вообще использует varint-ключи, а не идентификаторы полей фиксированной ширины? Потому что protobuf проектировался под сообщения из маленьких целых и коротких строк, логируемые в масштабах Google: однобайтовый ключ для пятнадцати самых горячих полей выигрывает у единообразного четырёхбайтового 20–30% на типичных пейлоадах, а декодер остаётся тесным циклом «прочитай varint, свитч по wire type». Цена — правило, вокруг которого вращается весь этот юнит: номер тега священен, потому что это единственная идентичность поля.
Edge-сервис вызывает orders с дедлайном 500 мс; orders тратит 300 мс и вызывает pricing с тем же ctx. Какой дедлайн увидит pricing и через какой механизм?
- 01Объясни на уровне кодирования, почему переименование protobuf-поля безопасно для провода, а смена тега — нет.
- 02gRPC-сервис за Kubernetes ClusterIP не перебалансируется при масштабировании подов. Разбери механизм и два исправления.
gRPC переворачивает место жизни контракта: вместо документации, описывающей HTTP-API, файл .proto компилируется в клиентскую заглушку и серверный интерфейс, и разногласие команд становится ошибкой сборки. На проводе поля — это ключи «тег плюс wire type», а имена — декорация, не покидающая процесс: поэтому переименования бесплатны, а смена тега — смена контракта. Varint-ключи делают теги с 1 по 15 однобайтовыми, так что горячим полям — маленькие номера. Честная производительность: в 2–5 раз меньше JSON и в 5–10 раз быстрее парсинг, но gzip сужает разрыв по размеру; долговечные выигрыши — стоимость парсинга и сгенерированные типы. Транспорт — HTTP/2, каждый RPC — стрим в одном соединении: эффективно и прямо отвечает за классический инцидент, когда масштабирование подов ничего не меняет, потому что L4-балансировщик раздаёт целые соединения; лечится клиентским round_robin поверх headless-DNS с MaxConnectionAge или L7-прокси, балансирующим по стримам. Ключевое слово stream даёт четыре формы вызова — unary, серверный стриминг для вотчей и фидов, клиентский для загрузок, bidi для чатоподобных протоколов. Дедлайн, поставленный на контекст, едет как grpc-timeout с остатком бюджета на каждом хопе, и всё дерево вызовов падает вместе — то, что HTTP-команды криво делают руками. Статус-коды живут в трейлерах, пока HTTP рапортует 200, так что дашборды обязаны считать grpc-status, а сквозную логику несут интерсепторы — ровно как обёртки http.Handler. Теперь, когда вы увидите, что новые поды стоят без дела, а старые перегружены, вы сразу проверите, как клиент открывал соединение — а не будете перезапускать поды вслепую.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.