Як ми збираємо Docker контейнери в Rush монорепозиторії
Технічні особливості роботи з Rush та Docker

I write in Ukrainian about DevOps, architecture, and everything that makes code (and teams) flow better. Markdown, diagrams, and real-world messes included.
Вирішив розповісти про те, як ми, працюючи в монорепозиторії, збираємо наші контейнери. Впевнений, що кожен із вас знає про docker build й може поставити собі питання “А що там такого цікавого?”. Так ось про це і поговоримо, бо цікавого там трохи є й це не просто docker build. І почну я з опису проблеми, починаючи від класичних сценаріїв, щоб вам було легше прослідкувати за ускладненням.
Один сервіс — один репозиторій
Майже завжди, принаймні з тими командами й проєктами з якими я працював, все починається з одного репозиторію й монолітного сервісу. Тобто у вас буквально один проєкт на весь репозиторій і ви цей проєкт можете зібрати однією командою, щось типу docker build --tag my-awesome-app path/to/context.
Все настільки просто, що ви навіть можете робити це в себе локально і не робити ніяких CI/CD. Інколи, за певних умов, я б навіть і сказав, що вам не потрібен CI/CD. Але сьогодні ми не про це.
Якщо ви все ж таки зробили собі якийсь простий CD, то у вас могло б це бути якимось простим скриптом типу:
#!/bin/bash
set -euo pipefail
pnpm install
pnpm run build
docker build --tag my-awesome-app:latest --file path/to/Dockerfile path/to/context
Цей скрипт ви загорнули б в якийсь YAML для GitHub чи GitLab й справу закрито. Воно собі там щось робить, контейнер збирає й кудись завантажує. Але… ускладнимо задачу.
Багато сервісів — один репозиторій
З цим сценарієм вже стикаюсь рідше, але він все одно є. Зазвичай, такий сценарій починають втілювати організації, в яких достатньо велика кількість розробників та сервісів, які між собою повʼязані, але операційно є окремими сутностями. Детальніше про монорепозиторії я б розповів іншим разом, а зараз продовжимо з контейнерами.
І, до речі, а скільки це “багато” сервісів? Ну, звісно це число може плавати від одного до другого в різних компаніях, тому я просто скажу скільки сервісів зараз у нас, з якими я прямо працюю. І в нас їх небагато — близько 30 з чимось, щось таке. Доводилось працювати в монорепозиторіях і з сотнями сервісів, тому вважаю що 30 - це небагато.
Так ось, всі ці сервіси лежать в одному репозиторії, просто що в різних теках. Структура вашого репозиторію з цими сервісами виглядала б якось так (всі назви вигадані, хочу підкреслити що ось таких Dockerfile може бути багато):
monorepo/
├─ packages/
│ ├─ logger/
│ ├─ eventually/
├─ services/
│ ├─ orchestrator/
│ │ ├─ Dockerfile
│ ├─ lookout/
│ │ ├─ Dockerfile
│ ├─ purger/
│ │ ├─ Dockerfile
Тепер, якщо ви спробуєте зробити класичний docker build в корні репозиторію, то, звісно, що ви не отримаєте ніякого корисного результату. Але ми ж розумні люди, правда? Чом би просто не взяти цей docker build та й не почати його викликати для кожного Dockerfile в його окремих теках. Робимо якесь рішення в лоб зі скриптом, який буде нам збирати всі наші контейнери:
#!/bin/bash
set -euo pipefail
find . -type f -name 'Dockerfile' | while read -r dockerfile; do
dir=$(dirname "$dockerfile")
tag=$(basename "$dir" | tr '[:upper:]' '[:lower:]')
docker build --tag "$tag" "$dir"
done
P.S. Писав цей скрипт по памʼяті, не перевіряв що він працює, але я хочу передати тут саму ідею, що ми ітеруємо по всім знайденим Dockerfile та збираємо їх.
І от, здавалось б, в нас вже є підтримка збірки контейнерів в нашому монорепозиторії. Зробили зміни, завантажили їх на GitHub, а воно вже вам всі контейнери зібрало — кошерно. Ні, не кошерно, давайте я трішки ускладню проблему.
Сервіси повʼязані з бібліотеками з інших тек
Сама суть монорепозиторіїв полягає в тому, що це дозволяє розробникам локально повʼязувати кодові бази з різних бібліотек чи сервісів. Для демонстрації візьмемо те ж вигадане дерево, що я зробив раніше.
monorepo/
├─ packages/
│ ├─ logger/
│ ├─ eventually/
├─ services/
│ ├─ orchestrator/
│ │ ├─ Dockerfile
│ ├─ lookout/
│ │ ├─ Dockerfile
│ ├─ purger/
│ │ ├─ Dockerfile
Пакети logger та eventually - це звичайні npm пакети. Але ці пакети через dependencies використовуються, наприклад, в сервісі lookout. Так, операційно, вони окремі сутності, які приходять через npm реєстр під час npm install. Але для того, щоб локально розробка була трішки легшою, вони повʼязуються локально за допомогою hardlink/softlink. Тож ми маємо сервіс lookout, який у своїх node_modules має посилання на теки в інших частинах монорепозиторію.
Якщо ми просто викличемо docker build для кожного Dockerfile в репозиторії, вказавши йому за контекст збірки теку з Dockerfile, то зібраний сервіс просто не матиме всіх залежностей, які знаходяться за рамками цього контексту. Тобто, він банально не запрацює. Ви отримаєте Cannot find module … чи як там той текст помилки йде.
Тут варто зазначити, що ми використовуємо підхід зі збіркою контейнерів із самого монорепозиторію. Якщо вибудувати робочий процес таким чином, що ви спочатку публікуєте все в реєстри із репозиторію, а потім із реєстрів вже тягнете через npm install під час збірки контейнерів, то цієї проблеми там не буде.
Тож нам, щоб успішно зібрати контейнер, потрібно не лише викликати docker build, вказавши теку з Dockerfile як контекст збірки, а і впевнитись, що в тому контексті збірки будуть всі необхідні залежності. А саме — сам сервіс і та частина монорепозиторію, яка необхідна цьому сервісу як залежність.
Готуємо контекст для збірки
Для менеджменту нашого монорепозиторію ми використовуємо Rush. Так от, нам пощастило, що в Rush є саме команда, яка цю проблему й вирішує. До речі, в pnpm теж є аналог цієї команди - pnpm deploy.
Коли ми викликаємо команду rush deploy, ми вказуємо проєкт, який ми хочемо проаналізувати й викинути в готовий до споживання артефакт. Що мається на увазі під “готовий до споживання”? Я маю на увазі, що ця тека буде містити в собі все необхідне для того, щоб сервіс запрацював.
Наведу невеликий приклад, на тому ж дереві, що ми бачили раніше:
monorepo/
├─ packages/
│ ├─ logger/
│ ├─ eventually/
├─ services/
│ ├─ orchestrator/
│ │ ├─ Dockerfile
│ ├─ lookout/
│ │ ├─ Dockerfile
│ ├─ purger/
│ │ ├─ Dockerfile
Я наводив приклад з lookout, який використовує залежності з packages/logger та packages/eventually. Так ось, якщо ми тепер викличемо команду rush deploy та вкажемо, що ми хочемо зібрати lookout:
rush deploy --project lookout --target-folder /tmp/somewhere/in/the/abyss
То ми отримаємо теку /tmp/somewhere/in/the/abyss, але, що цікаво, вже з такою структурою теки:
monorepo/
├─ packages/
│ ├─ logger/
│ ├─ eventually/
├─ services/
│ ├─ lookout/
│ │ ├─ Dockerfile
Зверніть увагу, що в цій теці присутня тільки та частина монорепозиторію, яка необхідна для нормальної працездатності сервісу. Відповідно всі hardlink/softlink, які були створені, будуть вказувати на реальні файли, а значить всі залежності будуть знайдені. А все що зайве — було викинуто.
На додаток, rush deploy не тільки викидає частину монорепозиторію, яка не стосується цільового сервісу. Він також ще й перебирає node_modules і залишає тільки ті, які справді потрібні цільовому сервісу. Тому в результаті ми отримаємо теку, в якій є необхідний мінімум для виклику Node.js.
node /tmp/somewhere/in/the/abyss/services/lookout/dist/index.js
Маючи ось такий subset основного монорепозиторію, ми вже не маємо проблему з локально повʼязаними бібліотеками й сервісами. А тому можемо й запакувати його в контейнер:
docker build \
--tag lookout:latest
--file /tmp/somewhere/in/the/abyss/services/lookout/Dockerfile
/tmp/somewhere/in/the/abyss
Єдине що я б тут додав, що цей процес починає виглядати дуже непривабливо для розробників. Оце піди, скажи через rush deploy який проєкт треба підготувати до збірки, а потім піди ще сам якісь docker build пороби з якимись незрозумілими контекстами. А тому я всю цю рутину запакував в скрипт до Rush.
Розширюємо команди Rush
Ще одна річ, яка мені дуже подобається в Rush, це те що ви можете розширювати його команди. Ну тобто писати свої власні скрипти, які ви можете підʼєднати до основного набору команд. Це робиться через command-line.json файл у його теці з конфігураціями.
Тобто, якщо раніше команда розробників вже навчилась збирати монорепозиторій через rush build, або тестувати його через rush test, то точно так само можна було б зробити й інтерфейс для збірки контейнерів!
Так я і зробив і надав командам скрипт, який доступний через rush build-docker команду. Основна суть цього скрипту саме в автоматизації тієї рутини, про яку я розповідав розділами вище. Це звичайний TypeScript скрипт, який використовує Rush API (так, в нього є API), для того, щоб зібрати контейнер для вказаного проєкту. Але я його ще більше розширив інтеграціями саме з нашим пропрієтарним стеком, а тому в результаті ми отримали команду, яку можна використати ось так:
rush build-docker --project lookout --push
І в результаті отримати:
Пошук по репозиторію саме цього проєкту й підготовка його метаданих (через Rush API)
Виклик
rush deploy, який в тимчасову теку скидає той самий контекст для збірки (через child process)Підготовка всіх тегів, версій, назв для контейнера — всього, що необхідно для GitLab, щоб він прийняв до себе в реєстр наш образ (через версії із package.json та маніпуляції із рядками)
Безпосередньо сама збірка контейнера (через Docker API, з яким я по сокету комунікую із TypeScript через
dockerode)Ну і саме завантаження зібраного контейнера на GitLab Container Registry (теж через
dockerodeіз TypeScript коду)
Таким чином, все те, про що ми щойно говорили, я приховав від команд розробників. Якщо їм потрібно зібрати контейнер для якогось зі своїх сервісів, то вони просто викликають то й же інструмент, яким вони й інші операції з репозиторієм роблять - Rush. Вони не те що про контексти збірок не думають, а навіть про версії та про те як там те завантажувати на реєстр.
Проте, якщо з погляду розробника, цього достатньо (розробник часто працює в рамках одного сервісу за одиницю часу), то з погляду DevOps є один незручний нюанс — а що, як нам треба зібрати всі контейнери? Ми ж не будемо бігати для кожного з них викликати rush build-docker?
Збираємо пачку контейнерів
Як щойно я зрозумів, що нам не вистачає команди, яка може працювати з сукупністю контейнерів, я одразу ж реалізував ще одну - rush build-docker-all.
Початкова реалізація була доволі проста — ітерація по всім Rush проєктам, які зазначені як сервіси, та виклик rush build-docker для них через дочірні процеси. Але я і тут стикнувся зі складнощами.
Річ у тім, що дуже часто розробники не змінюють ж всі сервіси у PR. Ну, це очевидно вже, так, але тоді я про це не подумав. І коли у вас в монорепозиторії вже більш ніж 30 сервісів, а змінився один, то… всі просто чекають поки зберуться всі 30 сервісів.
Тому я розширив свою команду ще одним прапорцем - --changed-only. Коли цей прапорець вмикається, rush build-docker-all після того, як збере всі сервіси на збірку, відфільтрує з них й залишить тільки ті сервіси, які реально були змінені. А тому rush build-docker вже викликався тільки для тих сервісів із монорепозиторія, які були змінені.
Як це виглядає на CI
Через те, що вся логіка реалізована на TypeScript, як звичайний скрипт, який використовує Rush API та Docker API, то там це все виглядає дуже просто. Ба більше, цей скрипт, він же оформлений як ще одна команда Rush. А тому, якщо дуже коротко, то в моїх скриптах збірки, процес виглядає приблизно ось так (спрощено):
#!/bin/bash
set -euo pipefail
rush build # Calls TypeScript
rush test # Tests the changes
rush build-docker-all --push --changed-only # Build and push Docker images for changed services
Справді, це все, тут більше нічого додати. Це буквально, спрощено звісно, частина CI, яка відповідає за збірку контейнерів — просто викликати rush build-docker-all --push --changed-only десь там у YAML файлах.
Ось так ми розвʼязали проблеми з локально повʼязаними бібліотеками, автоматизували збірку контейнерів через власний скрипт й зробили YAML трішки легшим для розуміння. Пишіть про свої досвіди збірки контейнерів. Як у вас побудований цей процес? :)



