Всем привет, меня зовут Фёдор — я руководитель фронтенд-разработки на проекте Smartbot Pro в компании KTS

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

Недавно на проекте остро встал вопрос оптимизации наших ci/cd пайплайнов, потому что релиз определенной версии мог занимать до 18 минут.

Для нас очень важно сократить это время, потому что мы хотим быстрее доставлять пользователям две вещи:

  • Новый функционал

  • Исправления багов

В статье я расскажу, как мы решили эту проблему с помощью оптимизации сборки Docker-образа, оптимизации установки зависимостей и сокращения количества шагов пайплайна. 

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

Содержание:

Структура проекта

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

Основной монорепозиторий

Основной репозиторий состоит из 3 пакетов:

  1. b2c — основная версия продукта

  2. b2b — версия для клиента, которая дополняет версию b2c специфичными функциями, которые подключаются к b2c версии с помощью плагинов.

  3. sa — приложение для администрирования. Которая использует утилиты/типы/модели из b2c версии.

Shopback монорепозиторий

Мы с командой часто тестируем разные продуктовые гипотезы. Shopback стал одной из таких гипотез — это конструктор магазинов внутри ботов в Telegram, который мы реализовали на базе Web App.

После успешного тестирования гипотезы было принято решение интегрировать этот конструктор в основной проект. Для интеграции потребовалось использовать UI-компоненты из shopback-репозитория для реализации превью магазина. Мы вынесли их в отдельную библиотеку @shopback/ui, которая используется внутри пакета app и внутри b2c пакета из основного репозитория.

настройка и превью того, как это реально выглядит в вашем сервисе
настройка и превью того, как это реально выглядит в вашем сервисе

Важно отметить, что задача заключалась в оптимизации пайплайнов именно основного репозитория. Пакет UI часто обновляется, потому что shopback сейчас активно разрабатывается. Из-за этого нам приходится часто изменять версию этого пакета в b2c. Поэтому нам важно, чтобы пайплайны длились как можно меньше именно при изменяющихся зависимостях.

Анализ пайплайна

Для начала необходимо было проанализировать наш пайплайн и понять, какие именно джобы (job) тормозят.

Пайплайн для ветки master
Пайплайн для ветки master

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 ряд преимуществ:

  1. Почти мгновенная установка зависимостей

  2. Более стабильная и надежная установка зависимостей

  3. Идеальная оптимизация дерева зависимостей

  4. Отказ от yarn install, так как все зависимости лежат в виде zip-архивов внутри репозитория

  5. Более быстрый запуск вашего приложения

Звучит хорошо, но надо разобраться: как это будет работать в реальном проекте, есть ли какие-то подводные камни?

В документации есть статья, как правильно мигрировать ваш репозиторий 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~~"
Особенности миграции
  1. Использование yarn v2+ не поддерживает слияние .yarnrc.yml, из-за этого приходится хранить npmAuthToken внутри .yarnrc.yml проекта. Что небезопасно:  если кто-то получит доступ к репозиторию, он может взять этот токен получить доступ к библиотекам. Тут нам помогли переменные окружения, которые поддерживает новая конфигурация:

npmAuthToken: ${NPM_TOKEN}
  1. В качестве компоновщика можно оставить node-modules, если вы не хотите мигрировать ваш проект на Plug’n’Play и при этом использовать все фичи v2+ (plugins, новый cli api и тд.).
    В нашем случае мы использовали PnP:

nodelinker: pnp
  1. Перед тем как запустить миграцию зависимостей, необходимо указать недостающие зависимости.
    Я уже сказал, что 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"
  ...
  1. Запустили 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 и тд.

Итоги второй попытки

  1. Мы убрали build-base джобу и стейдж, а также избавились от большого образа с зависимостями. Остались только образы с собранным приложением, которые занимают 25-30 Mb.

  2. Уменьшилась длительность пайплайнов

    1. Если зависимости не изменились: 2-4 минуты.
      Было 4-6 минуты

    2. Если зависимости изменились: 4-6 минут.
      Было 16-18 минут

Эти результаты на текущий момент нас полностью устраивают:

Дальнейшие планы

В дальнейшем планируется заняться оптимизацией сборки: попробовать заменить webpack на vite/esbuild или в webpack конфигурации заменить babel-loader на esbuild-loader.

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

Stay tuned! ????


Другие статьи про DevOps для начинающих:

Другие статьи про DevOps для продолжающих:

Комментарии (0)