Як ми розпилювали моноліт. Наш досвід переходу до мікросервісів
Привіт, мене звати Сергій Сафонов і я Tech Leader у Solidgate — українській продуктовій фінтех-компанії, що працює в ніші пейментів і допомагає розбудовувати платіжну інфраструктуру для інтернет-бізнесів.
З розвитком Solidgate зростав і розвивався і наш основний сервіс процесингу Cardgate. З часом він перевалив за 110 тис. рядків коду — це спричинило низку негативних ефектів в роботі з ним. Через те, що це Kotlin + Spring + Hibernate, сервіс стартує дуже довго, крім цього — доводиться довго чекати виконання юніт-тестів локально (ініціалізацію перед самими тестами), а загальна тривалість CI/CD-процесу від коміту до проду перевалила за годину.
Говорячи мовою цифр, ось деякі тривалі процеси в нашому пайплайні: білд — 8 хвилини, юніти — 7 хвилин, функціональні тести — 15 хвилин, деплой — 7 хвилин. А все разом з іншими задачами в середньому відбувалось
Ще одна очевидна незручність — складність роботи з таким сервісом. Багато змін, багато інженерів, багато комітів. Масштабувати роботу неможливо, а з усім тим компанія розвивається — тож фічей потрібно все більше. Збільшення команди призводить до «штовхання ліктями» при виконанні та викатуванні своєї роботи.
Як результат — з’явились мерж-конфлікти, черги деплою як на прод, так і на тестове середовище для написання AQA тестів. Це призвело до того, що в день ми могли викатити в прод не більше трьох комітів (ми працюємо за методологією trunk based development та деплоїмо кожну таску атомарно).
І хоч ми, звісно, багато працювали над прискоренням CI/CD-процесу, додали кешування, оптимізували деякі джоби — покращити ситуацію вдалося лише скороченням процесу з 1,5 годин до 40 хвилин, але не вирішити повністю. Ми вперлися в обмеження великого моноліту. Саме тому найприродніший шлях розв’язання проблеми — це розпилювання цього моноліту на менші сервіси.
Основні складнощі
У нас велика, складна і навантажена система. Ми не можемо дозволити собі мейнтенанс. Хвилина зупинки нашого сервісу коштує компанії приблизно $10.000, а при виділенні частини функціональності з’являються стандартні проблеми, як-то: спільний доступ до баз даних, роути на балансерах, перемикання трафіку, перевірка коректної роботи тощо.
Для розв’язання цих проблем немає стандартного рішення або патерну. Тож сьогодні я розповім про два підходи, які ми використовували — хоч наш процес розпилу триває і досі.
Чому не переписати?
За свій досвід роботи я бачив дуже багато поривів переписування старих систем наново «як треба». Кожній команді хочеться створити ідеальний, новий сервіс без недоліків попереднього — і в один момент перейти на нього. І щоразу виявляється, що це так просто не працює.
Коли я працював в Інформаційних Судових Системах України, керував командою розробки діловодства для Господарських та Адміністративних судів. В цей час сусідня команда, де розробляли діловодство Загальних судів, вирішила переписати старий застосунок на новий. Вони були вимушені одночасно підтримувати чинну систему та «з нуля» будувати нову. Витратили на це кілька років.
Це були кілька років розробки сферичного коня в вакуумі. Постійно хотілось його запустити, але функціональності не вистачало (в порівнянні з наявною системою) і доводилося знову і знову її нарощувати. Коли ж нарешті наважилися вийти в прод, все пішло шкереберть. Помилки та недоліки здавалися нескінченними. Вони, зрештою, впровадили цю систему, але на її стабілізацію витратили ще кілька місяців.
Щось подібне було коли я працював на початку нульових з системою діловодства Головного Управління Архітектури та містобудування Києва. Але в цей раз процес переписування не завершився взагалі. Побудована нова система так і не змогла повноцінно замінити стару. З роками змінювались команди розробки, але (наскільки мені відомо) і досі працівники використовують обидві системи одночасно для різних задач.
Ще гірше було під час моєї роботи в 3Shape. Надійну, але дещо застарілу систему 3D моделювання, написану на Delphi, хотіли переписати на C#. Роки розробки «поруч» призвели до того, що від нової просто відмовились і продовжили роботу над існуючою.
Тому варіант переписування частини сервісу заново, а потім перемикання на цей новий сервіс — дуже ненадійний варіант.
А які ж є тоді способи зменшення ризиків переписування старої системи на нові рейки? Розглянемо далі.
Підміна ендпоінтів
Трохи розкажу про такий важливий патерн, як Strangler. Суть цього патерну — створити такий собі новий проксі-сервіс, який буде всі вхідні запити прокидувати в основний сервіс, а відповіді з основного сервісу віддавати назад. З часом обробка деяких запитів почне відбуватися на новому сервісі і відразу віддавати результат, не звертаючись до старого. Поступово новий візьме на себе всю обробку та залишить старий сервіс без роботи.
Цією стратегією ми скористалися для переносу нашої status page — сторінки про статус платежа для клієнта. Під час оплати для деяких транзакцій вимагається додаткова перевірка 3D Secure — у цих випадках ми редіректили користувача на окрему сторінку, функціональність якої була реалізована на старому сервісі. Наші кроки:
- Зробили проксі на старий за всіма запитами взагалі.
- На балансері переключили на новий сервіс роути типу/api/v1/status/ID/TOKEN, /api/v1/track-3ds-status/ID/TOKEN і такі інші.
- Реалізовували на боці нового сервісу обробку цих ендпоінтів поступово, спринт за спринтом.
Це дало нам кілька суттєвих переваг.
- Функціональність не дублювалася в обох сервісах. Щойно ми завершили роботу над новою реалізацією певного запиту і переключалися на нього, стару можна було видаляти.
- Ми мали результат вже і зараз. Щойно один ендпоінт був готовий, він йшов у прод і використовувався користувачами. Не треба було чекати завершення розробки всього нового сервісу.
- Процес переписування проходив значно легше, тому що не потрібно було підтримувати дві системи одночасно. Відповідно — не потрібно вносити зміни в два місця.
- Отримали можливість canary переливки трафіку. У новому сервісі за допомогою feature flag (тоглів) можна будь-який відсоток трафіку направляти в старий сервіс, а решту опрацьовувати в новому.
- Можливість швидко відкатити все і переключити трафік на старий сервіс.
Коли вся система стабільно пропрацювала деякий час вже на 100% трафіку, а проблеми перестали з’являтись, ми створили останню задачу: чистка старого і нового сервісів від непотрібного коду.
Це дуже зручний патерн декомпозиції, але є випадки, коли його використання неможливе. Наприклад, у ситуації, коли ми виносили роутинг в окремий сервіс із сервісу процесингу.
Дублювання викликів
Частину платежів ми процесимо самостійно. Але в світі є регіони, де онлайн-платежі відбуваються за особливими правилами. Відтак, щоб отримати максимальну конверсію платежів, краще використовувати локальні платіжні системи (наприклад, Boleto у Латинській Америці, Razorpay у Індії чи mpessa в Африці). А тому ми інтегровані з кількома десятками інших систем у всьому світі. Яку з них використовувати в кожному конкретному випадку — вирішує наш сервіс роутингу.
Ми аналізуємо, в якій країні знаходиться користувач, магазин, банк, що випустив картку, у якій валюті й ще десятки інших параметрів. На основі цих даних ми ухвалюємо рішення про те, як саме і через яку систему найкраще провести платіж, аби забезпечити максимальну конверсію. Наприклад, для США ми використаємо TSYS, а для Європи — Adyen або Checkout.
Якщо ми все ж таки отримали відмову в платежі, наша система вміє використовувати каскади для нової спроби через іншу систему. Таким чином, поки користувач на сайті натиснув кнопку «сплатити» і чекає завершення транзакції, ми під капотом вирішуємо, який банк оптимальніше всього обрати заради досягнення максимальної конверсії. А якщо платіж впав — ми проводимо оплату на іншому банку.
Цей механізм роутингу виконується глибоко всередині системи. На запит сервіс процесингу проходить ланцюжок бізнес-логіки і лише після цього запускається розрахунок роуту. Щоб винести пошук роуту в окремий сервіс, патерн Strangler застосувати не вийде.
Тому ми пішли іншим шляхом.
- Всю логіку пошуку роутів ізолювали в коді, щоб виклик першого і наступного кроку каскаду зводився до одного місця.
- Реалізували в новому сервісі аналогічну логіку пошуку (при цьому використовуючи прямий конект до бази старого сервіса). Тобто в новому сервісі у нас з’явилось два ендпоінти getFirstStep та getNextStep.
- Ми мали переконатись, що новий сервіс працює без помилок, а для цього можна проводити його тестування. Або...
Ми знайшли кращий шлях. Через те, що у нас кілька сотень тисяч можливих варіацій маршрутів, протестувати всі майже неможливо. В тому місці, де ми ізолювали виклики першого і наступного кроків, зробили виклики цих самих методів лише у новому зовнішньому сервісі. Результати нового сервісу нікуди в розрахунки не брались, а лише логувались і порівнювались з результатами старої логіки.
Ми вивели повідомлення — чи збігаються результати та наскільки альтернативний пошук (через новий сервіс) повільніше існуючого. Через це можна було підключити новий сервіс, що гарантовано не впливало на работу всього процесингу, але при цьому бачити, як працює новий та чи співпадають його розрахунки з існуючим. Щоб не сповільнити опрацювання платежів, виклики ми робили в горутинах.
- Після тижня роботи обох роутингів і їхнього порівняння (авжеж були фікси багів), ми почали переключати частину платежів на результати нового роутингу. Так впродовж кількох днів все обчислення роутингу вже відбувалося за допомогою нового сервісу. Стара логіка працювала паралельно, але лише для контролю однаковості результатів.
- Наостанок, ми видалили в старому сервісі всі виклики старої логіки та її реалізацію.
Здавалося, от і все. Новий сервіс відмінно шукає маршрути, старий полегшав на суттєвий шматок коду. Але! Новий сервіс все ще ходив в «чужу» БД. Останнім кроком була ізоляція бази даних. Для цього:
- Ми зробили в новому сервісі CRUD ендпоінти для всіх користувачів цієї логіки (адмінка, аналітики, репортинг і т.д).
- Всіх перевели на використання цих ендпоінтів і досягли невикористання старого сервісу та таблиць у базі.
- Тепер, коли таблиці в базі використовував лише новий сервіс роутингу, перенесли їх до своєї окремої бази.
- Видалили в базі старого сервісу всі вже непотрібні таблиці.
Висновки
Декомпозицію великого моноліту можна проводити різними способами. Якщо необхідне винесення всієї логіки ендпоінта, то ідеально підходить Strangler Pattern. Якщо ж потрібно виносити частину логіки всередині — краще це робити через дублювання викликів.
Обидва підходи дають можливість роботи одночасно обох версій сервісів. А це дозволяє:
- поступово переключати трафік;
- порівнювати результати роботи обох версій;
- швидко повернутись до попередньої версії.
Також варто не обмежуватися лише стратегією розробки та не забувати про стратегії релізів (canary/blue-green), щоб зменшити вплив на трафік, інвестування у моніторинг системи, процеси реагування (SRE), тощо.
В системах, які не допускають репутаційних та фінансових втрат через збої (як в нашому випадку), слід уникати революційних замін сервісів на нові. Будь-який перехід має відбуватись поступово, етапами та з можливістю негайно повернутися до старої стабільної версії сервісу.
54 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів