Компьютерные сети RPC и gRPC: протобуф, стриминг, сравнение с REST
0%

RPC и gRPC: протобуф, стриминг, сравнение с REST

RPC и gRPC: протобуф, стриминг, сравнение с REST

Идея RPC соблазнительно проста: пусть вызов чужой функции на другой машине выглядит как вызов своей. user := client.GetUser(ctx, id) — и всё, никаких сокетов, сериализации, ретраев. Идея настолько хороша, что индустрия переизобретала её каждые десять лет: Sun RPC, CORBA, DCOM, XML-RPC, SOAP, Thrift, gRPC. И каждые десять лет натыкалась на одно и то же: сетевой вызов не является локальным вызовом и никогда им не станет.

Классический разбор этого заблуждения — «A Note on Distributed Computing» Уолдо, Уайанта, Волрата и Кендалла (Sun Labs, 1994). Их тезис: разница между локальным и удалённым вызовом не количественная (медленнее), а качественная — четыре несводимых различия:

  1. Задержка. Локальный вызов — единицы наносекунд, удалённый — сотни микросекунд внутри дата-центра и десятки миллисекунд между континентами. Разница в 10^4–10^6 раз меняет не константу, а сам дизайн: цикл for _, id := range ids { c.Get(id) } на 1000 элементов внутри процесса стоит микросекунды, а по сети — 30 секунд.
  2. Отдельная память. Указатель через границу процесса не имеет смысла. Значит, аргументы копируются, значит нужна сериализация, значит нужна схема, значит появляется версионирование.
  3. Частичный отказ. Локальная функция либо вернулась, либо процесс упал вместе с вызывающим. Удалённая может «не ответить», и вы принципиально не отличите «сервер не получил запрос» от «сервер выполнил и потерялся ответ». Отсюда идемпотентность — см. доставка и идемпотентность.
  4. Конкурентность. Удалённый объект обслуживает много клиентов одновременно; ваши предположения о порядке и атомарности не выполняются.

Все успешные RPC-фреймворки — те, что не прячут эти четыре различия, а дают их контролировать: дедлайны для (1), схему для (2), явные коды ошибок и политику ретраев для (3), стримы и backpressure для (4). gRPC — именно такой; в этом его отличие от CORBA, которая всерьёз пыталась сделать сеть прозрачной.

Статья предполагает, что вы прошли TCP, HTTP (особенно раздел про HTTP/2) и TLS. Общая карта — в обзоре трека. Сравнение стилей API с точки зрения архитектуры — в API styles; здесь мы лезем в байты.

Сорок лет попыток

Обратите внимание на закономерность: выживали те протоколы, которые проходили сквозь существующую инфраструктуру. CORBA/IIOP умерла в том числе потому, что корпоративные фаерволы её не пропускали, а XML-RPC и SOAP жили именно потому, что маскировались под обычный HTTP. gRPC повторил этот же трюк: он не изобретал транспорт, а сел на HTTP/2, который уже проходил везде, где проходил HTTPS. Тот же урок мы видели у QUIC, которому пришлось притвориться UDP-трафиком.

Protocol Buffers: схема, из которой растёт всё остальное

gRPC — это транспорт и семантика вызова. Данные же описывает protobuf, и его стоит понимать отдельно: protobuf прекрасно живёт без gRPC (в Kafka, в файлах, в кэше), а понимание wire-формата — это то, что отличает «работает» от «понимаю, почему сломалось».

syntax = "proto3";
package orders.v1;

option go_package = "github.com/acme/api/gen/orders/v1;ordersv1";

import "google/protobuf/timestamp.proto";

service OrderService {
  // Унарный: один запрос — один ответ.
  rpc GetOrder(GetOrderRequest) returns (Order);
  // Серверный стрим: подписка на изменения статуса.
  rpc WatchOrder(GetOrderRequest) returns (stream OrderEvent);
  // Клиентский стрим: загрузка позиций большой партией.
  rpc BulkAddItems(stream OrderItem) returns (BulkAddSummary);
  // Двунаправленный: интерактивный подбор.
  rpc Negotiate(stream Offer) returns (stream Offer);
}

message GetOrderRequest {
  string order_id = 1;
}

message Order {
  string id = 1;
  string customer_id = 2;
  Status status = 3;
  repeated OrderItem items = 4;
  google.protobuf.Timestamp created_at = 5;
  // Явное присутствие: отличаем «скидки нет» от «поле не прислали».
  optional int32 discount_percent = 6;

  enum Status {
    STATUS_UNSPECIFIED = 0;  // нулевое значение обязано быть «неизвестно»
    STATUS_NEW = 1;
    STATUS_PAID = 2;
    STATUS_SHIPPED = 3;
  }

  reserved 7, 9 to 11;             // номера удалённых полей — навсегда заняты
  reserved "legacy_total";         // и их имена тоже
}

Три правила, которые экономят годы боли:

  • package содержит версию (orders.v1). Полное имя метода — /orders.v1.OrderService/GetOrder — попадает в :path HTTP/2 и становится частью публичного контракта. Версия в пакете позволяет запустить v1 и v2 бок о бок на одном порту.
  • Нулевое значение enum — _UNSPECIFIED. В proto3 отсутствующее поле неотличимо от нуля, поэтому нулём должно быть «не знаю», а не осмысленный статус. Иначе старый клиент, не приславший поле, молча создаст заказ в статусе NEW.
  • reserved при удалении поля — не опция, а обязанность. Номер поля — единственный идентификатор на проводе. Если через год кто-то переиспользует номер 7 под bool is_test, старые сообщения с string legacy_total начнут декодироваться в мусор без единой ошибки.

Байты на проводе

Раскладка protobuf-сообщения по байтам: теги, varint, длины

Модель предельно простая: сообщение — это последовательность пар «тег — значение», больше ничего. Тег — это varint, в котором младшие 3 бита — тип кодирования, а остальное — номер поля.

