Анализ причин медленной сборки в monorepo Webpack перед переходом на новый инструмент сборки
Как анализ процесса сборки микро-фронтенда Webpack продолжительностью 20 минут помог выявить использование Terser, Babel, процедур установки и кэширования в Docker, а также какие исправления сократили время сборки до примерно двух минут.
Когда процесс сборки фронтенда замедляется, первой реакцией является обвинение инструмента для сборки кода и начало планирования его замены. Однако такая реакция часто бывает ошибочной. В этом кейс-стади рассматривается платформа с микрофронтендами, где время сборки с помощью Webpack превышало 20 минут; анализ показал, что проблема не в самом инструменте сборки. Благодаря ряду целенаправленных изменений время сборки сократилось примерно до 2 минут без замены Webpack. Вы узнаете, как выявить настоящий узкое место, какие инструменты на основе Rust заменили определённые этапы сборки, и почему установка зависимостей, токены реестра и использование слоёв Docker имели такое же значение, как и любой загрузчик.
Когда время сборки становится проблемой платформы
Упомянутая платформа ежемесячно получала более 600 запросов на изменения от более чем десяти инженеров, что приводило к более чем 5 000 запускам процессов CI. При 20 минутах на каждую сборку это составляет более 1 600 часов времени CI в месяц. При таком объеме медленные сборки перестают быть просто неприятностью и становятся узким местом для всей организации: обратная связь поступает с опозданием, очереди CI накапливаются, а срочные исправления в продакшене остаются в очереди.
Структура монорепозитория усугубляла ситуацию:
20 production apps: React and Next.js applications
7 shared libraries
1 centralized E2E test suite
~3,950 TSX/JSX files and 6,600+ TypeScript source files
~350 reusable component modules
144 npm dependencies (74 production, 70 development)
Workspace: single hoisted monorepo
Team: 10+ engineers, 600+ PRs/month
CI: 5,000+ build runs/month
Двадцать приложений, использующих семь библиотек в одном общем рабочем пространстве, означают, что любые изменения в инструментах влияют на всё сразу.
Почему профилирование было реализовано до миграции
Замена инструментов сборки в 20 приложениях, используемых в производстве, 7 общих библиотеках и 144 зависимостях — это проект с высоким уровнем риска. Ошибка в одной из общих библиотек может повлиять на все приложения, а повторная проверка всех результатов сборки может занять недели без гарантии успеха. Поэтому команда сначала проанализировала весь процесс. В ходе этого анализа были выявлены значительные затраты, не связанные напрямую с инструментами сборки, такие как проверка типов, организация тестирования и разрешение зависимостей, которые новый инструмент сборки не смог бы улучшить.
Более общий урок, хорошо известный из любой работы по оптимизации производительности, заключается в том, что оптимизация до измерения превращает каждую изменение в угадывание. Процесс сборки с помощью Webpack проходит через несколько отдельных этапов, прежде чем будет создан готовый результат:
- компиляция модулей
- запуск загрузчиков
- создание чанков
- оптимизация ресурсов
- сокращение размера кода
- выгрузка ресурсов
Без указания времени выполнения каждой фазы невозможно определить, какая из них требует внимания, поэтому в настройках загрузчика и плагина ничего не меняли до тех пор, пока не были учтены соответствующие цифры.
Поиск «точки нагрузки» с помощью ProgressPlugin
В составе Webpack поставляется ProgressPlugin, который не требует дополнительной установки. В файле конфигурации необходимо указать Webpack:
const webpack = require('webpack');
и добавить этот плагин в массив plugins:
plugins: [
new webpack.ProgressPlugin()
]
После включения вывода информации о профилировании сразу бросилась в глаза одна строка:
[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s
Один плагин занимал более 13 минут на выполнение. По сравнению с другими плагинами на этапе оптимизации это значительное отклонение:
copy-webpack-plugin 30ms
WriteIndexHtmlPlugin 22ms
RealContentHashPlugin 58ms
CompressionPlugin 70ms
LicenseWebpackPlugin 1.15s
TerserPlugin 825.31s
Все остальные плагины выполнялись за несколько миллисекунд, а плагин для работы с лицензиями — примерно за секунду. Проблема не была в самом инструменте сборки; проблемой была минификация JavaScript с использованием Terser. Именно это наблюдение направило все усилия на решение этой проблемы.
Временные показатели на уровне загрузчика с помощью плагина Speed Measure
ProgressPlugin показывает, на каком этапе происходит замедление, но не указывает, какая именно цепочка загрузчиков в процессе компиляции является причиной. Для этого команда добавила speed-measure-webpack-plugin, который оборачивает экспортируемую конфигурацию:
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);
SMP показывает, сколько времени тратит каждая цепочка загрузчиков на обработку модулей, что позволило сравнить производительность до и после внедрения SWC. Это также дало честную оценку реальной ситуации. Цепочка обработки CSS, включающая mini-css-extract-plugin, css-loader и postcss-loader, не стала быстрее; время её работы незначительно увеличилось с 8,66 до 9,36 секунд. CSS и модули, которые не обрабатываются ни одним из загрузчиков, составили почти 16 из оставшихся 20,78 секунд общего времени выполнения. Вместо споров о предпочтительных инструментах команда имела конкретные цифры, указывающие, где следует сократить время обработки.
Следует учитывать один нюанс: SMP оборачивает плагины и загрузчики для отслеживания времени их работы, и известно, что он может вступать в конфликт с некоторыми более новыми плагинами, поэтому его следует использовать только в целях диагностики, а не оставлять в производственной конфигурации.
Цели, которые определяли каждое решение
Прежде чем что-либо изменить, команда разделила цели на три группы.
Производительность сборки
- Снижение задержек при холодной сборке.
- Быстрая поэтапная сборка.
- Меньше времени на транспиляцию и минификацию.
- Более эффективное использование доступных ядер CPU.
Эффективность CI
- Чащее использование уровней кэша Docker.
- Более короткое время установки зависимостей.
- Более предсказуемые пайплайны.
Стабильность платформы
- Поддержание стабильности инструментов в масштабах предприятия.
- Избегание рискованных миграций.
- Сохранение совместимости с существующей экосистемой.
- Соотношение скорости выполнения и долгосрочной поддержки.
Почему Vite и Rspack были отклонены
Vite был тщательно оценен. Это отличное программное обеспечение, которое часто является правильным выбором для новых или более простых проектов. Ключевое отличие заключалось в том, что его тестировали на реальном производственном цикле, а не на небольших демо-версиях, приводимых во многих руководствах по миграции. Вывод состоял в том, что основная статья затрат — сжатие кода — не зависит от инструмента для объединения файлов: переход на Vite занял бы примерно четыре недели, и команда продолжила бы сталкиваться с примерно теми же ограничениями, плюс пришлось бы решать проблемы с новым форматом конфигурации и несовместимостью плагинов. В журнале решений Vite был отмечен как инструмент, к которому стоит вернуться, если компиляция когда-либо станет основным барьером.
Важная нюанс: Vite по умолчанию минифицирует JavaScript с помощью esbuild, а не Terser, поэтому миграция на самом деле также привела бы к смене инструмента минификации. Это лишь усиливает основную мысль, а не подрывает её. Ключевым фактором был выбор инструмента минификации, и Webpack может использовать тот же быстрый инструмент без необходимости миграции.
Tакже рассматривался Rspack — бандлер на языке Rust с конфигурацией, совместимой с Webpack, который показался многообещающим. Однако из-за недавних инцидентов с безопасностью и ещё не до конца сформировавшейся экосистемы команда проявила осторожность, поэтому он остался в списке на последующую переоценку. Проверьте его текущее состояние, прежде чем делать собственные выводы.
Пересоздание самых медленных этапов
После определения стратегии работа сместилась на постепенную замену самых медленных компонентов.
SWC вместо Babel для транспиляции
Транспиляция JavaScript и TypeScript оставалась одной из крупнейших стоимостных составляющих. Babel был заменён на SWC — компилятор на основе Rust, предназначенный для высокоскоростных преобразований JS и TS, который подключается с помощью swc-loader, а перед ним используется thread-loader:
{
test: /\.(jsx?|tsx?)$/,
use: [
'thread-loader',
{
loader: 'swc-loader'
}
]
}
Публичные тесты показывают, что SWC выполняет транспиляцию гораздо быстрее, чем Babel, и в данной инфраструктуре замена привела к ускорению процесса транспиляции, возможности параллельной обработки с помощью thread-loader, снижению блокировки основного процесса и укорочению времени выполнения тестов CI. В данном случае swc-loader использует файл .swcrc или встроенные параметры для настройки парсера и JSX; без них синтаксис TypeScript и JSX не будет распарсен.
esbuild вместо Terser для минификации
В анализе уже был выявлен основной проблемный фактор, поэтому минификация JavaScript была перенесена на esbuild с помощью плагина esbuild-loader:
new EsbuildPlugin({
target: 'es2015',
minify: true
})
Решение было принято на основе проекта minification-benchmarks, который сравнивает esbuild, terser, swc, uglify-js и другие инструменты по размеру минифицированного кода, размеру после сжатия и времени обработки. Согласно этим данным:
- Результаты минификации и сжатия кода с использованием esbuild были конкурентоспособными: они немного превышали лучший результат, но составляли примерно 5–8 процентов от него;
- На одинаковом входном данных esbuild занимал около 295 мс, тогда как terser требовал примерно 6,7 секунд; этот разрыв увеличивается при работе с монорепозиторием;
@swc/coreиoxc-minifyгенерировали более маленький размер выходного кода, но из-за проблем с скоростью и сложностей интеграции решение было отдано esbuild.
Параметр target: 'es2015' указывает esbuild на ту синтаксис, которую он может использовать; убедитесь, что он соответствует реальной поддержке браузерами, поскольку более новая версия позволяет генерировать более краткий код.
LightningCSS для сжатия CSS
Для сжатия CSS используется LightningCSS от команды Parcel, который подключается к css-minimizer-webpack-plugin в качестве функции сжатия:
new CssMinimizerPlugin({
minify: CssMinimizerPlugin.lightningCssMinify,
})
Тесты производительности LightningCSS сравнивают его с инструментами cssnano и esbuild для минификации CSS. Во всех протестированных сценариях он показывает наивысшую скорость, обеспечивая при этом более компактный результат; особенно значительное ускорение наблюдается при обработке крупных шаблонов, таких как проекты Tailwind. В средах CI, где оптимизация CSS напрямую влияет на общее время отклика, лучшая степень сжатия в сочетании с меньшим временем обработки делает его явным выбором. Благодаря улучшениям в инструментах минификации скорость обработки JavaScript увеличилась примерно в 10 раз, а скорость оптимизации CSS — примерно в 6 раз.
Использование всех ядер CPU
У агентов CI обычно есть несколько ядер, однако во многих пайплайнах большинство из них остаются неиспользуемыми. Добавление модуля thread-loader перед ресурсоемкими загрузчиками позволяет распределить их работу между разными ядрами:
use: ['thread-loader', 'swc-loader']
Это сократило задержки при компиляции, улучшило использование ресурсов и повысило производительность CI. Следует помнить, что у каждого рабочего процесса есть накладные расходы на запуск и передачу сообщений; для уже быстрого загрузчика вроде SWC в небольшом проекте рабочие процессы могут принести больше убытков, чем пользы, поэтому необходимо измерять производительность с их использованием и без него.
Более быстрая и предсказуемая установка с pnpm
Компиляция была не единственной статьей расходов; установка зависимостей также занимала время в рамках CI. Команда сравнила npm, yarn, pnpm и bun с использованием публичных тестов, охватывающих скорость установки, использование диска и механизмы решения зависимостей. Bun показал хорошие результаты в синтетических тестах, но pnpm опередил его по зрелости экосистемы, совместимости с Node.js и опыту использования в крупных производственных системах.
В то время как npm копирует пакеты в каждый каталог node_modules, pnpm использует глобальный хранилище с адресным управлением содержимым: каждая версия пакета загружается один раз и создает жесткую ссылку в проектах. Это приносит несколько преимуществ:
- установка происходит быстрее, поскольку пакеты загружаются один раз и повторно используются;
- снижается использование диска, так как рабочие пространства больше не дублируют одни и те же пакеты;
- строгий механизм разрешения блокирует фантомные зависимости — пакеты, которые ваш код импортирует без их объявления, что делает процесс сборки более предсказуемым;
- улучшается кэширование слоев Docker, поскольку хранилище можно повторно использовать между сборками.
В Docker кэш BuildKit позволяет сохранять хранилище pnpm между сборками, в то время как параметр --frozen-lockfile приводит к сбое установки, если файл lockfile устарел:
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
Деталь аутентификации, нарушившая кэширование
Одним из самых эффективных решений было то, которое не связано с инструментами фронтенда. Пакеты поступали из AWS CodeArtifact, у которого токены авторизации истекают через 12 часов. Постоянное получение токенов в рамках пайплайна приводило к аннулированию кэша, многократной авторизации и сбоям в слоях Docker, поскольку изменение значения, подаваемого в слой, меняло ключ кэша этого слоя.
Решением стало запрос токена один раз в процессе выполнения задачи Jenkins и его повторное использование на каждом этапе:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain <domain> \
--domain-owner <owner> \
--query authorizationToken \
--output text)
Это способствовало лучшему повторному использованию слоев, уменьшению количества избыточных установок и более стабильной работе пайплайнов. Если вы передаёте такой токен в процесс сборки Docker, предпочтите использование монтирования секретов вместо аргументов сборки, чтобы он не попадал в историю образа и не аннуливал кэш.
Структурирование Dockerfiles для ускорения доступа к кэшу
Наконец, Dockerfiles были перестроены в слои, расположенные от наименее часто изменяющихся к наиболее часто изменяющимся:
- базовая среда выполнения;
- установка зависимостей;
- копирование исходного кода;
- сборка с помощью Webpack.
В сочетании с параметрами --mount=type=cache для хранилища пакетов и --mount=type=secret для учетных данных такой порядок действий позволял при изменении кода пересобирать только последние два слоя, значительно повышая степень повторного использования кэша.
Основные выводы
- Рассматривайте скорость сборки как проблему системного уровня. Здесь наибольшую пользу принесли минификатор, процесс установки зависимостей, обработка учетных данных и использование слоев Docker, а не сам инструмент сборки.
- Сначала проведите анализ. Плагин
ProgressPluginсократил время выполнения операции Terser с 13 минут до нескольких минут; спекулятивная миграция заняла бы недели. - Инструменты на основе Rust, такие как SWC, esbuild и LightningCSS, дополняют друг друга: каждый из них снижает отдельный элемент задержки.