終わらないビルド地獄からの解放:`build.rs`の「再コンパイル連鎖」を外科手術する
大規模なRustプロジェクトにおいて、エンジニアの生産性を最も蝕む「見えないコスト」がある。それは、ほんの数行のコード修正で、なぜか無関係なはずのクレートまで巻き込んで再ビルドが走り、数分間、あるいは数十分間もCPUが空回りするあの瞬間だ。
特に、`build.rs`(ビルドスクリプト)を駆使したメタプログラミングやコード生成を行っているプロジェクトでこの現象は顕著だ。`cargo`のビルドスクリプトは強力だが、その挙動を正しく制御しなければ、それは「ビルドパイプラインを破壊する爆弾」と化す。
本稿では、`rerun-if-changed`を極限まで制御し、ビルドの「再コンパイル連鎖」を断ち切るためのアーキテクチャ最適化手法を伝授する。
—
1. なぜ「連鎖」は起きるのか:Cargoビルドの深層
Cargoは、`build.rs`が依存するファイルが変更されたかを、`rerun-if-changed`で監視する。ここでの致命的なミスは、「監視対象をディレクトリ単位で指定する」ことだ。
もしあなたが `println!(“cargo:rerun-if-changed=src/”);` のようにディレクトリを指定しているなら、その直下にあるログファイルの一時的な更新や、エディタが生成する隠しファイル(`.swp`等)のたびに、ビルドスクリプトは「再実行すべき」と判断する。これが連鎖の正体だ。
解決策:依存関係の「最小化と抽象化」
監視すべきは「ディレクトリ」ではなく「計算の入力となる最小単位のファイル」であるべきだ。
// build.rs
use std::path::Path;
use walkdir::WalkDir;
fn main() {
// 1. 不必要な監視を排除:.gitや一時ファイルを除外する
// 2. ディレクトリではなく、明示的なソースファイルのみをリストアップする
let schema_dir = Path::new(“schemas”);
for entry in WalkDir::new(schema_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == “json”))
{
// 個別のファイル単位で監視を登録。これにより、無関係なファイルの変更で再ビルドは起きない
println!(“cargo:rerun-if-changed={}”, entry.path().display());
}
// 環境変数の監視:環境変数が変わった時だけ再ビルドする(重要)
println!(“cargo:rerun-if-env-changed=DATABASE_URL”);
}
—
2. CI/CDパイプラインとの高度な連携:ビルドキャッシュの「罠」
Docker環境でRustをビルドする際、`build.rs`の再実行はキャッシュ効率を劇的に低下させる。特に、CI環境ではファイル更新時刻(mtime)が不自然に更新されることがあり、これが`cargo`を騙す。
ハック:git-restore-mtimeの活用
CI環境でcheckoutした直後、ファイルのmtimeをgitの最終コミット時刻に合わせることで、意図しない再ビルドを抑制する。
GitHub Actionsの例
- name: Restore mtime
run: |
# ファイルのmtimeをgitの記録に合わせることで、cargoのタイムスタンプ比較を正常化
git ls-files -z | xargs -0 -n1 sh -c ‘touch -d “$(git log -1 –format=%cI “$0″)” “$0″‘
—
3. 「ビルドスクリプト・オフロード」アーキテクチャ
`build.rs`で重い処理(C++ライブラリのリンクや、巨大なSchemaからのコード生成)を行っている場合、開発機でのビルド待ち時間は耐え難いものになる。ここで、アーキテクトがとるべきは「ビルドスクリプトの外部化」だ。
手法:ビルドの分離とアーティファクトの事前計算
`build.rs`内で重い処理をせず、専用のCLIツールをCI/CDのパイプライン(または`just`等のタスクランナー)で実行し、生成されたコードをリポジトリに含める、あるいはキャッシュサーバー経由で提供する。
1. 生成フェーズ: CIでコード生成専用のコンテナを回し、成果物をS3/GCSにアップロード。
2. 消費フェーズ: 開発環境では、`build.rs`はS3からキャッシュをダウンロードするだけにする。
// build.rsの高度な実装例
fn main() {
if std::env::var(“SKIP_CODE_GEN”).is_ok() {
// CIや開発環境でローカル生成をスキップし、既存の成果物を使う
println!(“cargo:warning=Skipping code generation.”);
return;
}
// 通常のコード生成ロジック…
}
—
4. プロファイリング:何がビルドを遅らせているのか?
「どのビルドスクリプトが時間を食っているか」を可視化しなければ、最適化は勘頼みになる。
ビルド時のタイムラインを可視化する
cargo build –timings
生成された `target/cargo-timings/cargo-timing-YYYY-MM-DD-HHMMSS.html` を開くと、どのクレートがどの順番で並列実行され、どの`build.rs`がボトルネックになっているかが一目瞭然だ。ここが「再コンパイルの連鎖」を特定する唯一の真実のソースである。
—
伝説のアーキテクトからの提言
Rustにおけるビルドの最適化とは、単なる「速くすること」ではない。「何を変更すれば、どの範囲に影響が及ぶか」という依存関係グラフ(Dependency Graph)を、エンジニアが完全に制御下に置くことである。
`rerun-if-changed`をただの命令として扱うな。それは、コンパイラに対する「どのトリガーを引けば再計算が必要か」という契約である。この契約を極限までタイトに絞り込むことこそが、開発体験を劇的に向上させ、CI/CDパイプラインを「待ち時間の長い苦行」から「即応する自動化エンジン」へと変貌させる唯一の道だ。
さあ、今すぐ `build.rs` を見直し、無駄な再コンパイルの連鎖を断ち切れ。その先に、かつてない開発体験が待っている。