Всем привет, меня зовут Фёдор — я руководитель фронтенд-разработки на проекте Smartbot Pro в компании KTS.
Наш проект — конструктор ботов для социальных сетей, в котором алгоритм бота представлен в виде визуального графа. Конструктор включает большое количество интеграций.
Недавно на проекте остро встал вопрос оптимизации наших ci/cd пайплайнов, потому что релиз определенной версии мог занимать до 18 минут.
Для нас очень важно сократить это время, потому что мы хотим быстрее доставлять пользователям две вещи:
Новый функционал
Исправления багов
В статье я расскажу, как мы решили эту проблему с помощью оптимизации сборки Docker-образа, оптимизации установки зависимостей и сокращения количества шагов пайплайна.
Это может быть полезно тем, кто столкнулся с проблемой долгих пайплайнов.
Содержание:
Структура проекта
Пробежимся чуть подробнее по этой схеме для понимания источников проблемы.
Основной монорепозиторий
Основной репозиторий состоит из 3 пакетов:
b2c — основная версия продукта
b2b — версия для клиента, которая дополняет версию b2c специфичными функциями, которые подключаются к b2c версии с помощью плагинов.
sa — приложение для администрирования. Которая использует утилиты/типы/модели из b2c версии.
Shopback монорепозиторий
Мы с командой часто тестируем разные продуктовые гипотезы. Shopback стал одной из таких гипотез — это конструктор магазинов внутри ботов в Telegram, который мы реализовали на базе Web App.
После успешного тестирования гипотезы было принято решение интегрировать этот конструктор в основной проект. Для интеграции потребовалось использовать UI-компоненты из shopback-репозитория для реализации превью магазина. Мы вынесли их в отдельную библиотеку @shopback/ui, которая используется внутри пакета app и внутри b2c пакета из основного репозитория.
Важно отметить, что задача заключалась в оптимизации пайплайнов именно основного репозитория. Пакет UI часто обновляется, потому что shopback сейчас активно разрабатывается. Из-за этого нам приходится часто изменять версию этого пакета в b2c. Поэтому нам важно, чтобы пайплайны длились как можно меньше именно при изменяющихся зависимостях.
Анализ пайплайна
Для начала необходимо было проанализировать наш пайплайн и понять, какие именно джобы (job) тормозят.
Base-build
В этой джобе собирается и пушится в registry Docker-образ со всеми установленными зависимостями монорепозитория. Мы приняли такое решение, чтобы не повторять установку зависимостей внутри каждой сборки наших пакетов (build stage).
Длительность base-build зависела от того, были ли изменены зависимости.
Если зависимости не изменились: ~30 секунд, благодаря Docker cache
Если изменились: ~10 минут. Этот вариант стал большой проблемой
Test
Запускаются тесты внутри Docker-образа, собранного на предыдущем шаге. Длительность тестов составляла в среднем 30 секунд и не сильно влияла на общую длительность.
Build
В зависимости от пакета длительность составляла 2-7 минут.
Для оптимизации мы будем рассматривать одну джобу: docker-b2c. Все они примерно одинаковые, проблема везде одна.
Длительность docker-b2c составляла 5-7 минут.
Deploy stages
Все джобы внутри deploy-* стейджей деплоят собранные на предыдущем шаге образы в Kubernetes-кластер. Длительность каждой из этих ~10 секунд.
Как и в случае с тестами, 10 секунд из 16-18 минут — это совсем небольшая часть. К тому же это стабильное время. Поэтому test и deploy стейджы в оптимизации не нуждаются.
Первая попытка оптимизации
После анализа стало очевидно:
Основная длительность сосредоточилась в base-build и build, эти джобы и нужно оптимизировать:
-
Длительность пайплайна зависит от того, есть ли измененные зависимости:
Если зависимости не изменились, длительность составляет 4-6 минут
Если изменились, длительность составляет 16-18 минут
Оптимизация base-build
В качестве пакетного менеджера мы использовали yarn v1.
Напомню, что длительность 10 минут соответствовала варианту с изменениями в зависимостях.
Сначала мы попробовали добавить кэширование. Про Docker cache и лучшие практики применения очень хорошо написано в документации.
После прочтения и анализа Docker-файла, стало понятно, что можно применить кэш для команды RUN:
RUN --mount=type=cache,target=.yarn/cache YARN_CACHE=.yarn/cache yarn install
Для использования данного кэша необходимо использовать BuildKit в качестве docker backend.
При таком вызове при следующей сборке будет использоваться yarn cache из директории .yarn/cache из предыдущего образа.
Оптимизация build
В качестве сборщика нашего проекта мы используем webpack v5.
Webpack по дефолту использует memory cache при development mode, но для production mode кэширование отключено — подробнее об этом можно прочесть в документации. Можно изменить это поведение, указав в настройках конфига cache:
{
...
cache: isProd ? {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
} : { type: 'memory' }
}
Теперь при production mode для кэширования будет использоваться файловая система.
По аналогии с добавлением кэширования при установке зависимостей добавим кэширование при сборке проекта:
RUN --mount=type=cache,target=.yarn/.cache/webpack yarn run build
Итоги первой попытки
После подключения кэширования в джобы были получены следующие цифры:
Если зависимости не изменились, длительность составляет 2,5-4,5 минуты.
Было 4-6 минутЕсли изменились, длительность составляет ~13-15 минут.
Было 16-18 минут
Стало лучше. Но как я уже говорил, зависимости изменяются часто, и для нас 13-15 минут — плохой результат.
Поэтому мы продолжили исследование.
Вторая попытка оптимизации
При повторном взгляде на base-build стало понятно, что на самом деле установка зависимостей занимает не так много времени относительно общей длительности этой джобы:
Длительность до первой оптимизации: ~140 секунд
Длительность после первой оптимизации: ~100 секунд
Напомню, что общая длительность составляет ~10 минут.
Остальное время уходило на push образа в registry. Тут же вскрылась другая проблема: большой образ с зависимостями занимает 1 Gb и негативно влияет на размеры registry, скорость пуша и пула.
Очевидный вопрос: «Можно ли вообще избавиться от этого образа?».
Первое, что пришло на ум — устанавливать зависимости не в отдельной джобе и хранить их в отдельном Docker-образе, а устанавливать в каждой docker-* джобе. Тогда при использовании multi-stage сборки размер будет включать только собранные файлы без зависимостей, но этот шаг будет повторяться в каждой из docker-* джоб. Это не лучший вариант, так как длительность установки зависимостей с ростом их числа тоже будет расти. К тому же установленные зависимости необходимы для прохождения тестов в джобе test.
Второй вариант — хранить зависимости внутри репозитория. Я предчувствую, что на этом месте половина читателей уже скроллит вниз, чтобы оставить плохой комментарий, но пожалуйста, подождите, я могу всё объяснить! ????
Для реализации такого подхода мы решили посмотреть в сторону yarn v2+ и их философии Zero-Installs.
Yarn Zero-Installs
Основная цель этой философии, исходя из документации: «Cделать проекты стабильнее и быстрее, насколько это возможно».
Для установки используется Plug’n’Play стратегия, которая имеет перед обычной стратегией с использованием node_modules ряд преимуществ:
Почти мгновенная установка зависимостей
Более стабильная и надежная установка зависимостей
Идеальная оптимизация дерева зависимостей
Отказ от
yarn install
, так как все зависимости лежат в виде zip-архивов внутри репозиторияБолее быстрый запуск вашего приложения
Звучит хорошо, но надо разобраться: как это будет работать в реальном проекте, есть ли какие-то подводные камни?
В документации есть статья, как правильно мигрировать ваш репозиторий c yarn v1 на yarn v2+. Ниже расскажу, как это делали мы, и с какими трудностями столкнулись.
Миграция
Шаг 1
Обновили версию yarn до latest v1:
npm install -g yarn
Шаг 2
Включили v2+ внутри репозитория с проектов:
yarn set version berry
Шаг 3
Так как на проекте мы использовали .npmrc, то заменили его на новый формат файла .yarnrc.yml:
enableStrictSsl: false
nodeLinker: pnp
npmAlwaysAuth: true
npmScopes:
shopback:
npmAlwaysAuth: true
npmAuthToken: ${NPM_TOKEN}
npmPublishRegistry: "~~https://nexus.example.com/repository/shopback-npm~~"
npmRegistryServer: "~~https://nexus.example.com/repository/shopback-npm~~"
Особенности миграции
Использование yarn v2+ не поддерживает слияние .yarnrc.yml, из-за этого приходится хранить
npmAuthToken
внутри .yarnrc.yml проекта. Что небезопасно: если кто-то получит доступ к репозиторию, он может взять этот токен получить доступ к библиотекам. Тут нам помогли переменные окружения, которые поддерживает новая конфигурация:
npmAuthToken: ${NPM_TOKEN}
В качестве компоновщика можно оставить node-modules, если вы не хотите мигрировать ваш проект на Plug’n’Play и при этом использовать все фичи v2+ (plugins, новый cli api и тд.).
В нашем случае мы использовали PnP:
nodelinker: pnp
Перед тем как запустить миграцию зависимостей, необходимо указать недостающие зависимости.
Я уже сказал, что yarn v2+ стремится сделать установку зависимостей более надёжной. Что это значит для нас: некоторые разработчики библиотек забывают указывать зависимости их библиотек, или случайно указывают их вdevDependencies
. Поэтому мы это должны делать за них, используя секцию packageExtensions внутри нашего .yarnrc.yml-файла.
Для диагностики таких ситуаций можно воспользоваться пакетом @yarnpkg/doctor. На нашем проекте мы столкнулись с 10 зависимостями, где были не указаны какие-то зависимости:
packageExtensions:
react-lottie@1.2.3:
dependencies:
prop-types: "^15.6.1"
swiper@9.2.0:
dependencies:
react: "^18.2.0"
...
Запустили
yarn install
для установки зависимостей.
Теперь все наши зависимости лежат внутри репозитория.
Шаг 4
Согласно статье в официальной документации, указали в .gitignore директории из .yarn, которые необходимо игнорировать.
Пример: .yarn/unplugged необходимо игнорировать, так как в этой директории обычно содержатся артефакты сборки под конкретную машину. Исходя из этого, yarn install всё равно нужно использовать для установки именно этих зависимостей. На нашем проекте установка этих зависимостей — всего их 13 — занимает ~7 секунд. Это поведение можно изменить, указав:
enableScripts: false
Шаг 5
Для корректной работы компилятора typescript(он некорректно определял типы внутри zip-архивов) необходимо воспользоваться пакетом @yarnpkg/pnpify
Для запуска понадобится команда:
yarn pnpify tsc
Шаг 6
Наши зависимости лежат внутри .yarn/cache в виде zip-архивов. На нашем проекте эта директория занимает ~200 Mb.
С одной стороны — немного, с учетом node_modules, которые занимали ~1 Gb. С другой — это то число, которое будет расти с ростом проекта, так что раздувать git-историю этими зависимостями не хочется.
С этим нам помог git lfs и GitLab, который поддерживает git lfs. Нам нужно пометить все архивы как ссылки:
git lfs install
git lfs track "*.zip"
После этого все zip-архивы хранятся внутри git-истории просто как ссылки. Ссылки ведут на реальные архивы, которые загружаются при pull, clone и тд.
Итоги второй попытки
Мы убрали build-base джобу и стейдж, а также избавились от большого образа с зависимостями. Остались только образы с собранным приложением, которые занимают 25-30 Mb.
-
Уменьшилась длительность пайплайнов
Если зависимости не изменились: 2-4 минуты.
Было 4-6 минутыЕсли зависимости изменились: 4-6 минут.
Было 16-18 минут
Эти результаты на текущий момент нас полностью устраивают:
Дальнейшие планы
В дальнейшем планируется заняться оптимизацией сборки: попробовать заменить webpack на vite/esbuild или в webpack конфигурации заменить babel-loader на esbuild-loader.
Если у вас остались вопросы по процессу миграции, дополнения или замечания, задавайте в комментариях, с удовольствием отвечу.
Stay tuned! ????
Другие статьи про DevOps для начинающих:
Другие статьи про DevOps для продолжающих: