MiniBin エンジニアリングノート

クラウドMac CIのファイル記述子枯渇を調査する

クラウドMac CIのファイル記述子枯渇を調査する

同じXcodeパイプラインを数十回連続で実行した後、突然 Too many open filesEMFILE が発生したり、テストプロセスが不規則に終了したりすることがあります。再実行すると成功する場合もあります。こうした障害は、ディスクの破損ではなく、ビルドエージェント、テストプロセス、ファイル監視プロセスが、プロセスで利用可能なファイル記述子を使い果たしたことが原因である場合がほとんどです。解決するには、ターミナルで一度だけ ulimit を実行するのでは不十分です。何が増加しているのか、エージェントがどの上限を継承しているのか、現在の並列実行数が予算を超えていないかを確認する必要があります。

障害がどのレイヤーで発生しているか確認する

ファイル記述子が表すのは通常のファイルだけではありません。ソケット、パイプ、ディレクトリハンドル、一部のプロセス間通信オブジェクトも含まれます。依存関係の解決、並列コンパイル、シミュレーターテスト、ログ収集を同時に実行すると、その数は急速に増加します。

まず、失敗したジョブと同じ実行環境で上限を記録します。

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

ulimit は、現在のシェルとその子プロセスが継承できる上限を示します。launchctl limit は起動コンテキストの上限を確認するためのもので、sysctl はシステムレベルの境界を示します。この3つは互いに代用できません。対話型ターミナルでは 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について、ファイル記述子数を10秒間隔で記録できます。

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 出力を保存し、REGIPv4IPv6PIPE などの種類別に範囲を絞り込みます。証拠を採取する前にプロセスを終了しないでください。最も価値のある障害発生時の状態が失われてしまいます。

エージェントの起動時上限を明示する

ログインシェルの設定ファイルに 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 を再度出力して確認します。エージェントがグラフィカルユーザーセッションで動作していない場合は、実際に所属するlaunchdドメインに合わせて読み込み方法を変更し、gui/<uid> をそのまま使用しないでください。

ピーク値から安全な並列実行数を算出する

上限を引き上げても、並列実行数を無制限に増やしてよいわけではありません。まず単一ジョブを三〜五回実行し、安定時とピーク時の値を記録します。たとえば、単一ジョブのピークが 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

再現可能な開発環境を専有物理ノードにデプロイ

構成、利用期間、5つのノードのいずれかを選択し、Xcodeビルド、自動テスト、リモート開発、モデル推論に利用できます。

構成を選んで注文