MiniBin 엔지니어링 노트

클라우드 Mac CI 파일 디스크립터 고갈 진단과 동시성 제어

클라우드 Mac CI 파일 디스크립터 고갈 진단과 동시성 제어

동일한 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, 테스트 호스트, 스크립트 하위 프로세스도 생성하므로 부모 프로세스만 확인하면 문제를 놓칠 수 있습니다. 10초마다 프로세스 트리에 있는 각 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"

그런 다음 실제 CI 작업에서 ulimit -Snulimit -Hn을 다시 출력해 확인해야 합니다. 에이전트가 GUI 사용자 세션에서 실행되지 않는다면 실제로 속한 launchd 도메인에 맞게 로드 방식을 조정해야 하며, gui/<uid>를 그대로 사용해서는 안 됩니다.

피크 사용량으로 안전한 동시 실행 수 계산하기

상한을 높였다고 해서 병렬 실행 수를 무한정 늘려도 되는 것은 아닙니다. 먼저 단일 작업을 3~5회 실행해 안정 구간과 피크를 기록합니다. 예를 들어 단일 작업의 피크가 8200이고 백그라운드 에이전트와 수집 프로세스의 기준 사용량이 1800이며, 작업 4개를 동시에 실행하려 한다면 예산은 다음과 같습니다.

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

이는 65536이면 여유가 있다는 뜻이지만, 메모리, 디스크 I/O, 시뮬레이터 수도 함께 관찰해야 합니다. 디스크립터 피크는 통제되고 있는데도 작업이 불안정하다면 파일 상한만 계속 조정하지 말고 테스트 샤드 수나 컴파일 동시 실행 수를 줄여야 합니다.

각 작업을 시작하기 전에 하드 임계값을 설정할 수도 있습니다.

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가 시작한 실행기는 서로 다른 제한을 상속할 수 있습니다. 로그인 터미널이 아니라 실제 CI 작업 안에서 ulimit -n을 출력하고 실행기 프로세스를 확인해야 합니다.

독점 물리 클라우드 Mac

재현 가능한 개발 환경을 독점 물리 노드에 배포

구성, 이용 기간과 다섯 개 노드 중 하나를 선택해 Xcode 빌드, 자동화 테스트, 원격 개발 또는 모델 추론에 사용하세요.

구성 선택 후 주문