MiniBin-Engineering-Notizen

Erschöpfte Dateideskriptoren im Cloud-Mac-CI untersuchen

Erschöpfte Dateideskriptoren im Cloud-Mac-CI untersuchen

Wenn dieselbe Xcode-Pipeline Dutzende Male hintereinander ausgeführt wurde, treten plötzlich Too many open files, EMFILE oder scheinbar zufällige Abbrüche des Testprozesses auf. Ein erneuter Lauf kann wiederum erfolgreich sein. Solche Fehler deuten häufig nicht auf einen beschädigten Datenträger hin. Meist erschöpfen Build-Agent, Testprozesse und Datei-Watcher gemeinsam die für einen Prozess verfügbaren Dateideskriptoren. Zur Behebung reicht es nicht, einmalig ulimit im Terminal auszuführen. Stattdessen muss geklärt werden, welcher Prozess kontinuierlich Deskriptoren belegt, welche Grenzwerte der Agent geerbt hat und ob die aktuelle Parallelität das verfügbare Budget überschreitet.

Zuerst die betroffene Ebene bestimmen

Dateideskriptoren stehen nicht nur für reguläre Dateien, sondern auch für Sockets, Pipes, Verzeichnis-Handles und bestimmte Objekte der Interprozesskommunikation. Wenn Abhängigkeitsauflösung, parallele Kompilierung, Simulator-Tests und Protokollerfassung gleichzeitig laufen, steigt ihre Anzahl schnell an.

Zunächst sollten die Grenzwerte in genau der Ausführungsumgebung protokolliert werden, in der der Job fehlschlägt:

printf 'soft limit: '
ulimit -Sn
printf 'hard limit: '
ulimit -Hn
launchctl limit maxfiles
sysctl kern.maxfiles kern.maxfilesperproc

ulimit zeigt die Grenzwerte, die die aktuelle Shell und ihre Kindprozesse erben können. Mit launchctl limit lässt sich der Startkontext überprüfen, während sysctl die systemweiten Grenzen ausgibt. Diese Angaben sind nicht austauschbar. Zeigt ein interaktives Terminal 65536 an, während im CI-Protokoll weiterhin 256 steht, hat der Agent die Terminaleinstellung nicht geerbt.

Typische Symptome lassen sich zunächst anhand der folgenden Tabelle einordnen:

Symptom Zuerst prüfen Mögliche Ursache
Der Fehler tritt jedes Mal in einer ähnlichen Phase auf Anzahl der von einem einzelnen Prozess geöffneten Objekte Feste Lastspitze des Jobs überschreitet das Soft-Limit
Der Fehler wird mit zunehmender Laufzeit wahrscheinlicher Wachstumstrend aus fortlaufenden Messungen Dateien, Sockets oder Pipes werden nicht freigegeben
Der Fehler tritt erst bei höherer Parallelität auf Anzahl gleichzeitig laufender Jobs Gesamtbudget ist zu klein
Im Terminal funktioniert alles, der Hintergrund-Agent schlägt fehl Startmethode des Agents Abweichende über launchd geerbte Grenzwerte

Ein erfolgreicher Wiederholungslauf ist keine Fehlerbehebung. Durch eine veränderte zeitliche Abfolge paralleler Vorgänge bleibt ein Leck oder eine Lastspitze möglicherweise vorübergehend unter dem Grenzwert, die Fehlerbedingung besteht jedoch weiterhin.

Die Wachstumsquelle anhand von Prozessdaten ermitteln

Zunächst wird die PID des Build-Agents ermittelt. Anschließend werden die geöffneten Objekte nach Prozess ausgewertet. Die folgenden Befehle verändern den laufenden Zustand nicht:

runner_pid="$(pgrep -n -f 'ci-runner|build-runner')"
test -n "$runner_pid" || exit 1

lsof -nP -p "$runner_pid" > "/tmp/runner-lsof-${runner_pid}.txt"
lsof -nP -p "$runner_pid" | awk 'NR > 1 {count[$5]++} END {
  for (type in count) print type, count[type]
}' | sort

Ein Agent startet in der Regel weitere Prozesse wie xcodebuild, Test-Hosts und Skripte. Wer nur den Elternprozess untersucht, kann die eigentliche Ursache übersehen. Die Deskriptoranzahl der einzelnen PIDs im Prozessbaum lässt sich alle zehn Sekunden erfassen:

root_pid="$runner_pid"
for sample in 1 2 3 4 5 6; do
  ps -axo pid=,ppid=,command= |
    awk -v root="$root_pid" '$1 == root || $2 == root {print $1}' |
    while read -r pid; do
      count="$(lsof -nP -p "$pid" 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
      printf '%s pid=%s open=%s
' "$(date '+%H:%M:%S')" "$pid" "$count"
    done
  sleep 10
done

Entscheidend ist nicht ein einzelner hoher Messwert, sondern ob die Anzahl nach Abschluss des Jobs wieder sinkt. Wächst sie bei demselben Kindprozess nach jedem Testdurchlauf weiter, sollte die vollständige lsof-Ausgabe gespeichert und anschließend nach Typen wie REG, IPv4, IPv6 und PIPE eingegrenzt werden. Der Prozess darf nicht bereits vor der Datenerfassung beendet werden, da sonst die wertvollsten Zustandsinformationen verloren gehen.

Eindeutige Startgrenzen für den Agent festlegen

Ein Eintrag wie ulimit -n 65536 in der Konfigurationsdatei einer Login-Shell wirkt sich normalerweise nicht auf einen von launchd gestarteten Agent aus. Robuster ist ein Einstiegsskript, das die Grenzwerte vor dem Start des Jobs überprüft:

#!/bin/zsh
set -euo pipefail

required=65536
hard="$(ulimit -Hn)"

if [[ "$hard" != "unlimited" ]] && (( hard < required )); then
  print -u2 "File descriptor hard limit is below ${required}"
  exit 78
fi

ulimit -Sn "$required"
exec /Users/runner/ci/bin/runner

Wird der Agent durch einen benutzerspezifischen LaunchAgent verwaltet, können die Grenzwerte explizit in dessen plist deklariert werden:

<key>SoftResourceLimits</key>
<dict>
  <key>NumberOfFiles</key>
  <integer>65536</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
  <key>NumberOfFiles</key>
  <integer>65536</integer>
</dict>

Nach der Änderung muss der Agent in der betreffenden Benutzersitzung neu geladen werden. Es darf nicht davon ausgegangen werden, dass die Einstellungen des aktuellen Terminals automatisch übernommen werden:

uid="$(id -u)"
plist="$HOME/Library/LaunchAgents/com.minibin.ci-runner.plist"

launchctl bootout "gui/${uid}" "$plist" 2>/dev/null || true
launchctl bootstrap "gui/${uid}" "$plist"
launchctl kickstart -k "gui/${uid}/com.minibin.ci-runner"

Danach müssen ulimit -Sn und ulimit -Hn erneut innerhalb eines echten CI-Jobs ausgegeben werden. Läuft der Agent nicht in einer grafischen Benutzersitzung, ist die Lademethode an seine tatsächliche launchd-Domäne anzupassen; gui/<uid> darf dann nicht unverändert übernommen werden.

Sichere Parallelität aus den Spitzenwerten ableiten

Ein höherer Grenzwert ist kein Grund, die Parallelität unbegrenzt zu steigern. Zunächst sollte ein einzelner Job drei bis fünf Mal ausgeführt werden, um die Werte in stabilen Phasen und unter Spitzenlast zu erfassen. Liegt die Spitze eines Jobs beispielsweise bei 8200 und die Grundlast des Hintergrund-Agents samt Erfassungsprozessen bei 1800, ergibt sich für 4 gleichzeitig laufende Jobs folgendes Budget:

8200 × 4 + 1800 = 34600
34600 × 1.3 = 44980

Damit bietet 65536 ausreichend Reserve. Dennoch müssen gleichzeitig Arbeitsspeicher, Datenträger-I/O und die Anzahl der Simulatoren beobachtet werden. Bleibt die Deskriptor-Spitze beherrschbar, während die Jobs weiterhin instabil laufen, sollten die Anzahl der Test-Shards oder die Kompilierungsparallelität reduziert werden, statt ausschließlich das Dateilimit weiter anzuheben.

Vor jedem Job kann ein verbindlicher Mindestwert geprüft werden:

limit="$(ulimit -Sn)"
open_now="$(lsof -nP -p $$ | tail -n +2 | wc -l | tr -d ' ')"
reserve=$((limit - open_now))

if (( reserve < 10000 )); then
  printf 'Insufficient descriptor reserve: %s
' "$reserve" >&2
  exit 75
fi

Dieser Schwellenwert muss aus den tatsächlich gemessenen Spitzenwerten abgeleitet werden. Eine fest einprogrammierte Zahl, die dauerhaft für alle Projekte gelten soll, ist ungeeignet. Große Testmatrizen haben deutlich andere Anforderungen als schlanke Kompilierungsläufe.

Die Behebung in eine überprüfbare Baseline überführen

Nach den Anpassungen müssen derselbe Commit und dieselbe Testsammlung mit fester Parallelität mehrfach hintereinander ausgeführt werden. Für jeden Durchlauf sind Startwert, Spitzenwert, Endwert und die PID des fehlgeschlagenen Prozesses zu protokollieren. Die Abnahme sollte mindestens folgende Punkte umfassen:

  • Das Soft-Limit innerhalb des CI-Jobs entspricht dem erwarteten Wert.
  • Das Hard-Limit liegt nicht unter dem Soft-Limit und bleibt auch nach einem Neustart des Agents wirksam.
  • Nach Abschluss eines einzelnen Jobs kehrt die Anzahl der geöffneten Objekte auf eine nachvollziehbare Baseline zurück.
  • Über mehrere aufeinanderfolgende Durchläufe zeigt kein Kindprozess ein monotones Wachstum.
  • Bei der angestrebten Parallelität bleibt die berechnete Reserve erhalten.
  • Fehlerprotokolle enthalten gleichzeitig den Prozessbaum, lsof-Stichproben und die jeweilige Jobphase.
  • Nach einer Verringerung der Parallelität verändert sich der Spitzenwert entsprechend der Anzahl der Jobs.

Wächst die Anzahl nur bei einer bestimmten Testsuite kontinuierlich, sollte diese separat ausgeführt und der Umfang der Testfälle schrittweise eingegrenzt werden. Schlagen dagegen alle Jobs in der Nähe desselben Schwellenwerts fehl, sind zuerst die Startgrenzen und das Parallelitätsbudget zu prüfen. Das Ziel besteht nicht darin, den Fehler vorübergehend verschwinden zu lassen, sondern eine überprüfbare rechnerische Beziehung zwischen Grenzwert, Spitzenlast und Parallelität herzustellen.

Häufig gestellte Fragen

Behebt ein höheres ulimit -n den EMFILE-Fehler dauerhaft?

Nein. Ein höherer Wert schafft nur Reserve. Gibt ein Testprozess Dateien, Sockets oder Pipes nicht frei, steigt die Nutzung weiter. Erfassen Sie lsof-Daten und beheben oder isolieren Sie den betreffenden Prozess.

Welche Grenze sollte ein Mac-CI-Agent verwenden?

Messen Sie den Spitzenwert eines Jobs, multiplizieren Sie ihn mit der geplanten Parallelität und ergänzen Sie etwa 30% Reserve. 65536 ist erst nach Prüfung der harten launchd-Grenze ein sinnvoller Startwert.

Warum funktioniert das Terminal, während der CI-Job scheitert?

Interaktive Shell und launchd-Agent können unterschiedliche Grenzen erben. Geben Sie ulimit -n im echten Job aus und prüfen Sie den Agentenprozess statt nur die Login-Shell.

Dedizierter physischer Cloud-Mac

Reproduzierbare Entwicklungsumgebungen auf dedizierten physischen Knoten bereitstellen

Wählen Sie eine Konfiguration, eine Laufzeit und einen von fünf Knoten für Xcode-Builds, automatisierte Tests, Remote-Entwicklung oder Modellinferenz.

Konfiguration auswählen und bestellen