gRPC вглубь: стриминг, deadlines и interceptors

gRPC за пределами unary-вызова на стенде с серверами и клиентами на Go и Java во всех четырёх парах, за Angie и Envoy: доходит ли deadline до нижнего сервиса, останавливается ли стрим после отмены, какой код видит клиент на ошибку и панику, что видят интерсепторы, что делают встроенные ретраи, когда keepalive рвёт соединение, почему за L4-балансировщиком все вызовы уходят на один бэкенд и что gRPC-Gateway делает с деталями ошибки. Всё — счётом исходов

Большинство сервисов используют gRPC как «REST, только с protobuf»: сгенерировали заготовку, вызвали unary-метод, забыли. Вопросы, которые потом всплывают в эксплуатации, лежат как раз за этим уровнем. Узнает ли нижний сервис, что клиент уже ушёл? Перестанет ли сервер слать стрим, когда его отменили? Какой код увидит клиент, если в обработчике паника? Почему за балансировщиком вся нагрузка легла на один под?

Всё это здесь проверено на стенде, а не пересказано из документации. Один .proto, сервер и клиент на Go (grpc-go 1.84) и на Java (grpc-java 1.84), и там, где важна совместимость, — все четыре пары клиент-сервер. Расхождения между языками — тоже результат, и их в статье несколько.

Ретрофутуристский инженерный лист: железнодорожная развязка сверху. Слева по одному пути «канал» идёт длинный поезд из одинаковых вагонов, у каждого ярлык с песочными часами. Путь раздваивается. На верхней ветке стрелка «L4» перевела поезд целиком: все вагоны стоят у первой платформы, вторая и третья пусты, рабочие ждут без дела. На нижней ветке сортировочная станция «L7» с рычагами разбирает поезд по вагонам, и у каждой из трёх платформ стоит своя небольшая группа вагонов

digital-cookbook/architecture/grpc

В статье

  • доходит ли срок вызова до нижнего звена цепочки и что его теряет;
  • что происходит со стримом после отмены клиентом;
  • какой код видит клиент на обычную ошибку, status, детали и панику;
  • что gRPC-Gateway делает с кодами и деталями ошибок;
  • в каком порядке ставить интерсепторы;
  • что делают встроенные ретраи из service config;
  • когда keepalive сам рвёт соединение и спасает ли он его за прокси;
  • почему долгое HTTP/2-соединение ломает L4-балансировку;
  • чего эти результаты не значат.

Стенд: один контракт, четыре пары

Контракт — пять методов: unary, серверный стрим, клиентский стрим, двунаправленный стрим и звено цепочки Chain, которое либо работает само, либо вызывает следующее звено. Поведение сервера для опытов задают поля запроса и переменные окружения, так что одна сборка ставит все опыты и различие исходов не может прийти из разных бинарников.

Четыре типа RPC различаются только тем, кто и сколько сообщений шлёт: один запрос — один ответ; один запрос — поток ответов; поток запросов — один ответ; два независимых потока поверх одного HTTP/2-потока. Сгенерированные заготовки у Go и Java выглядят по-разному, но в сети это одни и те же кадры, и контракт одинаково понимают обе стороны.

Что Go и Java ведут себя одинаково по умолчанию, проверяет отдельный опыт: один набор вызовов — все четыре типа RPC и коды ошибок — во всех четырёх парах клиент-сервер обязан дать одинаковые наблюдения. Он даёт. Расхождения, о которых пойдёт речь, появляются там, где поведение задаёт библиотека, а не протокол.

Как устроен сам protobuf и что ломается при эволюции схемы, разобрано в статье про бинарные форматы и статье про эволюцию схемы; здесь — то, что происходит с вызовом.

Deadline: срок вызова и кто о нём узнаёт

gRPC оперирует не таймаутом, а сроком — моментом, после которого результат никому не нужен. В API вызов ограничен сроком; в заголовке grpc-timeout библиотека передаёт оставшийся бюджет времени, а не сам момент, — так часы клиента и сервера не обязаны совпадать. Сервер превращает бюджет снова в срок в контексте вызова; если он сам вызывает следующий сервис с тем же контекстом, дальше уходит уже меньший бюджет — потраченное вычтено, — и вся цепочка знает, когда пора бросать работу.

Постановка. Клиент → звено A → исполнитель B. Срок клиента — 300 мс; исполнитель работает 10 шагов по 100 мс и перед каждым шагом проверяет отмену. A вызывает B либо со своим контекстом, либо с новым — в Go context.Background(), в Java Context.ROOT. Все восемь сочетаний языков клиента, A и B. Контроль — срок 3 с, больше всей работы.

клиент → A → B A передаёт контекст A создаёт новый
go → go → go B бросил работу B доделал все 10 шагов
go → go → java B бросил работу B доделал все 10 шагов
go → java → go B бросил работу B доделал все 10 шагов
go → java → java B бросил работу B доделал все 10 шагов
java → go → go B бросил работу B доделал все 10 шагов
java → go → java B бросил работу B доделал все 10 шагов
java → java → go B бросил работу B доделал все 10 шагов
java → java → java B бросил работу B доделал все 10 шагов

Контроль сошёлся: при сроке больше работы исполнитель доделывает все шаги, и клиент получает OK.

Срок проходит через границу языков в обе стороны: Go-звено передаёт его Java-исполнителю и наоборот, потому что живёт он в заголовке протокола, а не в объекте библиотеки. Теряет его не язык, а одна строка в середине цепочки:

$ python3 scripts/run-deadline.py go java go propagate 300
{"variant": "go->java->go/propagate/300ms", "outcome": "stopped", "client_code": "DeadlineExceeded", "detail": "B узнал об отмене и бросил работу после 3 шагов из 10"}
$ python3 scripts/run-deadline.py go java go fresh 300
{"variant": "go->java->go/fresh/300ms", "outcome": "wasted", "client_code": "DeadlineExceeded", "detail": "B доделал все 10 шагов, об отмене не узнал"}

Клиент получает DEADLINE_EXCEEDED в обоих случаях — у него свой срок. Разница видна только у исполнителя: с новым контекстом он доделывает работу, о которой уже никто не спросит, и держит под ней свои ресурсы. Такую строку легко написать с добрыми намерениями — «пусть запись в базу всё-таки завершится», — и тогда стоит сделать это явно: новый контекст со своим сроком, а не контекст без срока вовсе.

Перед замером стенд делает прогревочный вызов по той же цепочке: без него холодное Java-звено однажды не успело за 300 мс даже позвать B — это сбой постановки, а не свойство gRPC. Сколько шагов исполнитель успевает до отмены, всё равно разнится от прогона к прогону, поэтому публикуется исход — бросил или доделал, — а не число шагов.

Отмена стрима

Отмена — тот же механизм с другой стороны: клиент перестал ждать, и сервер узнаёт об этом из контекста. Узнаёт — но остановится ли, решает код обработчика.

Постановка. Сервер шлёт 20 сообщений с паузой 100 мс; клиент читает 3 и отменяет вызов. Сервер либо проверяет отмену перед каждой отправкой, либо нет. Счёт — на сервере: сколько попыток отправки он сделал после того, как отмена пришла. Контроль — клиент читает все 20 без отмены.

клиент → сервер сервер проверяет отмену не проверяет
go → go остановился цикл до конца, отправка с ошибкой
go → java остановился цикл до конца, отправка без ошибки
java → go остановился цикл до конца, отправка с ошибкой
java → java остановился цикл до конца, отправка без ошибки

Контроль сошёлся: без отмены клиент получает все 20 сообщений.

Без проверки обработчик доводит цикл до конца в обоих языках — по одной попытке отправки на каждое оставшееся сообщение. В сеть по отменённому стриму ничего не уходит, но работа идёт. В Go цикл узнаёт об отмене хотя бы косвенно: Send после отмены возвращает ошибку, и обработчик, который её проверяет, выйдет из цикла сам. В Java-сервере стенда onNext после отмены возвращался молча — но это свойство обработчика, а не библиотеки.

grpc-java различает две вещи: транспорт узнал об отмене и приложению об этом сообщили. Флаг isCancelled() меняется сразу, его можно опрашивать из любого потока. Само уведомление — callback onCancel — grpc-java доставляет тем же последовательным исполнителем, что и вызов обработчика, и пока обработчик не вернул управление, уведомление ждёт. А исключение CANCELLED из onNext бросается только после того, как уведомление доставлено, и только если обработчик отмены (setOnCancelHandler) не установлен (ServerCallStreamObserver). В стенде весь цикл отправки шёл внутри обработчика — уведомление до него не доходило, и исключения не было.

Постановка. Тот же опыт, сервер Java без проверки отмены; цикл отправки идёт либо внутри обработчика вызова, либо в отдельной задаче, запущенной после его возврата; обработчик отмены установлен или нет. Оба клиента.

где цикл отправки обработчик отмены onNext после отмены обработчик отмены вызван
внутри обработчика вызова нет возвращается молча —
внутри обработчика вызова есть возвращается молча после конца цикла
в задаче после возврата нет бросает CANCELLED —
в задаче после возврата есть возвращается молча до конца цикла

Цикл в отдельной задаче получает CANCELLED из onNext на каждой отправке после отмены — так же косвенно, как Send в Go. С обработчиком отмены исключения нет, зато вызывается сам обработчик — и вызывается вовремя, только если обработчик вызова уже вернул управление; внутри него уведомление приходит, когда цикл уже закончен. Надёжный способ для цикла в обработчике — опрашивать isCancelled() или контекст: ни исключение, ни callback туда не доберутся. Гипотеза, записанная до первого прогона, предполагала исключение и в синхронном цикле; стенд показал, что оно зависит от того, где идёт отправка.

Ошибки: какой код увидит клиент

gRPC передаёт статус вызова — код и сообщение — в трейлерах ответа и позволяет приложить к нему детали: типизированные сообщения protobuf вроде google.rpc.ErrorInfo. Код — то, по чему клиент решает, повторять ли вызов и что показать пользователю, поэтому важно, что именно до него доходит.

Постановка. Обработчик Unary завершается ошибкой одного из видов: обычная ошибка языка; status с кодом NotFound; тот же status с ErrorInfo в деталях; паника в Go или исключение в Java — без перехватчика и с ним. После вызова проверяется, жив ли сервер: и сам контейнер, и отдельный health-вызов. Все четыре пары; код у клиента не зависит от языка клиента, поэтому в таблице — по серверу.

в обработчике сервер Go сервер Java
обычная ошибка языка Unknown Unknown
status с кодом NotFound NotFound NotFound
то же с ErrorInfo в деталях NotFound, детали ErrorInfo NotFound, детали ErrorInfo
паника (Go) / исключение (Java) Unavailable, сервер упал Unknown
то же, с перехватчиком Internal Internal

Обычная ошибка языка превращается в UNKNOWN — клиент не может по ней решить ничего. Это самый частый источник кода UNKNOWN в логах: обработчик вернул err из нижнего слоя как есть. status с кодом доходит без потерь, детали — тоже, и клиент на Java разбирает ErrorInfo, отправленный Go-сервером, и наоборот.

Паника — место, где языки расходятся принципиально:

$ python3 scripts/run-errors.py go go errors panic
{"variant": "go->go/panic", "outcome": "observed", "detail": "код Unavailable; сервер упал"}
$ python3 scripts/run-errors.py go java errors panic
{"variant": "go->java/panic", "outcome": "observed", "detail": "код Unknown; сервер жив"}

Паника в обработчике grpc-go без перехватчика не перехватывается никем и завершает весь процесс: клиент видит UNAVAILABLE, а вместе с ним упали все остальные вызовы, которые сервер в этот момент обслуживал. grpc-java ловит исключение обработчика сама и отвечает UNKNOWN «Application error processing RPC»; сервер живёт. Перехватчик паники для Go — не украшение, а обязательная часть цепочки; с ним оба сервера отвечают INTERNAL. Где именно он стоит в цепочке, тоже важно — об этом в разделе про интерсепторы.

gRPC-Gateway: коды становятся HTTP-статусами

gRPC-Gateway генерирует REST/JSON-фасад из того же .proto — для клиентов без gRPC: браузера, внешних интеграций, curl. Маршруты здесь заданы отдельным YAML, а не аннотациями в контракте, — так Java-сборке не нужен googleapis.

Постановка. Тот же сервер (Go и Java) за шлюзом: те же ошибки через REST, серверный стрим через фасад и срок в заголовке Grpc-Timeout.

на сервере ответ шлюза
без ошибки 200
NotFound 404 (NotFound)
NotFound с ErrorInfo 500 (Internal)
то же, шлюз с импортом errdetails 404 (NotFound), ErrorInfo в теле
InvalidArgument 400 (InvalidArgument)
Unavailable 503 (Unavailable)
обычная ошибка языка 500 (Unknown)
паника с перехватчиком 500 (Internal)
серверный стрим 200, строк JSON: 3
Grpc-Timeout: 300m, работа 1 с 504 (DeadlineExceeded)

Шлюз переводит коды в HTTP-статусы по таблице: NotFound — 404, InvalidArgument — 400, Unavailable — 503, всё необъяснённое — 500. Срок из Grpc-Timeout: 300m доходит до сервера: исполнитель бросает работу, клиент получает 504. Серверный стрим приходит строками JSON, по объекту на сообщение.

Главная находка оси — ошибка с деталями:

$ python3 scripts/run-errors.py go gateway details
{"variant": "go/gateway/details", "outcome": "observed", "detail": "HTTP 500 (Internal)"}
$ python3 scripts/run-errors.py go gateway details-registered
{"variant": "go/gateway/details-registered", "outcome": "observed", "detail": "HTTP 404 (NotFound), в теле RECORD_NOT_FOUND"}

Та же ошибка NotFound, но с ErrorInfo в деталях, превращается в 500. Шлюзу нужно разобрать google.protobuf.Any с деталями, чтобы записать его в JSON, а для этого тип google.rpc.ErrorInfo должен быть зарегистрирован в его бинарнике. Сгенерированный код шлюза пакет с этими типами не импортирует; в журнале шлюза остаётся строка «unable to resolve type.googleapis.com/google.rpc.ErrorInfo», а клиент получает 500 вместо 404. Лечится одной строкой — пустым импортом пакета errdetails в main шлюза; с ней ответ — 404 и ErrorInfo в теле. Ошибка коварна тем, что проявляется только на ошибках с деталями: всё остальное через шлюз работает.

Интерсепторы: порядок решает, что видно

Интерсепторы — middleware gRPC: функции вокруг обработчика, через которые проходят авторизация, метрики, журнал, трассировка, перехват паники. Они выстраиваются в цепочку, и порядок в ней — не стиль.

Постановка. Три интерсептора — авторизация, метрики, журнал — в двух порядках. Клиент делает 10 вызовов без токена и 10 с токеном. Все пары.

порядок метрики видят журнал видит до обработчика
авторизация первой 10 (OK 10) 10 10
метрики первыми 20 (OK 10, Unauthenticated 10) 20 10

Контроль сошёлся: в обоих порядках до обработчика доходят ровно 10 вызовов с токеном.

Если авторизация первой, метрики и журнал не видят ни одного отклонённого вызова: на дашборде нет ни всплеска UNAUTHENTICATED, ни следа подбора токенов. Если метрики первыми, они видят все 20 вызовов и считают отклонённые своим кодом.

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

Постановка. Обработчик паникует; перехватчик стоит либо первым в цепочке (снаружи метрик), либо последним, ближе к обработчику. Один вызов с паникой и один без. Все пары.

перехватчик паники клиент получил метрики видят панику
первым, снаружи метрик Internal нет
последним, ближе к обработчику Internal да, Internal

Порядок, который видит всё: снаружи — трассировка и метрики, затем журнал, затем авторизация, и ближе всего к обработчику — перехватчик паники. Тогда метрики считают и отказы авторизации, и панику, а перехватчик ловит всё, что бросил обработчик. Тот же порядок рекомендует набор go-grpc-middleware. Граница опыта: перехватчик паники стенд проверял только на unary-вызове. Go-сервер подключает только unary-интерсепторы, и стримы ими не защищены и не посчитаны — для них нужна отдельная цепочка grpc.ChainStreamInterceptor. В grpc-java один интерфейс интерсептора служит всем видам вызовов, но что он поймает, зависит от того, какие callbacks он оборачивает. Перехватчик стенда оборачивает onHalfClose — в нём grpc-java вызывает обработчик unary- и серверного стрима. В клиентском и двунаправленном стриме код приложения получает сообщения через onMessage, и исключение оттуда этот перехватчик не поймает.

В Go цепочка задаётся списком в порядке вызова. В grpc-java ServerInterceptors.intercept применяет список так, что последний интерсептор вызывается первым, — порядок в коде противоположен порядку исполнения, и Java-сервер стенда строит список наоборот. Ошибку другого рода — код OK в метриках Java, записанный как «Ok», — поймала проверка, которая требует одинаковых метрик во всех парах. Как встраивается трассировка OpenTelemetry, разобрано в статье про инструментирование Go и Java.

Встроенные ретраи

gRPC умеет повторять вызовы сам по политике из service config — без кода в приложении. Политика называет коды, которые можно повторять, число попыток и паузы между ними. Сколько попыток доходит до сервера, стенд считает на сервере; что повторяет именно gRPC, а не проба, видно по заголовку grpc-previous-rpc-attempts, который библиотека ставит на повторную попытку.

Постановка. Сервер отказывает первым попыткам с заданным кодом. Политика повторяет UNAVAILABLE. Все пары.

вариант попыток на сервере клиент получил
без политики (контроль), 2 отказа UNAVAILABLE 1 Unavailable
повтор UNAVAILABLE, maxAttempts 4, 2 отказа 3 OK
та же политика, 2 отказа INTERNAL 1 Internal
maxAttempts 10, 7 отказов UNAVAILABLE 5 Unavailable
maxAttempts 4, пауза около 1 с, срок 500 мс 1 DeadlineExceeded
стрим без политики (контроль), отказ без заголовков ответа 1 Unavailable, сообщений 0
стрим, повтор UNAVAILABLE, отказ без заголовков ответа 4 Unavailable, сообщений 0
стрим, повтор UNAVAILABLE, заголовки ответа, затем отказ 1 Unavailable, сообщений 0
стрим, повтор UNAVAILABLE, отказ после первого сообщения 1 Unavailable, сообщений 1

Контроль сошёлся: без политики — одна попытка.

Повторяются только коды из retryableStatusCodes: INTERNAL проходит к клиенту с первой попытки, как и должно, — повторять ошибку логики бессмысленно. maxAttempts: 10 библиотеки молча урезают до 5:

$ python3 scripts/run-retry.py java java max10
{"variant": "java->java/max10", "outcome": "observed", "detail": "попыток на сервере 5, клиент получил Unavailable"}

Пять попыток вместе с первой, то есть не больше четырёх повторов, — предел по умолчанию: в grpc-go его задаёт WithMaxCallAttempts, и политика из service config выше него не поднимается. Срок вызова ограничивает и повторы. Пауза перед повтором в grpc-go и grpc-java 1.84 — initialBackoff с разбросом ±20%: при initialBackoff 1 с первая пауза от 0,8 до 1,2 с, длиннее срока 500 мс, и вторая попытка не успевает никогда — клиент получает DEADLINE_EXCEEDED после первой.

