Artikel

Der Build wollte sechseinhalb Gigabyte

Ein Bauvorgang, der monatelang als kaputt galt, war nie kaputt. Er brauchte nur mehr Speicher, als der Container hatte — und starb ohne verwertbare Fehlermeldung.

BlackZackBlackzack

1017 Wörter · 5 Min. Lesezeit

  • web
  • betrieb
  • fehlersuche

In meinem Bereitstellungsskript stand über Monate ein Kommentar: „Das Dashboard wird übersprungen (Build-Fehler mit dem Framework)". Ich hatte das irgendwann geschrieben, nachdem ein Bau mehrfach fehlgeschlagen war, und danach nie wieder angefasst.

Es war nie das Framework. Der Bau lief in eine Speichergrenze und wurde vom System beendet.

Wie so ein Fehler aussieht

Das ist der Kern des Problems: Er sieht nach gar nichts aus.

ELIFECYCLE Command failed with exit code 1

Mehr steht nicht im Bauprotokoll. Kein Hinweis auf Speicher, keine Ausnahme, kein Verlaufsstapel. Der Prozess wird von außen beendet, und ein beendeter Prozess kann nichts mehr schreiben.

Der Beleg steht in den Systemmeldungen des Wirtssystems: eine Zeile, dass ein Prozess wegen Speichermangels innerhalb einer Gruppe beendet wurde. Wer nicht weiß, dass man dort nachsehen muss, sucht im Anwendungscode.

Die zwei falschen Fährten, die ich selbst gelegt habe

Beide plausibel, beide falsch — und ich schreibe sie auf, weil sie zeigen, wie eine gute Geschichte die Suche in die Irre führt.

Erste Fährte: „Die Heap-Grenze liegt zu nah an der Containergrenze." Ich hatte für die JavaScript-Seite eine Obergrenze gesetzt, die fast die gesamte Containergrenze ausschöpfte. Das ist tatsächlich ein Fehler, und ich war überzeugt, die Ursache gefunden zu haben. Mit doppelter Containergrenze starb der Bau erneut — kurz unter der neuen Grenze.

Zweite Fährte: „Mehrere Arbeitsprozesse, und die Grenze gilt je Prozess." Ebenfalls richtig beobachtet, ebenfalls nicht die Ursache. Mit weniger Prozessen und halbierter Grenze starb er an derselben Stelle.

Was tatsächlich los war

Der Speicherbedarf liegt nativ im Übersetzer, nicht im JavaScript-Heap. Deshalb wirkt eine Heap-Obergrenze dagegen überhaupt nicht — man begrenzt einen Bereich, der gar nicht der Verursacher ist.

Mit ausreichend Speicher lief der Bau durch: Spitzenbedarf gut sechseinhalb Gigabyte, Dauer rund zwölf Minuten auf zwei Kernen.

Zwölf Minuten. Das ist der zweite Teil der Geschichte: Selbst wenn genug Speicher da ist, dauert es lange genug, dass man beim ersten Versuch geneigt ist abzubrechen — und dann hält man einen funktionierenden Bau für kaputt.

Zwei dauerhafte Änderungen

Die Containergrenze wurde erhöht, und für einen Bau lässt sie sich vorübergehend weiter hochsetzen. Nach einem Neustart fällt sie auf den Normalwert zurück, damit der laufende Betrieb nicht dauerhaft Speicher blockiert, den er nicht braucht.

Das Bauziel ist konfigurierbar. Das ist die wichtigere Änderung:

distDir: process.env.NEXT_DIST_DIR || ".next"

Damit baue ich in ein Nebenverzeichnis und schwenke erst nach Erfolg um.

Der Grund dafür ist eine Eigenschaft, die man schmerzhaft lernt: Ein fehlgeschlagener Bau leert das Ausgabeverzeichnis. Danach startet der Dienst gar nicht mehr, weil er keine gebaute Fassung findet. Ein Versuch, eine Änderung auszuliefern, kann also den laufenden Dienst mitnehmen — und genau das ist mir passiert, für einige Minuten Ausfall.

Warnung

Die verwandte Falle: In das Verzeichnis zu bauen, aus dem der laufende Dienst liest, auch wenn der Bau gelingt. Der laufende Prozess kennt die alte Kennung des Baus, auf der Platte liegt die neue — und dann fehlen ihm Dateien, die er erwartet. Bauen, parken, tauschen, neu starten. In dieser Reihenfolge.

Wie ich Bauvorgänge heute betreibe

Aus dieser Geschichte ist ein festes Verfahren geworden, das ich für jede Anwendung mit Bauschritt benutze.

Bauen und Starten sind getrennt. Der Start startet nur. Ein Bau ist ein eigener Vorgang mit eigener Ausgabe.

Gebaut wird in ein Nebenverzeichnis. Erst nach Erfolg wird umgeschwenkt. Damit kann ein fehlgeschlagener Bau den laufenden Dienst nicht mitnehmen.

Der vorherige Bau bleibt liegen, umbenannt, bis der neue sich bewährt hat. Der Rückweg ist damit ein Umbenennen.

Für den Bau werden Grenzen vorübergehend erhöht, nicht dauerhaft. Ein Dienst, der im Betrieb wenig braucht, soll nicht dauerhaft Speicher blockieren, den er nur beim Bauen benötigt.

Nach dem Umschwenken wird die Fassung geprüft — an der ausgelieferten Angabe, nicht am Bauprotokoll.

Der Kommentar, der drei Monate gelogen hat

Der Teil dieser Geschichte, der mich am meisten beschäftigt hat, ist nicht der Speicher. Es ist der Satz, der monatelang im Bereitstellungsskript stand: „Build-Fehler mit dem Framework."

Diese Zeile hat aktiv verhindert, dass jemand — also ich — die Sache noch einmal ansieht. Sie sah aus wie eine geklärte Ursache, und geklärte Ursachen prüft man nicht nach.

Meine Konsequenz ist eine Regel für Kommentare, die eine Diagnose enthalten:

// 2026-02-14: Bau schlägt fehl. Vermutung: Framework-Version.
// NICHT verifiziert — bei nächster Gelegenheit prüfen.

Datum, Einstufung, Prüfstand. Wenn dort „Vermutung" steht, weiß ich in sechs Monaten, dass ich es nicht wissen kann. Wenn dort ein Datum steht, weiß ich, wie alt die Aussage ist.

Ein Kommentar ohne diese Angaben ist eine Behauptung, die mit der Zeit an Autorität gewinnt, obwohl sie nie geprüft wurde.

Warum ein Bau so viel Speicher braucht

Für alle, die vor derselben Frage stehen — die Gründe sind nicht offensichtlich und erklären, warum eine Heap-Grenze nichts hilft.

Der Übersetzer arbeitet mit dem gesamten Abhängigkeitsbaum. Er muss wissen, was jedes Modul exportiert, um zu entscheiden, was wegfallen kann. Bei einem Projekt mit vielen Paketen ist dieser Graph groß.

Optimierung braucht Zwischenstände. Verkleinern, Zusammenfassen, Aufteilen — dabei liegen mehrere Fassungen desselben Codes gleichzeitig im Speicher.

Ein Teil davon liegt außerhalb des verwalteten Speichers. Moderne Übersetzer sind teilweise in nativem Code geschrieben. Deren Bedarf taucht in keiner JavaScript-Grenze auf — und genau deshalb wirkt eine solche Grenze nicht dagegen.

Der dritte Punkt ist die eigentliche Antwort auf meine falsche Fährte. Ich habe an einer Schraube gedreht, die einen anderen Bereich begrenzt als den, der zu groß war.

Was ich daraus mitnehme

Ein Kommentar im Code ist eine Behauptung mit Verfallsdatum. „Hat Build-Fehler mit dem Framework" stand monatelang da und war falsch. Schlimmer: Er hat verhindert, dass jemand — also ich — die Sache noch einmal ansieht. Ein Kommentar, der eine Diagnose enthält, sollte ein Datum und einen Beleg haben.

Wenn ein Prozess ohne Fehlermeldung stirbt, war es meistens das System. Speicher, ein Signal, ein Zeitlimit. Die Anwendung selbst schreibt in aller Regel etwas, bevor sie aufgibt.

Fehlschläge in Testaufbauten sind keine Fehlschläge des Projekts. Zwei meiner früheren Versuche waren an Fehlern in meiner eigenen Testumgebung gescheitert — an einer fehlenden Einhängung und an einem Weg, Umgebungsvariablen zu übergeben, bei dem Anführungszeichen im Wert landeten. Beide sahen wie Codefehler aus. Diese Erfahrung ist der Grund, warum ich einen fehlgeschlagenen Bau heute erst dann für einen Codefehler halte, wenn ich ihn zweimal in unterschiedlichen Umgebungen reproduziert habe.