top of page

Розподілені графи у мікросервісах: як уникнути помилок і зробити їхнє використання простим і зручним

Оновлено: 12 черв.



Поточна архітектура Prom.ua побудована на мікросервісах і GraphQL API, але так було не завжди. Максим Кіндріцький, Senior Python Engineer/Team Lead в EVO розповів, як команді вдалося зробити використання графів в мікросервісній архітектурі зручним і простим. Під час виступу на конференції Python + DS fwdays’24 він поділився, чому gRPC — ненайкраще рішення, щоби пов'язувати мікросервіси в GraphQL інфраструктурі, а також як технологія Apollo Federation допомогла зробили розробку більш доступною, розгортання — швидшим, а залишки моноліту — менш навантаженими. Публікуємо головне з його виступу. 

GraphQL та мікросервіси в Prom.ua

Проблеми розподіленого GraphQL на gRPC/REST

Як зробити розподілений GraphQL на Apollo Federation

Як працювати з Entities в Apollo Federation

Композиція схем та GraphQL Registry

Результати




GraphQL та мікросервіси в Prom.ua


Prom.ua – це один із найбільших маркетплейсів в Україні, яким щодня користуються понад три мільйони людей. Він написаний повністю на Python, також ми використовуємо GraphQL. Навантаження на бекенди складає близько 10 000 rps (моноліт+сервіси).


GraphQL — це мова запитів, а також Runtime (серверне середовище, яке підтримує GraphQL-специфікацію і вміє обробляти такі запити). Так виглядає найпростіший подібний  запит: ми викликаємо поле User, щоби дістати з нього інші поля. Праворуч — приклад простого GraphQL-сервера на фреймворку Strawberry, який дістає і повертає дані користувача. 



Розберемо приклад Кабінету Покупця на Prom.ua. До 2020 року архітектура виглядала так: найпростіший React SPA здійснював GraphQL-запити на моноліт, де були зосереджені всі дані та бізнес-логіка. З часом у нас з'явилися мікросервіси: Opinions, Favorites тощо. Водночас для користувача нічого не змінилося: GraphQL-запити так само йдуть на моноліт, який вирішує, як їх виконати. Наприклад, деякі поля дістає з бази, а за іншими звертається до сервісів через gRPC.


У схемі нижче і надалі розглядатимемо один мікросервіс Opinions для зручності.



Проблеми розподіленого GraphQL на gRPC/REST


Звʼязок між монолітом і мікросервісом через gRPC має деякі проблеми:


  1. Ми виділили мікросервіс, проте весь граф залишився в моноліті. Для будь-яких змін, пов'язаних з Opinions, коментарями тощо, потрібно код писати в моноліт. 

  2. Час на розробку фічей збільшився. Щоби додати якесь одне поле, наприклад, User ID, нам потрібно оновити протобафи, згенерувати з них клієнти та оновити їх у мікросервісі та моноліті. Потім у монолітному графі додати це поле із gRPC-респонсу, оновити мікросервіс та моноліт. Це займає багато часу. 

  3. Час деплою обмежений найповільнішим сервісом — монолітом. В той час, як мікросервіс може деплоїтись 10-15 разів на день, з монолітом працює багато людей, отже він потребує більшого тестування та перевірок.

  4. Трафік мікросервісів все ще проходить через моноліт, який фактично виступає певним шлюзом та передає запити в мікросервіс по gRPC, і ці дані повертаються назад. Ми ж прагнули менше залежати від моноліту, швидко деплоїтися та розробляти фічі тільки в мікросервісі.

  5. Оверфетчинг серверу. Загалом однією з переваг GraphQL є розв'язання цієї проблеми: якщо ви запитали з клієнта два поля, вам сервер поверне два поля. Проте коли ми почали використовувати gRPC, ми стикнулися з цією проблемою: коли ми запитуємо якийсь opinion, сервер повертає весь рядок із 20 полями, з яких ми беремо тільки два. Це не дуже оптимально.

  6. Забагато серіалізацій: json > dict > protobuf > dataclass > dict > json. Нижче видно, як трансформуються дані в одному GraphQL-запиті. Виглядає не дуже оптимально.