Совпадение кода с retryableStatusCodes ещё не означает повтор. Как только клиент получил от сервера заголовки ответа, вызов считается зафиксированным, и библиотека его больше не повторяет (правила повторов). Стенд проверяет эту границу на серверном стриме (строки «стрим» в таблице). Сервер отвечает UNAVAILABLE сразу, без заголовков, — стрим повторяется, как unary-вызов. Сервер отправляет заголовки ответа и только потом UNAVAILABLE, не отправив ни одного сообщения, — повтора уже нет: на сервере один вызов, у клиента ноль сообщений. Значит, фиксирует вызов именно заголовок, а не первое сообщение. Отказ после первого сообщения — тот же случай: заголовки ушли вместе с ним. Есть и случай, который стенд не ставил: прозрачные повторы — когда запрос не дошёл до обработчика сервера, библиотека повторяет его сама, без всякой политики. Когда повторять безопасно, как ограничивать повторы бюджетом и что такое retry throttling, разобрано в статье про resilience-паттерны.

Соединение: keepalive, прокси и балансировка

Слишком частые пинги

Keepalive gRPC — кадры PING HTTP/2: клиент проверяет, жив ли пир, и не даёт промежуточным узлам счесть соединение простаивающим. Сервер, в свою очередь, защищается от слишком частых пингов: по умолчанию в обеих библиотеках он разрешает их не чаще раза в 5 минут, а за повторные нарушения отвечает GOAWAY с кодом ENHANCE_YOUR_CALM и пометкой too_many_pings.

Постановка. Клиент пингует раз в 10 секунд — чаще клиенты Go и Java не позволяют, меньшие значения поднимают до 10 с. Долгий серверный стрим, который молчит: одно сообщение в начале и одно через 50 секунд. Политика сервера — по умолчанию или с разрешённым интервалом в 5 секунд.

политика сервера клиент Go клиент Java
по умолчанию (не чаще раза в 5 минут) GOAWAY, Unavailable GOAWAY, ResourceExhausted
разрешено раз в 5 с стрим дожил стрим дожил

Политика по умолчанию обрывает стрим. Код, который видит клиент, зависит от языка клиента, а не сервера: grpc-go называет обрыв UNAVAILABLE, grpc-java — RESOURCE_EXHAUSTED. Первая постановка опыта — стрим с сообщением каждую секунду — GOAWAY не получала вовсе: сервер прощает пинги, пока сам пишет в соединение. Под удар попадают как раз долгие тихие стримы — подписки и уведомления, ради которых keepalive и включают. Пинги клиента согласуют с политикой сервера, а не выбирают в одиночку.

Простой за прокси

Постановка. Вызов, простой 20 секунд, вызов; таймаут простоя у прокси — 15 секунд. Пути: напрямую (контроль), Angie и Envoy как L4 и как L7. Пинги: выключены или раз в 10 секунд, и сервер их разрешает. Пережило ли соединение простой, проба решает по локальному адресу TCP-соединения клиента с прокси: у обоих вызовов он один — соединение то же, разный — открыто новое. Состояние канала перед вторым вызовом записано рядом, но доказательством не служит: канал мог переподключиться и снова стать READY (состояния канала).

путь без пингов пинги раз в 10 с
напрямую (контроль) пережило пережило
Angie, L4 (stream) закрыто, новое соединение пережило
Envoy, L4 (tcp_proxy) закрыто, новое соединение пережило
Angie, L7 (grpc_pass) закрыто, новое соединение закрыто, новое соединение
Envoy, L7 (HTTP) закрыто, новое соединение закрыто, новое соединение

Контроль сошёлся: без прокси соединение переживает простой.

Второй вызов проходит во всех случаях — клиент переподключается сам. Разница в том, пережило ли соединение простой. За L4-прокси пинги клиента — обычный трафик, и они продлевают соединение. За L7 — нет: и у Angie grpc_pass, и у Envoy HTTP таймаут простоя соединения считает активные вызовы, а не байты, и PING-кадры его не сбрасывают.

