gRPC для микросервисов: Protocol Buffers, стриминг и работа в продакшене

Как устроен gRPC: контракт на Protocol Buffers и его эволюция, четыре типа вызовов, пример на Node.js, дедлайны и коды ошибок, балансировка в Kubernetes, mTLS, gRPC-Web и Connect для браузера.

Когда сервисов становится больше десятка, договорённости «какие поля в этом JSON» перестают держаться в головах разработчиков. Кто-то переименовал поле, кто-то поменял тип, и ошибка всплывает уже в продакшене. gRPC решает эту проблему на уровне инструмента: контракт описывается в файле схемы, код клиента и сервера генерируется из него, а данные передаются в компактном бинарном формате поверх HTTP/2. В этой статье — как устроен gRPC, как правильно описывать контракт на Protocol Buffers, какие типы вызовов он поддерживает, что нужно учесть в продакшене (дедлайны, ошибки, балансировка, безопасность), как быть с браузером и в каких случаях gRPC не нужен. Что такое gRPC gRPC — открытый фреймворк удалённого вызова процедур. Он вырос из внутренней системы Google под названием Stubby, в 2015 году Google опубликовала открытую версию, а в 2017 году проект принят в Cloud Native Computing Foundation. Идея RPC проста: клиент вызывает метод удалённого сервиса так, будто это обычная функция в его коде. Всё, что происходит между вызовом и результатом — сериализация, передача по сети, обработка ошибок, — берёт на себя фреймворк. gRPC опирается на три элемента: Protocol Buffers — язык описания интерфейсов и бинарный формат сериализации. Схема задаёт сообщения и методы, а номера полей позволяют развивать контракт без поломки старых клиентов. HTTP/2 — транспорт с мультиплексированием множества вызовов в одном соединении, сжатием заголовков и потоковой передачей в обе стороны. Кодогенерация — из одной схемы генерируются типизированные клиенты и серверные заготовки для Go, Java, C#, C++, Python, Node.js, Kotlin, Swift, Dart и других языков. На практике это означает, что сервис на Go и сервис на Python договариваются не через документацию, а через общий файл, изменения которого проходят ревью и автоматические проверки совместимости. Контракт на Protocol Buffers Схема хранится в файлах с расширением .proto . Ниже — пример сервиса пользователей, оформленный по рекомендациям, которые избавят от проблем при развитии API. syntax = "proto3"; package users.v1; import "google/protobuf/timestamp.proto"; option go_package = "example.com/platform/gen/users/v1;usersv1"; enum UserRole { USER_ROLE_UNSPECIFIED = 0; USER_ROLE_ADMIN = 1; USER_ROLE_EDITOR = 2; USER_ROLE_VIEWER = 3; } message User { string id = 1; string email = 2; string full_name = 3; UserRole role = 4; google.protobuf.Timestamp created_at = 5; reserved 6; // поле удалено, номер не переиспользуем reserved "phone"; } message GetUserRequest { string id = 1; } message CreateUserRequest { string email = 1; string full_name = 2; UserRole role = 3; } message ListUsersRequest { int32 page_size = 1; string page_token = 2; } message ListUsersResponse { repeated User users = 1; string next_page_token = 2; } message WatchUsersRequest { UserRole role = 1; } message ImportUsersResponse { int32 created_count = 1; } service UserService { rpc GetUser(GetUserRequest) returns (User); rpc CreateUser(CreateUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); rpc WatchUsers(WatchUsersRequest) returns (stream User); rpc ImportUsers(stream CreateUserRequest) returns (ImportUsersResponse); } Что здесь важно: Версия в имени пакета ( users.v1 ). Несовместимые изменения выпускаются в новом пакете v2 , а старый живёт, пока клиенты не перейдут. Нулевое значение перечисления — всегда «не указано». В proto3 отсутствующее поле читается как ноль, и без такого значения нельзя отличить «роль не передана» от «роль администратора». Номера полей неизменны. В бинарном формате передаётся номер, а не имя поля. Удалённые номера и имена помечаются reserved , чтобы их никто не занял. Стандартные типы. Для времени — google.protobuf.Timestamp , а не число или строка в произвольном формате. Постраничная выдача через токен , а не номер страницы: так список остаётся корректным, даже если данные меняются между запросами. Отдельные сообщения запроса и ответа для каждого метода: в них можно добавлять поля, не затрагивая другие методы. Код генерируется компилятором protoc с плагинами под нужные языки или инструментом Buf, который вдобавок проверяет схему линтером и умеет находить несовместимые изменения. Такую проверку стоит встроить в CI: # Проверка стиля схемы и обратной совместимости с основной веткой buf lint buf breaking --against ".git#branch=main" buf generate Четыре типа вызовов Унарный вызов Один запрос — один ответ. Аналог обычного HTTP-запроса и основной тип для большинства операций: получить, создать, изменить. Серверный поток Клиент отправляет один запрос, сервер возвращает последовательность сообщений. Подходит для выгрузки больших списков частями, подписки на изменения, отчёта о прогрессе долгой операции. Клиентский поток Клиент отправляет последовательность сообщений, сервер отвечает один раз в конце. Подходит для пакетной загрузки, передачи телеметрии, загрузки файла частями. Двунаправленный поток Обе стороны отправляют сообщения независимо друг от друга в рамках одного вызова. Применяется для синхронизации состояния, интерактивных сценариев и обмена событиями в реальном времени между сервисами. Пример сервера и клиента на Node.js Официальная реализация для Node.js — пакет @grpc/grpc-js . Схему можно загружать динамически через @grpc/proto-loader , как в примере ниже, или генерировать статический типизированный код. // server.js const grpc = require('@grpc/grpc-js'); const protoLoader = require('@grpc/proto-loader'); const { randomUUID } = require('node:crypto'); const definition = protoLoader.loadSync('users/v1/users.proto', { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, includeDirs: ['proto'], }); const { users: { v1: usersPkg } } = grpc.loadPackageDefinition(definition); const store = new Map(); function now() { const ms = Date.now(); return { seconds: Math.floor(ms / 1000), nanos: (ms % 1000) * 1e6 }; } const handlers = { GetUser(call, callback) { const user = store.get(call.request.id); if (!user) { return callback({ code: grpc.status.NOT_FOUND, details: 'user not found' }); } callback(null, user); }, CreateUser(call, callback) { const { email, full_name, role } = call.request; if (!email) { return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'email is required' }); } const user = { id: randomUUID(), email, full_name, role, created_at: now() }; store.set(user.id, user); callback(null, user); }, WatchUsers(call) { const { role } = call.request; for (const user of store.values()) { if (role === 'USER_ROLE_UNSPECIFIED' || user.role === role) { call.write(user); } } call.end(); }, }; const server = new grpc.Server(); server.addService(usersPkg.UserService.service, handlers); server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), (err, port) = { if (err) throw err; console.log(`gRPC server listening on ${port}`); }); Клиент в том же стиле. Обратите внимание на дедлайн — без него вызов может ждать ответа бесконечно: // client.js const client = new usersPkg.UserService('users:50051', grpc.credentials.createInsecure()); const metadata = new grpc.Metadata(); metadata.set('authorization', `Bearer ${token}`); client.GetUser( { id: 'a1b2c3' }, metadata, { deadline: Date.now() + 500 }, // 500 мс на весь вызов (err, user) = { if (err err.code === grpc.status.DEADLINE_EXCEEDED) { // вернуть резервный ответ или ошибку пользователю } } ); Небезопасные учётные данные ( createInsecure ) допустимы только в локальной разработке или внутри сервисной сетки, которая сама шифрует трафик. gRPC в продакшене: что нужно продумать Дедлайны В gRPC принято задавать не таймаут отдельного шага, а дедлайн всего вызова. Если сервис A вызывает B, а B — C, оставшееся время передаётся дальше по цепочке, и сервис C не тратит ресурсы на работу, результат которой уже никому не нужен. Дедлайн должен быть у каждого вызова без исключений. Коды ошибок У gRPC собственный набор статусов: INVALID_ARGUMENT , NOT_FOUND , ALREADY_EXISTS , PERMISSION_DENIED , UNAUTHENTICATED , RESOURCE_EXHAUSTED , UNAVAILABLE , DEADLINE_EXCEEDED и другие. Договоритесь в команде, какой код что означает, и повторяйте вызов только при временных ошибках — прежде всего UNAVAILABLE . Повтор при INVALID_ARGUMENT бессмыслен, а повтор неидемпотентной операции после DEADLINE_EXCEEDED может создать дубль. Балансировка нагрузки Самая частая неожиданность при переходе на gRPC в Kubernetes. HTTP/2 держит одно долгоживущее соединение, по которому идут все вызовы. Обычный балансировщик на уровне TCP распределяет соединения, а не запросы, и вся нагрузка от клиента попадает на один экземпляр сервиса, пока остальные простаивают. Решения: Балансировка на уровне L7 — прокси, понимающий HTTP/2 и gRPC, например Envoy, или сервисная сетка. Клиентская балансировка — клиент получает список адресов (в Kubernetes для этого используют headless Service) и сам распределяет вызовы, например по политике round robin. Ограничение возраста соединений на сервере, чтобы клиенты периодически переподключались и нагрузка перераспределялась. Проверки здоровья В gRPC есть стандартный протокол проверки состояния grpc.health.v1.Health . Kubernetes умеет использовать его в liveness- и readiness-пробах напрямую, без HTTP-обёрток. Безопасность Трафик между сервисами шифруется TLS, а в зрелых системах — взаимным TLS, когда и клиент, и сервер предъявляют сертификаты. Это исключает подмену сервиса внутри сети. В Node.js сервер с проверкой клиентского сертификата настраивается так: const fs = require('node:fs'); const credentials = grpc.ServerCredentials.createSsl( fs.readFileSync('certs/ca.crt'), [{ cert_chain: fs.readFileSync('certs/server.crt'), private_key: fs.readFileSync('certs/server.key') }], true // требовать сертификат клиента ); Если используется сервисная сетка, взаимный TLS она обычно берёт на себя. Авторизация пользовательских запросов строится на токенах, которые передаются в метаданных вызова — аналоге HTTP-заголовков — и проверяются на стороне сервера. Проверка прав на уровне конкретных объектов для gRPC так же обязательна, как для REST; типовые уязвимости API и защита от них разобраны в статье о безопасности API . Наблюдаемость Для gRPC есть инструментирование OpenTelemetry: трассы вызовов, метрики задержек и кодов ответа по каждому методу. Бинарный формат нельзя просто прочитать в логах, поэтому структурированное логирование на стороне сервиса важнее, чем для JSON-API. Как выстроить мониторинг распределённой системы, мы описали в материале о наблюдаемости в продакшене . Инструменты отладки grpcurl — аналог curl для gRPC, работает с файлами схем или через рефлексию сервера. Postman и другие API-клиенты с поддержкой gRPC — для ручной проверки методов. Рефлексия сервера — позволяет инструментам узнать схему у работающего сервиса. Во внешнем контуре её лучше отключать. Эволюция контракта без поломок Protocol Buffers хорошо переносит изменения, если соблюдать несколько правил: Добавлять новые поля и методы можно свободно: старые клиенты проигнорируют неизвестные поля. Нельзя менять номер или тип существующего поля. Нельзя переиспользовать номер удалённого поля — только reserved . Переименование поля не ломает бинарный формат, но ломает сгенерированный код и JSON-представление. Если нужно отличать «значение не передано» от нулевого значения, используйте optional или обёртки. Несовместимые изменения — через новую версию пакета и период параллельной поддержки. Автоматическая проверка buf breaking в конвейере ловит большинство нарушений до слияния. gRPC в браузере Браузерные API не дают управлять кадрами HTTP/2 так, как этого требует gRPC, поэтому напрямую из браузера вызвать gRPC-сервис нельзя. Варианты: gRPC-Web — протокол и библиотека для браузера. Запросы проходят через прокси (обычно Envoy), который переводит их в gRPC. Официальная реализация поддерживает унарные вызовы и серверные потоки; клиентские и двунаправленные потоки в браузере недоступны. Connect — семейство библиотек от Buf, с апреля 2024 года проект-песочница CNCF. Серверы Connect одновременно принимают gRPC, gRPC-Web и