Wire type Значение Типы
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, вложенные сообщения, packed-повторяющиеся поля
5 I32 fixed32, sfixed32, float

Типы 3 и 4 (SGROUP/EGROUP) — устаревшие группы из proto2, встречаются только в древнем коде.

Проверим это руками, без единой строчки кода. Нам понадобится protoc:

# Собираем те же 10 байт вручную и просим protoc разобрать их без схемы
$ printf '\x08\x96\x01\x12\x03Ann\x18\x01' | protoc --decode_raw
1: 150
2: "Ann"
3: 1

--decode_raw работает без .proto — именно потому, что структура самоописательна на уровне «номер + тип», но не на уровне «что это значит». Отсюда главный компромисс protobuf: без схемы вы видите 2: "Ann", но не знаете, что это name, а не city.

Со схемой:

$ printf '\x08\x96\x01\x12\x03Ann\x18\x01' | protoc --decode=User user.proto
id: 150
name: "Ann"
active: true

Ещё удобнее — protoscope, инструмент от самой команды protobuf:

$ printf '\x08\x96\x01\x12\x03Ann\x18\x01' | protoscope
1: 150
2: {"Ann"}
3: 1

Практические следствия побайтовой модели, которые реально влияют на прод:

  • Номера полей 1–15 стоят один байт тега, 16–2047 — два. В сообщении, которое летает миллионами в секунду, отдайте мелкие номера горячим полям, а редкие метаданные уводите за 16.
  • Отрицательные int32 занимают 10 байт. Varint не знает про знак, поэтому −1 кодируется как 0xFFFFFFFFFFFFFFFF. Для величин, которые бывают отрицательными, берите sint32/sint64 — там zigzag: (n << 1) ^ (n >> 31), то есть −1 → 1, 1 → 2, −2 → 3, и маленькие по модулю числа остаются короткими.
  • Отсутствующее поле не занимает ничего. Разреженные сообщения дёшевы; поэтому «широкая» схема с сотней опциональных полей на практике не так страшна, как кажется.
  • Порядок полей не гарантирован, дубликаты допустимы. Для скалярного поля побеждает последнее вхождение, для repeated — конкатенируется, для вложенного сообщения — сливается. Это позволяет склеивать protobuf конкатенацией байтов, но это же означает, что сериализация не канонична.
  • Из предыдущего пункта: никогда не подписывайте и не хэшируйте сериализованный protobuf. Порядок ключей в map в принципе не детерминирован, разные версии библиотек могут разложить байты иначе, а неизвестные поля сохраняются как есть. Подписывайте отдельное каноническое представление или подписывайте вместе с байтами, которые вы получили, а не с теми, что вы пересериализовали.

Эволюция схемы: что можно, чего нельзя

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

Безопасно Ломает совместимость
Добавить новое поле с новым номером Изменить номер существующего поля
Удалить поле, добавив его номер в reserved Удалить поле без reserved и потом переиспользовать номер
Переименовать поле (имя на wire не едет) Сменить тип с несовместимым wire type: int32string
Менять между int32/int64/uint32/uint64/bool Менять int32sint32 (varint против zigzag)
stringbytes, если содержимое — валидный UTF-8 fixed32int32 (I32 против VARINT)
Добавить значение в enum (в proto3 enum «открытый») Вынести поле из oneof или перенести между разными oneof
Обернуть одиночное поле в новый oneof с единственным членом Изменить repeated ↔ одиночное для VARINT/I32/I64

Важнейшая деталь: неизвестные поля сохраняются. Прокси, который читает сообщение, меняет одно поле и пересылает дальше, не потеряет поля, о которых не знает его версия схемы. В ранних версиях proto3 (до 3.5) их выбрасывали — это привело к реальным потерям данных в read-modify-write цепочках, и поведение вернули к proto2-семантике.

Проверять совместимость руками бессмысленно — это работа CI. Стандарт де-факто — buf:

$ buf lint
proto/orders/v1/order.proto:23:3:Field name "orderID" should be lower_snake_case, such as "order_id".

$ buf breaking --against '.git#branch=main'
proto/orders/v1/order.proto:14:3:Field "3" with name "status" on message "Order" changed type from "string" to "enum".
proto/orders/v1/order.proto:31:1:Previously present field "7" with name "legacy_total" was deleted without reservation.

Ставьте buf breaking блокирующей проверкой в PR на репозиторий схем. Это единственный способ не узнать о поломке контракта из инцидента.

gRPC на HTTP/2: что реально едет по проводу

gRPC — это соглашение о том, как уложить вызов в HTTP/2. Полная спецификация занимает одну страницу: PROTOCOL-HTTP2.md. Её стоит прочитать целиком — это редкий случай, когда протокол помещается в голову за десять минут.

Слои gRPC-вызова: кадры HTTP/2, префиксы длины, protobuf

Запрос — это обычный HTTP/2 POST:

:method = POST
:scheme = https
:path = /orders.v1.OrderService/GetOrder
:authority = orders.internal:443
content-type = application/grpc+proto
grpc-timeout = 1500m
grpc-encoding = gzip
grpc-accept-encoding = gzip, identity
te = trailers
user-agent = grpc-go/1.62.0

Каждая строка здесь несёт смысл:

  • :path = /пакет.Сервис/Метод. Роутинг на L7-прокси делается по префиксу пути ровно так же, как для REST. Это подарок: nginx и Envoy умеют маршрутизировать gRPC без специальных знаний.
  • content-type начинается с application/grpc. Именно по этому префиксу прокси понимает, что нужно уметь трейлеры. Суффикс +proto, +json задаёт кодек.
  • grpc-timeout — дедлайн, а не таймаут. Формат: число + единица (H, M, S, m — миллисекунды, u, n). 1500m = 1.5 секунды. Значение вычисляется клиентом из оставшегося времени контекста и пересчитывается на каждом хопе: если у вас цепочка A → B → C, то C получит остаток дедлайна A. Это и есть главная киллер-фича gRPC по сравнению с голым HTTP.
  • te: trailers обязателен по спецификации — так клиент подтверждает, что понимает трейлеры HTTP/2.
  • grpc-encoding — сжатие на уровне сообщения, не всего потока. Каждое сообщение несёт свой флаг «сжато/не сжато», поэтому в одном стриме могут быть и сжатые, и нет.