$ python3 scripts/run-keepalive.py go idle angie-l4 on
{"variant": "go/idle/angie-l4/ka-on", "outcome": "survived", "detail": "соединение пережило простой: оба вызова по одному соединению (канал READY)"}
$ python3 scripts/run-keepalive.py go idle envoy-l7 on
{"variant": "go/idle/envoy-l7/ka-on", "outcome": "reconnected", "detail": "соединение закрыто на простое, второй вызов — по новому (канал перед ним IDLE)"}

Это опыт про соединение без вызовов, и переподключение для следующего unary-вызова почти незаметно. Открытый стрим — уже активный вызов, и к нему таймаут простоя соединения не применяется; молчание внутри вызова ограничивают другие параметры — у Angie grpc_read_timeout (60 с по умолчанию), у Envoy stream_idle_timeout (5 минут), — а L4-прокси видит только тишину в TCP и о вызовах не знает вовсе (таймауты Envoy).

Постановка. Активный серверный стрим молчит 20 секунд — сообщение в начале и через 20 секунд — при том же таймауте простоя 15 секунд. Пути и пинги — те же.

путь стрим без пингов стрим с пингами раз в 10 с
напрямую (контроль) дожил дожил
Angie, L4 (stream) оборван дожил
Envoy, L4 (tcp_proxy) оборван дожил
Angie, L7 (grpc_pass) дожил дожил
Envoy, L7 (HTTP) дожил дожил

За L7 молчащий стрим доживает и без пингов: у открытого вызова свои, более длинные таймауты. За L4 без пингов он обрывается на 15 секундах тишины, с пингами — доживает. Получается зеркально: пинги клиента нужны за L4, чтобы дожил молчащий стрим, и бесполезны за L7, где соединение без вызовов закроют всё равно. Таймауты прокси настраивают по двум вопросам отдельно — сколько держать соединение без вызовов и сколько терпеть молчание внутри вызова.

Балансировка долгого соединения

Канал gRPC мультиплексирует вызовы поверх HTTP/2-соединений, и сколько у него соединений, решают адрес и политика балансировки: с pick_first — одно, с round_robin — по соединению на каждый адрес, который вернул резолвер. Когда перед сервисом один адрес балансировщика, соединение тоже одно, и для балансировщика, который распределяет соединения, это одна единица работы, сколько бы вызовов в ней ни шло.

Постановка. Три бэкенда; клиент делает 30 вызовов по одному каналу. Пути: напрямую по DNS-имени с политикой pick_first (контроль) и round_robin; Angie и Envoy как L4 и как L7. Все пары; счёт — какой бэкенд ответил и сколько соединений каждый принял.

путь куда ушли 30 вызовов
напрямую, pick_first (контроль) все на один бэкенд
напрямую, round_robin по DNS на все три
Angie, L4 (stream) все на один бэкенд
Envoy, L4 (tcp_proxy) все на один бэкенд
Angie, L7 (grpc_pass) на все три
Envoy, L7 (HTTP) на все три
$ python3 scripts/run-balance.py go go angie-l4
{"variant": "go->go/angie-l4", "outcome": "pinned", "detail": "все 30 вызовов на одном бэкенде"}
$ python3 scripts/run-balance.py go go angie-l7
{"variant": "go->go/angie-l7", "outcome": "spread", "detail": "вызовы по бэкендам: 10, 10, 10"}

L4-балансировщик выбрал бэкенд один раз — при установке соединения, — и все 30 вызовов ушли на него; два других простаивают. Это классическая картина «после выкладки вся нагрузка на одном поде»: Kubernetes Service без меша — тот же L4. Помогают L7-прокси, который разбирает HTTP/2 и выбирает бэкенд на каждый вызов, или клиентская балансировка: round_robin по DNS-имени, за которым все адреса бэкендов, открывает по соединению на каждый и раздаёт вызовы по кругу. В Kubernetes для этого нужен headless Service: обычный Service отвечает на DNS одним адресом ClusterIP, и round_robin поверх него балансирует по одному адресу, то есть никак; headless Service возвращает адреса подов (DNS для сервисов). Как пулом соединений управляет сам клиент, разобрано в статье про Go-клиент под нагрузкой.

