Головна / Статті / Профілювання повільної процедури збирання Monorepo у Webpack перед тим, як вдаватися до нового інструменту для об’єднання файлів

Профілювання повільної процедури збирання Monorepo у Webpack перед тим, як вдаватися до нового інструменту для об’єднання файлів

Як аналіз процесу будови мікро-фронтенду Webpack тривалістю 20 хвилин допоміг виявити Terser, Babel, процеси інсталяції та кешування в Docker, а також які зміни дозволили скоротити час до приблизно двох хвилин.

2078 слів

Коли процес будування фронтенду сповільнюється, першою реакцією є звинувачення у цьому інструменту для об’єднання коду та початок планування міграції. Однак ця реакція часто є хибною. У цьому кейс-стаді вивчається платформа з мікро-фронтендом, де час будування за допомогою 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 може використовувати той самий швидкий інструмент без необхідності міграції.

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 та інструментом мініфікації CSS від esbuild. Вони показують, що цей інструмент є найшвидшим у кожному тестованому сценарії, з постійно меншим розміром результату та особливо великими покращеннями при обробці великих стилішитів, таких як проекти Tailwind. У середовищі CI, де оптимізація CSS безпосередньо впливає на загальну затримку, краща ступінь стиснення у поєднанні з меншим часом обробки робить його очевидним вибором. Унаслідок змін у інструментах мініфікації швидкість обробки JavaScript зросла приблизно в 10 разів, а оптимізація CSS — приблизно в 6 разів.

Використання всіх ядер CPU

Агенти CI зазвичай мають кілька ядер, проте багато пайплайнів залишають більшість з них без роботи. Додавання thread-loader перед витратними лоадерами дозволяє розподілити цю роботу між різними працівниками:

use: ['thread-loader', 'swc-loader']

Це зменшило проблеми з повільною компіляцією, покращило використання ресурсів та підвищило продуктивність процесу інтеграційних тестів. Пам’ятайте, що кожен працівник має додаткові витрати на запуск та передачу повідомлень; у випадку вже швидкого інсталятора на кшталт SWC у невеликому проекті використання таких працівників може коштувати більше, ніж приносить користі, тому необхідно порівнювати результати з їх використанням та без нього.

Швидші та більш передбачувані процеси інсталяції за допомогою pnpm

Компіляція була не єдиним фактором, який впливав на час; інсталяція залежностей також займала час у процесі інтеграційних тестів. Команда порівняла 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 були реорганізовані у шари, впорядковані від найменш часто змінюваних до найчастіше змінюваних:

  1. базовий час виконання;
  2. встановлення залежностей;
  3. копіювання вихідного коду;
  4. складання проекту за допомогою Webpack.

У поєднанні з параметром --mount=type=cache для магазину пакетів та --mount=type=secret для облікових даних така послідовність дозволяла після зміни коду перескладати лише останні два етапи, що значно підвищувало коефіцієнт повторного використання кешу.

Основні висновки

  • Розглядайте швидкість складання як проблему системи. У цьому випадку найбільша ефективність була досягнута завдяки мініфайеру, процесам встановлення, обліковим даним та структуруванню шарів у Docker, а не завдяки інструменту для об’єднання коду.
  • Спочатку проведіть аналіз. ProgressPlugin знайшов, що крок обробки коду за допомогою Terser триває 13 хвилин; спекулятивна міграція зайняла б тижні.
  • Інструменти на основі Rust, такі як SWC, esbuild та LightningCSS, поєднують свої функції: кожен з них усуває окремий елемент затримки.
  • pnpm та ретельне кешування в Docker покращують детермінізм, а також швидкість.
  • Будь-що, що змінюється при кожному запуску, навіть токен автентифікації, може безслідно знищити кешування.
  • Залишайте міграції на розгляді, але контролюйте їх за допомогою даних. Завдяки модернізації окремих етапів ця платформа скоротила час виконання з понад 20 хвилин до приблизно 2, зберігши при цьому свою екосистему недоторканною.