Ответ приходит в три приёма:

# 1. HEADERS — начальные метаданные
:status = 200
content-type = application/grpc

# 2. DATA — длино-префиксованные сообщения

# 3. HEADERS с END_STREAM — трейлеры
grpc-status = 0
grpc-message =

Ключевая архитектурная особенность: результат вызова едет в трейлерах, а не в :status. HTTP-статус всегда 200, даже если RPC провалился. Причина: серверный стрим может отдать 900 сообщений и упасть на 901-м — статус в заголовках был бы уже отправлен. Отсюда самая частая продовая поломка gRPC: прокси или SDK, не умеющие трейлеры HTTP/2, обрывают вызов на самом интересном месте — тело пришло, grpc-status нет, клиент видит INTERNAL: server closed the stream without sending trailers.

Особый случай — Trailers-Only: если сервер отказывает сразу (метод не найден, нет прав), он шлёт один HEADERS-кадр с END_STREAM, где смешаны :status: 200 и grpc-status: 12. Самописные клиенты часто это ломают, ожидая обязательный DATA.

Полный жизненный цикл унарного вызова

Смотрим руками: grpcurl, tshark, openssl

Теория без байтов не усваивается. Поднимем сервер и разберём вызов.

grpcurl — curl для gRPC

Работает через server reflection: сервер отдаёт свои дескрипторы, и клиенту не нужен .proto.

# Что вообще есть на этом порту
$ grpcurl -plaintext localhost:50051 list
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
orders.v1.OrderService

$ grpcurl -plaintext localhost:50051 describe orders.v1.OrderService
orders.v1.OrderService is a service:
service OrderService {
  rpc BulkAddItems ( stream .orders.v1.OrderItem ) returns ( .orders.v1.BulkAddSummary );
  rpc GetOrder ( .orders.v1.GetOrderRequest ) returns ( .orders.v1.Order );
  rpc WatchOrder ( .orders.v1.GetOrderRequest ) returns ( stream .orders.v1.OrderEvent );
}

# Собственно вызов
$ grpcurl -plaintext -d '{"order_id":"o-1042"}' \
    localhost:50051 orders.v1.OrderService/GetOrder
{
  "id": "o-1042",
  "customerId": "c-77",
  "status": "STATUS_PAID",
  "createdAt": "2026-07-14T09:12:03Z"
}

Обратите внимание: JSON здесь — это JSON mapping protobuf, где order_id превращается в orderId, а enum — в строку. По проводу едут байты, JSON рисует grpcurl.

Подробный режим показывает метаданные и трейлеры — то, ради чего вы обычно и лезете отлаживать:

$ grpcurl -plaintext -vv -H 'x-request-id: 42' \
    -d '{"order_id":"нет-такого"}' \
    localhost:50051 orders.v1.OrderService/GetOrder

Request metadata to send:
x-request-id: 42

Response headers received:
content-type: application/grpc

Response trailers received:
(empty)
Sent 1 request and received 0 responses
ERROR:
  Code: NotFound
  Message: order "нет-такого" not found
  Details:
  1)  {"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"ORDER_NOT_FOUND","domain":"orders.acme.io","metadata":{"order_id":"нет-такого"}}

Богатая модель ошибок (Details) едет в трейлере grpc-status-details-bin — это base64 от сериализованного google.rpc.Status. Именно так передают машиночитаемые причины отказа вместо парсинга строк.

Стрим виден как последовательность JSON-объектов:

$ grpcurl -plaintext -d '{"order_id":"o-1042"}' \
    localhost:50051 orders.v1.OrderService/WatchOrder
{"status":"STATUS_PAID","at":"2026-07-14T09:12:03Z"}
{"status":"STATUS_SHIPPED","at":"2026-07-14T11:40:55Z"}

tcpdump и tshark: видим кадры

По TLS вы не увидите ничего — начинайте с -plaintext на локальном порту или используйте SSLKEYLOGFILE (см. TLS).

$ sudo tcpdump -i lo -w /tmp/grpc.pcap 'tcp port 50051'
# в другом окне делаем вызов, потом Ctrl+C

$ tshark -r /tmp/grpc.pcap -d tcp.port==50051,http2 -Y http2 \
    -T fields -e frame.number -e http2.streamid -e http2.type -e http2.headers.path -e grpc.message_length
1	0	4
3	1	1	/orders.v1.OrderService/GetOrder
5	1	0		24
7	1	1
9	1	0		300
11	1	1

Тип 1 — HEADERS, 0 — DATA, 4 — SETTINGS. Видно классическую тройку: HEADERS (запрос) → DATA → HEADERS (ответ) → DATA → HEADERS (трейлеры).

Подробный разбор одного кадра:

$ tshark -r /tmp/grpc.pcap -d tcp.port==50051,http2 -Y 'grpc' -V | head -40
HyperText Transfer Protocol 2
    Stream: DATA, Stream ID: 1, Length 29
        Length: 29
        Type: DATA (0)
        Flags: 0x00
        Stream Identifier: 1
GRPC Message
    Compressed Flag: Not Compressed (0)
    Message Length: 24
    Message Data: 24 bytes
Protocol Buffers: /orders.v1.OrderService/GetOrder,request
    Message: orders.v1.GetOrderRequest
        Field(1): order_id = o-1042
            [Field Name: order_id]
            Field Value: o-1042

Wireshark умеет разбирать protobuf по вашим .proto: Edit → Preferences → Protocols → ProtoBuf → Protobuf search paths, плюс включить «Dissect Protobuf fields as Wireshark fields». После этого можно фильтровать прямо по значениям полей: protobuf.field.name == "order_id".

Что тут видно про сеть, а не про gRPC:

  • Одно TCP-соединение на все вызовы. В отличие от HTTP/1.1, где на конкурентность работал пул соединений, HTTP/2 держит один сокет и мультиплексирует потоки. ss это подтверждает:
$ ss -tni state established '( sport = :50051 )'
Recv-Q Send-Q  Local Address:Port   Peer Address:Port
0      0       10.0.3.11:50051      10.0.4.27:41582
     cubic wscale:7,7 rto:204 rtt:0.412/0.117 mss:1448 cwnd:31
     bytes_sent:184203881 bytes_acked:184203881 segs_out:141203 send 871Mbps
     lastsnd:12 lastrcv:12 pacing_rate 1.7Gbps delivery_rate 640Mbps

Один сокет, 184 МБ трафика, всё это — сотни тысяч RPC. Отсюда и растут все проблемы с балансировкой.

  • Потеря пакета останавливает ВСЕ вызовы на этом соединении. HTTP/2 убрал head-of-line blocking на своём уровне, но TCP-то остался: пока потерянный сегмент не переслан, ядро не отдаст ни байта из последующих — включая кадры чужих потоков. В Wireshark это видно как «чёрная дыра»: [TCP Previous segment not captured], затем дубликаты ACK, [TCP Retransmission] — и все stream’ы стоят. Это ровно та проблема, ради которой сделали QUIC.

ALPN: как клиент вообще узнаёт про HTTP/2

gRPC поверх TLS требует согласования h2 через ALPN. Если у вас UNAVAILABLE: connection error на TLS-эндпоинте — проверьте это первым делом:

$ openssl s_client -connect orders.example.com:443 -alpn h2 -servername orders.example.com </dev/null 2>/dev/null | grep -E 'ALPN|Verify return'
ALPN protocol: h2
    Verify return code: 0 (ok)

Если вместо этого No ALPN negotiated — балансировщик или терминатор TLS не настроен на HTTP/2, и gRPC через него не пройдёт никогда. Частный случай: AWS ALB требует явного протокола GRPC в target group, иначе тихо деградирует до HTTP/1.1.

Для plaintext-режима HTTP/2 без TLS клиент обязан использовать prior knowledge — то есть просто начать с преамбулы. Её видно в дампе глазами:

$ sudo tcpdump -i lo -A -s0 'tcp port 50051' -c 1 | grep -a PRI
PRI * HTTP/2.0

Четыре типа вызовов и когда какой нужен

Главное заблуждение про стриминг: gRPC-стрим — это не очередь. Он живёт ровно столько, сколько живёт HTTP/2-поток, то есть до первого разрыва TCP, рестарта пода, GOAWAY от балансировщика или пятиминутного idle-таймаута на прокси. Никакой персистентности, никакого «дочитаю с того места». Если сообщения нельзя терять — нужен брокер (см. messaging), а стрим оставьте для «живого» состояния, которое можно перезапросить целиком.

Отсюда правило: любой серверный стрим обязан поддерживать переподключение с курсором. Запрос несёт resume_token/from_version, сервер начинает с него. Иначе первый же деплой балансировщика превратится в потерю событий.

Жизненный цикл вызова как автомат

Отмена — недооценённая половина gRPC. Когда клиент отваливается по дедлайну, gRPC шлёт RST_STREAM, и серверный контекст закрывается. Если ваш обработчик не смотрит в ctx.Done() и не пробрасывает контекст в БД, вы продолжаете жечь ресурсы на ответ, который никто не получит. При перегрузке это превращается в лавину: клиенты ретраят по таймауту, сервер копит мёртвую работу, latency растёт, клиенты ретраят чаще.

Коды ответа: главная семантическая разница с HTTP

В gRPC ровно 17 кодов (status.proto), и в отличие от HTTP они спроектированы под вопрос «можно ли ретраить?».

Код Смысл Ретрай
OK 0 успех
CANCELLED 1 клиент отменил нет
UNKNOWN 2 паника, необработанное исключение осторожно
INVALID_ARGUMENT 3 запрос невалиден сам по себе никогда
DEADLINE_EXCEEDED 4 не успели только с новым дедлайном
NOT_FOUND 5 объекта нет нет
ALREADY_EXISTS 6 конфликт создания нет
PERMISSION_DENIED 7 аутентифицирован, но не разрешено нет
RESOURCE_EXHAUSTED 8 квота, rate limit, нет памяти да, с backoff
FAILED_PRECONDITION 9 состояние системы не позволяет нет, пока не поправят состояние
ABORTED 10 конфликт транзакции/версии да, на уровне транзакции
OUT_OF_RANGE 11 вышли за границы нет
UNIMPLEMENTED 12 метода нет никогда
INTERNAL 13 сломан инвариант нет
UNAVAILABLE 14 сервис недоступен прямо сейчас да, основной ретраибельный
DATA_LOSS 15 необратимая потеря нет
UNAUTHENTICATED 16 нет/невалидны учётные данные после обновления токена

Тонкость, на которой все спотыкаются: FAILED_PRECONDITION против ABORTED против UNAVAILABLE. Официальная формулировка: FAILED_PRECONDITION — клиент не должен повторять, пока не исправит состояние системы; ABORTED — клиент должен повторить на уровне более высокой абстракции (перечитать, пересчитать, послать снова); UNAVAILABLE — можно повторить прямо этот же вызов с backoff.

Ошибка, которую совершают почти все: возвращать INTERNAL на всё подряд. Клиентские библиотеки на INTERNAL не ретраят — и вы теряете автоматическое восстановление там, где корректный UNAVAILABLE починил бы всё сам.

Ретраи, дедлайны и хеджирование — конфигом, а не кодом

gRPC умеет ретраить сам, декларативно, через service config:

{
  "methodConfig": [
    {
      "name": [{ "service": "orders.v1.OrderService", "method": "GetOrder" }],
      "timeout": "2s",
      "retryPolicy": {
        "maxAttempts": 4,
        "initialBackoff": "0.05s",
        "maxBackoff": "1s",
        "backoffMultiplier": 2,
        "retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
      }
    },
    {
      "name": [{ "service": "orders.v1.OrderService", "method": "CreateOrder" }],
      "timeout": "5s",
      "waitForReady": false
    }
  ],
  "retryThrottling": { "maxTokens": 100, "tokenRatio": 0.1 },
  "loadBalancingConfig": [{ "round_robin": {} }]
}

Что здесь важно:

  • retryThrottling обязателен. Без него ретраи умножают нагрузку ровно тогда, когда сервису плохо, — классический retry storm. Токен-бакет тратит токен на каждый неудачный ретрай и возвращает tokenRatio за каждый успешный; кончились токены — ретраи выключаются. Это то же самое, что circuit breaker, только встроенный.
  • Ретраятся только «безопасные» ситуации. gRPC не будет ретраить вызов, по которому сервер уже начал слать ответ, — это важно для стримов.
  • Дедлайн общий на все попытки. timeout: 2s с четырьмя попытками — это 2 секунды суммарно, а не 8. Так и надо: клиенту наверху всё равно, сколько раз вы попробовали.
  • waitForReady меняет поведение при отсутствии готового соединения: false (по умолчанию) — сразу UNAVAILABLE, true — ждать до дедлайна. Для критичных вызовов при старте true лучше, для интерактивных — хуже.

Хеджирование — более агрессивная альтернатива для хвостовых задержек: послать вторую попытку, не дожидаясь ошибки.

"hedgingPolicy": {
  "maxAttempts": 3,
  "hedgingDelay": "0.4s",
  "nonFatalStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}

Через 400 мс без ответа уходит вторая копия запроса на другой бэкенд, побеждает первый ответ. Классика борьбы с tail latency из «The Tail at Scale» (Dean & Barroso, 2013). Хеджировать можно только идемпотентные вызовы — вы буквально выполняете операцию дважды. И следите за нагрузкой: hedgingDelay меньше p95 превращает хеджирование в удвоение трафика.

Дедлайны в коде выглядят обыденно, но именно они делают систему предсказуемой:

// Клиент: дедлайн ставится ОДИН раз на границе входа, дальше только наследуется.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

order, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{OrderId: id})
if err != nil {
    st, _ := status.FromError(err)
    switch st.Code() {
    case codes.NotFound:
        return nil, ErrNoSuchOrder
    case codes.DeadlineExceeded, codes.Unavailable:
        // ретрай уже сделал сам gRPC — сюда попадаем, когда бюджет исчерпан
        return nil, fmt.Errorf("orders недоступен: %w", err)
    default:
        return nil, err
    }
}
// Сервер: сокращаем дедлайн для нижележащего вызова, оставляя себе бюджет на ответ.
func (s *server) GetOrder(ctx context.Context, req *ordersv1.GetOrderRequest) (*ordersv1.Order, error) {
    if req.GetOrderId() == "" {
        return nil, status.Error(codes.InvalidArgument, "order_id обязателен")
    }
    // Оставляем 10% бюджета на сериализацию и сеть обратно.
    if dl, ok := ctx.Deadline(); ok {
        budget := time.Until(dl)
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, budget*9/10)
        defer cancel()
    }
    row, err := s.db.QueryRowContext(ctx, `SELECT ... WHERE id = $1`, req.GetOrderId())
    switch {
    case errors.Is(err, sql.ErrNoRows):
        return nil, status.Errorf(codes.NotFound, "order %q not found", req.GetOrderId())
    case errors.Is(err, context.DeadlineExceeded):
        return nil, status.Error(codes.DeadlineExceeded, "БД не успела")
    case err != nil:
        return nil, status.Error(codes.Unavailable, "БД недоступна") // именно Unavailable — ретраибельно
    }
    return toProto(row), nil
}

Серверный стрим с корректной реакцией на отмену:

func (s *server) WatchOrder(req *ordersv1.GetOrderRequest, stream ordersv1.OrderService_WatchOrderServer) error {
    events, unsubscribe := s.bus.Subscribe(req.GetOrderId())
    defer unsubscribe()

    ticker := time.NewTicker(30 * time.Second) // keepalive на уровне приложения
    defer ticker.Stop()

    for {
        select {
        case <-stream.Context().Done():
            // Клиент отвалился или истёк дедлайн — выходим, иначе горутина течёт.
            return status.FromContextError(stream.Context().Err()).Err()
        case ev := <-events:
            // Send блокируется, когда окно HTTP/2 закрыто — это и есть backpressure.
            if err := stream.Send(toProtoEvent(ev)); err != nil {
                return err // поток уже мёртв, статус выставит рантайм
            }
        case <-ticker.C:
            if err := stream.Send(&ordersv1.OrderEvent{Heartbeat: true}); err != nil {
                return err
            }
        }
    }
}

Про stream.Send и backpressure стоит сказать отдельно: он блокирующий, и это фича. Когда получатель не успевает читать, окно HTTP/2 закрывается, Send останавливается, ваш продюсер притормаживает. Это ровно то, чего нет у «отправил в очередь и забыл». Подробнее о том, как это устроено внутри горутин, — в конкурентности Go.

Балансировка: место, где gRPC ломается чаще всего

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

Три рабочих решения:

  1. L7-прокси, разбирающий HTTP/2 — Envoy, nginx с grpc_pass, Linkerd. Прокси терминирует соединение клиента и раскидывает отдельные потоки по бэкендам. Плата — лишний хоп и лишняя терминация TLS. В nginx не забудьте про лимит потоков, он по умолчанию низкий:
http2_max_concurrent_streams 512;   # по умолчанию 128 — упирается на активных клиентах