@dataclass
class Opinion:
id: int
	date_created: datetime

def handle_request(body: str) -> str:
	data = json.loads(body)
	pb = api.get_opinion(data)
	dcl = Opinion(id=pb.id, date_created=pb.date_created.ToDatetime())
result = db.to_dict()	
return json.dumps(result)

Як зробити розподілений GraphQL на Apollo Federation


Чи існує рішення, яке може розвʼязати усі ці проблеми одночасно? Тобто таке, що дозволить перенести частину графа в мікросервіс та вести в ньому розробку фічей, прибрати частину трафіку з моноліту, щоби він не займався обробкою запитів, які не стосуються його даних, а також швидко деплоїтись незалежно від моноліту. Ці можливості дала нам технологія Apollo Federation. Це інструмент, який дозволяє взяти всі графи ваших мікросервісів і обʼєднати їх в одну велику схему. 


Як це працює:

  1. Весь трафік тепер проходить через шлюз (в Apollo він ще називається роутер), який аналізує, куди за якими даними сходити. Цей роутер написаний на Rust та є досить швидким, з latency близько 5 мс. 

  2. Щоби роутер знав, у які сервіси за якими даними сходити, йому, потрібна supergraph-схема. Для цього ми маємо скомпозувати схеми всіх мікросервісів. 

  3. Supergraph-схема зберігається в GraphQL Registry, до якого має доступ шлюз.


Якщо раніше GraphQL-запит з кабінету покупця йшов на моноліт, а далі на мікросервіс через gRPC, то в новій архітектурі зʼявився роутер. Він отримує запит, аналізує його, будує query-план, і далі робить запити в мікросервіси або Monolith тільки за даними, які там лежать.



Тепер в сервісі Opinions з'являються графові API, і всі його типи ми переносимо з моноліту в мікросервіс. За даними, що стосуються сервісу Opinions, роутер ходить одразу в мікросервіс, зменшуючи трафік на моноліт. Також роутер тепер запитує в мікросервісі тільки ті поля, які потрібні клієнту. Таким чином ми уникаємо оверфетчингу. 


Як це працює: якщо у вас є мікросервіс, який імплементує граф, потрібно включити в ньому підтримку Apollo Federation. Кожен із сервісів пушить свою схему в GraphQL Registry, який потім збирає supergraph-схему. При експорті схеми із цього графа в GraphQL Registry туди додаються деякі поля, директиви й типи, які потрібні Apollo роутеру. 


Так виглядає найпростіший GraphQL-сервер:


@strawberry.type
class Product:
	id: int
	name: str
@strawberry.type
class Query:
	products: list[Product] = strawberry.federation.field(resolver=get_products)
schema = strawberry.federation.Schema(query=Query, enable_federation_v2=True)

Як працювати з Entities в Apollo Federation


Ще одна корисна фіча в Apollo Federation — Entities. Це типи, які «живуть» в одному сервісі та можуть перевикористовуватися в інших. При цьому вам не потрібно описувати один і той самий тип в кожному із сервісів. Також можна розширювати тип одного сервісу в іншому. 


Уявімо, у нас є тип Product, в якого є ID та Name. Згодом зʼявляється потреба додати туди ще Opinions, щоби коли ми запитуємо продукт, ми могли дізнатися відгуки про нього. Проте тип Opinions живе в іншому сервісі. У старій схемі нам би довелося писати тип Opinions в моноліті, потім зробити gRPC-запит, розкладати його в цей тип і повертати.



В Apollo ми декларуємо тип продукту в мікросервісі Opinions. Насправді ж цей тип належить моноліту, а тут використовується як сигнатура (в термінології Apollo називається Stub Type). Директива @key вказує, що цей тип можна знайти в іншому сервісі по ID. У ньому ми додаємо поле Opinions. У такий спосіб ми можемо розширити «чужий» тип своїми даними, не змінюючи його в іншому сервісі.




Як це виглядає в моноліті:

@strawberry.federation.type(keys=["id"])
class Product:
	id: int
	name: str
