Rust プロジェクトの GitHub Actions で incremental build をするためのテクニック

4 min read読了の目安(約4200字

Rust のビルド時間はプログラム言語の中では割と長い方に入ると思われますが、incremental build をうまく使うことでビルド時間を短縮する事が出来ます。ローカル開発時には、とくに何も考えなくても勝手に incremental build になりますが CI で incremental build するためには一工夫が必要になります。この記事ではそのテクニックを紹介します。

TL;DR

  • actions/cachegit-restore-mtime コマンドを組み合わせると CI で incremental build が出来ます。
  • さらに release build で incremental ビルドしたい場合は incremental flag を明示的に on に上書きが必要です。

Rust プロジェクトのキャッシュ設定

Github Actions で actions/cache ステップを使うことで、ビルド時のアセットをキャッシュして、ビルド時間を短縮することが出来ます。

典型的な Rust プロジェクトでは以下のようなステップ設定になります。

- uses: actions/cache@v2
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

ref: https://github.com/actions/cache/blob/main/examples.md#rust---cargo

この設定で外部依存 Crate のビルドはスキップすることが出来ますが、レポジトリ内のコードのビルドはフルビルドになってしまいます。ある程度以上の規模のプロジェクトになってくると、このフルビルド時間がかなり長くなってしまい問題になってくる事があります。

Rust のビルド関連の生成物は全て target 以下に生成されていて、上の設定では target 以下をまるごとキャッシュしているので、incremental build が発動しないのはおかしいように思われます。なぜただキャッシュするだけではフルビルドになってしまうのでしょう?

mtime 問題

実は Rust はファイルの mtime タイムスタンプ (最終更新時刻) を見てリビルドするかキャッシュを使うかを判断しています。このことは、一回ビルド済のコードベース上で、特定ファイルに touch して再ビルドすると touch したファイルの再ビルドが走ることで確認出来ます。

一方で github actions でレポジトリを clone した際には実はファイルのタイムスタンプは保存されていません。これは git がそもそもファイルのタイムスタンプを保持していないため、当然といえば当然です。つまり、github actions が走るたびに repository 内の全てファイルの更新時刻がそのジョブ内で clone した時刻になってしまいます。

github actions ではすべてのファイルの更新時刻 (mtime) が clone した時刻になってしまい、その時刻は当然その前のビルドでキャッシュが作られた時刻よりも新しい時刻になります。すべてのソースコードの更新時刻がキャッシュよりも新しい状況なので、キャッシュを使うことは出来ずフルビルドが走ってしまいます。

mtime を復元する (git-restore-mtime)

ではファイルの更新タイムスタンプをどうにかしてビルド間でうまく合わせる方法が無いかというと、実は便利なスクリプトがあります。それが git-restore-mtime です。このスクリプトは git の commit ログからそのファイルが最後にコミットされた時刻に mtime を無理やり修正してくれます。これをビルド前に実行することで incremental build を github actions 内で実行できるようになります。

このスクリプトをリポジトリ内にコピーして以下のように実行すればコミット履歴に基づいた mtime を復元することが出来ます。(python スクリプトなので必要に応じて setup-python ステップなども併用しましょう)

- name: Install Python
  uses: actions/setup-python@v2
- name: Restore mtime
  run: python ./git-restore-mtime.py

なお、git の commit history から mtime を復元するため、git の全ての history が必要になります。全ての history を fetch するには、次のようにして actions/checkout のオプションで shallow clone を無効化することが必要になります。

- name: Clone repository
  uses: actions/checkout@v2
  with:
    fetch-depth: 0 # 0 にすると全履歴を fecth するようになります。

(history があまりにも長いレポジトリの場合は、全てでなくても十分大きい値にすれば十分かもしれません。)

以上の設定で、github actions 上でも incremental build が機能するようになるはずです。

補足1: release profile について

CI 上で release profile でビルド・テストしているという場合があると思います。release profile の場合はデフォルトで incremental モードが無効化されている (ref: https://doc.rust-lang.org/cargo/reference/profiles.html#release ) ため、release profile で incremental build したい場合はさらに明示的にフラグを true に上書きする必要があります。

プロファイルのデフォルトを変えてしまっても良いと言う場合は、Cargo.toml で以下のように上書きできます。

[profile.release]
incremental = true

デフォルト挙動は変えずに CI 上でのみ incremental build を有効化したい場合は、CARGO_INCREMENTAL 環境変数をセットすることで、フラグを上書きすることが出来ます。

jobs:
  build:
    env:
      CARGO_INCREMENTAL: 1

補足2: mac での cache の破損問題

GitHub Action の mac runner に標準インストールされている BSD tar が一部のファイル (libserde_derive.dylib など) を正しく圧縮・解凍出来ないという問題が知られています。 actions/cache#403

上の issue 内で議論されているように、この問題を回避するためには、次のような設定で GNU tar をインストールする必要があります。

# Work around https://github.com/actions/cache/issues/403 by using GNU tar
# instead of BSD tar.
- name: Install GNU tar 
  if: matrix.os == 'macos-latest'
  run: |
    brew install gnu-tar
    echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH

(matrix.os == 'macos-latest' の部分は適宜、各々の環境に合わせて書き換えてください。)

上の設定で GNU tar をインストールすると、actions/cache が自動的に GNU tar を選択して、GNU tar で圧縮・解凍してくれるようになります。

将来的には標準で GNU tar が mac runner にインストールされるようになり、上記の問題は自然に解決される想定のようですが、すべての mac image に変更が行き渡っているわけではないらしく、筆者の環境 (denoland/deno) では、まだ、上のワークアラウンドが必要でした。

まとめ

git-restore-mtime スクリプトを使って github actions 内で incremental build を有効化するための設定を紹介しました。