クラウドMac CIでAddress Sanitizerによるメモリエラー検出を自動化する

クラウドMac CIでAddress Sanitizerによるメモリエラー検出を自動化する

Swift、Objective-C、C/C++の依存関係が混在するiOSプロジェクトでは、通常のテストに繰り返し合格していても、実行頻度の低い特定のパスで配列の境界外アクセス、二重解放、解放後使用が発生する可能性があります。ジョブをクラウドMacへ移行した後も、既存のテストを繰り返すだけでは不十分です。Address Sanitizer(ASan)専用のパイプラインを追加し、不正なメモリアクセスが見つかった時点でビルドを失敗させ、再現に必要な結果バンドルを保存する方が効果的です。

ゲートで検出する対象を先に決める

ASanは主に、スタックやヒープの境界外アクセス、解放後使用、二重解放、一部の不正なポインタアクセスを検出します。特にObjective-Cのブリッジ層、画像処理ライブラリ、データベースのラッパー、独自実装したCインターフェースの検査に適しています。純粋なSwiftコードにも効果はありますが、一度通過したからといってメモリ問題が存在しないとは限りません。テストで実行されない分岐は検査されず、メモリリークやデータ競合の検出には別のツールも必要です。

まずは、安定して実行でき、手動操作を必要としないテスト群をゲートの入口に選ぶことを推奨します。最初からすべてのUIテストを組み込むべきではありません。コアパーサー、キャッシュ層、言語間インターフェースを最優先にします。

テスト層 実行頻度 失敗時の処理
コア単体テスト マージリクエストごと マージを阻止
統合テスト メインブランチまたはマージリクエスト リリースを阻止
完全なUIテスト 定期実行またはリリース候補段階 不具合を登録して再現

Sanitizerの価値は、単にジョブを失敗させることではありません。同じ入力で同じエラーを再発させられるようにし、コールスタック、テスト名、ビルド環境を記録として残すことが重要です。

独立したSchemeとディレクトリを作成する

Xcodeで既存のテストSchemeを複製し、App-ASanという名前を付けてSharedに設定します。開発者が日常的に使用するSchemeを直接変更すると、ローカルデバッグ、性能測定、CIパラメータが相互に影響してしまいます。Schemeは次のパスにコミットします。

App.xcodeproj/xcshareddata/xcschemes/App-ASan.xcscheme

ジョブごとに独立したDerivedDataを使用します。これにより、通常のビルドで生成されたオブジェクトファイルがASanビルドに誤って使われることや、並列ジョブがインデックスや中間生成物を取り合うことを防げます。結果バンドルも固定ファイルを繰り返し上書きせず、ジョブ番号ごとのディレクトリに保存します。

テスト対象を固定する

まず、現在利用できるシミュレータを確認します。

xcrun simctl list devices available

CIではデバイス種別とOSのメジャーバージョンを固定しつつ、インストール済みランタイムの更新に伴うパッチバージョンの変更は許容するのが適切です。複数のOSバージョンを検証する必要がある場合はマトリックスジョブに分割し、1つのコマンド内での自動選択に依存しないでください。

xcodebuildで検査を実行する

次のスクリプトでは、独立したビルドディレクトリと結果バンドルを使用します。ワークスペース、Scheme、シミュレータ名は、実際のプロジェクトに合わせて変更してください。

#!/bin/zsh
set -u

ROOT="${PWD}"
OUT="${ROOT}/artifacts/asan"
DERIVED="${ROOT}/.ci/DerivedData-ASan"

rm -rf "${OUT}" "${DERIVED}"
mkdir -p "${OUT}"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App-ASan \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "${DERIVED}" \
  -resultBundlePath "${OUT}/App-ASan.xcresult" \
  -enableAddressSanitizer YES \
  CODE_SIGNING_ALLOWED=NO \
  >"${OUT}/xcodebuild.log" 2>&1

status=$?
exit ${status}

テストコマンドに、無条件で成功扱いにする|| trueを追加してはいけません。CIプラットフォームでログの後処理が必要な場合は、まず終了コードを保存し、アーカイブが完了してからそのコードをそのまま返します。CODE_SIGNING_ALLOWED=NOはシミュレータテストに適しています。実機やアーカイブを扱うジョブでは独立した署名設定を使用し、この設定をそのまま流用しないでください。

ASanを有効にするとメモリ使用量と実行時間が増えるため、同じマシン上での並列実行数は通常の単体テストより少なく設定します。プロセスがシステムによって強制終了された場合は、まず並列度を下げ、その後でテスト対象アプリケーションの不具合かどうかを判断します。

引き継げる形で失敗時の証拠を保存する

ジョブが失敗した場合は、少なくともxcodebuild.log.xcresultを保存します。前者はSanitizerレポートの検索に適しており、後者にはテスト階層、失敗時の添付ファイル、アクティビティログが含まれます。アーカイブの前に、次のように完全性を確認できます。

test -s artifacts/asan/xcodebuild.log
test -d artifacts/asan/App-ASan.xcresult
xcrun xcresulttool get test-results summary \
  --path artifacts/asan/App-ASan.xcresult \
  > artifacts/asan/summary.json

xcresulttoolのサブコマンドはXcodeのバージョンによって変わる可能性があるため、ツールの呼び出し方法もXcodeのバージョンとセットで管理します。コマンドに互換性がない場合でも、元の結果バンドルを削除してはいけません。

レポートを調査するときは、まずERROR: AddressSanitizerを探し、エラーの種類、最初に現れるアプリケーションコードのスタックフレーム、発生させたテスト、入力データを記録します。スタック上部のシステムライブラリフレームは、多くの場合クラッシュが発生した場所を示しているだけです。実際にメモリを破壊した処理は、それより前に行われている可能性があります。エラーを安定して再現できない場合は、該当するテストだけを繰り返し実行し、テスト順序のランダム化を無効にして、複数の変数を同時に変更しないようにします。

段階的に実行し、よくある誤りに対処する

日常的なゲートでは高リスクなテスト群だけを実行し、メインブランチではASanの完全なテストスイートを実行できます。ファイル名をつなぎ合わせてコマンドを組み立てるよりも、テストタグやTest Planを使用する方が保守しやすくなります。新しいCインターフェース、ポインタ演算、安全でないバッファ処理を追加した場合は、関連するテストも高速テスト群へ同時に追加します。

よくある誤りは3つあります。1つ目は通常ビルドのキャッシュを再利用し、計測が不完全になることです。2つ目はテキストログだけをアップロードし、結果バンドルを保存しないことです。3つ目はASanの失敗を不安定なテストとして扱い、そのまま再試行することです。再試行によって再現性を確認することはできますが、最初のレポートは必ず保存しなければなりません。連続した再試行に成功しても、すでに検出された不正なメモリアクセスを自動的に許可してはいけません。

最後に、合格条件を明確に定義します。テストプロセスの終了コードが0であること、結果バンドルが存在すること、ログにASanエラーがないこと、さらにタイムアウトやリソース不足でジョブが中断されていないことが必要です。これにより、成功したように見える1行のテスト概要ではなく、完全な実行結果に基づいてゲートを判定できます。

よくある質問

Address Sanitizerはコミットごとに実行すべきですか?

重要な単体テストと高リスクなモジュールは変更ごとに実行できます。時間のかかるUIテストはマージ要求、主ブランチ、定期ジョブに分けるのが現実的です。

検査に通ればメモリ問題はないと言えますか?

言えません。その実行で通過したコードと特定種類の不正アクセスだけが対象です。リーク、データ競合、実機での確認も必要です。

専有物理Mac mini

タスクの長さに合わせてクラウドMacのレンタル期間を選択

2種類のM4構成を日単位、週単位、月単位、または四半期単位でレンタルできます。ノードと実際の利用可能状況は、コンソールからリアルタイムで返される情報をご確認ください。

構成を選択して注文