Strona główna / Artykuły / Profilowanie wolnego procesu budowania monorepo z Webpack przed wyborem nowego narzędzia do pakowania

Profilowanie wolnego procesu budowania monorepo z Webpack przed wyborem nowego narzędzia do pakowania

Jak profilowanie 20-minutowego procesu budowania mikrofrontendu z Webpacka doprowadziło do odkrycia narzędzi Terser i Babel, problemów z instalacjami oraz buforowaniem w Dockerze, oraz jakie poprawki skróciły ten czas do około dwóch minut.

2078 słów

Gdy procesy budowania interfejsu użytkownika stają się powolne, pierwszą reakcją jest obwinianie narzędzia do pakowania kodu i planowanie migracji. Ta reakcja jest często błędna. W tym studium przypadku omówiona jest platforma typu micro-frontend, w której czas budowania przy użyciu Webpacka przekraczał 20 minut; analiza pokazała, że problemem nie było samo narzędzie do pakowania, a seria celowanych zmian skróciła czas budowania do około 2 minut bez konieczności zastępowania Webpacka. Zobaczysz, jak znaleźć prawdziwy wąskie gardło, które narzędzia oparte na Rust zastąpiły poszczególne etapy procesu budowania oraz dlaczego instalacja zależności, tokeny rejestru i warstwowanie w Dockerze miały takie samo znaczenie jak każde inne narzędzie do obsługi kodu.

Gdy czas budowania staje się problemem platformy

Platforma ta otrzymywała ponad 600 próśb o integrację miesięcznie od ponad dziesięciu inżynierów, co skutkowało ponad 5 000 uruchomieniami procesów CI. Przy 20 minutach na każde budowanie oznacza to ponad 1 600 godzin czasu CI miesięcznie. Przy takim obciążeniu wolne procesy budowania przestają być irytujące i stają się wąskim gardłem dla całej organizacji: informacje zwrotne przychodzą późno, kolejki CI się gromadzą, a pilne naprawy w środowisku produkcyjnym czekają w kolejce.

Konstrukcja monorepo pogorszyła sytuację:

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

Dwadzieścia aplikacji dzielących siedem bibliotek w jednym wspólnym środowisku oznacza, że każda zmiana w narzędziach wpływa na wszystko jednocześnie.

Dlaczego najpierw profilowanie, a potem migracja

Zamiana narzędzi do pakowania w 20 aplikacjach produkcyjnych, 7 bibliotekach współdzielonych oraz 144 zależnościach to projekt o wysokim ryzyku. Regresja w jednej z bibliotek współdzielonych może wpłynąć na wszystkie aplikacje, a ponowna weryfikacja wszystkich wyników kompilacji może potrwać tygodnie bez żadnych gwarancji sukcesu. Dlatego zespół najpierw przeanalizował cały proces. W wyniku tego badania ujawniono znaczne koszty poza samym narzędziem do pakowania – w zakresie sprawdzania typów, organizacji testów i rozwiązywania zależności, z których żadne nie zostałyby poprawione przez nowe narzędzie do pakowania.

Ogólniejsza lekcja jest dobrze znana z wszelkich prac nad wydajnością: optymalizacja przed pomiarami zamienia każdą zmianę w domysł. Proces kompilacji Webpack przechodzi przez kilka odrębnych faz, zanim stworzy wynikowy plik:

  • kompilacja modułów
  • wykonywanie loaderów
  • wytwarzanie fragmentów kodu
  • optymalizacja zasobów
  • minifikacja
  • wygenerowanie plików zasobów

Bez określenia czasu trwania każdej fazy nie można stwierdzić, która z nich wymaga uwagi, dlatego w konfiguracji loadera ani pluginu nic nie zostało zmienione, dopóki nie udało się uzyskać odpowiednich danych.

Znajdowanie najbardziej obciążonej części za pomocą ProgressPlugin

Webpack dostarcza już ProgressPlugin, który nie wymaga dodatkowej instalacji. W pliku konfiguracyjnym musisz mieć zainstalowany Webpack:

const webpack = require('webpack');

i dodać ten plugin do tablicy plugins:

plugins: [
  new webpack.ProgressPlugin()
]

Gdy aktywowano wyjście z informacjami o profilowaniu, od razu rzucała się w oczy jedna linia:

[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s

Jeden plugin zajmował ponad 13 minut. W porównaniu z sąsiadami na etapie optymalizacji, różnica była wyraźna:

copy-webpack-plugin      30ms
WriteIndexHtmlPlugin     22ms
RealContentHashPlugin    58ms
CompressionPlugin        70ms
LicenseWebpackPlugin     1.15s
TerserPlugin             825.31s

Każdy inny plugin został zakończony w ułamku sekundy, a w przypadku pluginu licencyjnego około sekundy. Sam narzędzie do pakowania nie było wolne; wolna była minifikacja JavaScript za pomocą Tersera. Ta jedna obserwacja skierowała całe wysiłki w nowym kierunku.

Czas wykonywania na poziomie loadera za pomocą Speed Measure Plugin

ProgressPlugin pokazuje, która etapa jest wolna, ale nie wskazuje, który łańcuch loaderów w procesie kompilacji jest za to odpowiedzialny. W tym celu zespół dodał speed-measure-webpack-plugin, który otacza eksportowaną konfigurację:

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);

SMP pokazuje, ile czasu każdy łańcuch ładowaczy poświęca na przetwarzanie modułów, co umożliwiło porównanie procesu przed i po wprowadzeniu SWC. Dzięki temu uzyskano również rzetelną ocenę rzeczywistości. Łańcuch CSS składający się z mini-css-extract-plugin, css-loader i postcss-loader nie stał się szybszy – czas jego działania wzrósł lekko z 8,66 do 9,36 sekundy. CSS oraz moduły, którymi nie zajmują się żadne ładowacze, stanowiły prawie 16 z pozostałych 20,78 sekundy całkowitego czasu wydajności. Zamiast kłócić się o preferencje narzędziowe, zespół miał konkretną liczbę pokazującą, gdzie należy dokonać kolejnych oszczędności.

Należy pamiętać o jednej ważnej uwadze: SMP otacza pluginy i ładowacze w celu pomiaru czasu ich działania, a wiadomo, że może powodować konflikty z niektórymi nowszymi pluginami, dlatego należy go używać wyłącznie do celów diagnostycznych, a nie w konfiguracji produkcyjnej.

Cele, które kierowały każdą decyzją

Zanim cokolwiek zmieniono, zespół zapisał cele w trzech grupach.

Wydajność budowania

  • Niższa opóźniona latencja przy budowaniu w chłodnym stanie.
  • Szybsze budowanie w trybie inkrementalnym.
  • Mniej czasu na transpilację i minifikację.
  • Lepsze wykorzystanie dostępnych rdzeni CPU.

Efektywność CI

  • Częstsze ponowne wykorzystanie warstw cache w Dockerze.
  • Krótszy czas instalacji zależności.

Stabilność platformy

  • Podtrzymanie stabilności narzędzi w skali przedsiębiorstwa.
  • Podtrzymanie kompatybilności z istniejącym ekosystemem.
  • Uwzględnienie surowej szybkości w porównaniu z długoterminową utrzymywalnością.

Dlaczego Vite i Rspack zostały odrzucone

Vite został dokładnie przeanalizowany. Jest to doskonały program, który często stanowi odpowiedni wybór dla nowych lub prostszych projektów. Kluczową różnicą było to, że jego wydajność była sprawdzana w rzeczywistym procesie budowania aplikacji, a nie na podstawie małych demonstracji znajdujących się w wielu przewodnikach migracyjnych. Wniosek brzmiał, że największy koszt związany z minifikacją nie zależy od narzędzia do pakowania kodu: przechodzenie na Vite zajęłoby około czterech tygodni i nadal sprawiłoby, że zespół miałby do rozwiązania te same problemy, w tym niekompatybilność nowego formatu konfiguracji oraz pluginów. W dzienniku decyzji Vite został zaznaczony jako rozwiązanie do ponownego rozważenia, gdyby kompilacja stała się głównym problemem.

Ważna niuans: Vite domyślnie używa do minifikacji JavaScript-a narzędzia esbuild, a nie Terser, więc migracja w praktyce zmieniłaby również narzędzie minifikujące. To wzmacnia rzeczywisty punkt, zamiast go osłabiać. Kluczowym elementem była właśnie selekcja narzędzia minifikującego, a Webpack może korzystać z tego samego szybkiego narzędzia bez konieczności migracji.

Rspack, narzędzie do pakowania oparte na Rust z konfiguracją kompatybilną z Webpackiem, również zostało rozważone i wydawało się obiecujące. W tamtym czasie niedawne incydenty bezpieczeństwa oraz wciąż rozwijający się ekosystem sprawiły, że zespół postanowił zachować ostrożność, więc pozostało ono na liście do ponownej oceny później. Sprawdź jego aktualny stan, zanim wyciągniesz własne wnioski.

Odbudowa najwolniejszych etapów

Gdy strategia została ustalona, praca skupiła się na stopniowym zastępowaniu najwolniejszych komponentów.

SWC zamiast Babel do transpilacji

Transpilacja JavaScript i TypeScript była jednym z największych pozostałych kosztów. Babel został zastąpiony przez SWC – kompilator oparty na języku Rust, zaprojektowany do szybkich transformacji kodu JS i TS – integrowany za pomocą swc-loader, a poprzedzony przez thread-loader:

{
  test: /\.(jsx?|tsx?)$/,
  use: [
    'thread-loader',
    {
      loader: 'swc-loader'
    }
  ]
}

Publikowane testy pokazują, że SWC przeprowadza transpilację znacznie szybciej niż Babel, a w tym procesie ta zmiana przyniosła szybszą transpilację, możliwość pracy równoległej dzięki thread-loader, mniejsze blokowanie głównego procesu oraz krótszy czas wykonywania testów CI. Moduł swc-loader polega tutaj na pliku .swcrc lub opcjach wstawionych bezpośrednio dla ustawień parsera i JSX; bez nich składnia TypeScript i JSX nie zostanie zinterpretowana.

esbuild zamiast Terser do minifikacji

W profilu już wskazano głównego winowajcę, więc minifikacja JavaScript przeniesiona została do esbuild za pomocą pluginu esbuild-loader:

new EsbuildPlugin({
  target: 'es2015',
  minify: true
})

Decyzja została podjęta na podstawie projektu minification-benchmarks, który porównuje esbuild, terser, swc, uglify-js oraz inne narzędzia pod kątem rozmiaru po minifikacji, rozmiaru po kompresji gzip oraz czasu działania. Z tych danych wynika:

  • Wyniki esbuild po minifikacji i kompresji gzip były konkurencyjne – nieco większe od najlepszego wyniku, ale w granicach około 5 do 8 procent od niego;
  • esbuild potrzebował około 295 ms, podczas gdy terser zajmował około 6,7 sekundy na tym samym wejściu; ta różnica wzrasta w monorepo;
  • @swc/core i oxc-minify generowały mniejsze pliki, ale ich kompromisy pod względem szybkości oraz trudności integracji skłoniły do wyboru esbuild.

Ustawienie target: 'es2015' informuje esbuild, jaką składnię może używać; upewnij się, że jest ono zgodne z rzeczywistym wsparciem przeglądarki, ponieważ nowszy cel umożliwia generowanie krótszego kodu.

LightningCSS do minimalizacji CSS

Minimalizacja CSS została zastąpiona przez LightningCSS z zespołu Parcel i została włączona do css-minimizer-webpack-plugin jako funkcja minimalizacji:

new CssMinimizerPlugin({
  minify: CssMinimizerPlugin.lightningCssMinify,
})

Benchmarki LightningCSS porównują go z narzędziami cssnano oraz esbuild do minimalizacji CSS. Pokazują, że jest najszybszy we wszystkich przetestowanych scenariuszach, dając konsekwentnie mniejsze pliki wyjściowe, a szczególnie duże oszczędności przy dużych plikach stylów, takich jak projekty Tailwind. W pipeline’ach CI, gdzie optymalizacja CSS bezpośrednio wpływa na całkowity czas reakcji, lepsza kompresja w połączeniu z krótszym czasem przetwarzania czyni go oczywistym wyborem. Wraz z tymi zmianami w narzędziach do minimalizacji, proces minimalizacji JavaScript stał się około 10 razy szybszy, a optymalizacja CSS około 6 razy szybsza.

Wykorzystanie wszystkich rdzeni CPU

Agenty CI zazwyczaj mają kilka rdzeni, jednak wiele pipeline’ów pozostawia większość z nich nieaktywną. Dodanie thread-loader przed kosztownymi loaderami pozwala rozproszyć to zadanie między różne wątki:

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

To zmniejszyło opóźnienia podczas kompilacji, poprawiło wykorzystanie zasobów i zwiększyło przepustowość CI. Należy pamiętać, że każdy pracownik wymaga czasu na uruchomienie oraz przekazywanie wiadomości; w przypadku już szybkiego narzędzia takiego jak SWC przy małym projekcie, pracownicy mogą kosztować więcej, niż oszczędzają, dlatego należy sprawdzić efekty z ich użyciem i bez niego.

Szybsze i bardziej przewidywalne instalacje dzięki pnpm

Kompilacja nie była jedynym kosztem – instalacja zależności również pochłaniała czas w procesie CI. Zespół porównał npm, yarn, pnpm i bun przy użyciu publicznych testów porównawczych dotyczących szybkości instalacji, wykorzystania dysku oraz mechanizmów rozwiązywania konfliktów. Bun sprawdził się dobrze w testach syntetycznych, natomiast pnpm wygrał pod względem dojrzałości ekosystemu, kompatybilności z Node.js oraz doświadczenia w dużych systemach produkcyjnych.

Gdzie npm kopiuje pakiety do każdego folderu node_modules, pnpm utrzymuje globalną przestrzeń przechowywania z adresowaniem treści: każda wersja pakietu jest pobierana tylko raz i łączona bezpośrednio z projektami. Daje to kilka korzyści:

  • instalacje są szybsze, ponieważ pakiety są pobierane tylko raz i ponownie wykorzystywane;
  • spada zużycie dysku, ponieważ przestrzenie robocze nie kopiują już tych samych pakietów;
  • ściśła procedura rozwiązywania zależności blokuje tzw. fantomowe zależności, czyli pakiety, które twój kod importuje bez ich deklaracji, co sprawia, że procesy budowania są bardziej przewidywalne;
  • poprawia się cacheowanie warstw Docker, ponieważ przestrzeń przechowywania może być wykorzystywana ponownie pomiędzy procesami budowania.

W Dockerze montowanie cache BuildKit zapewnia zachowanie przechowywania pnpm pomiędzy procesami budowania, natomiast opcja --frozen-lockfile powoduje niepowodzenie instalacji, jeśli plik lockfile jest przestarzały:

RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

Detal autoryzacji, który zepsuł cacheowanie

Jednym z najskuteczniejszych rozwiązań nie były narzędzia front-endowe. Pakiety pochodziły z AWS CodeArtifact, którego tokeny autoryzacji wygasają po 12 godzinach. Powtarzające się pobieranie tych tokenów w ramach procesu pipeline powodowało unieważnianie cache’u, wielokrotne procesy autoryzacji oraz problemy z warstwami Dockera, ponieważ zmieniająca się wartość wpływająca na warstwę modyfikowała jej klucz cache’u.

Rozwiązaniem było żądanie tokena raz na zlecenie Jenkins i jego ponowne wykorzystywanie na każdym kroku:

export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain <domain> \
  --domain-owner <owner> \
  --query authorizationToken \
  --output text)

To przyczyniło się do lepszego wykorzystania warstw, mniej zbędnych instalacji oraz bardziej stabilnego działania pipeline’ów. Jeśli przekazujesz taki token do procesu budowania w Dockerze, lepiej użyć montażu sekretów zamiast argumentu budowania, aby nie trafił on do historii obrazu ani nie unieważnił cache’u.

Strukturyzacja plików Dockerfile dla szybszego dostępu do cache’u

Ostatecznie pliki Dockerfile zostały przearanżowane w warstwy uporządkowane od tych, które zmieniają się najrzadziej, do tych, które zmieniają się najczęściej:

  1. baza środowiska wykonywania;
  2. instalacja zależności;
  3. kopiowanie źródeł kodu;
  4. budowa przy użyciu Webpacka.

W połączeniu z flagą --mount=type=cache dla sklepu pakietów oraz --mount=type=secret dla danych uwierzytelniających, taka kolejność oznaczała, że przy typowej zmianie kodu budowano tylko dwa ostatnie warstwy, co znacznie zwiększało ponowne wykorzystanie pamięci cache.

Główne wnioski

  • Traktuj szybkość budowy jako problem systemowy. Największe korzyści przyniosły tutaj narzędzia do minimalizacji kodu, instalacje, dane uwierzytelniające oraz warstwowanie w Dockerze, a nie sam narzędzie do pakowania.
  • Najpierw przeprowadź profilowanie. ProgressPlugin załapał 13-minutowy krok z Terserem w zaledwie kilka minut; spekulatywna migracja zajęłaby tygodnie.
  • Narzędzia oparte na Rust, takie jak SWC, esbuild i LightningCSS, działają synergistycznie – każde z nich redukuje odrębną część opóźnienia.
  • pnpm oraz zdyscyplinowane cacheowanie w Dockerze poprawiają zarówno determinizm, jak i szybkość.
  • Cokolwiek zmienia się przy każdym uruchomieniu, nawet token autoryzacyjny, może w tajemnicy zniszczyć cache.
  • Zachowaj opcję migracji, ale uzależnij ją od danych. Dzięki modernizacji poszczególnych etapów ta platforma skróciła czas wykonywania z ponad 20 minut do około 2, zachowując przy tym nienaruszony ekosystem.