Мій CI/CD: огляд одного з процесів очима розробника
Як CI/CD перетворює десятки змін в єдиний потік від PR до Production

I write in Ukrainian about DevOps, architecture, and everything that makes code (and teams) flow better. Markdown, diagrams, and real-world messes included.
Disclaimer
Цей пост виявився більшим, аніж я собі представляв, а тому, перед тим як почати розповідати про цікаве, хотілось би трохи задати контексту. Про що ми будемо говорити, а про що не будемо.
Перш за все, скажу, що говоритиму я сьогодні про щоденну роботу розробника його очима. Тобто, як це взагалі, працювати розробнику в тому, що я там настворював.
По друге, хочу сказати, що жодних тонкощів реалізації, вихідних кодів, посилань безпосередньо на реалізацію, й інше - не буде. Але це не заважатиме нам говорити про концепції самого процесу як такого.
Ну і, вся ця тема з CI/CD доволі обʼємна. Тож, я не намагаюсь втиснути в один пост взагалі все, що відбувалось. Є дуже багато питань, які я свідомо пропускаю в цьому пості, але які, можливо, ми піднімемо в наступних постах, коли буде ясно, про що саме можна було б поговорити.
Також, майте на увазі, все про що ми будемо говорити, реалізувалось виключно мною протягом року. Сюди входить як і початкова фаза дизайну й намагань продавити його в бюджет й інше, так і сама реалізація, включно з допомогою командам мігрувати в нове середовище, розвʼязуючи їх проблеми на шляху до нього. Тому хоч я і кажу про великий обсяг роботи, який я робив майже рік, але сюди входить не тільки CI/CD, а і купа іншого супроводу.
Але який все-таки результат ми отримали? Почнемо з розробника, який прокинувся і взяв задачу в роботу.
Доброго ранку, девчик!
Розробник з команди, який береться за задачу після ранкового созвону, першим ділом підтягує собі останні зміни основної гілки - нічого незвичного. Стягнув собі через git pull, чи що він там використовує, останній commit з гілки, зробив йому rush install та rush all, аби переконатись, що станом на сьогодні, основна його гілка збирається та з нею все добре.
Якщо з git pull все зрозуміло, то, думаю, з rush треба трішки розповісти. Ми його використовуємо для менеджменту нашого моно репозиторію.
І от, rush install встановлює на робочій станції розробника залежності тих версій, які зараз знаходяться в основній гілці, та лінкує між собою всі наші пакети та сервіси, щоб локально воно все відчувалось як одне спільне під час розробки. Він також робить й інші речі, але не будемо про це в цьому пості.
Після успішного rush install, розробник, опціонально, може викликати rush all. Це просто сахар поверх інших команд: rush build, rush lint, rush test. Він, відповідно, викликає всі білд скрипти в усіх сервісах та пакетах, потім лінтери й врешті решт тести. Але, він не робить це для всього репозиторію. Не потрібно чекати, поки вся велика монорепа збереться. Він аналізує ваш локальний стан, дивиться, що було змінено за час відсутності розробника, і викликає ці команди тільки в тих місцях, які були змінені відносно вашого попереднього білда репозиторію. Тож, якщо викликати rush all на чистому репозиторії, то це білд всього, а вже наступний виклик rush all пропускає всі скрипти і закінчує свою роботу за менш ніж секунду. Дуже зручно для локальної роботи - дозібрати тільки те, що змінилось.
І ось розробник працює собі з таскою в його IDE, воно йому там все підсвічує, автоматично чинить помилки, форматує й так далі. Зміни в сервісі чи пакеті одразу поширюються на суміжні сервіси, які з ними повʼязані, і розробник може одразу ж локально все це запускати й перевіряти що воно працює.
В певний момент, він такий дивиться й каже: “Ну, наче можна відкривати PR”.
Створюємо Pull Request
Ми використовуємо GitLab для нашого коду, але це не дуже відрізняється від того, що на GitHub. Розробник бере гілку, в якій він працював, пушить на GitLab та створює новий Pull Request. Знову ж, нічого незвичного.
На цьому етапі, він може зробити паузу, піти попити чаю чи кави, але недовго :)
Одразу після створення PR, білд ферма починає робити свою роботу. Встановлюються залежності для репозиторію через rush install (воно, звісно, закешоване) та починаються запуски rush build (в основному, це виклик TypeScript компілятора, щоб зібрати dist), rush lint (тут, зазвичай, просто ESLint запускається) та rush test (залежно від видів тестів, які розробники пишуть, можуть бути різні інструменти, але переважно це mocha або jest).
І, здавалось б, наче нічого такого, але є там одна цікава деталь, яка допомагає робити це дуже швидко.
Я вже писав трішки вище, що, коли розробник робить rush all, воно здатне зрозуміти, що було змінено в порівнянні з попереднім станом, та на основі цієї інформації вивести список сервісів й пакетів, які змінились.
Так ось, такий самий алгоритм застосовується і на білд фермах. Береться PR та порівнюється з основною гілкою. Все що було змінено в PR, мапиться на список сервісів, які нам потрібно зібрати. Але і тут є нюанс.
Окрім того, що ми користуємось цим алгоритмом для розуміння де нам потрібно прогнати скрипти, нам також потрібно не забувати і про топологію. Якщо, скажімо, змінився сервіс А, який не працюватиме без пакету В (який не змінився, наприклад), то щоб зібрати цей сервіс А, нам потрібно також зібрати і пакет В, навіть якщо він не був змінений.
І ось тут ми приходимо до класичної проблеми графів та вибору його під-дерев (не знаю, як subtrees правильніше написати). І ми зробили наступним чином:
rush build- обирає частину репозиторію, де явно були змінені файли. Включно з їх залежностями, щоб воно могло взагалі зібратись, та всіма, хто залежить від цих змінених файлів, щоб перевірити, що ми не зламали downstream, когось другого, хто від нас залежить.rush lint- обирає частину репозиторію, де явно були змінені файли і більше нічого. Перевіряти на best practices код, який не змінився, немає сенсу. Так само як і перевіряти downstream. Це повністю атомарна й ізольована задача, яка просто перевірить, що ваші зміни не порушують наші практики.rush test- обирає частину репозиторію, де явно були змінені файли. Включно з усіма, хто залежить від цих файлів. Таким чином, ми перевіряємо, що безпосередньо зміни не зламали нічого в сервісі. Але, також, перевіряємо і всіх, хто залежить від цих змін, на випадок, якщо хтось щось зламав для іншої команди чи іншого сервісу.
Ось такі, здавалось б, прості задачі, а під ними багато всякого цікавого відбувається :)
У випадку, якщо всі ці процеси пройдуть успішно, CI почне перевіряти, а чи описали ви зміни, які ви зробили. Це потрібно для наступної фази нашого CI процесу.
Версіонуємо наші зміни
Питання версіонування це завжди складно. Потрібно враховувати канали дистрибуції, чи потрібно нам підтримувати старі версії, чи потрібно нам бекпорти підтримувати й багато іншого. Це прям окремий пост можна писати.
В нашому випадку, в нас версіонування відносно просте, а тому ми вирішили його зробити повністю асинхронним та автоматичним. Але ж виникає тоді питання, а хто взагалі приймає рішення про те, яка має бути наступна версія, якщо це повністю автоматичне та асинхронне?
Так ось, ми вирішили це наступним чином. Якщо розробник забуде описати свої зміни, то CI завалить йому білд і напише список сервісів, які він змінив, але не описав ці зміни. В такому випадку, це означатиме, що розробник просто забув викликати ще одну команду rush - rush change. Якщо ж він не забуде її викликати перед створенням PR, то звісно що і білд валитись не буде. Що ж це за команда така?
rush change аналізує сервіси та пакети, які були змінені, та які піддаються політикам версіонування (ми можемо вказувати різні політики версіонування та розділяти внутрішні пакети на зовнішні й так далі). На основі цієї інформації, в інтерактивному режимі, розробник має описати, що він змінив, та вказати яка має бути наступна версія: patch чи minor (важливо зрозуміти, що ми вказуємо не саму версію, а лиш стратегію, за якою оновлювати версію).
Результатом цього інтерактивного опитування, яке відбувається виключно на локальному компʼютері розробника і не потребує нічого складного, стають JSON файли, які коммітяться разом з іншими змінами.
Тож тепер, CI бачить не тільки зміни і що вони працюють, а ще й має інформацію про те, який сервіс на яку версію потрібно буде оновити - ця інформація є частиною PR.
Це дає нам змогу не синхронізувати взагалі жодним чином інших розробників та команди. Кожен розробник, створюючи новий Pull Request, просто вказує стратегію оновлення версії через rush change і коммітить це разом зі своїми змінами.
Тому цей процес я і називаю асинхронним - тут відсутня координація між розробниками. Хоч у вас буде 200+ PR-ів, але якщо в кожному із них буде вказано розробником, що, наприклад, наступна версія має бути patch, то хто б там що не робив, але після мержу в основну гілку, наступна версія буде - теперішня версія з основної гілки + patch.
Якщо весь цей процес відбувається в рамках Pull Request, то окрім того, що CI робить нові версії згідно опису автора цього PR, він ще додає до нього dev-$COMMIT суфікс. Тож, якщо процес запустився на PR, то ми отримали дев збірку, яку ми можемо задеплоїти на тестові кластери та потестувати. Ну і, відповідно, якщо воно відбувається в основній гілці, то нова версія буде вже без dev-$COMMIT суфіксу.
Реалізувавши асинхронне (автор PR не координується з іншими) та автоматичне (CI бере теперішню версію й просто інкрементує згідно опису автора) версіонування, ми вже можемо зібрати артефакти з новими версіями, та завантажити їх по всім нашим реєстрам.
Деліверимо артефакти
Про артефакти я довго розповідати не буду. В нас тут все доволі класично і, на мою думку, нічого цікавого не відбувається.
Маючи нові версії пакетів та сервісів, наш CI прописує їх в package.json всіх тих пакетів, які підпадають під нову версію. Оновлені package.json, з новими version полями, використовуються для того, щоб вивести всі версії для всіх артефактів в подальших кроках.
Якщо це npm пакет, то використовується звичайний pnpm publish (звісно, що з врахуванням всіх тих нюансів, але в кінці це просто publish). Пакети публікуються в закритий реєстр на GitLab Package Registry, який знаходиться в тому ж проєкті, що і сам репозиторій.
Якщо це Docker образ, то ми його збираємо з використанням наших кастомних скриптів, які через програмне API комунікує з Docker Engine. Це нам дозволяє побудувати місток між Rush API та Docker Engine, що, своєю чергою, дозволяє нам реалізувати збірку контейнерів частиною команд rush. Що, своєю чергою…, дозволяє нам використати всі ті корисні речі, про які я розповідав раніше, для збірки саме контейнерів.
Ми розуміємо, де контейнер треба збирати, де не треба і тому подібне. Тегом контейнера виступає той ж version із package.json проєкту, який раніше був оновлений на CI. Результатом цього всього є команда rush build-docker-all --push --changed-only, яку ми викликаємо на CI, а він вже розбирається, що змінилось, й зібрав контейнери з новими версіями та запушив їх на GitLab Container Registry.
Ну і з Helm аналогічна ситуація. Окрім самого Helm, поруч з ним зберігається такий ж package.json, але він використовується лише для того, щоб зберігати там версію й інтегрувати в спільний workflow монорепозиторію. Ми і тут розуміємо, коли змінився Helm, і коли він змінився, то розробник описує зміну й стратегію для нової версії, а все інше - те саме. А, ну і сховищем для Helm Charts у нас є GitLab Package Registry.
Тож, на цьому етапі, в нас вже всі нові версії є, всі артефакти зібрані й всі розкидані по всім реєстрам. Вони задеплоїлись на тестові кластера, прогнались e2e тести і розробник отримує зелену галочку на свої dev-$COMMIT версії.
Даємо добро на мерж
Всі ці процеси, описані вище, відбуваються в Pull Request, а тому вони повністю безпечні. Мало того, ми не просто запускаємо весь цей CI процес на окремій гілці розробника, а робимо ефемерні комміти, які є результатом автоматичного злиття гілки розробника з основною гілкою. Це дозволяє нам розуміти, чи буде мерж успішним, чи ні, ще до того, як ми його змержимо. Тобто в нас немає ситуацій, коли ми постфактум дізнаємось, що після мержа основна гілка вже не працює. Ми про це дізнаємось ще на етапі Pull Request-а.
А ще, ми використовуємо Merge Train, як додатковий спосіб перевірки, чи буде мерж успішним, чи ні. Ці Merge Train-и здатні автоматично мержити декілька гілок в один комміт та запускати наш CI процес на комбінаціях декількох ПР-ів з основною гілкою. Як на мене, про це варто і окремий пост зробити, а тут не буду вдаватись в деталі.
Що б там не було, які б комбінації мержів там не тасувались, але ось, нарешті, розробник отримав ту зелену галочку і отримав дозвіл на мерж. Після чого це все вливається в основну гілку. Причому, важливо, що вливається як squash commit. Тобто що б там розробник собі не робив у своєму PR, то його пісочниця і він може робити що завгодно. Але в основній гілці це буде один єдиний комміт, з посиланням на PR розробника, де можна прослідкувати всю історію.
Таким чином, ми страхуємо себе від ситуації, коли недобросовісний розробник наробив коммітів по типу asdf та bla-lba, та які попали в основну гілку - такі ситуації ми забороняємо, але надаємо можливість робити що завгодно у гілці розробника, поки це залишається там.
Після змерженого Pull Request в основну гілку, запускаємо знову CI. Але цього разу, викидаємо з його роботи деякі моменти, які вже й так були виконані ще на етапі Merge Train. Через те, що ось той ефемерний комміт, про який я казав раніше, вже і так містить в собі комбінацію кодових баз розробника з його гілки та основної гілки - ми можемо не запускати той ж лінтер чи e2e тести - бо ми вже і так їх прогнали на цьому комміті, перед тим як мержитись.
А тому, CI на основній гілці викликає тільки rush build для змінених сервісів, щоб отримати артефакти в dist. Ми їх одразу ж оновлюємо на нові версії згідно опису автора PR та пакуємо в реєстри - CI закінчив свою роботу - ми отримали нові production артефакти.
Деплоїмось в dev
Як тільки ми отримали нові production артефакти, тобто з версіями типу 1.5.12, а не дев збірки, ми їх одразу ж деплоїмо на dev кластера. Так, я знаю, що для деяких команд, автоматичний деплой це щось страшне і штибу в “пʼятницю нічого не чіпайте”. Але я проти такої культури і, більш того, вважаю її неефективною. Потрібно працювати над створенням процесів, які дозволяють деплоїтись, коли вам завгодно, а не лякати людей і стримувати їх мантрами “працює - не чіпай”. Бо коли настане час справді чіпати - може бути вже пізно.
Так ось, для деплоїв на наші кластера, ми використовуємо Helm. Це доволі великий чарт, в якому прописані всі наші мікросервіси разом з їх базами даних й іншими операційними залежностями. Для того, щоб цей деплой був автоматичний, я реалізував скрипти, які беруть інвентар зібраних Docker образів та оновлює ними потрібний Helm chart. Це банальний обхід дерева YAML файлу та оновлення всіх збігів по Docker-у на нові версії. Тобто відкрили YAML, розпарсили його, отримали дерево, по якому пробіглись рекурсивно, та оновили всі збіги образів на нові версії.
Змінене дерево я зберігаю назад у файл і роблю новий комміт в основну гілку. Всі ці файли ми зберігаємо в текі .gitops й використовуємо Flux, для того, щоб застосовувати нові зміни на наші кластера. Тобто, таким чином, скрипт оновив та закоммітив нові версії в .gitops, а Flux уже прийшов й зробив reconcilation loop.
В результаті, ми отримали процес, в якому розробник після мержу свого Pull Request, може одразу бачити свої зміни на дев кластерах. Це, по відгуках, особливо приємно для frontend розробників. Бо вони не чекають когось, хто задеплоїть їх зміни. Вони змержились й хвилин через 5 вже бачать свої зміни на живому дев кластері. Ну, а для backend розробників, це теж корисно, бо вони одразу бачать чи не зламали вони щось після мержу свого PR.
Деплоїмось в production
Ось розробники пописали коду, перемержили PR-и, і такі сидять, і думають - може в прод? І це, я вважаю, і є те, заради чого, всі ці речі потрібно робити. Я вважаю, що деплоїтись в прод має бути виключно політичне рішення. Чи то маркетингове, неважливо. Не має бути жодних труднощів для команд задеплоїтись в production. Це має бути відбуватись за бажанням в будь-яку годину (ну, майже в будь-яку, в рамках розумного).
Для цього, команда йде на пайплайни репозиторію, та просто запускає ще одну задачу - deploy to production. І цей скрипт робить все те саме, що робить і скрипт для деплою в dev. Бабільше, використовується той ж скрипт, просто з іншими аргументами. Він пішов у YAML файл, але який стосується вже проду, оновив там всі версії й закомітив назад в .gitops, щоб Flux застосував зміни на кластери.
Єдиний, правда, момент в тому, що ми вирішили себе трохи таки застрахувати. Коли ми деплоїмось в прод, то версії для проду ми беремо виключно із версій теперішнього дев кластеру. Таким чином, ми хочемо себе застрахувати від моментів, коли щось могло б трапитись лише після довгої роботи кластера.
А тому, спочатку команда деплоїться в dev автоматично після мержу PR і, якщо на dev-і все добре, то ми можемо вважати, що і на проді теж буде норм, а тому версії можна перекласти в прод з деву.
Результати
На новому CI/CD процесі, команда вже працює в режимі деплою щоранку. Раніше, це було чи то раз у два тижні, чи щось таке, не памʼятаю. Звісно, тут ще багато було роботи, в тому числі культурної та менторської: розповісти, показати, зруйнувати міфи і тому подібне, але вже зараз вони відчувають покращення в порівнянні з минулою системою.
Станом на сьогодні, весь цей процес, я б сказав, знаходиться вже у формі публічної бети всередині компанії. Це рішення масштабується та дозволяє інтегрувати більше команд в наш репозиторій, та одразу отримати для себе всі ці автоматизації безкоштовно.
Звісно, ми ще знаходимо баги, чинимо їх, але загалом - маємо монорепозиторій, в який можна набирати більше команд, та який надає весь необхідний інструментарій для повного SDLC.
На цьому ми й закінчимо це велике полотно. Дякую вам, якщо дочитали до цього місця, та допоможіть мені лайком й розповсюдженням, якщо вам сподобалось. Тема велика, а тому пишіть коментарі, ставте питання та пропонуйте, про що б хотілось почитати далі.