Размер сообщения

Предел принимаемого сообщения по умолчанию — 4 МБ у обоих клиентов. Ответ в 5 МБ клиент отклоняет кодом RESOURCE_EXHAUSTED — сервер его честно отправил, и ошибка видна только у получателя; с поднятым пределом ответ доходит. Большие данные в gRPC отдают стримом частями, а не поднимают предел до размера самого большого ответа.

Как выбирать настройки

что на стенде что делать
срок вызова теряется на звене с новым контекстом передавать контекст дальше; новый — только со своим сроком
отмена стрима без проверки цикл идёт до конца; в Java уведомление об отмене ждёт возврата обработчика проверять отмену в цикле: в Go — контекст, в Java — isCancelled()
ошибки обычная ошибка — UNKNOWN возвращать status с кодом; детали — ErrorInfo
паника Go без перехватчика падает целиком; перехватчик снаружи метрик прячет панику от них перехватчик паники ближе всего к обработчику; для стримов — своя цепочка
шлюз детали ошибки — 500 пустой импорт errdetails в шлюзе
интерсепторы метрики после авторизации слепы к отказам метрики и журнал снаружи, авторизация внутри
ретраи только retryableStatusCodes, по умолчанию не больше 5 попыток вместе с первой, не после заголовков ответа повторять только идемпотентное, по UNAVAILABLE
keepalive пинги чаще политики сервера — GOAWAY на тихом стриме согласовать интервал клиента с MinTime / permitKeepAliveTime
простой соединения за L7 пинги не спасают, следующий вызов переподключается таймаут без вызовов — по цене переподключения
молчание стрима за L4 без пингов обрыв пинги клиента чаще таймаута L4, согласованные с сервером
балансировка L4 сажает соединение на один бэкенд L7-прокси или клиентский round_robin по headless Service

Чего эти результаты не значат

  • Ничего о задержке и пропускной способности. Сроки и паузы — постановка.
  • Одна машина: macOS arm64, Docker Desktop, сеть — мост Docker.
  • Версии: grpc-go и grpc-java 1.84, protoc 36.2, grpc-gateway 2.31, Angie 1.12.2, Envoy 1.39. Минимальный интервал пингов, предел maxAttempts и отображение кодов в HTTP — свойства этих версий.
  • Прокси — в конфигурации стенда с таймаутами простоя 15 с. У балансировщиков облаков и сервис-мешей свои таймауты и своя реакция на пинги — это проверяют своим опытом.
  • Ретраи — по одному вызову на ключ. Под нагрузкой повторы ещё и ограничиваются retry throttling, который стенд не включал; прозрачные повторы стенд не ставил.
  • Flow control не разобран. Что делает отправитель, когда получатель стрима читает медленно, и как в двунаправленном стриме это доводит до взаимной блокировки, — отдельный опыт, которого здесь нет (flow control).

Выводы

  • Срок вызова доходит до нижнего звена через любые языки, если его не потерять в середине новым контекстом.
  • Отменённый стрим останавливает только проверка в обработчике. В Java исключение и callback отмены доходят лишь до кода, который работает после возврата обработчика; цикл внутри обработчика опрашивает isCancelled().
  • Обычная ошибка — UNKNOWN; код ставят явно. Перехватчик паники в Go обязателен.
  • gRPC-Gateway без зарегистрированных типов деталей превращает ошибки с деталями в 500.
  • Порядок интерсепторов решает, что видят метрики: перехватчик паники — ближе к обработчику, иначе паника проходит мимо них.
  • Встроенные ретраи повторяют только названные коды, в пределах настроенного числа попыток (на стенде предел по умолчанию — пять вместе с первой) и прекращаются, как только пришли заголовки ответа.
  • Keepalive клиента согласуют с политикой сервера; он держит молчащий стрим за L4-прокси и не держит соединение без вызовов за L7.
  • За L4-балансировщиком соединение канала — один бэкенд.

Ссылки

Обсуждение в Telegram

Присоединиться →