Notes d’ingénierie MiniBin

Diagnostiquer l’épuisement des descripteurs sur un Mac cloud CI

Diagnostiquer l’épuisement des descripteurs sur un Mac cloud CI

Après plusieurs dizaines d’exécutions successives d’un même pipeline Xcode, des erreurs Too many open files ou EMFILE peuvent apparaître soudainement, ou le processus de test peut s’arrêter sans motif apparent. Une nouvelle exécution peut pourtant réussir. Ce type d’incident signale rarement un disque défectueux : l’agent de build, les processus de test et les observateurs de fichiers ont généralement épuisé ensemble les descripteurs disponibles pour le processus. Pour corriger durablement le problème, il ne suffit pas de lancer ponctuellement ulimit dans un terminal. Il faut déterminer quels processus continuent de croître, quelles limites l’agent a héritées et si la concurrence actuelle dépasse le budget disponible.

Identifier d’abord la couche à l’origine de l’échec

Un descripteur ne représente pas uniquement un fichier ordinaire. Il peut aussi correspondre à un socket, un tube, un handle de répertoire ou certains objets de communication interprocessus. Lorsque la résolution des dépendances, la compilation parallèle, les tests sur simulateur et la collecte des journaux s’exécutent simultanément, leur nombre peut augmenter rapidement.

Commencez par relever les limites dans l’environnement d’exécution exact où la tâche a échoué :

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

ulimit indique les limites que le shell courant et ses processus enfants peuvent hériter. launchctl limit permet de vérifier le contexte de lancement, tandis que sysctl expose les bornes au niveau du système. Ces trois sources ne sont pas interchangeables. Si un terminal interactif affiche 65536 alors que les journaux CI indiquent toujours 256, l’agent n’a pas hérité de la configuration du terminal.

Le tableau suivant permet d’orienter rapidement le diagnostic :

Symptôme Vérification prioritaire Cause possible
L’échec survient toujours à une étape similaire Nombre d’éléments ouverts par un seul processus Le pic d’une tâche déterministe dépasse la limite souple
Le risque d’échec augmente avec la durée d’exécution Tendance observée par échantillonnage continu Des fichiers, sockets ou tubes ne sont pas libérés
Le problème apparaît seulement après une hausse de la concurrence Nombre de tâches exécutées simultanément Budget global insuffisant
Le terminal fonctionne, mais l’agent en arrière-plan échoue Mode de démarrage de l’agent Les limites héritées de launchd sont différentes

Ne considérez pas la réussite d’une nouvelle exécution comme une correction. Une modification de l’ordonnancement concurrent peut empêcher temporairement une fuite ou un pic d’atteindre la limite, sans faire disparaître les conditions de l’incident.

Trouver la source de la croissance à partir des processus

Commencez par récupérer le PID de l’agent de build, puis comptabilisez les éléments ouverts par processus. Les commandes suivantes ne modifient pas l’état d’exécution :

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

L’agent lance généralement aussi xcodebuild, des hôtes de test et des scripts enfants. Se limiter au processus parent risque donc de masquer le problème. Vous pouvez relever toutes les dix secondes le nombre de descripteurs associés à chaque PID de l’arborescence :

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

L’important n’est pas qu’une mesure isolée soit élevée, mais que la valeur redescende après la fin de la tâche. Si un même processus enfant continue d’augmenter après chaque cycle de test, conservez la sortie lsof complète, puis réduisez le périmètre en examinant les types REG, IPv4, IPv6, PIPE, entre autres. N’arrêtez pas le processus avant d’avoir collecté les preuves : vous perdriez les informations les plus utiles sur l’état de l’incident.

Donner à l’agent des limites de démarrage explicites

Ajouter uniquement ulimit -n 65536 au fichier de configuration d’un shell de connexion est généralement sans effet sur un agent lancé par launchd. Une méthode plus fiable consiste à faire vérifier la limite par le script d’entrée de l’agent avant de démarrer la tâche :

#!/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

Si l’agent est géré par un LaunchAgent au niveau utilisateur, déclarez explicitement les limites dans son fichier plist :

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

Après la modification, rechargez le service dans la session de l’utilisateur concerné au lieu de supposer que la configuration du terminal courant sera propagée :

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"

Il faut ensuite afficher de nouveau ulimit -Sn et ulimit -Hn depuis une véritable tâche CI. Si l’agent ne s’exécute pas dans une session utilisateur graphique, adaptez la méthode de chargement à son domaine launchd réel au lieu de reprendre directement gui/<uid>.

Déduire une concurrence sûre à partir des pics

Augmenter la limite ne justifie pas d’accroître indéfiniment le parallélisme. Commencez par exécuter une seule tâche pendant trois à cinq cycles, puis relevez sa consommation en régime stable et son pic. Par exemple, si une tâche atteint un pic de 8200, que l’agent en arrière-plan et les processus de collecte utilisent une base de 1800, et que vous souhaitez exécuter 4 tâches simultanément, le budget est le suivant :

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

La limite de 65536 laisse donc une marge, mais il faut également surveiller la mémoire, les E/S disque et le nombre de simulateurs. Si les pics de descripteurs sont maîtrisés alors que les tâches restent instables, réduisez le partitionnement des tests ou le parallélisme de compilation au lieu de continuer à relever uniquement la limite de fichiers.

Vous pouvez imposer un seuil strict avant le démarrage de chaque tâche :

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

Ce seuil doit provenir des pics réellement observés, et non d’une valeur figée censée convenir à tous les projets. Les besoins d’une vaste matrice de tests diffèrent fortement de ceux d’une compilation légère.

Transformer la correction en référence vérifiable

Une fois les réglages appliqués, enchaînez les exécutions avec le même commit, le même ensemble de tests et un niveau de concurrence fixe. Pour chaque cycle, consignez la valeur de départ, le pic, la valeur finale et le PID du processus ayant échoué. La validation doit couvrir au minimum les points suivants :

  • La limite souple observée dans la tâche CI correspond à la valeur attendue.
  • La limite stricte n’est pas inférieure à la limite souple et reste effective après le redémarrage de l’agent.
  • À la fin d’une tâche unique, le nombre d’éléments ouverts revient à un niveau de référence explicable.
  • Aucun processus enfant ne présente une croissance monotone sur plusieurs cycles successifs.
  • La marge calculée reste disponible au niveau de concurrence visé.
  • En cas d’échec, les journaux conservent l’arborescence des processus, les échantillons lsof et l’étape de la tâche.
  • Lorsque la concurrence diminue, l’évolution du pic suit celle du nombre de tâches.

Si un seul ensemble de tests continue de croître, exécutez-le séparément et réduisez progressivement le périmètre des cas testés. Si toutes les tâches échouent près du même seuil, vérifiez en priorité les limites de démarrage et le budget de concurrence. L’objectif final n’est pas de faire disparaître temporairement l’erreur, mais d’établir une relation calculable et vérifiable entre les limites, les pics et la concurrence.

Questions fréquentes

Augmenter ulimit -n suffit-il à supprimer définitivement EMFILE ?

Non. La hausse ajoute seulement de la marge. Si un processus de test conserve des fichiers, sockets ou tubes ouverts, le compteur continuera d’augmenter. Il faut relever lsof puis corriger ou isoler ce processus.

Quelle limite choisir pour un agent CI sur Mac ?

Mesurez le pic d’une tâche, multipliez-le par la concurrence prévue et gardez environ 30% de marge. 65536 peut servir de point de départ après vérification de la limite dure imposée par launchd.

Pourquoi le terminal fonctionne-t-il alors que la tâche CI échoue ?

Le shell interactif et l’agent lancé par launchd peuvent hériter de limites différentes. Exécutez ulimit -n dans la tâche réelle et inspectez le processus de l’agent.

Mac physique exclusif dans le cloud

Déployez votre environnement de développement reproductible sur un nœud physique exclusif

Choisissez une configuration, une durée de location et l’un des cinq nœuds pour les builds Xcode, les tests automatisés, le développement à distance ou l’inférence de modèles.

Choisir une configuration et commander