Міграція на gRPC. Як зменшити розмір payload та покращити швидкість обміну даними

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Привіт! Мене звати Руслан Терехов, я 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-повідомлення, що негативно впливало на обсяг трафіку між сервісами.

Тому з цих пунктів сформувалась мотивація:

  1. Уніфікувати всі формати повідомлень.
  2. Зменшити їх розмір при транспортуванні між сервісами.

В індустрії існують декілька технологій, які розв’язують цю задачу: 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

Гнучкість у виборі протоколу та серіалізації

Дуже компактний формат даних

Застосування

Мікросервіси, взаємодія між сервісами

Робота з великими даними

Різноманітні випадки використання

Обмін даними між клієнтом та сервером

Автоматична генерація коду

Так

Так

Так

Ні

Сумісність з великими обʼємами даних

Обмежена

Висока

Середня

Низька

Можливості стрімінгу

Так

Так

Так

Ні

Базуючись на інформації вище, ми маємо ось такі висновки:

  1. Продуктивність. gRPC та Thrift мають високу продуктивність, хоча gRPC виграє завдяки HTTP/2 та Protocol Buffers. Avro та MessagePack також ефективні, але можуть поступатися залежно від сценарію використання.
  2. Мультиплатформність. Всі розглянуті технології підтримують багато мов програмування. Thrift та gRPC мають ширшу підтримку.
  3. RPC підтримка. gRPC та Thrift повністю підтримують RPC, тоді як Avro та MessagePack більше орієнтовані на серіалізацію даних без вбудованої підтримки RPC.
  4. Зручність розробки. 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 всім, хто прагне підвищити ефективність та надійність своїх систем, особливо в умовах, коли важлива швидкість та об’єм переданих даних. Правильне планування та налаштування процесу міграції допоможуть уникнути підводних каменів та забезпечити гладкий перехід на новий протокол. Якщо у вас є питання щодо цього процесу або ви хочете обговорити інші аспекти розробки та оптимізації, не соромтеся звертатися!

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Я вважаю, що інженер повинен володіти кількома мовами програмування та вміти обирати відповідну залежно від задачі

Можеш, будь-ласка, навести приклади задач з аргументами на користь обраної мови?

Ну окрім очевидних штук типа мов для мобільних застосунків, Swift, Objective C, Java, Kotlin, JS (у вигляді React Native etc), Dart, там embed зі їх c, c++, rust, asm

наприклад якщо у вас в команді купа JS розробників, і вам потрібно написати якійсь невеличкий сервіс, або прототип MVP в якому нема heavy cpu utilisation задач, то норм вибором буде node.js, deno, bun etc.

Далі вже можна прийняти рішення про масштабування цього сервісу в умовах реального життя — реалізувати на мові зі статичним рантаймом та/або багатопоточністю, канкаренсі etc.

Звісно тут без фанатизму — бо неможливо гарно знати всі мови та рантайми

наприклад якщо у вас в команді купа JS розробників

Якщо в команді купа JS розробників, то вибір наче очевидний. Але у нас в команді не купа розробників JS, у нас в команді «інженери, що володіють кількома мовами програмування». Нехай це буде JS, Python, .NET, Java. Яку мову обрати для

невеличкий сервіс, або прототип MVP в якому нема heavy cpu utilisation

(з аргументами на користь кожної з мов, будь-ласка)

Допустим ми обрали, як ти сказав, node.js.

Далі вже можна прийняти рішення про масштабування цього сервісу в умовах реального життя — реалізувати на мові зі статичним рантаймом та/або багатопоточністю, канкаренсі etc.

Як ти приймаєш рішення, що треба щось переробити «на мові зі статичним рантаймом». Доречі, що таке «мова зі статичним рантаймом»?
Багатопоточність. Я не знаюся на ноді, але хіба не можна на ноді зробити «багатопоточну» обробку. «Багатопоточну» взяв у лапки, тому що навіть якщо сама нода однопоточна, то можна ж сам додаток змаштабувати до потрібної кількості екземплярів, або щось накшталт того.
Якщо сервіс великий, яку мову обрати (доречі, як зрозуміти великий сервіс чи ні)?

Звісно тут без фанатизму — бо неможливо гарно знати всі мови та рантайми

В мене не сходиться «повинен володіти кількома мовами програмування» і «неможливо гарно знати всі мови та рантайми». Який сенс в такому «володінні» мовами?

Я не готовий приймати ваш tone of voice, необхідні вам аргументи присутні в загальному доступу або в університеті) гляньте там

Тобто аргументів у вас більше нема.

Під статичним рантаймом я маю на увазі статічно злінкований executable, якому не потрібен окремий рантайм, лише умовний libc або взагалі і без нього(wasm)

А де buf? Лінтери, перевірка оберненої сумісності, ...? Так вже не носять у 2024 )

Buf у себе в репці) лінтери і все необхідне на місці, стаття не про те, щоб продати комусь Protobuf, після запуску цього пілота — завжди буде доступне поле для покращення, тому в наступних ітераціях будемо закривати болі які зʼявляться на першій (якщо)

Коментар порушує правила спільноти і видалений модераторами.

Сценарій: Передача 1000 повідомлень розміром 1 KB.

А які були повідомлення? Яке кодування?

Середній розмір повідомлення (KB)

А якщо усі повідомлення були 1 КБ, то в чому сенс рахувати середній розмір?

Це бенчмарки заради бенчмарків:
— зрозуміло, що JSON (тим паче без компресії) меньш ефективний;
— пропускна здатність сервера залежить від його реалізації (треба було порівняти з fasthttp)

саме так,

fasthttp

— використовуємо на мікросервісах, які взаємодіють з користувачами через REST. Grpc між-сервісна взаємодія

Це синтетичні тести, стаття не про продаж Protobuf як чарівного рішення для вашого перформансу

Стаття про те, як protobuf допоміг вам зменшити трафік і прискорити обробку запитів. Тому, хотілося б побачити реальні графіки, на яких зафіксовані ці покращення. Скільки мережевих ресурсів та процесорного часу вдалося вивільнити...

Cap’n’proto замість protobuff не розглядали?

дякую за цікавий коментар —

Cap’n’proto

виглядає цікаво, але мені особисто не вистачило одного тулчейну для кодогенерації, якось досліджу детальніше

Виконання 1000 запитів до сервера з простим повідомленням.

Це послiдовнi запити чи паралельнi?

у даному контексті це не принципово, тести синтетичні та ілюстративні

Підписатись на коментарі