5 октября

Монорепозитории NX и Lerna, или Туда и обратно

ПрограммированиеGitСистемы управления версиямиDevOpsМикросервисы

Как все знают, монорепозиторий - это несколько частей проекта (приложения, сервисы, библиотеки и т.д.), которые хранятся в одном репозитории. Плохо это или хорошо - не тема данной публикации. Но все же упомяну некоторые причины, по которым я решил их использовать.

В скриптах деплоймента (или в моем случае - еще и в настройках GitLab репозитория), нужно сформировать токены/ключи доступов к docker реджистри, kubernetes, разным кластерам и т.д. Если у вас пару сервисов, то это не проблема. Но, если сервисов 15-20, то это весьма болезненный процесс. Особенно, когда настройки кластера меняются, и нужно данные изменения вносить во все репозитории.

В определенных случаях среди нескольких сервисов, можно выделить общий код, который можно переиспользовать (описание интерфейсов, утилиты и т.д.). На самом старте проекта, когда эти общие куски кода только формируются, особенно важно не тратить время на коммиты в отдельные репозитории, если нужно всего лишь добавить поле в общий интерфейс.

После того, как мы перешли на монорепозиторий, мы выявили еще ряд преимуществ, без которых в принципе тоже можно жить, но это был приятный бонус.

И вот принято решение использовать монорепозиторий для наших приложений на JavaScript. С чего начать и как все организовать?

Самый простой и естественный способ решить две проблемы, описанные выше, это создать репозиторий, а в нем - папки с отдельными приложениями. Но это же не наш метод. В XXI веке, когда есть масса систем управления такими репозиториями.

Поанализировав рынок, почитав Хабр, поспрашивав у друзей, я остановился на интересной системе Lerna. В тот момент очень важным для меня преимуществом оказалась возможность перенести на нее уже созданные собственные репозитории, не потеряв всю историю коммитов.

Как работает Lerna в общих чертах

В корне репозитория помещается файл lerna.json, в котором конфижится все, что только можно. Из основного - это секция packages, в которой перечисляются папки, которые мы хотим отдать под управление Лерне. При выполнении команды lerna bootstrap, она пробегает по всем этим папкам, устанавливает там модули (или общие модули в корне всего репозитория - опять же настраивается) и резолвит зависимости между пакетами. Вот, что я имею ввиду в последней фразе:

  • Lerna сканит все package.json'ы в своих подконтрольных каталогах;

  • собирает имена всех этих пакетов;

  • ищет эти же имена в секциях dependencies или devDependencies в этих же package.json'ах;

  • вырезает все упоминания про ссылки друг на друга;

  • выполняет npm i или yarn (опять же - как настроите);

  • возвращает все назад;

  • после этого в папке нод модулей создает симлинки со ссылками на папки пакетов.

Например, у нас есть следующие пакеты:

  • apps

    • api (использует utils и logger)

    • ui

  • libs

    • utils (использует логгер)

    • logger

В lerna.json будет что-то вроде:

"packages": [  "libs/*",  "apps/*"],

Выполняя lerna bootstrap, будут выполнены: инсталляции модулей во всех 4х папках, а также в папке utils в нод модулях будет создана ссылка (symlink) на папку logger, а в api будут две ссылки: на utils и на logger.

Чем это удобно? Тем, что и utils и logger фактически являются внешними отдельными пакетами, но при этом, менять их вообще не больно. Просто внесли изменения и они тут же есть в приложении. Иначе приходилось бы библиотеку: менять, повышать версию, коммитить, пушить, паблишить, повышать версию в зависимостях приложения, инсталлировать и всё! Мы видим обновленный код. Можно конечно в dependencies прописывать ссылку не на пакет, а сразу на папку, но тогда нужно будет следить и случайно не запушить изменения. А после всех изменений все равно проделать операции, перечисленные выше.

Это все решило несколько проблем. Но и породило новые. Вот самые важные:

Dockerfile стал сложным. Нужно либо динамически понимать, что сервис api зависит от пакетов utils и logger либо загружать в докер вообще весь репозиторий, чтобы выполнить lerna bootstrap. Но, как мы уже знаем, этого недостаточно. Потому как структура исходников в докере будет другая и симлинки уже не подойдут, придется их все вручную копировать как обычные папки. И еще несколько проблем по мелочам.

А если не хочешь билдить всё командой (lerna bootstrap), а только один сервис? Для этого специальный ключ scope, но всё равно это невозможно, не сбилдив прежде вручную все его зависимости. В нашем примере, это надо сначала билдить logger, затем utils и уже только потом api. И эту последовательность нужно знать и получать методом проб и ошибок.

Вернемся к нашему проекту. И снова поанализировав рынок, почитав Хабр, поспрашивав у друзей, я узнал про NX. А так как у нас всё равно все проекты на TypeScript, то решили перебраться на него. Было больно, ведь разработчики рекомендуют разработку начинать сразу с NX , а у нас уже накопилось несколько сервисов и библиотек (около 10ти). Со скрипом и овертаймами переделали все на NX. На нем мы просидели 9 месяцев и доросли до 20ти сервисов. И вот недавно мы решили с него уйти, но об этом чуть позже.

Как работает NX в общих чертах

Есть один package.json в корне монорепозитория, в нем перечислены все внешние зависимости всех приложений и библиотек, которые контролирует NX. У NX есть свой json, но он в основном формальный (пошуршав по интернетам, я не нашел никого, кто бы там вручную описывал зависимости пакетов). А внутренние зависимости разруливаются через tsconfig.json, который содержит в себе алиасы путей. Для нашего примера выше:

"paths": {  
    "@octopus/api": ["apps/api/src/index.ts"],  
    "@octopus/ui": ["apps/ui/src/index.ts"],  
    "@octopus/utils": ["libs/utils/src/index.ts"],
    "@octopus/logger": ["libs/logger/src/index.ts"],

Благодаря этому все импорты из этих путей будут просто при компиляции TypeScript подтягивать соответствующую локальную директорию с файлами.

Получается, что это стандартная возможность TypeScript и NX собственно не нужен вообще? И да и нет. Действительно, все будет собираться и без него, но у него есть несколько полезных возможностей, которые мы постоянно использовали.

package.json в NX

Это спорная тема. Я много общался с коллегами и некоторым подход NX нравится, а некоторым - нет. Я из тех, кому эта система не понравилась.

Из-за того, что package.json один на весь монорепозиторий, есть 3 особенности:

  1. Версия проекта всегда одна на все приложения и библиотеки внутри.

  2. Версия любой внешней зависимости только одна на все приложения и библиотеки.

  3. Все зависимости намешаны и понять какому из приложений какие нужны нельзя.

Первый пункт можно пропустить, он не принципиален. Ну и пусть у всех приложений будет одинаковая версия - это не критично.

Повысить версию внешнего пакета сразу в 20ти сервисах может быть непростой задачей. Мы сталкивались с тем, что повышая минорные версии, проблем вообще не возникало, но иногда, повышая мажорные, приходилось что-то менять в коде и необходимо было менять сразу во всем монорепозитории. Даже в тех сервисах, которые уже давно забыты и не трогаются.

Третий пункт чреват оооочень долгой установкой пакетов. Особенно, когда явно в бэкчасти совсем не нужен реакт, а ставить его приходится. Мы пытались решить эту задачу по-разному, но что-то пошло не так. В итоге в некоторых приложениях мы создали отдельные package.json, в которых были зависимости только этого приложения, и которые (package.json) использовались только в момент билда операции yarn. Проблема появилась в том, что эти отдельные package.json'ы пришлось синхронизировать с основным вручную (версии и наличие зависимостей). Зато сборка проходила быстро.

CI/CD

В NX есть замечательная возможность посмотреть все приложения, которые были изменены после последней сборки. Весь процесс деплоймента я настроил так:

На каждый мерж реквест или пуш в мастер вызывался скрипт сборки и тестов всех приложений, которые менялись после выпуска последней сборки.

yarn install --frozen-lockfile
# Looking for all changes from last version tag
current_version=$(npx -c 'echo "$npm_package_version"')
echo "Current version: ${current_version}";
"$(npm bin)"/nx affected:test --base="v$current_version";
"$(npm bin)"/nx affected:build --base="v$current_version";

--base="v$current_version" - это означает искать, начиная с определенного коммита. В данном случае с тэга нашей текущей версии. Т.е. я тут прогоняю тесты только для тех сервисов, которые мы зацепили после последнего выпуска версии. Точно так же я запускаю билд. Это я сделал для контроля, что не сломали то, что не покрыто тестами. Откуда берется тэг?

Для этого я сделал отдельный скрипт повышения версии. Он добавляется к каждому коммиту в репозиторий как manual job. Состоит из следующих частей:

Как и в прошлом скрипте, определяем текущую версию приложения

yarn install --frozen-lockfile
current_version=$(npx -c 'echo "$npm_package_version"')
echo "Current version: ${current_version}";

После этого используем еще одну полезную возможность NX: определяем список изменившихся приложений:

message=$("$(npm bin)"/nx affected:apps --base="v$current_version" --plain);
echo "Changed services: ${message}"

После этого повышаем версию всего проекта в package.json

yarn version --patch --message "${messageTag}"
current_version=$(npx -c 'echo "$npm_package_version"')
git push --follow-tags origin HEAD:master -o ci.skip

Вот именно тут создается коммит с изменением версии и ему присваивается тэг с этой версией. С этого момента все affected будут считаться именно с этого коммита.

Дальше я вызываю сборку всех изменившихся приложений, размещая тэг определенного формата, на который реагируют джобы в CI.

read -a arr <<<"$message"
for i in "${arr[@]}"; do
  git tag --annotate "${i}#v${current_version}" --message "BUILD ${i} ${current_version}";
  git push origin "${i}#v${current_version}"
done

В gitlab-ci есть две похожие джобы, одна для билда, а вторая для деплоя (просто вызывают разные скрипты).

build:
  stage: build
  script: docker_build
  only:
    - /^[a-zA-Z0-9-]+#v\d+\.\d+\.\d+(-\d+)?$/

Как только появляется тэг формата api#v1.0.21, запускается скрипт docker_build, который внутри разбивает эту строку по решётке, и знает какое приложение собирать, и какую версию назначить docker образу. Что-то вроде:

    IFS='#' read -ra data <<< "$CI_COMMIT_TAG"
    export SERVICE_NAME="${data[0]}"
    export IMAGE_VERSION="${data[1]}"
    
    docker build -t $IMAGE_REPOSITORY/$SERVICE_NAME:$IMAGE_VERSION \
      -f ./.build/$SERVICE_NAME/Dockerfile .

Еще один недостаток этой системы: она делает коммит для повышения версии, а это означает, что сборку можно производить только по последнему коммиту. Это небольшая проблема, так как из любого не последнего коммита можно создать ветку и собрать уже в ней. На практике из-за этого проблем не возникало.

Dockerfile в NX

К сожалению, в NX докекфайл не стал проще Lerna, даже (возможно), наоборот. Все равно передавать в докер нужно контекст всего репозитория, другое дело, что копировать можно только один сервис и все его зависимые либы. А так как мы не можем получить их динамически, то нужно перечислять их вручную или просто копировать вообще все. Кроме того, для NX еще нужны его служебные файлы.

COPY libs/utils libs/utils
COPY libs/logger libs/logger
COPY nx.json workspace.json tsconfig.json ./

После этого вспоминаем раздел выше и у нас два варианта. Либо мы мучались до этого и держали отдельный package.json с зависимостями именно этого приложения и теперь очень быстро выполним npm ci (yarn). Либо мы не парились и package.json у нас один общий в корне. Ну а потом

RUN $(npm bin)/nx build api

Итоги NX

Очень удобно то, что все скрипты универсальны. Одна джоба в CI сразу для всех приложений, где просто имя передается параметром. NX легко выдает список affected приложений, с которыми потом можно работать. Он сам знает список всего, что в нем есть: не нужно хранить и не забывать постоянно пополнять массивы. Т.е. появилось новое приложение, мы добавили его специальной NX командой, и оно автоматом будет участвовать во всех CI/CD процессах. Кроме того, этот список измененных приложений очень полезен при повышении версии, что помогает не повышать версии приложениям, которые не менялись вообще. Не пересобирать их и не передеплоивать. НО! это в теории )))

