HireVM エンジニアリング記録

クラウドMacでXcodeのビルドグラフ循環と重複出力を診断する

クラウドMacでXcodeのビルドグラフ循環と重複出力を診断する

リモートのクラウドMacでXcodeビルド中に突然 Cycle inside ...Multiple commands produce ... が発生したとき、最初にやりがちな誤りは、すぐにDerivedDataを削除することです。一時的にエラーの発生順序が変わる可能性はありますが、原因の特定に必要な手がかりまで消えてしまいます。より確実なのは、コード、Scheme、構成、Xcodeのパスを固定して完全なログを保存し、問題がTargetの依存関係の循環なのか、複数のBuild Phaseが同じ出力パスを取り合っているのかを判断する方法です。

まず障害を安定して再現する

調査を始める前に、コードの自動取得、依存関係のアップグレード、並列ビルドを一時停止し、2回の検証の間にワークスペースが変化しないようにします。現在のコミット、Xcodeのバージョン、実際に選択されているDeveloper Directoryを記録します。

set -o pipefail
git rev-parse HEAD
xcodebuild -version
xcode-select -p

mkdir -p .diagnostics
xcodebuild \
  -project App.xcodeproj \
  -scheme App \
  -configuration Debug \
  -destination 'generic/platform=iOS Simulator' \
  -showBuildTimingSummary \
  build 2>&1 | tee .diagnostics/build.log

プロジェクトでWorkspaceを使用している場合は、-project App.xcodeproj-workspace App.xcworkspaceに置き換えます。両方を同時に指定してはいけません。その後、重要な箇所を抽出しますが、前後関係を確認できるように元のログも残しておきます。

grep -nE \
  'Cycle inside|cycle in dependencies|Multiple commands produce|will be run during every build' \
  .diagnostics/build.log

ログの最後に出てくるコマンドは、競合が検出された場所にすぎないことが多く、循環の起点とは限りません。Xcodeが出力した依存関係のチェーンを上流へたどり、最初に重複するTarget、スクリプト、または成果物のパスを探します。

よく似た2種類のエラーを区別する

依存関係の循環とは、AがBを待つ必要がある一方で、Bも直接または間接的にAを待つ状態です。重複出力は、2つのコマンドが同じファイルを自分の担当として宣言している状態を指します。両方が同時に発生することもありますが、修正方法は異なります。

ログの特徴 優先して確認する項目 よくある根本原因
Cycle inside Target Dependencies、暗黙的な依存関係 AppとFrameworkが相互に依存している
Multiple commands produce Copy Files、Compile Sources、Run Script 同じファイルを2回コピーまたは生成している
スクリプトが毎回実行される Run Scriptの入出力 依存関係が未宣言、または依存関係の解析が無効
Archiveでのみ失敗する Embedフェーズ、アーカイブ用スクリプト DebugとReleaseでフェーズ構成が一致していない

Xcodeの画面で現在選択されている項目から推測するのではなく、コマンドを使って今回実際にビルドされるTargetと構成を確認します。

xcodebuild -list -json -project App.xcodeproj \
  > .diagnostics/project-list.json

xcodebuild -showBuildSettings \
  -project App.xcodeproj \
  -scheme App \
  -configuration Debug \
  > .diagnostics/build-settings.txt

TargetとBuild Phaseから依存関係のチェーンを追跡する

XcodeのTarget Dependenciesで、エラーチェーンの始点と終点に関係するTargetから確認します。AppがFrameworkに依存することはできますが、FrameworkがApp内の型へアクセスするためにAppへ依存し返す構造にしてはいけません。共有コードは独立したモジュールへ切り出すか、プロトコルと注入を使って逆方向の参照を解消します。

続いてBuild Phasesを一つずつ確認し、特に次の関係を重点的に調べます。

所有権の重複を確認する

同じソースファイルを、同一のオブジェクトファイルを生成する2つのCompile Sources項目へ同時に含めてはいけません。同じFrameworkをシステム生成の埋め込みフェーズで処理しながら、カスタムのCopy Filesでもう一度コピーする構成も避ける必要があります。コードジェネレーターを使用する場合は生成先ディレクトリを明確にし、別のTargetがすでにコンパイルしているソースディレクトリへ出力を書き戻さないようにします。

暗黙的な依存関係を確認する

スクリプトが固定パスから別のTargetのビルド成果物を読み取っているにもかかわらず、依存関係を明示していない場合、並列度によってビルド順序が変化する可能性があります。並列ビルドを無効にして問題を隠してはいけません。エラーが発生しにくくなるだけで、グラフ構造そのものは修正されません。

Run Scriptの入力と出力を明示する

Run Scriptは循環が発生しやすい箇所です。スクリプトが構成ファイルを読み込む、Swiftファイルを生成する、またはリソースをコピーする場合は、そのフェーズのInput FilesとOutput Filesを設定するか、ファイルリストを使用します。また、各出力を単一のフェーズだけが担当するようにスクリプトを構成する必要があります。

たとえば、バージョンファイルを生成するスクリプトでは、必要な変数を先に検証し、一時ファイルとアトミックな置換を利用できます。

set -euo pipefail

input="${SRCROOT}/Config/version.txt"
output="${DERIVED_FILE_DIR}/GeneratedVersion.swift"
tmp="${output}.tmp"

test -f "$input"
mkdir -p "$(dirname "$output")"

version="$(tr -d '
' < "$input")"
printf 'enum GeneratedVersion { static let value = "%s" }
' \
  "$version" > "$tmp"

if test -f "$output" && cmp -s "$tmp" "$output"; then
  rm "$tmp"
else
  mv "$tmp" "$output"
fi

対応するフェーズでは、$(SRCROOT)/Config/version.txtを入力、$(DERIVED_FILE_DIR)/GeneratedVersion.swiftを出力として宣言します。生成ファイルをリポジトリ内のファイルへ直接書き込まず派生ディレクトリに配置すると、所有者を特定しやすくなり、並列タスクが互いの出力を上書きする問題も減らせます。

最小限の変更で修正を検証する

一度に変更するのは、依存関係1つ、フェーズ1つ、または出力パス1つだけにし、その都度ログを保存します。複数の参照をまとめて削除すればビルドが通ることはありますが、本当の根本原因を特定しにくくなり、アーカイブ構成に残っている同種の問題を見落とす可能性もあります。

修正後は、次の順序で検証します。

  1. クリーンビルドを1回実行し、完全なグラフをゼロから構築できることを確認します。
  2. クリーンアップせずに2回続けてビルドし、入力に変更がなければスクリプトが正しくスキップされることを確認します。
  3. DebugとReleaseをそれぞれ確認し、リリース対象のプロジェクトではArchiveも1回実行します。
  4. 2回分のログを比較し、新たな重複出力や無条件で実行されるスクリプトの警告がないことを確認します。
  5. Targetの依存関係の変更、各フェーズの入出力、および変更理由をコードレビューの説明に記載します。

DerivedDataの削除は最後の切り分けテストとして利用できますが、それ自体を修正と判断してはいけません。本当に安定したXcodeプロジェクトでは、並列度が異なる場合でも、クリーンビルドと増分ビルドのどちらでも、同じ依存関係の順序が得られる必要があります。そうすることで、クラウドMac上の対話的な開発作業と無人タスクが、スケジューリングの変化によって不規則に失敗する事態を防げます。

よくある質問

DerivedDataを削除すればビルド循環は解消しますか?

通常は解消しません。古い中間生成物は消えますが、Target依存関係や重複した出力先が残っていれば同じ問題が再発します。

Run Scriptがビルドグラフを壊す主な理由は何ですか?

他のフェーズの生成物を読むのに入力を宣言しない、またはCopy Filesと同じ場所へ書き込むことで、実行順序が曖昧になるためです。

修正後は何を確認すべきですか?

クリーンビルドを一度、クリーンしない連続ビルドを二度行い、循環、重複出力、毎回実行されるスクリプトの警告がないことを確認します。

専有Apple Silicon物理ノード

次のリモートMacタスクを専有ノードで実行

タスクの規模に合わせて、販売中の2種類の構成、5つのノード、日額・週額・月額・四半期の利用期間から選べます。注文前に構成と米ドル金額をすべて確認できます。

構成を選んで注文