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). Их тезис: разница между локальным и удалённым вызовом не количественная (медленнее), а качественная — четыре несводимых различия:
- Задержка. Локальный вызов — единицы наносекунд, удалённый — сотни микросекунд внутри дата-центра и десятки миллисекунд между континентами. Разница в 10^4–10^6 раз меняет не константу, а сам дизайн: цикл
for _, id := range ids { c.Get(id) }на 1000 элементов внутри процесса стоит микросекунды, а по сети — 30 секунд. - Отдельная память. Указатель через границу процесса не имеет смысла. Значит, аргументы копируются, значит нужна сериализация, значит нужна схема, значит появляется версионирование.
- Частичный отказ. Локальная функция либо вернулась, либо процесс упал вместе с вызывающим. Удалённая может «не ответить», и вы принципиально не отличите «сервер не получил запрос» от «сервер выполнил и потерялся ответ». Отсюда идемпотентность — см. доставка и идемпотентность.
- Конкурентность. Удалённый объект обслуживает много клиентов одновременно; ваши предположения о порядке и атомарности не выполняются.
Все успешные 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— попадает в:pathHTTP/2 и становится частью публичного контракта. Версия в пакете позволяет запустить v1 и v2 бок о бок на одном порту.- Нулевое значение enum —
_UNSPECIFIED. В proto3 отсутствующее поле неотличимо от нуля, поэтому нулём должно быть «не знаю», а не осмысленный статус. Иначе старый клиент, не приславший поле, молча создаст заказ в статусеNEW. reservedпри удалении поля — не опция, а обязанность. Номер поля — единственный идентификатор на проводе. Если через год кто-то переиспользует номер 7 подbool is_test, старые сообщения сstring legacy_totalначнут декодироваться в мусор без единой ошибки.
Байты на проводе
Модель предельно простая: сообщение — это последовательность пар «тег — значение», больше ничего. Тег — это 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: int32 → string |
Менять между int32/int64/uint32/uint64/bool |
Менять int32 ↔ sint32 (varint против zigzag) |
string ↔ bytes, если содержимое — валидный UTF-8 |
fixed32 ↔ int32 (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. Её стоит прочитать целиком — это редкий случай, когда протокол помещается в голову за десять минут.
Запрос — это обычный 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
Четыре типа вызовов и когда какой нужен
в каждую сторону?} --> U["1 → 1
Unary"] Q --> SS["1 → N
Server streaming"] Q --> CS["N → 1
Client streaming"] Q --> BD["N → N
Bidirectional"] U --> U1["90% реальных API.
Ретраи, дедлайны, LB — всё работает штатно"] SS --> S1["Подписки, длинные выборки, прогресс.
Замена SSE внутри дата-центра"] CS --> C1["Загрузка файлов чанками, батч-импорт.
Backpressure бесплатно"] BD --> B1["Интерактив: чат, торги, синхронизация.
Дороже всего в отладке"] S1 --> W1{"Нужна ли durability
при обрыве?"} C1 --> W1 B1 --> W1 W1 -->|"да"| MQ["Не стрим, а брокер:
Kafka/NATS/RabbitMQ.
gRPC-стрим ничего не хранит"] W1 -->|"нет"| OK["Стрим подходит.
Обязателен reconnect с курсором"] style U fill:#5b9bd5,fill-opacity:0.2 style MQ fill:#c2413f,fill-opacity:0.2 style OK fill:#3f9d6b,fill-opacity:0.2
Главное заблуждение про стриминг: gRPC-стрим — это не очередь. Он живёт ровно столько, сколько живёт HTTP/2-поток, то есть до первого разрыва TCP, рестарта пода, GOAWAY от балансировщика или пятиминутного idle-таймаута на прокси. Никакой персистентности, никакого «дочитаю с того места». Если сообщения нельзя терять — нужен брокер (см. messaging), а стрим оставьте для «живого» состояния, которое можно перезапросить целиком.
Отсюда правило: любой серверный стрим обязан поддерживать переподключение с курсором. Запрос несёт resume_token/from_version, сервер начинает с него. Иначе первый же деплой балансировщика превратится в потерю событий.
Жизненный цикл вызова как автомат
(унарный: сразу; клиентский стрим: CloseSend) Open --> HalfClosedRemote: сервер закрыл свою сторону Open --> Closed: RST_STREAM (CANCELLED) HalfClosedLocal --> Closed: трейлеры с grpc-status HalfClosedRemote --> Closed: клиент дослал и закрылся state Closed { [*] --> OK: grpc-status=0 [*] --> Retryable: UNAVAILABLE / RESOURCE_EXHAUSTED
+ грамотный backoff [*] --> Fatal: INVALID_ARGUMENT / PERMISSION_DENIED
ретрай бессмыслен [*] --> Deadline: DEADLINE_EXCEEDED
ретрай ТОЛЬКО с новым дедлайном } Closed --> [*] note right of Open Отмена контекста на клиенте → RST_STREAM(CANCEL) → на сервере ctx.Done() Сервер ОБЯЗАН это проверять, иначе горутина живёт вечно. end note
Отмена — недооценённая половина 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 ломается чаще всего
Симптом всегда один: подняли десять реплик, а нагрузка легла на две. Причина — в мультиплексировании.
навсегда"| LB4["L4 LB
(iptables, NLB)"] LB4 --> P1["pod-1
100% RPS"] LB4 -.->|"соединения нет"| P2["pod-2
0%"] LB4 -.-> P3["pod-3
0%"] end subgraph L7["L7-прокси: работает"] C2[Клиент] --> LB7["Envoy / nginx
разбирает HTTP/2"] LB7 -->|"поток 1"| Q1[pod-1] LB7 -->|"поток 3"| Q2[pod-2] LB7 -->|"поток 5"| Q3[pod-3] end subgraph CSB["Клиентская балансировка: работает и быстрее"] C3["Клиент
dns:/// + round_robin"] --> R1[pod-1] C3 --> R2[pod-2] C3 --> R3[pod-3] end style P1 fill:#c2413f,fill-opacity:0.25 style P2 fill:#8a94a3,fill-opacity:0.15 style P3 fill:#8a94a3,fill-opacity:0.15
Три рабочих решения:
- 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;
}
}
- Клиентская балансировка. Клиент сам резолвит все адреса и держит по соединению к каждому:
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 секунд — при частых деплоях это заметно.
- Серверный
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.
Типичные ошибки, каждая из которых уже кого-то уронила
- gRPC через L4-балансировщик. Нагрузка липнет к первым бэкендам. Лечится L7-прокси, клиентской балансировкой или
MaxConnectionAge— см. выше. - Вызовы без дедлайна. Один зависший бэкенд превращается в исчерпание пула горутин/потоков по всей цепочке. Дедлайн ставится на входе в систему и наследуется; вызов без дедлайна должен падать на code review.
- Обработчик не смотрит в
ctx.Done(). Клиент ушёл, работа продолжается. При перегрузке это гарантированный коллапс. INTERNALвместоUNAVAILABLE. Убивает автоматические ретраи там, где они бы всё починили.- Ретраи без throttling. Retry storm: чем хуже сервису, тем больше на него льют.
- Считать, что
optionalне нужен. В proto3 без явногоoptionalвы не отличите «прислали 0» от «не прислали». Для PATCH-подобных операций это порождает тихое затирание полей нулями. Либоoptional, либоgoogle.protobuf.FieldMask. - Переиспользование номеров удалённых полей. Молчаливая порча данных, самый дорогой тип бага.
reserved— всегда. - Сообщения больше лимита. Дефолт приёма — 4 МиБ; вместо
grpc.MaxCallRecvMsgSize(100<<20)почти всегда правильнее клиентский стрим с чанками по 64–256 КиБ. Большое сообщение — это ещё и всплеск аллокаций: оно целиком собирается в памяти перед разбором. - Стрим вместо очереди. Ждать durability от gRPC-стрима — потерять сообщения на первом же деплое.
- Забыть
CloseSend()в клиентском стриме. Сервер ждёт продолжения до дедлайна, ресурсы висят. - Логировать сообщения целиком через
%v. В protobuf-сообщении обычно лежат персональные данные; в проде логируйте выборочно и с редакцией (см. безопасность API). - Отсутствие
buf breakingв CI. Поломка контракта обнаруживается в проде клиента, а не в PR. grpc_read_timeoutпо умолчанию на nginx. Стримы рвутся ровно через 60 секунд, и все ищут проблему в приложении.- Не сжимать крупные ответы.
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 может выиграть у любого бинарного протокола, просто не доехав до сервера.
Источники
- Protocol Buffers — Encoding — побайтовое описание wire-формата, первоисточник.
- Protocol Buffers — Proto Best Practices и правила совместимости.
- gRPC over HTTP/2 — вся спецификация транспорта на одной странице.
- gRPC Core Concepts, Service Config, Status codes.
- gRFC-предложения — как принимаются изменения (ретраи, xDS, hedging).
- RFC 9113 — HTTP/2, особенно разделы про кадры, потоки и flow control.
- Waldo, Wyant, Wollrath, Kendall. A Note on Distributed Computing, 1994.
- Dean, Barroso. The Tail at Scale, CACM 2013 — про хеджирование.
- RFC 5531 — ONC RPC v2, прародитель жанра.
- Инструменты: buf, grpcurl, grpcui, ghz, protoscope, grpc_health_probe.
- Connect protocol и grpc-gateway.
- Google AIP — API Improvement Proposals — как проектировать методы, ошибки и версионирование.
Что дальше
Мы упёрлись в то, что gRPC живёт в одном долгом соединении, и это ломает наивную балансировку. Дальше — про то, как устроены прокси и балансировщики: чем L4 отличается от L7, как настраивается nginx, как работают health checks и почему sticky sessions чаще проблема, чем решение.
Прокси и балансировка: L4 vs L7, nginx, health checks, sticky sessions