Вот тут начинаются минусы. На практике мы страдали из-за нескольких существенных недостатков. И основной недостаток - это отсутствие версионности либ. Из-за того, что NX работает на основании tsconfig'a (читай выше), он может работать с либами только текущей версии. Он, конечно, позволяет их паблишить в npm registry, но работать и редактировать с ними (более старыми версиями) он не позволит. Получается, самый крутой недостаток - если ты хочешь изменить что-то в либе, будь готов, что сломаются сразу все приложения, где эта либа используется.

Грустная история ((

Опишу тут для примера один из моих самых грустных случаев.

Создал я логгер (точнее прослойку для использования нашего фирменного логгера logevo). Так как он конфигурируется через вызов метода configureLogging, я создал в своей прослойке такой же метод, подключил во все сервисы эту прослойку и вызвал configureLogging при старте каждого приложения. При этом configureLogging в logevo использует ENV переменные. И все работало.

Позже у меня появилось время это быстрое решение улучшить и я отвязался от ENV переменных logevo (типа LOGEVO_LEVEL или SYSLOG_APP_NAME). Решил в своем логгере прослойке принимать параметрами все необходимое и уже в logevo передавать в его формате. Но тут я столкнулся с проблемой, когда нужно было изменить либу, которая используется в множестве сервисов. Получается мне надо сделать ее с кучей мусорного кода (поддержку старой версии и новой) или сразу поменять ее везде. Я выбрал третий вариант: создал еще один логгер ))

Забыл упомянуть. Мы используем Nestjs. Чуть позже у меня опять появилось время и я решил не парить юзеров моего логгера с вызовом configureLogging, а автоматически в конструкторе логгера прочитать все, что мне нужно из инжектнутого конфиг сервиса (встроенный механизм Неста). И опять боль. И опять новая версия логгера.

Когда у меня снова появилось время, я решил эту прослойку (логгер) оформить как сервис адаптивный для инжекции в Нест. И появилась 4я версия логгера.

Позже я увидел, что модуль подключения этого сервиса тоже одинаков получается везде. Я вынес его в либу - и снова боль.

Как показал опыт, очень не хватает версионности либ. Чтобы можно было обновить либу и использовать ее новую версию только в одном сервисе, а остальные чтобы продолжали работать с прошлой версией.

Второй недостаток, который сильно мешал жить, описан выше. Хочешь быстрые сборки, дели package.json, но тогда мучайся с их синхронизацией. А если ты решил апнуть Nest, TypeScript или Ноду, готовся, что надо пробежаться сразу по всем сервисам и мигрировать сразу всё.

Ну и последний значительный недостаток, который был несколько неприятен. Из-за того что в NX нет версионности либ, меняя любую либу, мы аффектим все приложения, которые на нее ссылаются. В общем-то правильное поведение, но уж очень печально менять в core-types какой-то малозначительный интерфейс и наблюдать как пересобираются вообще все приложения. И даже те, которые работают стабильно, и уже очень давно не меняются и менять их нет нужды.

Еще была проблема с запуском React приложений в режиме хот релоад через NX. Они сжирали всю ОЗУ и два запущенных приложения приводили к зависанию мыши на довольно современном ПК. В итоге переписали скрипты запуска без использования NX - полегчало.

Так же NX использует для сборки webpack, что привело к куче ошибок при компиляции (он не хотел видеть многие модули), пришлось создавать почти во всех папках index.ts. Неудобство было еще и в логах, когда видишь ошибку вроде "cannot find key of undefined in main.js:23645". Все в одном файле и очень трудно хоть приблизительно понять где ошибка. Поговаривают, что можно как то его конфигурировать или вообще отключить, но мы быстро не разобрались как.

Назад в будущее

У меня долгое время зрел план по новой организации монорепозитория. Который сочетает в себе преимущества разных схем и снизит недостатки к минимуму. И вот, когда момент был самый неподходящий и нужно было срочно запилить новый сервис, я решился сразу запилить его по-новому. И вот, что мы имеем уже пару месяцев (не факт, что это решение окончательное))).

Lerna отлично справляется с зависимостями пакетов и их версионностями. Сначала я подумал просто все перевести на нее и получить такой же NX, но с возможностью где хочу использовать не самые последние версии либ. Но мне показалось, что получу те же проблемы, потому что версии используемых библиотек будут повышаться везде одновременно. Да и бутстрапиться все будет довольно долго. Поэтому только либы были вынесены как отдельные npm пакеты и прописана папка либ в lerna.json. Теперь, меняя что угодно в любой либе, мы вызываем команду lerna publish.Она четко определяет какие другие либы ссылаются на текущую, повышает версию текущей, затем в зависимостях "зависимых" либ также меняет версию на новую, повышает версию этих "зависимых" либ и так далее по цепочке. Ну и в конце публикует их все. В итоге мы получаем обновленные либы, которые используют последние версии друг-друга.

После этого я заметил одну замечательную особенность Lern'ы. Кроме сканирования наименования пакетов (как я писал выше), Lerna еще и сканирует версии пакетов. И симлинки создаются только для пакетов, в которых версия последняя. По умолчанию это просто независимые сервисы, которые используют npm пакеты определенных версий. Если меняется пакет, сервис этого даже не замечает. Но если нужно, программист сознательно повышает версию используемого пакета до последней и в этом случае автоматически создадутся симлинки на пакет, и его можно править, и видеть изменения в реальном времени. Все это происходит изолированно друг от друга. Именно благодаря этому решению при изменении либы не аффектятся абсолютно все сервисы, где эта либа используется.

В данном примере ui ссылается на устаревшую версию пакета utils@1.0.1. Она в node_modules будет как папка, которая загрузилась из npm registry. Если разработчику вдруг понадобится что-то исправить в utils, он должен включить ui в lerna .json, в зависимостях package.json приложения ui повысить версию utils до последней 1.0.1 и опять выполнить lerna bootstrap. После этого в node_modules utils станет уже символической ссылкой и можно ее менять. А после всех операций выполняем lerna publish, Lerna спросит в каких пакетах повышать версии, повысит их и запушит изменения в репозиторий, а либы запаблишит в npm registry.

Вот с какими недостатками мы уже столкнулись.

  1. Для повышения версии пакетов, необходимо пушить либу сразу в мастер. Решили пушить в ветку и использовать ключик --canary, тогда версия пакетов будет что-то вроде "1.0.1-alpha.1". Но в этом случае появляются некрасивые номера версий и необходимость в мастере время от времени делать стабильный билд.

  2. Публикация библиотек возможна только вручную. Невозможно настроить CI/CD для этого дела, потому что повышение версии вынуждено создавать новый коммит с обновленными версиями. Это риск, что произойдет две джобы одновременно и возникнет конфликт версий. Этот конфликт, кстати, в любом случае произойдет, если в двух разных ветках исправлялись либы. И это тоже минус над которым я сейчас сильно размышляю.

  3. Зоопарк версий. Это обратная сторона медали возможности версионности пакетов. Появилось 20 package.json, у которых одни и те же зависимости, но в разных версиях. Первое, что плохо - это их сложнее обновлять. Нужно пройти много файлов (в NX только один). А второе - это несовместимость версий некоторых пакетов. Например, typeorm или axios очень болезненно реагируют, если ты в приложении используешь версию не такую, как какая-то твоя либа, которую ты используешь в этом же приложении. Это проявляется даже при разнице в 3й цифре.

Над первым и вторым я еще работаю и, надеюсь, найду красивое решение (а может вы в коментарях мне поможете))). К третьему решили относиться философски. Это гибкость системы, за которую нужно платить. Зато, теперь мы можем обновлять не все 20 сервисов одновременно, а поэтапно. И в CI/CD каждого приложения у нас нет никакой lerna. Мы работаем с приложениями отдельно. Копируем только его исходники, выполняем npm ci и готово!

Итоги

Монорепозиторий - это хорошо и удобно в любом случае. Рабочие ветки после мержа мы удаляем, потому фактически мы имеем только master. Тормозов с работой с репозиторием не замечено.

NX подходит только для небольших решений на TypeScript. Он будет идеален, в системах, где мало приложений и библиотек. Где все приложения постоянно живые и обновляются.

Lerna - универсальное решение. Умеет работать в режиме NX, поддерживает JS, версионность. Но если использовать эту универсальность, придется дорого платить.

Теги:gigitlabgitlab-cilernanxmicroservicesmonorepoмонорепозиторииdocker
Хабы: Программирование Git Системы управления версиями DevOps Микросервисы
+3
1,4k 14
Комментарии 3
Похожие публикации
Лучшие публикации за сутки