Gradleビルドの「ブラックボックス」を剥ぎ取る:BuildServiceとBuildEventListenerRegistryで構築するビルドKPIダッシュボード自動化
テックリードの皆さん、日々のCI/CDパイプラインにおいて、こんなフラストレーションを抱えていないでしょうか。
- 「最近、ビルドが遅くなった気がするが、どのタスクがボトルネックになっているのか感覚値でしか分からない」
- 「ローカル環境では高速なのに、なぜかGitHub ActionsやGitLab CIなどのリモート環境でだけ異常に時間がかかる」
- 「ビルドの失敗傾向やトレンドを分析したいが、CIのプレーンなログ(ANSIカラーが入り交じった数千行のテキスト)を解析する気になれない」
インターネットを検索すれば、「`./gradlew build –scan` を使え」という決まり文句に行き着くでしょう。Develocity(旧Gradle Enterprise)は素晴らしいツールです。しかし、企業のセキュリティポリシーやライセンスコストの壁により、すべてのプロジェクトで導入できるわけではありません。また、自社のデータ基盤(Datadog、Prometheus、あるいは社内BI)へ直接、構造化されたビルドメトリックを流し込みたいという要求には、SaaSの標準機能だけでは対応しきれない場面があります。
Gradleの真価は、単なるビルドツールではなく「ビルドJVM上で動作するプログラマブルなプラットフォーム」である点にあります。
今回は、Gradle 6.1以降で導入された `BuildService` と `BuildEventListenerRegistry` を完全に手懐け、「ビルド中の全タスクのライフサイクルイベントをリアルタイムにキャプチャし、外部のメトリック基盤へ自動送信するカスタムリスナープラグイン」を自作するアーキテクチャを解説します。
単なるAPIの使い方説明にとどめず、並行ビルド(Configuration Cache)時代における正しいデータハンドリングや、現場の生産性を爆発的に高める実践的な設定まで踏み込んで伝授します。
—
1. なぜ「ログのパース」ではなく「ビルドイベントAPI」なのか?
従来のCIでのパフォーマンス計測は、`./gradlew build` の標準出力を `grep` や正規表現でパースするという泥臭い手法が主流でした。しかし、このアプローチは致命的な欠点を抱えています。
1. 並行実行(Parallel Execution)の崩壊: `–parallel` を有効にすると、複数タスクのログがインターリーブ(混ざり合い)し、タイムスタンプベースでの正確な順序や所要時間の算出が極めて困難になる。
2. Configuration Phaseの不可視性: 依存関係の解決やプロジェクト評価(Evaluation)にかかった時間は、タスクの標準出力には現れない。
3. オーバーヘッド: テキストログの出力自体がI/Oボトルネックを生む。
Gradle Build Lifecycle Architecture
Gradleの内部では、ビルドは厳密なフェーズ(Initialization -> Configuration -> Execution)を経て実行され、すべてのイベントがイベントバス(Event Bus)を介して流れています。
[Initialization] —> [Configuration] —> [Execution]
│
▼
+——————-+
| BuildEventListener| <--- フック!
+-------------------+
│
▼
+-------------------+
| BuildService | <--- スレッドセーフに集約
+-------------------+
│
▼
[外部分析基盤へ送信]
`BuildEventListenerRegistry` を使うと、このイベントバスに直接リスナーを登録できます。タスクの開始・終了、成功・失敗・スキップといったステータス、そして正確なミリ秒単位の実行時間が、構造化されたオブジェクトとして安全に取得できるのです。
—
2. 実装:カスタムビルドリスナー・プラグインの構築
それでは、実際に動くコードを見ていきましょう。ここでは、ビルド全体および各タスクのメトリックを収集し、ビルド終了時にJSONペイロードとして外部エンドポイント(あるいはローカルのファイル/ログ)へ送信するアーキテクチャを実装します。
プロジェクト内に `buildSrc` または独立したプレコンパイルスクリプトプラグインとして実装するのが最もクリーンです。今回は `buildSrc/src/main/kotlin/metrics-collector.gradle.kts` として配置する構成を想定します。
Step 1: 収集データを保持する BuildService の定義
Gradleは現在、タスクの並行実行やConfiguration Cacheへの適合を厳しく求めています。グローバル変数や静的フィールドの使用はビルドのキャッシュ汚染を引き起こすため厳法です。そこで、ライフサイクルがGradleのビルドセッションに完全にスコープされた `BuildService` を定義します。
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import java.time.Instant
import java.util.concurrent.ConcurrentLinkedQueue
// ビルド中のメトリックをスレッドセーフに蓄積するためのデータクラス
data class TaskMetric(
val taskPath: String,
val taskType: String,
val state: String, // SUCCESS, FAILED, SKIPPED, UP_TO_DATE
val durationMs: Long,
val startTime: Instant
)
// BuildServiceはGradleデーモン内で安全に状態を保持するために使用する
abstract class MetricsCollectionService : BuildService
// 複数のワーカー・スレッドから同時に書き込まれるため、スレッドセーフなキューを使用
private val taskMetrics = ConcurrentLinkedQueue
private val buildStartTime: Instant = Instant.now()
fun recordTask(metric: TaskMetric) {
taskMetrics.add(metric)
}
// ビルド終了時に呼び出され、蓄積されたデータを集計・送信する
fun flushAndReport() {
val buildEndTime = Instant.now()
val totalDuration = java.time.Duration.between(buildStartTime, buildEndTime).toMillis()
println(“=== [Gradle Metrics Report] ===”)
println(“Total Build Duration: ${totalDuration}ms”)
println(“Total Tasks Executed: ${taskMetrics.size}”)
// 実運用ではここで ObjectMapper 等を用いて JSON にシリアライズし、
// HTTP (cURL / OkHttp) で Datadog や社内インフラへ非同期送信する
val failedTasks = taskMetrics.filter { it.state == “FAILED” }
if (failedTasks.isNotEmpty()) {
println(“WARNING: Failed tasks detected: ${failedTasks.map { it.taskPath }}”)
}
// 上位の重いタスクトップ5を表示
println(“Top 5 Slowest Tasks:”)
taskMetrics.sortedByDescending { it.durationMs }
.take(5)
.forEach {
println(” – ${it.taskPath} (${it.type}): ${it.durationMs}ms [${it.state}]”)
}
println(“===============================”)
}
}
Step 2: ライフサイクルをフックする TaskExecutionListener とプラグインの登録
次に、`TaskExecutionListener`(または `OperationCompletionListener`)を実装し、`BuildEventListenerRegistry` を通じてGradleのイベントバスに登録します。
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.execution.TaskExecutionListener
import org.gradle.api.tasks.TaskState
import org.gradle.build.event.BuildEventListenerRegistry
import org.gradle.api.Task
import java.time.Instant
import javax.inject.Inject
// イベントリスナーの実装
class MetricsListener(
private val service: MetricsCollectionService
) : TaskExecutionListener {
// タスク開始時刻を一時保持するマップ(スレッドセーフな構造が必要)
private val startTimes = java.util.concurrent.ConcurrentHashMap
override fun beforeExecute(task: Task) {
startTimes[task.path] = Instant.now()
}
override fun afterExecute(task: Task, state: TaskState) {
val startTime = startTimes.remove(task.path) ?: Instant.now()
val duration = java.time.Duration.between(startTime, java.time.Duration.between(Instant.MIN, Instant.now()).toMillis() as Long / 簡易的な計算 /)
// 実際のduration計算
val durationMs = java.time.Duration.between(startTime, Instant.now()).toMillis()
val status = when {
state.failure != null -> “FAILED”
state.skipped -> “SKIPPED”
state.upToDate -> “UP_TO_DATE”
else -> “SUCCESS”
}
// BuildServiceへメトリックを送信
service.recordTask(
TaskMetric(
taskPath = task.path,
taskType = task.javaClass.name,
state = status,
durationMs = durationMs,
startTime = startTime
)
)
}
}
// プラグインのエントリーポイント
abstract class MetricsPlugin @Inject constructor(
private val registry: BuildEventListenerRegistry
) : Plugin
override fun apply(project: Project) {
// プロジェクトのルートでのみ一度だけサービスを登録する
if (project == project.rootProject) {
val serviceProvider = project.gradle.sharedServices.registerIfAbsent(
“metricsCollectionService”,
MetricsCollectionService::class.java
) {}
// Gradleのイベントレジストリにリスナーとサービスをバインド
// 注: Gradleのモダンな API (OperationCompletionListener) を使うのが理想だが、
// タスク単位のシンプルなフックとして TaskExecutionListener をサービス経由で登録する
project.gradle.taskGraph.addTaskExecutionListener(MetricsListener(serviceProvider.get()))
// ビルド完了時にレポート出力をトリガー
project.gradle.buildFinished {
serviceProvider.get().flushAndReport()
}
}
}
}
—
3. チーム開発で役立つ設定の共有化ルール & ベストプラクティス
このカスタムプラグインを全社、あるいはチームの全リポジトリで一貫して機能させるためには、属人性を排除した仕組み作りが必要です。
1. Version Catalogs (`libs.versions.toml`) による依存関係の一元管理
プラグインや共通設定は、野良スクリプトとして放置せず、バージョンカタログ経由で管理します。
[versions]
metrics-plugin = “1.2.0”
[plugins]
enterprise-metrics = { id = “com.internal.gradle.metrics”, version.ref = “metrics-plugin” }
2. `settings.gradle.kts` での強制適用 (Enforcement)
個別の `build.gradle.kts` にプラグインの適用を書き忘れさせないために、`settings.gradle.kts` の `plugins` ブロックまたは `includeBuild` を用いて、すべてのサブプロジェクトに自動適用させます。
// settings.gradle.kts
plugins {
// 全リポジトリで強制的にメトリック収集プラグインをロードする
id(“com.internal.gradle.metrics”) version “1.2.0” apply false
}
rootProject.name = “enterprise-backend-service”
// 全サブプロジェクトに対して自動的にプラグインを適用
subprojects {
apply(plugin = “com.internal.gradle.metrics”)
}
—
4. 開発スピードを劇的に高める「プロの実践テクニック」
ここからは、日々のコーディングおよびCI/CDパイプライン運用において、開発効率を極限まで引き上げるための隠れた知見を共有します。
1. IDE(IntelliJ IDEA)のショートカットと設定の極意
Gradleのカスタムプラグインや `buildSrc` を開発する際、IDEAのインデックス作成待ちや同期遅延は生産性の殺人鬼です。
- プロジェクト同期のショートカット: `Ctrl + Shift + O` (Windows/Linux) または `Cmd + Shift + O` (macOS) は頻繁に使いますが、それ以上に `Settings` -> `Build, Execution, Deployment` -> `Build Tools` -> `Gradle` -> “Reload project changes” を「Any changes」から「Explicitly on save or button click」に変更してください。これだけで、ファイルをちょっと編集しただけで勝手に重い同期が走るストレスから解放されます。
- タスクの素早い実行: ターミナルを開いて `./gradlew` と打つ必要はありません。`Double Shift` で「Search Everywhere」を開き、`t: bootRun` や `t: test` のように `t:` プレフィックスを付けてタスク名を検索すれば、一瞬でターゲットタスクを実行できます。
2. CI環境(GitHub Actions)でのメトリック効率化YAML構成
収集したビルドメトリックをローカルに埋もれさせず、GitHub Actionsのアーティファクト、あるいはDatadogへシームレスに飛ばすためのワークフロー設定例です。
name: CI with Metrics Collector
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: ‘temurin’
java-version: ’17’
cache: ‘gradle’
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Gradleの実行。カスタムプラグインが自動的にビルド時間を集計し、
# ビルド終了時に標準出力へサマリーを吐き出す
- name: Build with Gradle
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} # 外部送信する場合の認証キー
run: ./gradlew build –parallel –configuration-cache
# 標準出力されたメトリックログをGitHub ActionsのArtifactとして保存
- name: Upload Build Summary
if: always()
uses: actions/upload-artifact@v4
with:
name: build-metrics-report
path: build/reports/metrics/
—
5. アーキテクチャのまとめと次のステップ
今回構築した `BuildService` とイベントリスナーを組み合わせたアーキテクチャにより、以下のメリットがもたらされます。
1. 完全な非同期・スレッドセーフ: Gradleの並行実行性能(`–parallel`)を一切スポイルすることなく、正確なタスク実行時間を計測できる。
2. ベンダーロックインの回避: 高価な有償SaaSツールを導入しなくても、自社で使い慣れたログ基盤(ELKスタック、Datadog、Prometheusなど)へデータを直接流し込める。
3. データドリブンな改善: 「どのタスクがビルドの足を引っ張っているか」を定量的(ミリ単位)に把握でき、キャッシュ戦略(`@CacheableTask` の付与など)の効果測定を客観的に行える。
明日からの開発で、まずは `buildSrc` またはインビルドプラグインとしてこの仕組みのプロトタイプをデプロイしてみてください。チームのビルドスピードに対する意識が、劇的に変わるはずです。