クラウド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"

2つの並行ジョブで同じDerived Dataを共有してはいけません。プロジェクトとブランチが同じでも、中間生成物、インデックスデータベース、クリーンアップ処理が競合する可能性があります。

exit trapで失敗と中断に対応する

スクリプトの末尾でのみクリーンアップを実行する方法では不十分です。コンパイル失敗、タイムアウトシグナル、手動キャンセルによって、末尾の処理が実行されない可能性があります。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だけを削除しても、サービスマネージャーによって再起動されるため、多くの場合は効果がありません。

再現可能な検証チェックリストを作る

クリーンアップが完了しても、問題が解決したとは限りません。同じジョブを2回連続で実行し、各実行の終了後にリソースがベースラインへ戻っていることを確認します。

  1. lsof でテスト用ポートがリッスン状態でないことを確認する。
  2. pgrep -afil "$RUN_ID" で、今回のバックグラウンドプロセスが返されないことを確認する。
  3. xcrun simctl list devices に、今回作成した名前とUDIDが残っていないことを確認する。
  4. 一時実行ディレクトリが削除され、アーカイブが必要なログと結果バンドルが先に永続ディレクトリへコピーされていることを確認する。
  5. 2回目の実行では新しい実行識別子を使い、1回目が残したキャッシュやサービスに依存しない。
  6. 並行ジョブが存在する場合は、現在のジョブのマニフェストに記録されたリソースだけをクリーンアップする。

ポートが引き続き使用中であれば、lsof の出力、2階層分の親プロセス、起動時刻、コマンドラインを保存してから、クリーンアップ漏れか正当な並行実行かを判断します。強制キャンセル時にだけ問題が発生する場合は、ラッパースクリプトへシグナルが伝播しているか、バックグラウンドプロセスが独立したセッションを作成していないかを重点的に確認してください。こうした確認をRunnerの終了処理へ組み込む方が、定期的にプロセスをスキャンして一括終了するよりも安全で、再現もしやすくなります。

よくある質問

必要なポートを使用中のプロセスはすぐ終了してよいですか?

ポート番号だけで終了してはいけません。PID、親プロセス、開始時刻、コマンドライン、実行識別子を照合し、終了済みジョブのものと確認してからTERMを送り、一定時間後も残る場合だけKILLを検討します。

CIスクリプト終了後も子プロセスが残るのはなぜですか?

バックグラウンドプロセスが新しいセッションを作成したり、サービス管理機構に引き継がれたりするためです。生成時にPIDや資源IDを保存し、EXIT、INT、TERMを扱うtrapで個別に整理します。

XcodeVM クラウドMac

現在のワークロードに適した専有物理マシンを選択

メモリ、ストレージ、ノード、課金期間を確認してから注文設定に進みます。すべてのノードは365日継続稼働しています。実際の可用性はコンソールのリアルタイム表示をご確認ください。

プランを選んでレンタルする