Русский
Русский
English
Статистика
Реклама

Monorepo

Lerna CI ? Или как не запутаться в трёх соснах

02.01.2021 22:16:45 | Автор: admin

Вместо предисловия

Доброго времени суток! Меня зовут Сергей, и я тимлид в компании Медпоинт24-Лаб. Я занимаюсь разработкой на nodejs чуть больше полутора лет - до этого был C#, ну а ещё до того, всякое разное и не очень серьёзно. Ну то есть, опыта у меня не так чтобы вагон, и иногда приходится серьёзно поломать голову при решении возникающих проблем. Решив такую, всегда хочется поделиться находками с товарищами по команде.

И вот несколько дней назад, они посоветовали мне завести блог... а я подумал, может тогда просто написать на Хабр?

Возможно, примеры практических ситуаций, из которых со скрипом вылезает толковый, но не очень опытный разработчик, будут интересны таким же толковым и неопытным )) А может и ещё кому пригодится.

Рассказывать постараюсь без погружения в теорию, но со ссылками на оную.

О чём пойдёт речь?

Пилот будет посвящён интересной проблеме с которой мы столкнулись при попытке организовать CI/CD для монорепозитория с lerna. Сразу скажу, что этот пост:

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

  • не про инструменты для управления монорепозиториями. Монорепу можно реализовать при помощи Nx, rush, даже просто yarn workspaces. Но так получилось, что мы выбрали lerna и поживём с ней какое то время.

  • не про пакетные менеджеры. Могу порекомендовать хороший видос со сравнением npm, yarn и pnpm и офигенную серию постов в которой работа c npm объясняется с самых азов и очень тщательно. А у нас npm (пока)...

  • не про nestjs. Но он классный!

Обо всём этом будет рассказано только в том объёме, который нужен для понимания проблематики.

Тогда о чём?

Дано:

Имеется маленький монорепозиторий, в котором лежит сервер на несте и npm-пакет, который содержит всё необходимое для клиентского приложения, которое будет этот сервер вызывать.

packages+-- @contract|+-- src|+-- package.json|   ...|+-- application|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...
Зачем пакет?

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

То есть, вызывающий код делает не просто axios.post(....) передавая туда никак не проверяемые статически параметры (any), а вызывает метод с типизированным входом и выходом.

import { Client } from '@contract/some-service';const client = new Client(options);const filters: StronglyTypedObject = ...const data = await client.getSomeData(filters)/** И результат у нас тоже типизированный.* А ещё из getSomeData() вылетают типизированные ошибки известного формата,* чем в нас обычно кидается, axios.*/

Наша практика показала, что это очень облегчает и ускоряет разработку для всех, кто использует наш сервис, включая фронтенд. Так что нам подход понравился.

Кроме того, недавно мы пошли чуть дальше и сделали так:

const query = new SomeQuery({ ... });const data = await client.call(query);/** Теперь клиент у нас общий на все сервисы, а вот запросы и команды -* волшебные и сами знают, что делать. Это мы используем с rabbitMQ.*/

Такой подход мы используем не только для http-сервисов, но и для сервисов, которые обрабатывают сообщения из RabbitMQ, а также при помощи одного из самописных средств транспорта на основе redis. Но усложнять статью этими деталями не будем.

Так вот, у нас есть монорепа, что она нам даёт? В первую очередь, удобство разработки. Базовая фича для всех релевантных инструментов - это то, что в lerna называется bootstrap.

lerna bootstrap --hoist

Флаг --hoist - самая приятная часть. Он говорит лерне, что все зависимости, если можно, надо ставить в node_modules в корне проекта. Мы на этом экономим место + получаем ещё бонус, который нам пригодится дальше.

Помимо установки пакетов lerna bootstrap создаёт симлинки на пакеты имеющиеся в репозитории. То есть, хотя в application/package.json указано

"dependencies": {"@contract/core": "^1.0.0"}

в действительности, этот пакет не будет установлен из npm-реестра, а просто прилинкуется в node_modules из папки packages. Таким образом, мы его меняем, собираем и сразу используем новую версию в нашем приложении.

Задача

Мы строим систему CI/CD. И нам нужно научиться красиво вписать наш монорепозиторий в конвеер. Казалось бы, задача должна была уже 1000 раз быть решена - настолько она очевидна.

И действительно, есть куча issues на github, посты на Stackoverflow и др. ресурсах. Но нет рецептов.. костыли есть, и то не все рабочие, а нормального "штатного" решения я не нашёл (искал, чесслово).

Так вот, когда наш сервис готов к релизу:

  1. Мы хотим смержить PR и, таким образом, запустить пайплайн.

  2. Мы хотим собрать проект, прогнать линтер, unit-тесты.

  3. Мы хотим поднять версию приложения и пакета (а в чём не было изменений - там не поднимать).

  4. Мы хотим опубликовать пакет @contract в npm registry (в нашем случае, приватном).

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

  6. Ну и мы хотим, чтобы наш артефакт был разумного размера и содержал только то, что ему реально нужно для работы. node_modules по ГБ - не совсем то, что нам нужно.

Поехали!

Первый пункт берут на себя CI/CD системы.

Со вторым проблем быть не должно:

Для третьего lerna нам предлагает две прекрасные команды: lerna version и lerna publish (последняя включает в себя первую и мы будем использовать её). Примерно так:

lerna publish --conventional-commits --yes# На заметку: команда publish принимает все флаги команды version.# В доке это есть, но я с первого раза пропустил.
Чуть подробнее про conventional commits.

Если делать команду lerna publish без указанных ключей, то поднятие версии будет интерактивным,что для CIонвейра не годится. На помощь приходит спецификация Conventional Commits. Соблюдение этого простого и понятного соглашения по структуре commit-сообщений, позволяет lerna автоматически определить, какую как правильно по semver поднять (минор, мажор или патч). Самое милое, что мы можем вынудить наших разработчиков писать правильные коммиты (что само по себе хорошо)! Вот достаточно подробная инструкция.

С пунктом 4 у нас тоже нет проблем.lerna publish нам это уже сделала, а если нас это почему-то не устраивает (ну, к примеру, мы не хотим ставить теги или ещё что), то используем lerna version в сочетании с npm publish из директории пакета. Забавно, что npm publish не имеет ключа --registry, чтобы указать, куда пушить пакет. В случае lerna publish, нас выручит lerna.json (стр. 7):

{  "version": "1.2.2",  "npmClient": "npm",  "command": {    "publish": {      "message": "chore(release): publish",      "registry": ....    }  },  "packages": [    "packages/@contract",    "packages/application"  ]}

Иначе нам понадобится файл .npmrc (файл с настройками npm) в директории пакета.

Первые сложности

Итак, наша CI-машина должна делать примерно следующее (без привязки к конкретной системе CI/CD):

# Pull и checkoutlerna bootsrap --hoist lerna run build # Запустит команду npm run build в каждом пакете.lerna publish --conventional-commits --yescp packages/application/build /tmp/place/for/artifact...

Но для работы нашему приложению нужны ещё node_modules.

Попытка 1. Можно конечно взять да и скопировать папку node_modules из корня проекта в наш /tmp/place/for/artifact. Но тогда:

  • Мы точно получим лишние зависимости (всякие jest, typescript и кучу ещё всего ненужного в рантайме). А если у нас в репозитории не 2 пакета, а 22, то размер node_modules может быть неприличным.

  • Мы, возможно, недополучим нужные зависимости, т. к. lerna поднимает пакеты в корень только если может. Так бывает не всегда - могут быть где-то разные версии, например.

Попытка 2. Не вопрос. У нас же есть package.json внутри packages/application. Там ведь перечисленно всё, что надо! Копируем package.json в папку с артефактом, запускаем npm i - профит! Но:

Дело в том, что для обеспечения повторяемости билда, в CI среде вместо команды npm install принято использовать npm ci. Основное отличие от npm install в том, что пакеты ставятся не из package.json, а из package-lock.json или shrinkwrap.json (смысл тот же). Подробнее о lock-файлах можно почитать в хорошем переводе от Андрея Мелихова.

Для моего рассказа важно понимать следующее:

  • Обойтись без lock-файла никак нельзя. Даже если указать в dependencies точные версии зависимостей без всяких "~" и "^" - это не поможет, т. к. транзитивные зависимости (то есть зависимости ваших зависимостей) вы не контролируете.

  • lock-файл должен быть синхронизирован с package.json. Это значит, что если у нас в package.json появилась новая зависимость (или наоборот), а в package-lock.json её нет, то npm ci будет ругаться:

Приятно, что текст ошибки содержит прямое указание на то, что нам нужно сделать - npm install.

Давайте вспомним, с чего мы начинали нашу сборку: lerna bootstrap --hoist Эта команда на самом деле уже создала нам package-lock.json в корне проекта. Однако, это нам мало помогает.

Можете убедиться в этом сами, скопировав в артефакт package.json из packages/application и lock-файл из корня - получите ошибку. Конечно, ведь там никакого намёка не будет на синхронизацию! А в application lock-файла у нас нет. Поэтому:

Попытка 3. Давайте попробуем обойтись без "всплытия". Да, не супер удобно, зато lock-файл будет там где надо. Делаем просто:

lerna bootstrap

Это действительно даст нам по lock-файлу на каждый проект. Но и тут всё не слава Богу! Потому что при попытке с этим файлом сделать npm ci, нам опять скажут нехорошие слова про синхронизацию. Как так?

Изучаем файл package-lock.json и видим.. что там не хватает пакета @contract/core!Ну и правильно, мы его не устанавливали, а делали симлинк...

Попытка 4. Ок, делаем просто npm install внутри каждого пакета. Тут нам поможет:

lerna exec -- npm i

Ура, теперь lock-файл и манифест внутри пакета синхронизированы! npm ci работает! Победа!

Запускаем наше приложение и при первом же запросе...

Оно падает

Говорит, что с модулем @contractчто-то не то. Конечно не то! Ведь npm i поставил этот модуль из npm registry. Ну тогда понятно - это только формально та же версия. А по факту, мы локально могли внести изменения, в том числе и ломающие, но версию ещё не поднимали и пакет не пушили (напоминаю, что build у нас до publish). Если же никаких изменений в пакете не было, то всё сработает.. и это скорее плохо, чем хорошо. Лучше пусть всегда не работает, чем то так, то сяк.

Думали гадали насчёт того, как бы делать publish сначала. Но это не логично - код надо собрать, потом протестировать, потом только паблишить - а он, зараза, не собирается.

Попытка 4. Ну ок, нам нужен симлинк, давайте его сделаем...

Выполняем:

lerna exec -- npm i      # Создаётся lock-файлы в пакетах.lerna link               # Создаются симлинки.lerna run buildlerna publish --conventional-commits ...cp packages/application/build /path/to/artifact# Можно ещё вместо копирования сделать production сборку# - без sourceMaps и деклараций.cp packages/application/package*.json /path/to/artifact(cd /path/to/artifact && npm ci --production)

И оно даже работает! Только мы кое-что забыли.. Добавим вызов jest где-нибудь между 3-й и 4-й строкой...

И внезапно падают уже тесты

Могут конечно и не упасть... особенно, если их нет. Но приложение может работать некорректно. А точнее, не так, как на машине разработчика, где он делает lerna bootstrap --hoist а потом билд.

До этого момента все проблемы были в общем-то тривиальны. Времени много ушло потому, что не было некоторых базовых знаний платформы и инструментов. Сейчас - после нескольких часов проведённых в гугле (хабре, медиуме, гитхабе...) - уже кажется, что всё просто. А вот новая проблема прямо мистическая. И возможно, вы с ней не столкнётесь. Но в чём тут суть, ИМХО понимать полезно.

Итак, lerna bootstrap --hoist и lerna exec -- npm i && lerna link - в чём может быть разница? Ведь второе - это по сути lerna bootstrap, но без --hoist. Пробуем на машине разработчика убрать флаг hoist... тесты падают. Добавляем - проходят.

На самом деле, когда первый приступ отупения проходит, можно осознать следующее:

packages+-- @contract|   +-- node_modules|       +-- class-transformer|+-- src|+-- package.json|   ...|+-- application|   +-- node_modules|       +-- class-transformer|       +-- @contract -> символическая ссылка|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...

На схеме подсказка. И application и contract зависят от пакета class-transformer. Вообще-то, там есть и другие общие зависимости, но, к счастью, не все зависимости ломаются, когда в структуре node_modules они присутствуют в двойном экземпляре.

class-transformer - из тех, что ломается.

Подробнее о том, почему

class-transformer - удобная библиотека для преобразования объектов основанная на декораторах. В nestjs она встроена в дефолтную систему валидации (ValidationPipe). Самый простой пример её использования может быть такой:

import { Type } from 'class-transformer';import { IsInt, IsPositive } from 'class-validator';export class Query {  @IsInt()  @IsPositive()  @Type(() => Number)  id: number;}

При этом мы это посылаем в GET запросе (?id=100500) и получается, что nest на вход получит строчку, а не число. И валидатор IsInt() на это ругнётся (может и нет, но IsPositive() ругнётся 100%).

Поэтому мы говорим несту: преобразуй пожалуйста в число. Декоратор @Type() - самый простой способ. Если я не ошибаюсь, то он сделает просто return Number(id) Для более сложных случаев можно использовать декоратор @Transform() в который можно передать функцию преобразования.

Всё это вы можете найти в доке по class-validator и class-transformer.

Но вот только будте осторожны - функция трансформации НИ В КОЕМ СЛУЧАЕ не должна бросить ошибку. Это положит поток (на горьком опыте - потерянные 3 часа жизни)

Так вот:

Что произойдёт в нашем случае. Когда отработает декоратор @Type(), он он запишет в специальный объект в недрах class-transformer метаданные: "у вот этого класса надо преобразовывать входящее значение в число". Потом, когда объект придёт в ваше приложение nest вызовет функцию plainToClass из той же самой библиотеки, передав туда данные и конструктор Query. Она из того же объекта считает метаданные и проведёт преобразование.

В этом "том же" вся проблема. Если копий библиотеки у нас две, то это может быть два разных объекта и когда plainToClass будет работать, метаданных установленных в декораторе @Type() там не окажется!

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


Если кто-то уже достиг дзен - напишите пожалуйста в комментах, кому-то точно пригодится.

А мы как раз поместили наш класс Query в один пакет, а приложение в другой и если внимательно посмотреть на структуру проекта выше, но становится понятно, что у @contractнет никаких шансов найти нужную копию class-transformer.

Забавно, что у class-validator такой проблемы нет. Возможно, они хранят метаданные иначе (в global?). Как именно ещё не успел посмотреть.

Вот собственно ответ и найден. Получается, из-за того, как работает резолв зависимостей в ноде (ищем в node_modules, потом поднимаемся выше, ищем в node_modules... и так до рута) в случае симлинков нам очень выгоден --hoist. В случае установки из registry, пакетный менеджер сделает примерно (меня пугает это примерно...) то же - поднимет всё, что сможет поднять.

Дальше исследовать ситуацию было уже физически невозможно, поэтому было принято решение - переспать...

Что в итоге?

Чуток устаканив в голове новое понимание (и вопросы), я родил примерно следующее:

  • Когда программист начинает работу над репозиторием (только что слонировав его), он действует по плану:

lerna bootstrap --hoist # Не npm i в корне! Это сломает ваш lock-file!lerna run buildjest# ну и работаем...
  • В CI делаем то же самое, потом lerna publish, а затем:

# Makefile# Получаем версию приложения.BUILD:=build.$(shell jq .version packages/application/package.json | sed 's/"//g')artifact:  # Скрипт build/prod убирает sourceMap'ы и декларации, которые не нужны в продакшене(cd packages/application && npm run build:prod -- --outDir ../../deploy/$(BUILD))cp -r packages/application/package*.json deploy/$(BUILD)  # Ставим только рантайм-зависимости из package-lock.json(cd deploy/$(BUILD) && npm ci --production)    # Я вырезал кое-какие несущественные подробности, типа удаления package*.json и  # создания tar.gz архива.rm deploy/$(BUILD)/package*.jsosdf

Мы используем make, но это не суть. В итоге, это всё планируется перенести в Dockerfile, когда наша инфраструктура будет к этому готова.

  • Как создать корректные lock-файлы, если их нет?

lerna exec -- npm ilerna clean --yes# Именно так. Ставим модули, а потом удаляем. Но это единственный способ получить# lock-файлыlerna bootstrap -- hoist
  • Ну и последнее и, пожалуй, самое неприятное. Как установить новый пакет в наш application (или @contract)таким образом, чтобы сохранить консистентность lock-файлов:

# Makefileadd:# (ОЧЕНЬ ВАЖНО) Здесь обновится только package.jsonlerna add --scope=$(scope) $(package) --no-bootstrap# Обновится package-lock.json внутри пакетаlerna exec --scope=$(scope) -- npm i# node_modules внутри units/application нам не нужны!lerna clean --yes# вернёт нам все зависимости в корень и обновит рутовый package-lock.jsonlerna bootstrap --hoist  # Запускаем так (в scope надо передавать имя пакета из package.json):$ make add scope=app_name package left-pad

Почему так сложно? Потому что команда lerna add не обновляет package-lock.json, а только сам манифест. Не понятно почему. Может быть я не нашёл чего-то. Подскажите...

Выводы:

  • Зависимости в ноде - это сложно.

  • Управление зависимостями в монорепозиториях в условиях CI/CD - это ещё сложнее.

  • Но самое главное, свет в конце всегда туннеля есть! И пока решаешь такого рода заморочки, частенько удаётся поднять хороший пласт новых знаний.

Уверен, что это не последняя итерация. Меня не покидает ощущение, что всё можно сделать проще, чище - буду рад мнениям и идеям в комментариях.

Надо ещё поиграться с командой npm shrinkwrap, например...

Большое спасибо тем, кто дочитал до конца... Если здесь ещё кто-нибудь есть?

Если такой формат "история из практики" интересен, напишите пожалуйста, что "так", что "не так". Потому что историй... их есть у меня.

Спасибо за внимание!

Подробнее..
Категории: Node.js , Ci/cd , Nodejs , Nestjs , Monorepo , Lerna

Из песочницы Масштабирование CICD монорепозитория

19.07.2020 18:07:47 | Автор: admin

Lerna


Дано


  1. Монорепозиторий на базе Lerna и Yarn workspaces.
  2. Десяток приложений, и десятки общих пакетов на TypeScript, Angular, NodeJS.
  3. Высокое покрытие тестами самых разных мастей (модульные, интеграционные, e2e).
  4. и Atlassian Bamboo CI/CD.

Задача


Ускорить имеющиеся пайплайны в 2 раза (до, хотя бы, получаса). Попутно повысив стабильность до 90%.


Забегая вперед, скажу что требуемые показатели были достигнуты.


Было



Для инкрементальной сборки lerna filter options:


lerna run build:packages --since --include-merged-tags --include-dependencies

Чтобы попасть в инкремент пакеты должны проходить фазу lerna publish в артифакторий (JFrog):


# Masterlerna publish patch --yes# Featurelerna publish prepatch --yes --no-push --preid "${PREID}"

При такой организации pipeline, возможно только вертикальное масштабирование путём увеличения мощностей elastic агентов.


Этот подход крайне ограничен. И с ростом числа пакетов средняя длительность постепенно росла (~1ч).


Надо заметить, что в силу короткого релизного цикла (сутки), стабильность JFrog и, как следствие, всего pipeline была низка (~70%).


Идея


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


Тестировать тоже независимо от остальных.
На входе production image (зависимости устанвлены, все пакеты собраны)
На выходе отчеты о тестировании и покрытии.


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


Но в таком случае размер node_modules составил бы ~1.5Gb (суммарные зависимости всех пакетов монорепозитория). Что негативно отразилось бы на размере image, времени его загрузки в AWS ECR, и времени развертывания.


Фокусировка


Чтобы "урезать" ("сфокусировать") монорепозиторий для сборки, тестирования и развертывания одного конкретного приложения, достаточно найти подмножество пакетов в общем графе пакетов и переписать декларацию workspaces в корневом package.json непосредственно перед сборкой на CI.


#!/usr/bin/env nodeconst { spawnSync } = require('child_process');const { existsSync, promises: { readFile, writeFile } } = require('fs');const { join, dirname, relative, normalize } = require('path');const PACK_JSON_PATH = './package.json';(async (apps) => {    const packJSON = JSON.parse((await readFile(PACK_JSON_PATH)).toString());    await spawnSync('yarn', ['global', 'add', 'lerna'], { shell: true });    const locations = await listPackages(apps);    const [someLocation] = locations;    const basePath = findBasePath(someLocation);    // All paths should be relative to monorepo root    const workspaces = locations.map((loc) => normalize(relative(basePath, loc)));    packJSON.workspaces.packages = workspaces;    const packJSONStr = JSON.stringify(packJSON, undefined, 2);    await writeFile(PACK_JSON_PATH, `${packJSONStr}\n`);})(    process.argv.slice(2),);async function listPackages(apps = []) {    const filterOptions = apps.flatMap((app) => ['--scope', app]);    const { stdout } = await spawnSync(        'lerna',        ['ls', '-pa', '--include-dependencies', ...filterOptions],        { shell: true },    );    return String(stdout).split(/[\r\n]+/).filter(Boolean);}function findBasePath(packageLocation) {    return existsSync(join(packageLocation, 'lerna.json'))        ? packageLocation        : findBasePath(dirname(packageLocation));}

После "фокусировки" все команды (в том числе и changed) будут относится лишь к подмножетсву пакетов конкретного приложения.


Размер node_modules удалось снизить в среднем в 3 раза.


Fixed mode


Lerna fixed mode, отказ от lerna publish и артифактория позволили повысить стабильность и упростить логику pipeline.


Но как же быть с инкрементальностью сборок?


Инкремент


Для инкремента достаточно отслеживать изменения через команду lerna changed


lerna changed -a --include-merged-tags

Если изменений не обнаружено, то можно переиспользовать latest image приложения для развертывания и тестирования:


#!/usr/bin/env bashAPP=$1lerna-focus.js "${APP}"function nothing_changed() {    [[ -z "$(lerna changed -a --include-merged-tags || true)" ]]}function pull_latest_image() {...}function push_latest_image() {...}function tag_latest_with_version() {...}pull_latest_imageif nothing_changed; then    tag_latest_with_version    exitfibuild-app.sh "${APP}"if is-master.sh; then    push_latest_imagefi

Стало



Что дальше?


Сейчас активно набирают обороты такие решения как Nx: Extensible Dev Tools for Monorepos. Это предмет следующих разборов.


Если эта статья окажется полезной, то в следующей расскажу о горизонтальном масштабировании "на коленке" модульных тестов (Angular, Jest, ElasticSearch, Bamboo CI).

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru