Context
- とあるコードベースでXcodeのビルド時間がインクリメンタルビルドですら3分近くかかる問題がある
- 「ビルド時間おそいですね」課題感は共通認識なものの、総量として定量的にみてどれくらいの問題の大きさなのかが把握できていない状況でもある
- ビルド時間を開発速度のhealthcheck的な指標としてモニタリングしたい
どうやるか
- XCMetricsというTHEソレのようなOSSをspotifyが公開していたりする
- github repo: https://github.com/spotify/XCMetrics
- (v0系ではあるものの)1st releaseが2021/01とわりと最近
- vapor製
- Swift大統一
- dashboard UIがイケてる
- サービス然りアプリ然りspotifyちょいちょいイケてる
- 日本からリモートで働き口ないか探してみたが、BizDev/マーケ方面でしか募集してなさそう(Engineering職はもっぱら欧米リージョン。格差社会)
- XCMetricsはつかえるか🤔
- Backend Deploymentをみると、アプリケーション/DBサーバの運用が必要
- localhostでつかう分にはDocker imageも提供されているしサクッと動かせる
- 業務での運用を考えると、サーバをお守りするのはセキュリティ事がついてまわるので話が変わってくる
- アプリケーションサーバについてはCloud Runにオフロードする構成もとれるっぽいが、別でCloud SQLもとなるとコストの割高感が否めない..
- インフラ管理不要なマネージドに寄せつつ低コストでおなじようなことをやりたいというモチベーションがあったりする
- Backend Deploymentをみると、アプリケーション/DBサーバの運用が必要
- XCMetricsはビルドログ収集どうやってるの
- XCLogParserをつかっている
- とは
- Xcodeがビルドログとして
~/Library/Developer/Xcode/DerivedData/
に吐き出す.xcactivitylog(gzip圧縮されたSLF encodedなファイル(?))があって、 - これをparseするとビルドの詳細情報(どのschemeで/どのtargetで/どのソースファイルで/どれだけビルド時間がかかったか情報etc)がとれる
- 解析方法の情報源として@vincentisambart氏によるcookpad社のblog記事がlinkされてる(つよい..)
- このparse処理をラップしてビルドメトリクス情報をhtmlやjsonで出力できる、というツール
- Xcodeがビルドログとして
- なにげにmain contributorがXCMetricsと同じspotifyの人
- とは
- XCLogParserをつかっている
- ビルドログ情報はXCLogParserから取得できるから、以下が揃えばXCMetricsでやってるようなビルド時間可視化はいける
- ログデータの加工(postBuildScript)
- ログデータの保存(DB)
- ログデータの可視化(のfrontend)
- 「データがあってそれを可視化」といったらBigQuery+Data Portalの構成が降ってくる(というかWebアプリ以外だとそれしかおもいつかない..)
構成
Overview
- a.) Xcodeでビルドが実行されたら、postBuildScriptによりxclogparserでビルドログ情報を抽出&可視化用に加工したデータ(json)をs3にupload
- b.) BigQuery Data Transfer Serviceでs3にuploadされたデータをBigQueryにロード(定期or手動実行)
- c.) Google Data Portalでビルドメトリクスを(dashboardとして)可視化
Post Build Scriptフェーズ
各フェーズ詳細
-
- ビルド開始
-
- ビルド完了後、Post Build Scriptとしてlauncherスクリプトを実行
- launcherとは
- 本体のスクリプトをbackground実行するためのスクリプト
- なぜ
- .xcactivitylogがXcodeからdumpされるのはPost Build Processの後になる為
- Post Build Scriptでいくらsleepしたとしてもビルドログは一生dumpされない
- すると「今ビルドしたもの」より1つ前のビルドログが参照されてしまう
- eg. 「Cleanビルドしたが、ビルドの方ではなくCleanの方のログが参照されてしまう」
- ので、background実行を挟むことでPost Build Processを踏ませ、ビルドログのdumpを待ち受けるといったworkaroundが必要というかんじ
- cf. https://github.com/MobileNativeFoundation/XCLogParser#tips--tricks
-
- ビルド後、.xcactivitylogが/path/to/DerivedData/にdumpされる
-
- 本体のPost Build Scriptが実行される
-
- 内部処理でxclogparserによるビルドログのparse結果を取得
-
- これをBQテーブルスキーマに合わせて加工
-
- 加工したビルドログデータをs3にupload
やっていき
Post Build Scriptを仕込む
-
XcodeGen project.yml
... targets: xxx: ... postBuildScripts: - name: Upload xclogparser result JSON to S3 script: | cd "$PROJECT_DIR" && \ S3_BUCKET=s3://xcbuildmetrics.example.com \ scripts/post_build_script_launcher.sh \ # 先述のlauncherスクリプト python3 scripts/xcbuildmetrics_uploader.py # s3にビルドログをuploadする本体スクリプト
-
scripts/post_build_script_launcher.sh
executable=$1 shift; $executable "$@" <&- >&- 2>&- &
-
scripts/xcbuildmetrics_uploader.py
from __future__ import annotations import inspect import json import logging import os import pathlib import subprocess import tempfile import time import typing as t from copy import deepcopy from dataclasses import asdict, dataclass from datetime import datetime from enum import Enum JSON = t.Dict[str, t.Any] logging.basicConfig( handlers=[ logging.FileHandler(".xcbuildmetrics_uploader.log"), logging.StreamHandler(), ], level=logging.DEBUG, format="[%(levelname)s](%(process)s-%(threadName)s) @%(asctime)s - %(name)s.%(filename)s#%(funcName)s():L%(lineno)s - %(message)s", ) logger = logging.getLogger(__name__) @dataclass class BuildStep: startTimestamp: float compilationDuration: float schema: str fetchedFromCache: bool subSteps: list[BuildStep] machineName: str type: str schema: str def __post_init__(self): self.subSteps = [BuildStep.from_dict(x) for x in self.subSteps] @classmethod def from_dict(cls, data: JSON) -> BuildStep: attrs = inspect.signature(cls).parameters return cls(**{k: v for k, v in data.items() if k in attrs}) def flatten(self) -> list[BuildStep]: steps: list[BuildStep] = [] no_sub_steps = deepcopy(self) no_sub_steps.subSteps = [] steps.append(no_sub_steps) for subStep in self.subSteps: steps.extend(self._flattenSubStep(subStep)) return steps def _flattenSubStep(self, subStep: BuildStep) -> list[BuildStep]: details: list[BuildStep] = [] no_sub_steps = deepcopy(subStep) no_sub_steps.subSteps = [] details.append(no_sub_steps) for detail in subStep.subSteps: no_sub_steps = deepcopy(detail) no_sub_steps.subSteps = [] details.append(no_sub_steps) if detail.subSteps: details.extend(detail.subSteps) return details class BuildCategory(Enum): NOOP = "noop" CLEAN = "clean" INCREMENTAL = "incremental" @dataclass class BQRecord: start_time: float compilation_duration: float machine_name: str build_category: str scheme: str def tweak_parse_result(data: JSON) -> JSON: """ To prevent schema inconsistencies, unify the data types of the 'duration' like fields with float. """ for k in data: if isinstance(data[k], list): for d in data[k]: if isinstance(d, dict): tweak_parse_result(d) elif isinstance(data[k], dict): tweak_parse_result(data[k]) elif "duration" in k.lower(): data[k] = float(data[k]) return data def get_xclogparse_json() -> JSON: """ Generate a JSON of XCLogParser's parse result """ parse_output = subprocess.check_output(["make", "dump-xclogparser-json"]) parse_result_json = json.loads(parse_output.decode("utf-8")) tweak_parse_result(parse_result_json) return parse_result_json def parse_build_category(build_step: BuildStep) -> BuildCategory: """ Determine whether the build is clean or incremental ref. https://github.com/spotify/XCMetrics/blob/main/Sources/XCMetricsBackendLib/UploadMetrics/LogProcessing/LogParser.swift """ steps = build_step.flatten() detail_steps = [s for s in steps if s.type == "detail"] n_fetched_from_cache = len([s for s in detail_steps if s.fetchedFromCache]) if n_fetched_from_cache == len(detail_steps): return BuildCategory.NOOP elif n_fetched_from_cache / len(detail_steps) > 0.5: return BuildCategory.INCREMENTAL else: return BuildCategory.CLEAN def gen_bq_record(parse_result_json: JSON) -> BQRecord: """ Generate data corresponding to BigQuery table record """ build_step = BuildStep.from_dict(parse_result_json) build_category = parse_build_category(build_step) return BQRecord( build_step.startTimestamp, build_step.compilationDuration, build_step.machineName, build_category.value, build_step.schema, ) def upload_to_s3(json_path: pathlib.Path): """ Upload the build metrics data to AWS S3 """ subprocess.run( [ "envchain", "aws", "aws", "s3", "cp", str(json_path.absolute()), os.environ["S3_BUCKET"], ] ) def main(): logger.info("💨 started") # Since Xcode will take a while to finish writing the log to the file, # ensuring build log file is created by waiting for a seconds time.sleep(3) os.environ["PATH"] += os.pathsep + "/opt/homebrew/bin" if not os.getenv("S3_BUCKET"): logger.warning("🙄 `S3_BUCKET` environment variable is not set...") return dir_path = os.path.dirname(os.path.realpath(__file__)) prj_root_dir = os.path.join(dir_path, "..") os.chdir(prj_root_dir) with tempfile.TemporaryDirectory() as tmpdir: suffix = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") fpath = pathlib.Path(tmpdir) / f"xclogparser_{suffix}.json" with open(fpath, "w") as tmpf: parsed_json = get_xclogparse_json() bq_record = gen_bq_record(parsed_json) tmpf.write(json.dumps(asdict(bq_record))) upload_to_s3(fpath) logger.info( "✅ successfully uploaded build metrics data:" f"{json.dumps(asdict(bq_record), indent=2)}" ) logger.info("✔ finished") if __name__ == "__main__": main()
- ポイント2つ
- a.) スキーマ不整合の回避
- xclogparserによって抽出されたjsonのなかでビルド時間を表す
duration
やcompilationDuration
というフィールドがあり、通常はfloat値だがビルドキャッシュが存在する場合は0
とint値になる場合がある - するとBQテーブルスキーマ不整合が起こり得る
- ので、強制的にfloatでcastする処理を施している
- xclogparserによって抽出されたjsonのなかでビルド時間を表す
- b.) cleanビルドなのかincrementalビルドなのか判定
- ビルド時間を観測する場合、clean/incrementalは明確に区別しておきたいというモチベーションがある
- が、xclogparser結果には明示的に存在しない情報
- 一方で、各ビルドstepでcache hitしているかどうかの情報はとれているので、この情報をつかって判定ロジックを書く
- コードコメント記載のとおりここのロジックはXCMetricsを参考にさせていただいた(まったく同一ではなく端折っている)
- XCMetrics実装では、「schemeをビルドするにあたりそれにぶら下がるBuild Targetsの内、半数以上がcache hitしているか」でclean判定している模様
- 今回書いたのは「Bulid Targetを構成するdetail Build stepの内、半数以上がcache hitしているか」で判定しているかんじ
- target単位でみるかソースファイル単位でみるかの違いで、実装がラクな方を選択(schemeの単位でみればcleanかincrementalかを判定するには十分と考えた)
- target単位で細かくみる場合はまた違ってくるとおもう
- a.) スキーマ不整合の回避
- ポイント2つ
S3とBigQuery側にデータの受け皿を用意
- s3 bucket作成
$ aws s3 mb s3://xcbuildmetrics.example.com
- BQ dataset作成
$ bq mk \ --location=asia-northeast1 \ --dataset {PROJECT_ID}:{DATASET}
- BQ table作成
$ bq mk \ --table \ --time_partitioning_field start_time \ --require_partition_filter \ {PROJECT_ID}:{DATASET}.xcbuildmetrics \ start_time:timestamp,compilation_duration:float,machine_name:string,build_category:string,scheme:string
- BQ transfer_config(Data Transfer Service)作成
bq mk \ --transfer_config \ --project_id={PROJECT_ID} \ --data_source=amazon_s3 \ --display_name=transfer_xcbuildmetrics_data_from_s3 \ --target_dataset={DATASET} \ --params='{ "data_path":"s3://xcbuildmetrics.example.com/", "destination_table_name_template":"xcbuildmetrics", "access_key_id":"xxx", "secret_access_key":"xxx", "file_format":"JSON"}'
- データ転送が作成されると即時で実行される
- データ転送が成功すると、こんなかんじでBQテーブルにデータが投入されている
- データ転送が作成されると即時で実行される
Data Portalで可視化する用のビューを作成する
Cleanビルドの時系列ビルド時間
SELECT
start_time
, compilation_duration
, machine_name
, build_category
, scheme
FROM
`{PROJECT_ID}.{DATASET}.xcbuildmetrics`
WHERE
start_time >= TIMESTAMP("yyyy-mm-dd", "Asia/Tokyo")
AND build_category = "clean"
ORDER BY
start_time
Incrementalビルドの時系列ビルド時間
SELECT
start_time
, compilation_duration
, machine_name
, build_category
, scheme
FROM
`{PROJECT_ID}.{DATASET}.xcbuildmetrics`
WHERE
start_time >= TIMESTAMP("yyyy-mm-dd", "Asia/Tokyo")
AND build_category = "incremental"
AND compilation_duration > 0
ORDER BY
start_time
dailyのビルドstats
WITH
daily_build_compilations AS (
SELECT
FORMAT_TIMESTAMP("%Y-%m-%d", start_time, "Asia/Tokyo") AS `date`
, compilation_duration
FROM
`{PROJECT_ID}.{DATASET}.xcbuildmetrics`
WHERE
start_time >= TIMESTAMP("yyyy-mm-dd", "Asia/Tokyo")
ORDER BY
start_time
)
SELECT
PARSE_DATETIME("%Y-%m-%d", date) AS `date`
, COUNT(*) AS num_builds
, SUM(compilation_duration) AS total_compilation_duration
FROM
daily_build_compilations
GROUP BY
`date`
ORDER BY
`date`
Data Portalで可視化する
作成したビューをデータソースとしてData Portalでグラフ化する
- 「いつCleanビルドが行われビルド時間どれくらいかかったか」
- 「いつIncrementalビルドが行われビルド時間どれくらいかかったか」
- 「1日のトータルビルド実行回数」
- 「1日のトータルビルド時間」
あたりをカンタンなクエリ書いて可視化できる
ログデータにはより詳細なtarget別ソースファイル別といった情報があるので、やろうとおもえばもっとspecificなdashboardがつくれそう(マルチモジュール構成なんかだと欲しそう)
まとめ
昨今の便利なツールとマネージドサービスのおかげで安価でサーバレスな構成でXcodeのビルド時間を監視するしくみはつくれる
参考
- XCMetrics: Discover valuable insights hiding inside Xcode’s build logs
- MobileNativeFoundation/XCLogParser: Tool to parse Xcode and xcodebuild logs stored in the xcactivitylog format
- Xcode のビルドログの読込 - クックパッド開発者ブログ
- Digging deeper into xcbuild: Rules and Tasks | Asif’s Blog
- ビルド時間のグラフ化の夢について考える - tasuwo-ios
- iOSアプリ用のコード自動生成ツールを作って業務効率を上げる - Yahoo! JAPAN Tech Blog