클라우드 Mac CI 잔류 프로세스와 포트 충돌 정리

클라우드 Mac CI 잔류 프로세스와 포트 충돌 정리

동일한 클라우드 Mac에서 CI를 여러 번 연속 실행할 때 가장 설명하기 어려운 장애는 컴파일 오류가 아니라 “이전 작업은 끝났는데 다음 작업이 시작되지 않는” 상황인 경우가 많습니다. 테스트 서비스는 포트가 사용 중이라고 보고하고, 시뮬레이터는 계속 늘어나며, 빌드 디렉터리가 삭제되지 않거나 같은 이름의 백그라운드 프로세스 여러 개가 하나의 로그 파일에 동시에 쓰기도 합니다. 머신을 재시작하면 일시적으로 정상화할 수 있지만, 어떤 작업이 해당 리소스를 소유했는지는 파악할 수 없게 됩니다. 더 신뢰할 수 있는 방법은 먼저 증거를 수집한 다음, 각 실행에서 생성되는 리소스를 식별하고 회수할 수 있는 범위 안에 격리하는 것입니다.

잔류 리소스의 소유 작업부터 확인하기

Address already in use가 표시되더라도 곧바로 전역 killall을 실행해서는 안 됩니다. 먼저 포트를 수신 중인 프로세스를 찾고, 부모 프로세스를 거슬러 올라가 이미 종료된 작업에 속한 프로세스인지 확인합니다.

PORT=8080
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN
ps -o pid=,ppid=,user=,lstart=,command= -p 41872
ps -o pid=,ppid=,user=,command= -p 41801

lsof에서 PID를 확인한 뒤에는 최소한 사용자, 시작 시간, 전체 명령어와 PPID를 대조해야 합니다. 부모 프로세스가 여전히 현재 Runner라면 해당 포트는 실행 중인 병렬 작업이 사용하고 있을 수 있습니다. 부모 프로세스가 시스템 프로세스로 바뀌었더라도 그것만으로 삭제해도 된다고 판단할 수는 없습니다. 백그라운드 작업이 원래 세션에서 의도적으로 분리되었을 가능성이 있기 때문입니다.

각 작업 실행마다 RUN_ID를 생성하고 이를 프로세스 인자, 로그 경로 또는 환경 변수에 포함하는 것이 좋습니다. 문제를 조사할 때는 다음 명령으로 범위를 좁힐 수 있습니다.

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
export XCODEVM_RUN_ID="$RUN_ID"
pgrep -afil "$RUN_ID"

정리 작업은 “리소스 식별자가 종료된 작업과 일치한다”는 사실을 근거로 수행해야 하며, “프로세스 이름이 익숙해 보인다”는 이유로 실행해서는 안 됩니다. 동일한 머신에서 병렬로 실행되는 작업들이 완전히 같은 명령을 사용할 수도 있습니다.

실행마다 독립된 경계 할당하기

공유 디렉터리와 고정 포트는 일시적인 중단을 지속적인 장애로 확대합니다. 빌드 디렉터리, 결과 번들, 임시 파일과 서비스 포트는 실행별로 격리해야 합니다.

리소스 불안정한 방식 권장 경계
Derived Data 모든 작업이 기본 디렉터리 공유 $WORK_ROOT/$RUN_ID/DerivedData
테스트 결과 고정된 result.xcresult 사용 실행 식별자로 이름 지정
임시 디렉터리 /tmp/build에 직접 기록 mktemp -d로 생성
로컬 서비스 모든 작업에서 8080으로 고정 관리되는 포트 풀에서 할당
시뮬레이터 이름으로 반복 검색 현재 실행에서 생성한 UDID 저장

xcodebuild에는 빌드 데이터와 결과 경로를 명시적으로 지정할 수 있습니다.

WORK_ROOT="${CI_WORK_ROOT:-$HOME/ci-work}"
RUN_ROOT="$WORK_ROOT/$RUN_ID"
DERIVED_DATA="$RUN_ROOT/DerivedData"
RESULT_PATH="$RUN_ROOT/Test.xcresult"

mkdir -p "$RUN_ROOT"
xcodebuild test \
  -scheme App \
  -derivedDataPath "$DERIVED_DATA" \
  -resultBundlePath "$RESULT_PATH"

동시에 실행되는 두 작업이 같은 Derived Data를 공유하지 않도록 해야 합니다. 프로젝트와 브랜치가 같더라도 중간 산출물, 인덱스 데이터베이스와 정리 작업 사이에 경합이 발생할 수 있습니다.

종료 트랩으로 실패와 중단까지 처리하기

스크립트의 마지막 부분에서만 정리를 수행해서는 충분하지 않습니다. 컴파일 실패, 시간 초과 신호와 수동 취소로 인해 마지막 몇 줄이 실행되지 않을 수 있습니다. Shell의 trap을 사용하면 정상 종료와 일반적인 중단 경로를 일관되게 처리할 수 있습니다.

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/xcodevm-${RUN_ID}.XXXXXX")"
SIM_UDID=""
SERVICE_PID=""

cleanup() {
  if [[ -n "$SERVICE_PID" ]] && kill -0 "$SERVICE_PID" 2>/dev/null; then
    kill -TERM "$SERVICE_PID" 2>/dev/null || true
    for _ in 1 2 3 4 5; do
      kill -0 "$SERVICE_PID" 2>/dev/null || break
      sleep 1
    done
    kill -KILL "$SERVICE_PID" 2>/dev/null || true
  fi

  if [[ -n "$SIM_UDID" ]]; then
    xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
    xcrun simctl delete "$SIM_UDID" 2>/dev/null || true
  fi

  rm -rf "$RUN_ROOT"
}