server {
    listen 443 ssl;
    http2 on;
    location /orders.v1.OrderService/ {
        grpc_pass grpc://orders_backend;
        grpc_read_timeout 3600s;    # иначе стримы будут рваться каждую минуту
        grpc_send_timeout 3600s;
    }
}
  1. Клиентская балансировка. Клиент сам резолвит все адреса и держит по соединению к каждому:
conn, err := grpc.NewClient(
    "dns:///orders.default.svc.cluster.local:50051",  // headless Service, все поды в A-записи
    grpc.WithTransportCredentials(creds),
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                30 * time.Second, // пинг при простое
        Timeout:             10 * time.Second,
        PermitWithoutStream: true,
    }),
)

Тут нужен headless Service (clusterIP: None), иначе DNS вернёт один виртуальный IP и балансировки не будет. Резолвер dns:/// по умолчанию перечитывает записи не чаще чем раз в 30 секунд — при частых деплоях это заметно.

  1. Серверный MAX_CONNECTION_AGE — обязателен в любом варианте, где живут долгие соединения. Сервер сам разрывает соединение, вынуждая клиента переподключиться и перебалансироваться:
srv := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      30 * time.Minute,
        MaxConnectionAgeGrace: 5 * time.Minute,  // время дожить активным вызовам
        Time:                  2 * time.Hour,
        Timeout:               20 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             15 * time.Second,   // клиентам чаще пинговать нельзя
        PermitWithoutStream: true,
    }),
)

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

Классическая ошибка в паре: клиент пингует чаще, чем разрешает EnforcementPolicy.MinTime (по умолчанию 5 минут), сервер отвечает GOAWAY с ENHANCE_YOUR_CALM и debug_data: too_many_pings, клиент переподключается, снова пингует — бесконечный цикл реконнектов. Если видите в логах too_many_pings — согласуйте Time на клиенте и MinTime на сервере.

Подробный разбор L4 против L7, health checks и sticky sessions — в следующей статье трека. Про health checks добавлю только gRPC-специфику: есть стандартный сервис grpc.health.v1.Health, а Kubernetes с версии 1.24 умеет его дёргать нативно (GA в 1.27):

readinessProbe:
  grpc:
    port: 50051
    service: orders.v1.OrderService   # опционально — можно пробовать конкретный сервис
  periodSeconds: 5
$ grpc_health_probe -addr=localhost:50051
status: SERVING

gRPC и браузер: почему нужен ещё один слой

Браузерный fetch() не даёт контроля над кадрами HTTP/2 и не отдаёт трейлеры. Поэтому «просто gRPC» в браузере невозможен — не из-за политики, а из-за отсутствия API.

  • gRPC-Web переносит трейлеры в тело ответа: после последнего сообщения идёт ещё один length-prefixed фрейм, у которого в байте флагов установлен старший бит (0x80), а содержимое — текстовые трейлеры. Кодеки: application/grpc-web+proto (бинарь) и application/grpc-web-text (base64, для окружений, где нельзя бинарь). Двунаправленный стриминг не поддерживается, серверный — да. Нужен прокси-транслятор: Envoy с grpc_web фильтром или grpcwebproxy.
  • Connect пошёл иначе: унарные вызовы — обычный POST с JSON или proto в теле и нормальным HTTP-статусом, ошибки — JSON-объект. То есть Connect-сервер отвечает и обычному curl, и gRPC-клиенту:
$ curl -sv -H 'Content-Type: application/json' \
    -d '{"order_id":"o-1042"}' \
    https://orders.example.com/orders.v1.OrderService/GetOrder
< HTTP/2 200
< content-type: application/json
{"id":"o-1042","customerId":"c-77","status":"STATUS_PAID"}

# ошибка — обычный HTTP-статус и читаемое тело
$ curl -s -o /dev/null -w '%{http_code}\n' -d '{"order_id":"нет"}' \
    https://orders.example.com/orders.v1.OrderService/GetOrder
404

Это радикально упрощает жизнь: curl работает, кэши работают, логи балансировщика осмысленны, а Connect-сервер при этом умеет говорить и на честном grpc-протоколе для внутренних клиентов.

  • grpc-gateway — третий путь: генерирует REST-фасад из аннотаций прямо в .proto:
import "google/api/annotations.proto";

rpc GetOrder(GetOrderRequest) returns (Order) {
  option (google.api.http) = {
    get: "/v1/orders/{order_id}"
  };
}

Получается один контракт и два интерфейса: gRPC внутрь, REST + OpenAPI наружу. Плата — лишний процесс трансляции и то, что REST-фасад выходит «протокольным», а не ресурсным (см. Google API Design Guide, если хотите сделать его прилично).

Можно ли отправить чистый gRPC-запрос curl’ом? Технически да, если собрать фрейм руками:

# 0x00 — не сжато, 00 00 00 08 — длина, дальше protobuf для {order_id:"o-1042"}
$ printf '\x00\x00\x00\x00\x08\x0a\x06o-1042' | curl -s --http2-prior-knowledge \
    -H 'content-type: application/grpc' -H 'te: trailers' \
    --data-binary @- http://localhost:50051/orders.v1.OrderService/GetOrder \
    --output - | xxd | head -3
00000000: 0000 0000 2c0a 066f 2d31 3034 3212 0463  ....,..o-1042..c
00000010: 2d37 3718 0222 0e0a 0c08 f38a d0c6 0610  -77..."........

Трейлеры curl показывает только с --trace-ascii -. Это упражнение для понимания, а не рабочий инструмент — для отладки берите grpcurl или grpcui (веб-интерфейс поверх reflection).

gRPC против REST: где проходит граница

Честное сравнение по пунктам, без маркетинга:

Критерий gRPC REST + JSON
Схема обязательна, машиночитаема, проверка совместимости в CI опциональна (OpenAPI), расходится с кодом
Размер payload в 2–5 раз меньше на структурированных данных больше, но gzip/br сокращает разрыв
Скорость сериализации заметно быстрее, особенно на вложенных структурах JSON-парсинг часто топ-1 в профиле CPU
Стриминг четыре режима из коробки SSE или WebSocket отдельным механизмом
Дедлайны и отмена сквозные, часть протокола руками, через свои заголовки
Мультиплексирование из коробки (HTTP/2) требует HTTP/2 и всё равно нет отмены
Отладка глазами нужен grpcurl/tshark, curl бесполезен curl + браузер, читается человеком
HTTP-кэширование отсутствует (всё POST) работает: ETag, Cache-Control, CDN
Браузер только через gRPC-Web/Connect нативно
Прокси и фаерволы нужен L7 с поддержкой HTTP/2 и трейлеров работает везде
Публичное API требует от клиента тулинга стандарт де-факто
Порог входа команды protoc/buf, кодогенерация, CI на схемы нулевой

Практическое правило. Внутри дата-центра между сервисами, которые вы контролируете, — gRPC: строгий контракт, дедлайны и стриминг окупают тулинг за месяц. Наружу, для партнёров и браузера — REST/JSON или Connect. Между этими крайностями Connect и grpc-gateway дают хороший компромисс: одна схема, два интерфейса.

Отдельно про кэширование: пункт «HTTP-кэширование отсутствует» — самый недооценённый. Все gRPC-вызовы это POST, значит ни один CDN и ни один промежуточный кэш вам не поможет. Если ваша нагрузка — это 95% чтений одних и тех же данных, REST с честным Cache-Control может оказаться на порядок эффективнее любого бинарного протокола просто потому, что запрос не доедет до сервера.

Измеряем, а не верим

Цифры «gRPC в 7 раз быстрее REST» из блогов бесполезны: они меряют разные вещи на разных payload. Мерьте своё — ghz для gRPC:

$ ghz --insecure --proto ./proto/orders/v1/order.proto \
      --call orders.v1.OrderService.GetOrder \
      -d '{"order_id":"o-1042"}' \
      -c 50 -n 200000 --connections 5 \
      localhost:50051

Summary:
  Count:        200000
  Total:        6.84 s
  Slowest:      41.22 ms
  Fastest:      0.18 ms
  Average:      1.68 ms
  Requests/sec: 29239.77

Latency distribution:
  10 % in 0.62 ms
  25 % in 0.91 ms
  50 % in 1.39 ms
  75 % in 2.05 ms
  90 % in 3.11 ms
  95 % in 4.02 ms
  99 % in 8.77 ms

Status code distribution:
  [OK]   200000 responses

Что здесь критично для честности замера:

  • --connections. По умолчанию ghz открывает одно соединение, и вы упрётесь в мультиплексирование на одном сокете, а не в сервер. Реальные клиенты обычно держат несколько.
  • Сравнивайте на одинаковом payload. Разница protobuf/JSON зависит от структуры: на плоском объекте с длинными строками разницы почти нет (строки-то те же байты), на глубоко вложенных структурах с числами — кратная.
  • Смотрите на хвост, а не на среднее. p99 и p999 — это то, что видит пользователь; методика — в измерении производительности.

Порядок величин, на который можно ориентироваться до собственных замеров: внутри дата-центра унарный gRPC-вызов — это десятки-сотни микросекунд сети плюс единицы-десятки микросекунд на (де)сериализацию небольшого сообщения; protobuf обычно в 2–5 раз меньше по размеру и в 3–10 раз быстрее по CPU, чем эквивалентный JSON. На типовом сервисе, где 90% времени уходит в БД, эта разница не видна вообще — и это тоже надо честно себе сказать перед миграцией.

Наблюдаемость: интерцепторы

Интерцептор — это middleware. Один на процесс, и вы получаете единые метрики, логи и трейсы:

func UnaryServerLogger(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler) (any, error) {

        start := time.Now()
        // Метаданные — аналог заголовков; отсюда достаём trace-id, авторизацию и т.п.
        md, _ := metadata.FromIncomingContext(ctx)

        resp, err := handler(ctx, req)

        var budgetLeft time.Duration
        if dl, ok := ctx.Deadline(); ok {
            budgetLeft = time.Until(dl) // остаток бюджета — бесценно при разборе таймаутов
        }
        logger.LogAttrs(ctx, slog.LevelInfo, "grpc",
            slog.String("method", info.FullMethod),
            slog.String("code", status.Code(err).String()),
            slog.Duration("took", time.Since(start)),
            slog.Duration("budget_left", budgetLeft),
            slog.String("request_id", first(md.Get("x-request-id"))),
        )
        return resp, err
    }
}

Готовые кирпичи — go-grpc-middleware и OpenTelemetry gRPC instrumentation, который сам пробрасывает traceparent в метаданных: контекст трассировки перетекает между сервисами без вашего участия. Подробнее — в наблюдаемости распределённых систем.

Обязательные метрики (имена из стандартной инструментации): rpc.server.duration с разбивкой по rpc.method и rpc.grpc.status_code, счётчик активных стримов, размер сообщений. Дополнительно — «сколько бюджета дедлайна осталось на входе»: систематически низкое значение означает, что кто-то выше по цепочке уже проел время, и ваши таймауты бессмысленны.

Для отладки на живом сервисе включите логи транспорта — они показывают кадры HTTP/2 без tcpdump:

$ GRPC_GO_LOG_SEVERITY_LEVEL=info GRPC_GO_LOG_VERBOSITY_LEVEL=2 ./orders-service
INFO: [transport] [server-transport 0xc0001] loopyWriter exiting with error: transport closed by client
INFO: [core] [Channel #1 SubChannel #2] Subchannel Connectivity change to READY
INFO: [balancer] [round_robin] Update: READY addrs=[10.0.3.11:50051 10.0.3.12:50051]

В C-реализациях (Python, C++, Ruby) аналог — GRPC_VERBOSITY=DEBUG GRPC_TRACE=http,call_error,connectivity_state.

Типичные ошибки, каждая из которых уже кого-то уронила

  1. gRPC через L4-балансировщик. Нагрузка липнет к первым бэкендам. Лечится L7-прокси, клиентской балансировкой или MaxConnectionAge — см. выше.
  2. Вызовы без дедлайна. Один зависший бэкенд превращается в исчерпание пула горутин/потоков по всей цепочке. Дедлайн ставится на входе в систему и наследуется; вызов без дедлайна должен падать на code review.
  3. Обработчик не смотрит в ctx.Done(). Клиент ушёл, работа продолжается. При перегрузке это гарантированный коллапс.
  4. INTERNAL вместо UNAVAILABLE. Убивает автоматические ретраи там, где они бы всё починили.
  5. Ретраи без throttling. Retry storm: чем хуже сервису, тем больше на него льют.
  6. Считать, что optional не нужен. В proto3 без явного optional вы не отличите «прислали 0» от «не прислали». Для PATCH-подобных операций это порождает тихое затирание полей нулями. Либо optional, либо google.protobuf.FieldMask.
  7. Переиспользование номеров удалённых полей. Молчаливая порча данных, самый дорогой тип бага. reserved — всегда.
  8. Сообщения больше лимита. Дефолт приёма — 4 МиБ; вместо grpc.MaxCallRecvMsgSize(100<<20) почти всегда правильнее клиентский стрим с чанками по 64–256 КиБ. Большое сообщение — это ещё и всплеск аллокаций: оно целиком собирается в памяти перед разбором.
  9. Стрим вместо очереди. Ждать durability от gRPC-стрима — потерять сообщения на первом же деплое.
  10. Забыть CloseSend() в клиентском стриме. Сервер ждёт продолжения до дедлайна, ресурсы висят.
  11. Логировать сообщения целиком через %v. В protobuf-сообщении обычно лежат персональные данные; в проде логируйте выборочно и с редакцией (см. безопасность API).
  12. Отсутствие buf breaking в CI. Поломка контракта обнаруживается в проде клиента, а не в PR.
  13. grpc_read_timeout по умолчанию на nginx. Стримы рвутся ровно через 60 секунд, и все ищут проблему в приложении.
  14. Не сжимать крупные ответы. grpc-encoding: gzip на списках в сотни килобайт экономит больше, чем любой микрооптимизации сериализации. Мелкие сообщения (<1 КБ) сжимать не надо — накладные расходы съедят выигрыш.

Практика: разобрать вызов до байтов

Час работы, который окупается на первом же инциденте.

# 1. Поднять любой gRPC-сервер с reflection (примеры grpc-go: examples/helloworld)
$ go run google.golang.org/grpc/examples/features/reflection/server@latest &

# 2. Посмотреть API без .proto
$ grpcurl -plaintext localhost:50051 describe

# 3. Записать дамп и сделать вызов
$ sudo tcpdump -i lo -w /tmp/g.pcap 'tcp port 50051' &
$ grpcurl -plaintext -d '{"name":"Ann"}' localhost:50051 helloworld.Greeter/SayHello
$ sudo pkill tcpdump

# 4. Найти кадры и границы сообщений
$ tshark -r /tmp/g.pcap -d tcp.port==50051,http2 -Y http2 \
    -T fields -e http2.streamid -e http2.type -e http2.headers.path -e http2.headers.grpc_status

# 5. Вытащить payload и разобрать protobuf вручную
$ tshark -r /tmp/g.pcap -d tcp.port==50051,http2 -Y 'grpc' \
    -T fields -e grpc.message_data | head -1
0a03416e6e
$ printf '\x0a\x03Ann' | protoc --decode_raw
1: "Ann"

# 6. Сравнить размеры
$ echo -n '{"name":"Ann"}' | wc -c   # 14
$ printf '\x0a\x03Ann' | wc -c       # 5

Затем поломайте это специально: уберите te: trailers из самописного клиента, поставьте grpc-timeout: 1m (одна миллисекунда!) и посмотрите на DEADLINE_EXCEEDED, убейте сервер посреди стрима и найдите в дампе RST_STREAM. Методика такой отладки — в диагностике сети.

Мини-итог

  • RPC не делает сеть прозрачной, и хорошие фреймворки этого не обещают. Задержка, отдельная память, частичный отказ и конкурентность остаются — gRPC даёт инструменты (дедлайны, коды, стримы, backpressure), а не иллюзию.
  • Protobuf — это «номер поля + тип кодирования + значение», больше ничего. Отсюда и компактность, и эволюционируемость, и требование иметь схему, и отсутствие канонической сериализации.
  • Номера полей вечны. reserved при удалении, buf breaking в CI, _UNSPECIFIED нулём в enum.
  • gRPC — это HTTP/2 POST с /пакет.Сервис/Метод в :path, префиксом длины перед каждым сообщением и результатом в трейлерах. Понимание этих трёх фактов объясняет 90% проблем с прокси.
  • Мультиплексирование ломает L4-балансировку. L7-прокси, клиентская балансировка или MaxConnectionAge — выберите минимум одно.
  • Стрим — не очередь. Нужна durability — берите брокер; нужен «живой» апдейт — стрим плюс обязательный resume-токен.
  • Внутри — gRPC, наружу — REST/Connect. А если нагрузка — это повторяющиеся чтения, кэшируемый REST может выиграть у любого бинарного протокола, просто не доехав до сервера.

Источники

Что дальше

Мы упёрлись в то, что gRPC живёт в одном долгом соединении, и это ломает наивную балансировку. Дальше — про то, как устроены прокси и балансировщики: чем L4 отличается от L7, как настраивается nginx, как работают health checks и почему sticky sessions чаще проблема, чем решение.

Прокси и балансировка: L4 vs L7, nginx, health checks, sticky sessions

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов