Startseite / Artikel / Profilierung einer langsamen Webpack-Monorepo-Kompilierung, bevor man zu einem neuen Bundler greift

Profilierung einer langsamen Webpack-Monorepo-Kompilierung, bevor man zu einem neuen Bundler greift

Wie das Profilieren eines 20-minütigen Webpack-Micro-frontend-Builds auf Terser, Babel, Installationen sowie Docker-Caching hinwies und welche Korrekturen die Dauer auf etwa zwei Minuten reduzierten.

2078 Wörter

Wenn die Kompilierzeit des Frontends langsamer wird, ist der erste Impuls, den Bundler dafür verantwortlich zu machen und mit einer Migration zu beginnen. Dieser Impuls ist oft falsch. Diese Fallstudie befasst sich mit einer Micro-Frontend-Plattform, bei der die Webpack-Kompilierzeit auf über 20 Minuten angestiegen war. Durch Profiling stellte sich heraus, dass der Bundler selbst nicht das Problem war; eine Reihe gezielter Änderungen reduzierte die Kompilierzeit auf etwa 2 Minuten, ohne Webpack ersetzen zu müssen. Sie werden erfahren, wie man das eigentliche Engpassproblem findet, welche auf Rust basierenden Tools welche Schritte ersetzt haben und warum die Installation von Abhängigkeiten, Registry-Tokens sowie das Docker-Layering genauso wichtig sind wie jeder Loader.

Wenn die Kompilierzeit zu einem Plattformproblem wird

Die betreffende Plattform erhielt monatlich über 600 Pull Requests von mehr als zehn Entwicklern, was sich in über 5.000 CI-Ausführungen äußerte. Bei 20 Minuten pro Build sind das mehr als 1.600 Stunden CI-Zeit im Monat. Bei diesem Umfang werden langsame Builds nicht mehr nur lästig, sondern bilden ein Engpass für die gesamte Organisation: Feedback kommt zu spät, CI-Warteschlangen bilden sich, und dringende Produktionsreparaturen müssen warten.

Die Struktur des Monorepos verschlimmerte die Situation noch:

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

Zwanzig Anwendungen, die sieben Bibliotheken in einem gemeinsamen Arbeitsbereich teilen, bedeuten, dass jede Änderung an den Tools sofort alle betreffen.

Warum Profiling vor der Migration kam

Der Austausch der Bundler in 20 Produktionsanwendungen, 7 gemeinsamen Bibliotheken und 144 Abhängigkeiten ist ein hochriskantes Projekt. Eine Regression in einer gemeinsamen Bibliothek kann sich auf alle Anwendungen auswirken, und die erneute Überprüfung aller Build-Ausgaben könnte Wochen in Anspruch nehmen, ohne dass eine Lösung garantiert ist. Deshalb analysierte das Team zunächst den gesamten Prozessfluss. Dabei kamen erhebliche Kosten außerhalb des Bundlers selbst zum Vorschein – bei der Typüberprüfung, der Testorchestrierung und der Abhängigkeitslösung – wobei kein neuer Bundler diese Aspekte verbessert hätte.

Die allgemeine Lektion ist aus jeder Leistungsoptimierung bekannt: Optimieren, bevor man misst, macht jede Veränderung zu einer Vermutung. Ein Webpack-Build durchläuft mehrere unterschiedliche Phasen, bevor ein Ergebnis erzeugt wird:

  • Modulkompilierung
  • Laderausführung
  • Chunk-Generierung
  • Ressourcenoptimierung
  • Minifizierung
  • Ressourcenerzeugung

Ohne Zeitangaben für jede Phase kann man nicht erkennen, welche Aufmerksamkeit verdient, weshalb in der Konfiguration des Loaders oder Plugins nichts geändert wurde, bis die Zahlen vorlagen.

Auffinden des Hotspots mit ProgressPlugin

Webpack liefert bereits den ProgressPlugin mit, der keine zusätzliche Installation erfordert. In der Konfigurationsdatei muss Webpack angegeben werden:

const webpack = require('webpack');

und der Plugin wird dem plugins-Array hinzugefügt:

plugins: [
  new webpack.ProgressPlugin()
]

Sobald die Profilierungsausgabe aktiviert war, fiel sofort eine Zeile auf:

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

Ein einzelnes Plugin verbrauchte mehr als 13 Minuten. Im Vergleich zu seinen Nachbarn in der Optimierungsphase ist das Ungleichgewicht auffällig:

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

Jeder andere Plugin wurde in Millisekunden abgeschlossen, im Fall des Lizenz-Plugins etwa eine Sekunde. Der Bundler war nicht langsam – die Minifizierung von JavaScript mit Terser hingegen schon. Diese eine Beobachtung lenkte alle Bemühungen in eine andere Richtung.

Zeitmessung auf Loader-Ebene mit dem Speed Measure Plugin

ProgressPlugin zeigt an, in welcher Phase es langsam geht, nicht jedoch, welche Loader-Kette innerhalb der Kompilierung dafür verantwortlich ist. Dafür fügte das Team speed-measure-webpack-plugin hinzu, welches die exportierte Konfiguration umhüllt:

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

SMP gibt an, wie lange jede Ladekette zur Verarbeitung von Modulen benötigt, was es ermöglichte, den Prozess vor und nach Einführung von SWC zu vergleichen. Zudem lieferte es eine ehrliche Realitätsprüfung. Die CSS-Kette aus mini-css-extract-plugin, css-loader und postcss-loader wurde nicht schneller – sie stieg leicht von 8,66 auf 9,36 Sekunden an. CSS-Dateien sowie Module, die von keinem Lader gemeinsam verarbeitet wurden, machten fast 16 der verbleibenden 20,78 Sekunden der Gesamtverarbeitungszeit aus. Anstatt über Vorlieben bezüglich Tools zu streiten, hatte das Team konkrete Zahlen, die zeigten, wo weitere Optimierungen ansetzen sollten.

Eine wichtige Einschränkung: SMP umhüllt Plugins und Lader, um deren Laufzeit zu messen, und es ist bekannt, dass dies mit einigen neueren Plugins in Konflikt geraten kann. Daher sollte es nur für diagnostische Zwecke verwendet werden und nicht in der Produktionskonfiguration bleiben.

Ziele, die jede Entscheidung beeinflussten

Vor jeder Änderung notierte das Team die Ziele in drei Gruppen.

Bauleistung

  • Niedrigere Latenz bei kalten Builds.
  • Schnellere inkrementelle Builds.
  • Kürzere Zeit für Transpilation und Minifizierung.
  • Bessere Nutzung der verfügbaren CPU-Kerne.

CI-Effizienz

  • Höhere Wiederverwendung der Docker-Cache-Ebenen.
  • Kürzere Installation von Abhängigkeiten.
  • Mehr deterministische Pipelines.

Plattformstabilität

  • Stabile Tools auf Unternehmensebene beibehalten.
  • Risikoreiche Migrationsvorgänge vermeiden.
  • Kompatibilität zum bestehenden Ökosystem gewährleisten.
  • Rohgeschwindigkeit gegen langfristige Wartbarkeit abwägen.

Warum Vite und Rspack beiseitegelegt wurden

Vite wurde gründlich bewertet. Es handelt sich um hervorragende Software, die oft die richtige Wahl für neue oder einfachere Projekte ist. Der entscheidende Unterschied bestand darin, dass es im Vergleich zu diesem tatsächlichen Pipeline-Setup getestet wurde und nicht gegen die kleinen Demos, die in vielen Migrationsanleitungen zu finden sind. Das Fazit war, dass der größte Kostenfaktor, die Minifizierung, unabhängig vom Bundler ist: Der Umstieg auf Vite hätte etwa vier Wochen in Anspruch genommen und das Team weiterhin mit dem gleichen Engpass konfrontiert, zusätzlich wären ein neues Konfigurationsformat sowie Inkompatibilitäten mit Plugins zu bewältigen gewesen. Im Entscheidungsprotokoll wurde Vite als Option festgehalten, die erneut in Betracht gezogen werden sollte, falls die Kompilierung jemals zum Hauptengpass würde.

Eine faire Nuance: Vite minifiziert JavaScript standardmäßig mit esbuild und nicht mit Terser, sodass eine Migration in der Praxis auch den Minifier geändert hätte. Das unterstreicht den eigentlichen Punkt, anstatt ihn zu schwächen. Der entscheidende Faktor war die Wahl des Minifiers, und Webpack kann denselben schnellen Minifier ohne Migration verwenden.

Rspack, ein auf Rust basierender Bundler mit einer Webpack-kompatiblen Konfiguration, wurde ebenfalls in Betracht gezogen und schien vielversprechend. Zu dieser Zeit führten jüngste Sicherheitsvorfälle sowie ein noch im Entwicklungsstadium befindliches Ökosystem dazu, dass das Team vorsichtig blieb, weshalb Rspack weiterhin auf der Überwachungsliste für eine spätere Neubewertung stand. Prüfen Sie seinen aktuellen Zustand, bevor Sie eigene Schlussfolgerungen ziehen.

Aufbau der langsamsten Schritte neu

Sobald die Strategie feststand, konzentrierte sich die Arbeit darauf, die langsamsten Komponenten nacheinander auszutauschen.

SWC anstelle von Babel für die Transpilation

Die Transpilation von JavaScript und TypeScript war einer der größten verbleibenden Kostenfaktoren. Babel wurde durch SWC ersetzt, einen auf Rust basierenden Compiler, der für hochleistungsstarke Umwandlungen von JS- und TS-Code konzipiert ist. Er wird über swc-loader eingebunden und von thread-loader vorgeschaltet:

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

Offizielle Benchmarks zeigen, dass SWC die Transpilation deutlich schneller durchführt als Babel. In diesem Pipeline führte der Wechsel zu einer schnelleren Transpilation, paralleler Arbeit durch thread-loader, weniger Behinderung des Hauptprozesses sowie kürzere CI-Ausführungen. Der swc-loader-Einstieg setzt hier auf eine .swcrc-Datei oder inline-Optionen für Parser- und JSX-Einstellungen; ohne diese werden TypeScript- und JSX-Syntaxe nicht parsen.

esbuild anstelle von Terser zur Minifizierung

Das Profil hatte bereits den Hauptursachenfaktor identifiziert, weshalb die Minifizierung von JavaScript über das Plugin von esbuild-loader auf esbuild verlagert wurde:

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

Die Entscheidung basierte auf dem Minification-Benchmarks-Projekt, das esbuild, terser, swc, uglify-js und weitere Tools hinsichtlich der komprimierten Größe, der gzip-komprimierten Größe sowie der Verarbeitungszeit vergleicht. Aus diesen Daten geht hervor:

  • Die von esbuild erzeugten komprimierten und gzip-komprimierten Ausgaben lagen auf einem konkurrenzfähigen Niveau – etwas größer als das beste Ergebnis, aber etwa 5 bis 8 Prozent darunter;
  • Bei derselben Eingabe benötigte esbuild rund 295 ms, während terser etwa 6,7 Sekunden brauchte; dieser Unterschied vergrößert sich in einem Monorepo noch weiter;
  • @swc/core und oxc-minify erzeugten kleinere Ausgaben, doch ihre Geschwindigkeitsnachteile sowie Integrationsprobleme führten dazu, dass die Entscheidung zugunsten von esbuild fiel.

Die Einstellung target: 'es2015' weist esbuild an, welche Syntax verwendet werden darf; stellen Sie sicher, dass sie mit der tatsächlichen Browserunterstützung übereinstimmt, da ein neueres Ziel es ermöglicht, kürzere Code zu erzeugen.

LightningCSS für die CSS-Minifizierung

Die CSS-Minifizierung wurde durch LightningCSS vom Parcel-Team ersetzt, welches als Minifizierungsfunktion in css-minimizer-webpack-plugin integriert ist:

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

Die LightningCSS-Benchmarks vergleichen es mit den CSS-Minifizierern von cssnano und esbuild. Sie zeigen, dass es in jedem getesteten Szenario am schnellsten ist, eine konstant kleinere Ausgabe liefert und insbesondere bei großen Stylesheets wie Tailwind-Builds große Vorteile bietet. In einer CI-Pipeline, in der die CSS-Optimierung direkt zur Gesamtlatenz beiträgt, macht eine bessere Kompression in Kombination mit weniger Zeit es zur klaren Wahl. Durch die Änderungen an den Minifizierern wurde die JavaScript-Minifizierung etwa 10 Mal schneller und die CSS-Optimierung etwa 6 Mal schneller.

Nutzung aller CPU-Kerne

CI-Agenten verfügen in der Regel über mehrere Kerne, doch viele Pipelines lassen die meisten davon ungenutzt. Durch Hinzufügen von thread-loader vor den ressourcenintensiven Loadern wird die Arbeit auf mehrere Worker verteilt:

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

Dadurch verringerten sich die Engpässe bei langsamen Kompilierungen, die Ressourcennutzung verbesserte sich und die Durchsatzrate im CI stieg. Man sollte bedenken, dass jeder Worker Startkosten sowie Overhead durch den Datenaustausch mitbringt; bei einem bereits schnellen Loader wie SWC in kleinen Projekten können Worker mehr kosten, als sie einsparen, weshalb man die Leistung mit und ohne sie messen sollte.

Schnellere, deterministischere Installationen mit pnpm

Die Kompilierung war nicht die einzige Kostenfaktor; auch die Installation von Abhängigkeiten fraß Zeit im CI. Das Team verglich npm, yarn, pnpm und bun anhand öffentlicher Benchmarks zu Installationsgeschwindigkeit, Festplattennutzung und Lösungsmodellen. Bun zeigte in synthetischen Tests gute Ergebnisse, doch pnpm setzte sich durch seine höhere Reifegrad des Ökosystems, bessere Kompatibilität mit Node.js sowie Erfahrung in großen Produktionsumgebungen durch.

Während npm Pakete in jede node_modules-Datei kopiert, verwendet pnpm einen inhaltsadressierbaren globalen Speicher: Jede Paketversion wird nur einmal heruntergeladen und in Projekten als Hardlink gespeichert. Dies bringt mehrere Vorteile mit sich:

  • Die Installationen sind schneller, da die Pakete nur einmal abgerufen und wiederverwendet werden;
  • Der Festplattennutzung wird entgegengewirkt, da Workspaces nicht mehr dieselben Pakete doppelt speichern;
  • Durch strenge Auflösungsmechanismen werden sogenannte Phantom-Abhängigkeiten verhindert – also Pakete, die Ihr Code importiert, ohne sie explizit zu deklarieren – was die Build-Prozesse deterministischer macht;
  • Durch das Caching von Docker-Schichten wird die Leistung verbessert, da der Speicher zwischen verschiedenen Builds wiederverwendet werden kann.

In Docker sorgt ein Mount des BuildKit-Caches dafür, dass der pnpm-Speicher über mehrere Builds hinweg erhalten bleibt, während --frozen-lockfile die Installation abbricht, wenn das Lockfile veraltet ist:

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

Eine Authentifizierungsangabe, die das Caching störte

Eine der wirksamsten Lösungen hatte nichts mit Frontend-Tools zu tun. Die Pakete stammten von AWS CodeArtifact, dessen Autorisierungstoken nach 12 Stunden ablaufen. Das wiederholte Abrufen der Token innerhalb des Pipelines führte zur Invalidation des Caches, zu wiederholten Authentifizierungsprozessen sowie zu beschädigten Docker-Schichten, da sich ändernde Werte, die in eine Schicht eingespeist werden, deren Cache-Schlüssel verändern.

Die Lösung bestand darin, das Token pro Jenkins-Job einmal anzufordern und es für jeden Schritt erneut zu verwenden:

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

Dadurch wurde eine bessere Wiederverwendung der Schichten erreicht, es gab weniger überflüssige Installationen und die Pipelines waren stabiler. Wenn Sie ein solches Token in einen Docker-Build übergeben, sollten Sie lieber eine geheime Montage statt eines Build-Arguments verwenden, damit es weder im Image-History landet noch den Cache invalidiert.

Dockerfiles in Schichten organisieren für Cache-Erfolge

Schließlich wurden die Dockerfiles so neu geordnet, dass sie von den am seltensten bis zu den am häufigsten verändernden Schichten reihenfolgen.

  1. der Basistrenner;
  2. Installation der Abhängigkeiten;
  3. Kopieren der Quelldateien;
  4. die Webpack-Kompilierung.

In Kombination mit --mount=type=cache für den Paketspeicher und --mount=type=secret für Anmeldeinformationen führte diese Reihenfolge dazu, dass bei typischen Codeänderungen nur die letzten beiden Schichten neu kompiliert wurden, wodurch die Wiederverwendung des Caches erheblich gesteigert wurde.

Haupterkenntnisse

  • Betrachten Sie die Kompiliergeschwindigkeit als ein Systemproblem. Hier ergaben sich die größten Verbesserungen durch den Minifier, die Installationen, die Anmeldeinformationen sowie das Docker-Layering – nicht durch den Bundler.
  • Analysieren Sie zunächst das Profil. Der ProgressPlugin fand einen 13-minütigen Terser-Schritt, während eine spekulative Migration Wochen in Anspruch genommen hätte.
  • Rust-basierte Tools wie SWC, esbuild und LightningCSS wirken sich additiv aus: Jedes davon verringert einen separaten Anteil an Latenz.
  • pnpm sowie ein diszipliniertes Docker-Caching verbessern sowohl die Vorhersagbarkeit als auch die Geschwindigkeit.
  • Jedes Element, das bei jedem Ausführungsvorgang ändert – selbst ein Authentifizierungstoken – kann das Caching heimlich zerstören.
  • Behalten Sie Migrationsmöglichkeiten im Blick, aber gestalten Sie sie datenbasiert. Durch die Modernisierung der einzelnen Schritte sank die Laufzeit dieser Plattform von über 20 Minuten auf etwa 2 Minuten, ohne dass ihr Ökosystem beeinträchtigt wurde.