同じコミットがWebhookの再試行、手動での再実行、定期ジョブによって同時にトリガーされると、数秒のうちに2つのXcodeビルドが同じワークスペースへ入ることがあります。すぐに失敗するとは限らず、DerivedDataを同時に書き換えたり、アーカイブディレクトリを上書きしたりした結果、発生頻度が低く再現も難しいエラーが残るケースのほうが一般的です。クラウドMacを常時稼働させる環境では、単発のコマンド失敗よりも、こうした重複実行を事前に制御することが重要です。
本当に排他制御が必要なリソースを特定する
最初からノード全体にグローバルロックをかけてはいけません。ロックの範囲はリポジトリ名ではなく、共有される書き込み先に基づいて決めます。2つのジョブが次のいずれかのリソースを共有する場合は、同じロックキーを使用する必要があります。
- 同じワークスペース内の生成ファイルまたは依存関係ディレクトリ;
- 同じDerivedDataパス;
- 同じアーカイブ、エクスポート、アップロード用ディレクトリ;
- 直列でしかアクセスできないシミュレータのデバイスセット;
- 同じ最終リリース先。
一方、テストシャードごとに独立したcheckout、DerivedData、シミュレータのデバイスセットがあれば、異なるロックキーを使用して並列実行できます。ロックキーは、たとえば client-release-archive のように「リポジトリ、Scheme、処理」の組み合わせにするのが適切です。ブランチ名をそのまま含めると、異なる2つのブランチが同じアーカイブ先を取り合う可能性が残ります。
| シナリオ | 推奨するロック粒度 | 並列実行 |
|---|---|---|
| DerivedDataを共有するアーカイブ | リポジトリ + Scheme + archive | 不可 |
| 独立したデバイスセットを使うテストシャード | リポジトリ + shard番号 | 可 |
| 同名のリリース成果物をアップロード | アプリ + リリースチャネル | 不可 |
| 読み取り専用の静的チェック | 通常はロック不要 | 可 |
ロックで保護するのは共有された副作用であり、CPUではありません。並行性を高めるために必要なロックを回避しても、通常は待ち時間が後処理の時間に置き換わるだけです。
mkdirで原子的な競合点を作る
macOS標準の mkdir は、シンプルで信頼性の高い競合点として利用できます。複数のプロセスが同じパスを同時に作成しようとしても、成功するのは1つだけです。「ファイルが存在するか確認してからPIDを書き込む」方式とは異なり、確認と書き込みの間に競合状態が生じません。
次のスクリプトでは、ロック用にランダムな所有者トークンを生成し、15秒ごとにハートビートを更新します。終了時にロックを削除できるのは、トークンが引き続き一致しているプロセスだけです。これにより、古いジョブが新しいジョブのロックを誤って削除するのを防ぎます。
#!/bin/bash
set -euo pipefail
LOCK_ROOT="${HOME}/Library/Caches/ci-locks"
LOCK_KEY="client-release-archive"
LOCK_DIR="${LOCK_ROOT}/${LOCK_KEY}.lock"
TOKEN="$(uuidgen)"
mkdir -p "$LOCK_ROOT"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "build lock is already held"
test -f "$LOCK_DIR/owner" && cat "$LOCK_DIR/owner"
exit 75
fi
printf 'token=%s
pid=%s
started=%s
' \
"$TOKEN" "$$" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$LOCK_DIR/owner"
touch "$LOCK_DIR/heartbeat"
heartbeat() {
while true; do
touch "$LOCK_DIR/heartbeat"
sleep 15
done
}
heartbeat &
HEARTBEAT_PID=$!
cleanup() {
kill "$HEARTBEAT_PID" 2>/dev/null || true
if grep -Fq "token=${TOKEN}" "$LOCK_DIR/owner" 2>/dev/null; then
rm -rf "$LOCK_DIR"
fi
}
trap cleanup EXIT
trap 'exit 130' INT TERM HUP
xcodebuild \
-workspace Client.xcworkspace \
-scheme Client \
-configuration Release \
-derivedDataPath "$PWD/.ci/DerivedData" \
archive
終了コード 75 を使えば、「ジョブを現在は実行できない」ことを明示できます。キュー側で遅延して再試行することはできますが、同じ秒内に無制限で再実行してはいけません。すべてのパスは引用符で囲みます。また、ロックのルートディレクトリは、現在の実行アカウントが書き込めて、リポジトリのクリーンアップコマンドで削除されない場所に置く必要があります。
PIDだけで期限切れを判断しない
PIDファイルは診断には役立ちますが、単独で所有権を証明する用途には適していません。macOSではPIDが再利用される可能性があります。また、ノードの再起動後には、古いファイルに記録された番号がまったく無関係な新しいプロセスを指していることもあります。そのため、クリーンアップ処理では少なくとも次の3項目を組み合わせて確認する必要があります。
- ハートビートの更新時刻がパイプラインの最大実行時間を超えているか;
- ジョブの記録がキュー上ですでに終了またはタイムアウトと判定されているか;
- 保護対象のディレクトリへ、関連するビルドプロセスが引き続き書き込んでいるか。
ハートビートが古いという事実は、「調査が必要」であることしか示しません。「ロックを奪ってよい」と直ちに判断する根拠にはなりません。ディスクI/Oのブロックによってビルドが停止していても、プロセスがファイルを保持している可能性があります。この状態で2つ目のビルドを強制的に開始すると、破損の範囲が広がります。ロックを待っているジョブが前の状態を自ら削除するのではなく、独立した回収ジョブにクリーンアップさせるほうが安全です。
ロックに観測可能なフィールドを追加する
owner ファイルには、引き続きコミットハッシュ、ジョブ番号、Scheme、作業ディレクトリを記録できます。ただし、トークン、署名情報、その他の機密値は書き込まないでください。失敗ログには少なくともロックキー、開始時刻、ジョブ番号を出力し、正常な順番待ちなのか、前回の実行が異常終了したのかをエンジニアが判断できるようにします。
待機、タイムアウト、キャンセルを分けて扱う
排他制御を導入しても、後から来たすべてのジョブを失敗させる必要はありません。実際のキューでは、一般に次の3つの戦略を使います。
- リリースアーカイブ:先に開始したジョブを維持し、後続ジョブを待機させる;
- ブランチ検証:同じブランチの古いジョブをキャンセルし、最新コミットを残す;
- 定期ビルド:同じキーのジョブが実行中なら、今回の実行をスキップする。
どの戦略を採用する場合でも、待機時間には上限を設定します。待機のタイムアウトで終了させてよいのは待機側だけであり、ロック所有者のロックを削除してはいけません。所有者をキャンセルするときは、まず通常の終了シグナルを送り、trap にクリーンアップを完了させます。プロセスが終了できない場合に限り、手動または独立した回収フローへ移行します。
ロック保持中に無関係な処理を実行しないことも重要です。コードのダウンロード、読み取り専用の検証、パラメータの準備はロック取得前に行い、共有ディレクトリを実際に変更する段階でロックを取得します。これによりクリティカルセクションが短くなり、キューの混雑も抑えられます。
本番導入前に障害を演習する
まず、リリース以外のジョブでスクリプトを続けて2回トリガーし、xcodebuild に入るジョブが1つだけであることを確認します。次に、正常終了、コマンド失敗、終了シグナルの受信をそれぞれ再現し、ロックディレクトリが削除されることを確認します。最後に古いロックを手動で残し、待機中のジョブが使用中であることだけを報告し、勝手にクリーンアップしないことを検証します。
受け入れ確認では、次のチェックリストを使用できます。
- ロックキーが共有リソースに正確に対応している;
- 1回の原子的操作だけでロックを取得している;
- ロック解除前にランダムな所有者トークンを検証している;
EXIT、INT、TERM、HUPのすべてにクリーンアップ経路がある;- ハートビートは診断のみに使用し、それだけを根拠にロックを奪わない;
- ログからロックを保持するジョブを特定でき、機密情報は含まれていない;
- 待機タイムアウトが実行中のビルドに影響しない;
- 独立したジョブが別々のワークスペースと出力ディレクトリを使用している。
これらの確認が完了すれば、重複トリガーはビルド状態をランダムに破損する見えにくい競合ではなく、理由を説明できる順番待ちイベントになります。今後さらに並行性を高める場合は、排他制御を直接取り除くのではなく、まず共有ディレクトリとリリース先を分離し、その後でロックキーを細分化してください。
よくある質問
PIDファイルだけでは不十分なのはなぜですか?
PIDは再利用される可能性があり、ファイルだけでは所有者を確実に識別できません。原子的なディレクトリ作成を競争点にし、ランダムな所有者トークンを併用します。
すべてのXcodeビルドを一本のロックで直列化すべきですか?
いいえ。作業領域、DerivedData、アーカイブ先、公開先を共有するジョブだけを同じロックキーで保護し、完全に分離されたテストは並列実行できます。
ハートビートが古ければロックを削除してよいですか?
古さだけで削除してはいけません。パイプラインの制限時間超過と関連プロセスの停止を確認し、独立した回収処理から削除します。
ビルド、開発、実験に使える専用物理Macノードを選択
Mac mini M4の2つの構成、4種類の契約期間、注文可能な5つのノードを比較し、実際のワークフローに合うプランを選べます。