@classmethod
def resolve_reference(cls, id: strawberry.ID, info: Info):
product = info.context.products.get(int(id))
return Product(id=product.id, name=product.name)

Додаємо директиву Key до типу продукту, а також імплементуємо Resolve Reference метод, який Apollo Federation просить реалізувати для кожного Entity типу. Він буде викликатися роутером, після чого кожен мікросервіс зможе використовувати Entity тип Product.


Як це виглядає у сервісі Opinions:

@strawberry.type
class Opinion:
	id: int
	product_id: int
@strawberry.federation.type(keys=[“id”])
class Product:
	id: int
@classmethod
def resolve_reference(cls, id: strawberry.ID, info: Info):
opinions = info.context.opinions.get_by_product(int(id))
return Product(id=id, opinions=opinions)

Описуємо, що цей тип ми використовуємо через ID, імплементуємо Resolve Reference. Тепер роутер прийде до нас з ID продукту, по якому ми дістанемо та повернемо усі відгуки.


Водночас у цій схемі може виникнути N+1 проблема. Коли роутер робить запит на ваш сервіс, він агрегує ID і надсилає їх масивом. Але у більшості фреймворків Resolve Reference здійснює окремі запити бази для кожного ID. Щоби пофіксити це, варто використовувати Data Loader — це фіча, яка посилається на Loading функцію, яка отримує перелік ID, агрегує їх під капотом та надсилає один запит в базу. 



Композиція схем та GraphQL Registry


Є два способи обʼєднати сабграфи в одну велику схему. Перший: можна використовувати тулзу Apollo Rover. Ви вказуєте в YAML-файлі всі ендпоінти ваших сервісів і запускаєте Apollo Rover, який по інтерспекції ходить у всі сервіси, дістає їхні схеми й генерує окрему supergraph-схему. Другий: можна використовувати GraphQL Registry і налаштувати CI/CD-пайплайни — саме такий спосіб використовують у Prom.



На схемі зображено два репозиторії, які на push в мастер і створення тегу передають схеми на CI. Щоразу, коли GraphQL Registry отримує нову схему, він обʼєднує їх в одну і позначає, як активну. Apollo Router з певною періодичністю, яку ви можете налаштувати, (наприклад, раз на 10 секунд) підтягує нову схему, перезапускає конфіги, і вона потрапляє на продакшен. 


Якщо, наприклад, до GraphQL Registry потрапила невалідна схема, через що він не зміг її скомпозувати і впав з помилкою, то нова схема не буде позначена як активна, і не потрапить через роутер на продакшен. 


Заекспортити схему можна з допомогою таких CLI команд:

# strawberry export-schema reviews.graph:schema —output schema.graphql
# hive schema:publish —service reviews —url http://reviews.svc/graphql schema.graphql

Отже, GraphQL Registry має такі можливості:


  1. Зберігання схем.

  2. Валідація. Якщо ви здійснили певні зміни в GraphQL, наприклад, видалили поле, GraphQL Registry перевірить і повідомить про breaking change. 

  3. Композиція сабграфів у supergraph схему.

  4. API для витягування схем (альтернатива інтроспекції на продакшені). 



Результати


  1. Швидша реалізація фічей та деплой. Зараз в моноліті ми лише визначаємо entity-типи, все інше робимо в мікросервісі. 

  2. Моноліту стало легше, адже тепер роутер самостійно фільтрує запити. Якщо в запиті немає полів, які потрібні дістати із моноліта, то він піде напряму на мікросервіс. 

  3. Приємний бонус — менший асинхронний сервер та окрема база. Функціонал працює швидше.

  4. Можливість відстежувати та безпечно видаляти поля, які більше не використовуються через GraphQL Registry. У такий спосіб ми тримаємо актуальну схему. 

  5. Можливість додати в Graph будь-який сервіс в мережі. Для цього не потрібно писати gRPC, а заекспортити його в GraphQL Registry Graph API і одразу задеплоїти. Тоді роутер, отримавши запит з певними полями, автоматично сходить до нового мікросервісу. 



Корисні посилання:


© 2035 by Business Name. Made with Wix Studio™

bottom of page