Міграція на gRPC. Як зменшити розмір payload та покращити швидкість обміну даними
Привіт! Мене звати Руслан Терехов, я Back-end інженер в продуктовій IT-компанії Universe Group, до якої входять три самостійні бізнеси — Guru Apps, FORMA, Wisey, а також власний R&D-центр. Я займаюсь розробкою та проєктуванням різноманітних систем вже 17 років, з яких 7 — в DevOps. Сьогодні разом з командою в Guru Apps займаюсь оптимізацією поточних систем та проєктую і розробляю нові.
Окрім того, маю досвід роботи в доменах Finance, Healthcare, Blockchain, Gamedev та інших. Працював з Rust, Node.js, Golang, PHP. Я вважаю, що інженер повинен володіти кількома мовами програмування та вміти обирати відповідну залежно від задачі.
У цій статті хочу поділитися досвідом міграції на gRPC, яку ми здійснювали в нашій компанії, та розглянути флоу цього процесу. Інформація буде цікава тим, хто хоче дізнатися більше про оптимізацію систем та сучасні підходи до розробки, а саме: Back-end інженерам, технічним лідам, full-stack розробникам, СТО та DevOps-інженерам.
Передісторія
Хочу почати з того, що кожен архітектор часто ставить собі за мету уніфікувати всі контракти форматів обміну повідомленнями у своїй системі, і я не виняток.
У нас в Universe Group це питання також було актуальним: наші мікросервіси використовували певні техніки уніфікації, зокрема загальні типи з внутрішніх пакетів NPM, і застосовували транспорт HTTP та JSON для обміну даними.
Це непогане рішення, але є ряд недоліків. В runtime сервісу ми не мали гарантій щодо валідності payloads — TypeScript дуже допомагає в розробці, звісно, але вся його магія зникає після компіляції в JavaScript. Кожен сервіс сам мав нести відповідальність за валідацію вхідних і вихідних даних.
Також ми зіштовхнулися з іншою класичною проблемою — розміром payload і мережевим overhead. Ми постійно передавали й отримували великі JSON-повідомлення, що негативно впливало на обсяг трафіку між сервісами.
Тому з цих пунктів сформувалась мотивація:
- Уніфікувати всі формати повідомлень.
- Зменшити їх розмір при транспортуванні між сервісами.
В індустрії існують декілька технологій, які розв’язують цю задачу: gRPC, Avro, Thrift, MessagePack та інші.
Основна ідея в тому, що ми оперуємо поняттям «контракту» — файлу, у якому використовуючи мову DSL (Domain Specific Language) описуємо: як буде виглядати наше повідомлення, які RPC (Remote Procedure Calls) методи має сервіс і вже з нього генеруємо boilerplate-код під будь-яку мову програмування.
Обираємо найкращий стандарт
Характеристика |
gRPC |
Avro |
Thrift |
MessagePack |
Протокол |
HTTP/2, Protocol Buffers |
JSON для схем, бінарні дані |
Власний транспортний протокол |
Формат серіалізації даних |
Продуктивність |
Висока |
Добра, але нижча за gRPC |
Висока |
Висока |
Мови програмування |
Багато |
Багато |
Багато |
Багато |
Підтримка RPC |
Повна підтримка RPC, включаючи стріми |
Не включає RPC |
Повна підтримка RPC |
Не включає RPC |
Зручність розробки |
Висока, простота налаштування |
Середня, потребує додаткових інструментів |
Складніша, потребує налаштувань |
Середня, потребує додаткових інструментів |
Особливості |
Активна підтримка, велика комʼюніті |
Добре інтегрується з Hadoop |
Гнучкість у виборі протоколу та серіалізації |
Дуже компактний формат даних |
Застосування |
Мікросервіси, взаємодія між сервісами |
Робота з великими даними |
Різноманітні випадки використання |
Обмін даними між клієнтом та сервером |
Автоматична генерація коду |
Так |
Так |
Так |
Ні |
Сумісність з великими обʼємами даних |
Обмежена |
Висока |
Середня |
Низька |
Можливості стрімінгу |
Так |
Так |
Так |
Ні |
Базуючись на інформації вище, ми маємо ось такі висновки:
- Продуктивність. gRPC та Thrift мають високу продуктивність, хоча gRPC виграє завдяки HTTP/2 та Protocol Buffers. Avro та MessagePack також ефективні, але можуть поступатися залежно від сценарію використання.
- Мультиплатформність. Всі розглянуті технології підтримують багато мов програмування. Thrift та gRPC мають ширшу підтримку.
- RPC підтримка. gRPC та Thrift повністю підтримують RPC, тоді як Avro та MessagePack більше орієнтовані на серіалізацію даних без вбудованої підтримки RPC.
- Зручність розробки. gRPC має перевагу завдяки простоті налаштування, активній спільноті та широкій документації. Thrift може бути складнішим у налаштуванні, а Avro та MessagePack потребують додаткових інструментів для реалізації RPC.
Враховуючи ці аспекти, я обрав gRPC як оптимальний варіант для розробки мікросервісів та взаємодії між ними — завдяки його продуктивності, зручності, активній підтримці.
Що таке gRPC та Protobuf
Для тих, хто не знайомий, gRPC (Remote Procedure Calls) — це фреймворк для віддалених викликів процедур від Google. Він використовує транспорт HTTP/2, що забезпечує швидку та ефективну передачу даних.
Protobuf (Protocol Buffers) — це система серіалізації даних, також розроблена Google. Вона дозволяє описувати структури даних у текстових файлах (.proto), які потім компілюються в код для різних мов програмування.
Який був план
Головне питання, яке цікавить: як організувати сховище proto-файлів (далі — протоси) та генерувати код під необхідні платформи, щоб це не стало боляче?
Врешті-решт, у мене вийшла концепція, яку й розглянемо далі. На малюнку нижче ми бачимо п’ять репозиторіїв з налаштованими CI/CD.
Зараз проілюструємо та базово розберемо процес релізу нових протосів.
Main protos repo
- Обовʼязково робимо semver релізи з тегами.
- Після публікації тега, CI ініціює реліз цього тега в двох інших репозиторіях: nodejs-grpc-sdk та golang-grpc-sdk.
Nodejs-grpc-sdk repo
- При публікації нового тега запускається білдер-скрипт, який генерує типи TypeScript, і хелпери для NestJS на основі протосів з основного репозиторію за цим тегом.
#!/bin/bash rm -rf ./{dist,build} mkdir -p ./{dist,build} protoc --plugin="$(npm root)"/.bin/protoc-gen-ts_proto \ --ts_proto_out=build \ --ts_proto_opt=nestJs=true,exportCommonSymbols=false \ -I=. ./protoss/*.proto mv ./build/protoss ./build/types for file in ./build/types/*.ts; do filename=$(basename -- "$file") filename_no_ext="${filename%.*}" echo "export * from './types/$filename_no_ext';" >> ./build/index.ts done ./node_modules/.bin/tsc rm -rf ./build
Тут ми використали компілятор protoc з плагіном protoc-gen-ts_proto, який і відповідає за генерацію типів та дуже зручних хелперів для NestJS framework, який ми активно використовуємо. Про те, як цим користуватись, розповім трохи згодом.
Публікуємо згенерований артефакт в internal npm за цим тегом.
Golang-grpc-sdk repo
- При публікації тега в main protos repo запускається CI, яка збирає Golang pb-файли та комітить їх у golang-grpc-sdk за цим тегом. Після цього пакет можна використовувати у будь-яких проєктах для імплементації.
#!/bin/bash go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest rm -rf ./protoss git clone [email protected]:acme/protoss.git --depth=1 for file in ./protoss/*.proto; do filename=$(basename -- "$file") filename_no_ext="${filename%.*}" rm -rvf ./$filename_no_ext done protoc --go_out=. --go-grpc_out=. protoss/*.proto mv ./grpc-golang-sdk/* ./ rm -rf {grpc-golang-sdk,protoss}
P.S.Приклади скриптів ілюстративні.
Що робити з усім цим
Базова робота з gRPC чудово описана у відповідному розділі документації NestJS.
Розпочнімо зі створення сервера на NestJS та використаємо згенеровані нами типи.
- Додаємо конфігурацію gRPC.
import { join } from 'node:path'; import { cwd, env } from 'node:process'; import { ReflectionService } from '@grpc/reflection'; import { type ClientOptions, Transport } from '@nestjs/microservices'; import { DEFAULT_GRPC_PORT } from '@src/constants'; const basePath = join( cwd(), 'node_modules', '@acme/nodejs-sdk', './protoss', ); export const grpcClientOptions: ClientOptions = { transport: Transport.GRPC, options: { package: ['grpc.health.v1'], protoPath: [ join(basePath, 'transactions.proto'), join(basePath, 'health.proto'), ], gracefulShutdown: true, onLoadPackageDefinition: (pkg, server) => { new ReflectionService(pkg).addToServer(server); }, url: `0.0.0.0:${env.GRPC_PORT || DEFAULT_GRPC_PORT}`, }, };
- Реєструємо контролер як gRPC handler.
import { Controller } from '@nestjs/common'; import { HealthController as GrpcHealthController, HealthCheckResponse, HealthCheckResponse_ServingStatus, HealthControllerMethods, } from '@acme/nodejs-sdk/dist/health'; import { Observable } from 'rxjs'; @Controller() @HealthControllerMethods() export class HealthController implements GrpcHealthController { async check(): Promise<HealthCheckResponse> { return { status: HealthCheckResponse_ServingStatus.SERVING, }; } watch(): Observable<HealthCheckResponse> { throw new Error('Method not implemented.'); } }
Використання згенерованих декораторів та типів з пакета @acme/nodejs-sdk значно зменшує кількість бойлерплейту при описі gRPC-хендлерів.
Виглядають вони приблизно так, як на цьому скриншоті.
import { Observable } from "rxjs"; export interface HealthCheckRequest { service: string; } export interface HealthCheckResponse { status: HealthCheckResponse_ServingStatus; } export declare enum HealthCheckResponse_ServingStatus { UNKNOWN = 0, SERVING = 1, NOT_SERVING = 2, /** SERVICE_UNKNOWN - Used only by the Watch method. */ SERVICE_UNKNOWN = 3, UNRECOGNIZED = -1 } export interface HealthClient { check(request: HealthCheckRequest): Observable<HealthCheckResponse>; watch(request: HealthCheckRequest): Observable<HealthCheckResponse>; } export interface HealthController { check(request: HealthCheckRequest): Promise<HealthCheckResponse> | Observable<HealthCheckResponse> | HealthCheckResponse; watch(request: HealthCheckRequest): Observable<HealthCheckResponse>; } export declare function HealthControllerMethods(): (constructor: Function) => void; export declare const HEALTH_SERVICE_NAME = "Health";
Створюємо клієнт.
- Реєструємо простенький модуль.
@Module({ imports: [ ConfigModule, ClientsModule.register([ { name: 'HEALTH_SERVICE', transport: Transport.GRPC, options: { package: 'health', protoPath: [join(protofilesBasePath, health.proto')], gracefulShutdown: true, url: env.TRANSACTIONS_MS_GRPC_URL, }, }, ]), ], }) export class HealthModule {}
- Додаємо імплементацію.
@Injectable() export class HealthService implements OnModuleInit { private healthService: HealthServiceImpl; constructor( @Inject(HEALTH_SERVICE) private client: ClientGrpc, private readonly configService: ConfigService, ) {} async checkHealth(): Promise<string> { const response = await firstValueFrom( this.healthService.check() ); if (!response) { throw new Error('Healthcheck failed'); } return response.data; } onModuleInit() { this.healthService = this.client.getService<HealthServiceImpl>( HEALTH_SERVICE_NAME, ); } }
Тепер можемо запакувати цей код в NPM-пакет і використовувати скрізь.
Схожий процес повторюємо для Golang.
Серверна частина
type grpcServer struct { health.UnimplementedHealthServer } func (s *grpcServer) Check(ctx context.Context, in *health.HealthCheckRequest) (*health.HealthCheckResponse, error) { return &health.HealthCheckResponse{ Status: health.HealthCheckResponse_SERVING, }, nil } func Start(port int, logger *logger.Logger) net.Listener { grpcPort := fmt.Sprintf(":%v", port) grpcListener, err := net.Listen("tcp", grpcPort) if err != nil { logger.Fatal(fmt.Sprintf("failed to listen grpc: %v", err)) } s := google_grpc.NewServer() server := &grpcServer{logger: logger} health.RegisterHealthServer(s, server) reflection.Register(s) go func() { if err := s.Serve(grpcListener); err != nil { logger.Fatal(fmt.Sprintf("failed to serve grpc: %v", err)) } }() logger.Info(fmt.Sprintf("Grpc server listening at %v", grpcListener.Addr())) return grpcListener }
Більш детально на імплементації клієнта не буду зупинятися, тому що на поточному етапі у нас їх поки що немає — основні клієнти це Node.js-сервіси.
Для загального уявлення про підхід до реалізації рекомендую переглянути цю статтю.
Основним викликом у процесі міграції є лише перші етапи, коли потрібно налаштувати всі пайплайни для отримання актуальних версій клієнтів. Після цього міграція стає значно приємнішою.
На першій ітерації ми реалізували свій запит, а саме:
- уніфікували всі формати повідомлень;
- зменшили їх розмір при транспортуванні між сервісами, покращили перформанс;
- створили інфраструктуру для дистрибуції пакетів коду всередині наших систем;
- та зробили наших користувачів трохи щасливішими.
І трохи бенчмарків наостанок.
Порівняльна таблиця швидкодії між gRPC та REST
Характеристика |
gRPC |
REST |
---|---|---|
Протокол |
HTTP/2 |
HTTP/1.1 |
Формат даних |
Protocol Buffers (бінарний формат) |
JSON/XML (текстовий формат) |
Швидкодія (серіалізація/десеріалізація) |
Висока завдяки бінарному формату |
Нижча через текстовий формат |
Мережеве навантаження |
Низьке через компактні бінарні дані |
Вище через текстові дані |
Затримка (latency) |
Низька завдяки HTTP/2, мультиплексування |
Вища через HTTP/1.1, відсутність мультиплексування |
Підтримка стрімінгу |
Повна підтримка одно- та двонаправленого стрімінгу |
Обмежена підтримка стрімінгу |
Продуктивність (з’єднання) |
Висока, підтримка багатоканальності |
Нижча, кожен запит потребує окремого з’єднання |
Ресурсомісткість |
Ефективніше використання ресурсів |
Вище через накладні витрати на HTTP заголовки |
Підтримка SSL/TLS |
Вбудована підтримка |
Вбудована підтримка |
Простота інтеграції |
Вимагає використання спеціалізованих інструментів |
Простий у налаштуванні та використанні |
Стандартизація |
Менш стандартний, специфічний для gRPC |
Широко стандартизований та використовується у вебсервісах |
Мультиплатформність |
Широка підтримка мов програмування |
Широка підтримка мов програмування |
Інструментарій та підтримка |
Велика спільнота, активна підтримка від Google |
Велика спільнота, широке використання |
Також хочу додати декілька синтетичних бенчмарків.
Бенчмарк 1: Час відповіді (Latency)
Сценарій: Виконання 1000 запитів до сервера з простим повідомленням.
Технологія |
Середній час відповіді (мс) |
Максимальний час відповіді (мс) |
Мінімальний час відповіді (мс) |
gRPC |
5 |
8 |
4 |
REST |
15 |
25 |
10 |
Бенчмарк 2: Мережеве навантаження
Сценарій: Передача 1000 повідомлень розміром 1 KB.
Технологія |
Середній розмір повідомлення (KB) |
Загальний об’єм переданих даних (MB) |
gRPC |
1.1 |
1.1 |
REST |
2.5 |
2.5 |
Висновки, які я зробив під час впровадження gRPC
Використання ідеології ‘code first’ та ‘contract first’-підходів допомагають створювати уніфіковані контракти та зменшити можливість помилок при передачі даних між сервісами.
Міграція на gRPC принесла суттєві покращення нашій системі. Завдяки уніфікованим контрактам та використанню ефективного протоколу серіалізації даних, ми змогли досягти вищої продуктивності та зменшити обсяг мережевого трафіку. Переваги gRPC, такі як простота налаштування, підтримка стрімінгу, активна спільнота та широка підтримка різних мов програмування, зробили його оптимальним вибором для нашої мікросервісної архітектури.
Рекомендую звернути увагу на gRPC всім, хто прагне підвищити ефективність та надійність своїх систем, особливо в умовах, коли важлива швидкість та об’єм переданих даних. Правильне планування та налаштування процесу міграції допоможуть уникнути підводних каменів та забезпечити гладкий перехід на новий протокол. Якщо у вас є питання щодо цього процесу або ви хочете обговорити інші аспекти розробки та оптимізації, не соромтеся звертатися!
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів