После нескольких десятков последовательных запусков одной и той же Xcode-пайплайна внезапно могут появиться ошибки Too many open files и EMFILE, а тестовый процесс иногда завершается без очевидной закономерности. При повторном запуске сбой может не воспроизвестись. Обычно это указывает не на повреждение накопителя, а на то, что агент сборки, тестовые процессы и наблюдатели за файлами совместно исчерпали доступные процессу файловые дескрипторы. Для устранения причины недостаточно один раз выполнить ulimit в терминале. Необходимо выяснить, у какого процесса растёт число дескрипторов, какие лимиты унаследовал агент и не превышает ли текущий параллелизм рассчитанный бюджет.
Сначала определите уровень, на котором возникает сбой
Файловые дескрипторы относятся не только к обычным файлам. Они также используются для сокетов, каналов, дескрипторов каталогов и некоторых объектов межпроцессного взаимодействия. Когда разрешение зависимостей, параллельная компиляция, тесты в симуляторах и сбор журналов выполняются одновременно, их количество быстро увеличивается.
Сначала зафиксируйте лимиты в той же среде выполнения, где завершилась с ошибкой задача:
printf 'soft limit: '
ulimit -Sn
printf 'hard limit: '
ulimit -Hn
launchctl limit maxfiles
sysctl kern.maxfiles kern.maxfilesperproc
ulimit показывает лимиты, которые могут унаследовать текущая оболочка и её дочерние процессы. launchctl limit позволяет проверить контекст запуска, а sysctl — системные границы. Эти показатели не заменяют друг друга. Если интерактивный терминал показывает 65536, а в журнале CI по-прежнему указано 256, агент не унаследовал настройки терминала.
Для первичной классификации типичных симптомов можно использовать следующую таблицу:
| Симптом | Что проверить в первую очередь | Возможная причина |
|---|---|---|
| Сбой каждый раз происходит примерно на одном этапе | Количество открытых объектов у отдельного процесса | Постоянный пик задачи превышает мягкий лимит |
| Чем дольше выполняется процесс, тем вероятнее сбой | Тенденцию роста по последовательным замерам | Файлы, сокеты или каналы не освобождаются |
| Сбой появился только после повышения параллелизма | Количество одновременно выполняемых задач | Недостаточный общий бюджет |
| В терминале всё работает, а фоновый агент завершается с ошибкой | Способ запуска агента | Различаются лимиты, унаследованные через launchd |
Успешный повторный запуск нельзя считать исправлением. Из-за изменения порядка параллельных операций утечка или пиковая нагрузка могут временно не достичь лимита, однако условия для сбоя сохраняются.
Найдите источник роста по данным процессов
Сначала получите PID агента сборки, затем подсчитайте открытые объекты для процесса. Следующие команды не изменяют состояние запущенной системы:
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
Обычно агент также запускает xcodebuild, хосты тестов и дочерние процессы сценариев. Если проверять только родительский процесс, источник проблемы можно пропустить. Количество дескрипторов для каждого PID в дереве процессов можно записывать каждые десять секунд:
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
Важен не отдельный высокий показатель, а то, снижается ли он после завершения задачи. Если у одного и того же дочернего процесса число дескрипторов продолжает расти после каждого цикла тестирования, сохраните полный вывод lsof, а затем сузьте поиск по типам REG, IPv4, IPv6, PIPE и другим. Не завершайте процесс до сбора данных, иначе наиболее ценные сведения о его состоянии будут потеряны.
Задайте агенту явные лимиты запуска
Запись ulimit -n 65536 только в конфигурационном файле оболочки входа обычно не влияет на агент, запущенный через launchd. Надёжнее использовать входной сценарий, который сначала проверяет лимиты и только затем запускает задачу:
#!/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
Если агентом управляет пользовательский LaunchAgent, лимиты можно явно объявить в его plist:
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
После изменения конфигурацию необходимо заново загрузить в соответствующем пользовательском сеансе. Нельзя рассчитывать, что настройки текущего терминала автоматически передадутся агенту:
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"
После этого обязательно снова выведите ulimit -Sn и ulimit -Hn внутри реальной задачи CI. Если агент работает не в графическом пользовательском сеансе, способ загрузки нужно адаптировать к фактическому домену launchd, а не копировать gui/<uid> без изменений.
Рассчитайте безопасный параллелизм по пиковым значениям
Повышение лимита не означает, что параллелизм можно увеличивать без ограничений. Сначала выполните одну задачу от трёх до пяти раз и зафиксируйте показатели в стабильной фазе и на пике. Например, если пиковое значение одной задачи равно 8200, базовая нагрузка фонового агента и процессов сбора данных составляет 1800, а одновременно должны выполняться 4 задачи, бюджет рассчитывается так:
8200 × 4 + 1800 = 34600
34600 × 1.3 = 44980
Это означает, что лимит 65536 обеспечивает запас. Однако одновременно следует контролировать память, дисковый ввод-вывод и количество симуляторов. Если пик дескрипторов остаётся управляемым, но задачи по-прежнему работают нестабильно, уменьшите количество сегментов тестирования или параллелизм компиляции, а не продолжайте повышать только файловый лимит.
Перед запуском каждой задачи можно проверять обязательный минимальный запас:
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
Пороговое значение должно рассчитываться по фактическим пикам, а не задаваться навсегда одним числом для всех проектов. Требования большой тестовой матрицы существенно отличаются от требований небольшой задачи компиляции.
Превратите исправление в проверяемый базовый уровень
После внесения изменений выполните несколько последовательных запусков одного и того же коммита, одного и того же набора тестов и с фиксированным параллелизмом. Для каждого запуска записывайте начальное, пиковое и конечное значения, а также PID процесса, завершившегося с ошибкой. Приёмочная проверка должна охватывать как минимум следующие пункты:
- Мягкий лимит внутри задачи CI соответствует ожидаемому значению.
- Жёсткий лимит не ниже мягкого и сохраняется после перезапуска агента.
- После завершения одной задачи количество открытых объектов возвращается к объяснимому базовому уровню.
- Ни один дочерний процесс не демонстрирует монотонного роста на протяжении нескольких последовательных запусков.
- При достижении целевого параллелизма сохраняется рассчитанный запас.
- Вместе с журналом ошибки сохраняются дерево процессов, образцы
lsofи этап задачи. - После уменьшения параллелизма пиковое значение изменяется в соответствии с количеством задач.
Если устойчивый рост наблюдается только у определённого набора тестов, запускайте его отдельно и постепенно сужайте перечень тестовых случаев. Если все задачи завершаются с ошибкой примерно у одного и того же порога, сначала проверьте лимиты запуска и бюджет параллелизма. Конечная цель состоит не во временном исчезновении ошибки, а в создании проверяемой расчётной зависимости между лимитами, пиковыми значениями и параллелизмом.
Часто задаваемые вопросы
Устранит ли увеличение ulimit -n ошибку EMFILE навсегда?
Нет. Повышенный лимит лишь добавляет запас. Если тестовый процесс не закрывает файлы, сокеты или каналы, счётчик продолжит расти. Нужно сохранить данные lsof и исправить либо изолировать процесс.
Какой лимит дескрипторов выбрать для агента Mac CI?
Измерьте пик одного задания, умножьте его на число параллельных заданий и добавьте около 30% запаса. Значение 65536 подходит как отправная точка только после проверки жёсткого лимита launchd.
Почему в терминале всё работает, а задание CI завершается ошибкой?
Интерактивная оболочка и агент под управлением launchd могут наследовать разные лимиты. Выполните ulimit -n внутри реального задания и проверьте процесс агента.
Выделенный физический облачный Mac
Разверните воспроизводимую среду разработки на выделенном физическом узле
Выберите конфигурацию, срок аренды и один из пяти узлов для сборки в Xcode, автоматизированного тестирования, удалённой разработки или инференса моделей.