trap cleanup EXIT INT TERM

여기서는 이름이 같은 모든 리소스를 검색해 삭제하는 대신, 현재 실행에서 생성한 PID와 UDID를 저장합니다. TERM은 프로세스가 파일 핸들을 해제하고 로그 기록을 마칠 기회를 줍니다. 일정 시간 기다린 뒤에도 종료되지 않을 때만 KILL로 전환합니다.

PID 재사용으로 인한 오작동 방지하기

장시간 실행되는 작업에서는 이전 PID가 시스템에 의해 다시 할당될 수 있습니다. 프로세스를 종료하기 전에 명령줄을 다시 읽고 실행 식별자가 포함되어 있는지 확인해야 합니다. 더 안전한 방법은 백그라운드 서비스가 PID, 시작 시간과 명령 요약을 $RUN_ROOT/manifest에 기록하도록 하고, 정리할 때 이 필드들을 함께 대조하는 것입니다.

시뮬레이터와 서비스 관리자 처리하기

시뮬레이터를 이름만으로 관리해서는 안 됩니다. 이름이 같은 기기가 다른 작업에서 생성되었을 수 있기 때문입니다. 시뮬레이터를 생성한 즉시 UDID를 저장합니다.

SIM_UDID="$(xcrun simctl create "ci-$RUN_ID" \
  "iPhone 16" \
  "com.apple.CoreSimulator.SimRuntime.iOS-18-0")"
xcrun simctl boot "$SIM_UDID"

실행 환경에 해당 Runtime이 설치되어 있지 않다면 먼저 xcrun simctl list runtimes로 실제 사용 가능한 식별자를 확인해야 합니다. 공용 스크립트에 버전을 하드코딩하지 마십시오.

종료한 프로세스가 자동으로 다시 나타난다면 사용자 수준 서비스에서 관리되고 있는지 확인해야 합니다. 출처를 알 수 없는 서비스를 곧바로 제거하지 말고, 먼저 현재 사용자 도메인을 조회합니다.

USER_ID="$(id -u)"
launchctl print "gui/$USER_ID" | grep -B 3 -A 6 "$RUN_ID"

작업에서 실제로 임시 서비스를 생성했다면 해당 label을 저장하고 종료 경로에서 같은 label을 사용해 정확히 제거해야 합니다. 프로세스 PID만 삭제하면 서비스 관리자가 프로세스를 다시 시작하므로 대개 효과가 없습니다.

반복 가능한 검증 체크리스트 만들기

정리가 끝났다고 해서 문제가 해결된 것은 아닙니다. 동일한 작업을 두 번 연속 실행하고, 각 실행이 끝난 뒤 리소스가 기준 상태로 돌아왔는지 확인합니다.

  1. lsof로 테스트 포트가 더 이상 수신 중이 아닌지 확인합니다.
  2. pgrep -afil "$RUN_ID"에서 현재 실행의 백그라운드 프로세스가 반환되지 않아야 합니다.
  3. xcrun simctl list devices에 현재 실행에서 생성한 이름과 UDID가 더 이상 존재하지 않아야 합니다.
  4. 임시 실행 디렉터리는 삭제되어야 하며, 보관해야 할 로그와 결과 번들은 먼저 영구 디렉터리로 복사해야 합니다.
  5. 두 번째 실행에서는 새로운 실행 식별자를 사용하고, 첫 번째 실행이 남긴 캐시나 서비스에 의존하지 않아야 합니다.
  6. 병렬 작업이 있다면 현재 작업의 목록에 등록된 리소스만 정리합니다.

포트가 여전히 사용 중이라면 lsof 결과, 두 단계의 부모 프로세스, 시작 시간과 명령줄을 보존한 뒤 정리 누락인지 정상적인 병렬 실행인지 판단합니다. 강제 취소 시에만 문제가 발생한다면 신호가 래퍼 스크립트까지 전달되는지, 백그라운드 프로세스가 별도 세션을 생성했는지 중점적으로 확인합니다. 이러한 검사를 Runner의 마무리 단계에 포함하는 편이 주기적으로 스캔해 프로세스를 일괄 종료하는 방식보다 안전하며, 문제를 재현하기도 더 쉽습니다.

자주 묻는 질문

필요한 포트를 점유한 프로세스를 바로 종료해도 되나요?

포트 번호만 보고 종료하면 안 됩니다. PID, 부모 프로세스, 시작 시각, 명령줄과 실행 식별자를 확인한 뒤 종료된 작업의 프로세스임이 명확할 때 TERM을 보내고, 제한된 대기 후에도 남으면 KILL을 검토합니다.

CI 스크립트가 끝났는데도 자식 프로세스가 남는 이유는 무엇인가요?

백그라운드 프로세스가 새 세션을 만들거나 서비스 관리 도구에 인계될 수 있기 때문입니다. 생성 시 PID와 자원 식별자를 기록하고 EXIT, INT, TERM을 처리하는 trap으로 선택적으로 정리해야 합니다.

XcodeVM 클라우드 Mac

현재 워크로드에 적합한 독점 물리 서버를 선택하세요

메모리, 스토리지, 노드와 과금 주기를 확인한 후 주문 설정으로 이동하세요. 모든 노드는 365일 연중 정상 운영되며, 실제 사용 가능 여부는 콘솔의 실시간 응답을 기준으로 합니다.

요금제를 선택하고 대여하기