Rustプロジェクトのビルド高速化に関するベストプラクティス(ローカル環境編)
はじめに
こんにちは。Fairy Devices プロダクト開発部の河野(かわの)です。業務ではRustを使ったWebバックエンドサービスの開発や、AWSを使ったインフラ構築を担当しています。Rust歴は約8年。rust-lang-jp等のRustのコミュニティーではtatsuya6502
として活動しています。
この記事ではRustプロジェクトのビルドにかかる時間を削減する方法を紹介します。
Rustで開発しているとビルド時間が長くなることがあります。たとえばasync
関係のクレートを導入すると、間接的に依存しているクレートの数が多く、ビルド時間が急に伸びることがあります。特にCI/CDパイプラインでのビルドは影響が大きいでしょう。なぜなら、パイプラインの定義によってはジョブが走るたびに全ての依存クレートをビルドし直すことになるからです。これにより開発効率が悪化したり、CI/CDのコストが増加するかもしれません。
この記事では、いくつかあるビルド時間の削減方法の中で、筆者が試して特に効果が大きかったものを紹介します。ビルド時間を削減することで、開発体験を向上させていきましょう!
内容が少し長くなるので2つの記事に分けて紹介します。
- 本記事:ローカルの開発環境におけるビルド時間の削減
- 次回の記事:CI/CDパイプラインにおけるビルド時間の削減
TL;DR
ローカル開発環境にてWebバックエンドのプロジェクトを使って試してみました。以下のような効果がありました。
ビルド内容 | ターゲットプラットフォーム | 変更内容 | 効果 |
---|---|---|---|
cargo test の 差分ビルド時間 |
Linux x86_64 | リンカーをmold に変更 |
Linux x86_64でリンクにかかる時間が 最大で約10分の1に短縮された |
cargo test の フルビルド時間 |
Linux x86_64, 同AArch64, macOS arm64 |
sccache を導入 |
2回目以降のフルビルド時間が約半分に短縮された |
Dockerイメージの ビルド時間 | Linux x86_64, 同AArch64 |
cargo chef を導入 |
2回目以降のビルド時間が最大で約5分の1に短縮された |
Windows環境については試していませんが、同様の効果が期待できそうです。特にWindows Subsystem for Linux 2(WSL2)やDocker Desktopを使っている場合は、この記事のLinux向けの内容をそのまま適用できます。
まずは計測
ビルド時間の削減に入る前に、現在のビルドにかかっている時間を計測し、何に時間がかかっているのかを把握しましょう。そうすることで後の改善効果がはっきりしますし、もし、本記事の例とは状況が異なる場合でも適切な対応を取れるかもしれません。
cargo build
やcargo test
に--timings
オプションを追加することで、個々のクレートのビルドにかかった時間を計測し、視覚化できます。
フルビルドにかかる時間を計測する
ここで言うフルビルドとは、cargo clean
を実行したあとの何もない状態からビルドし直すことです。これにより、プロジェクトが依存している全てのクレートが再度ビルドされます。
参考までに、Fairy Devicesで開発しているWebサーバーのプロジェクトのひとつで計測してみました。小さなプロジェクトですが、Axum、SQLx、AWS SDK、Reqwestなどのasync
系のクレートに依存しており、cargo test
実行時の依存クレート数は400を超えています。
## 計測の準備
$ cargo fetch # 依存クレートをダウンロード
$ cargo clean
## テストのデバッグビルドにかかる時間を計測。--no-runオプションでビルドのみ実行
$ cargo test --timings --no-run
計測結果のレポートはtarget/cargo-timings
ディレクトリに出力されます。HTMLファイルなのでWebブラウザーで開きます。
レポートの最初の表にはビルドの概要が記載されています。
以下のことがわかります。
- Dirty units(ビルドが必要なクレートやバイナリーの数)が435個
- Total units(クレートやバイナリーの総数)が438個
- Max concurrency(ビルド時の最大並列数)が8
- Total time(ビルドにかかった時間)が63.1秒
- rustcのバージョンは1.83.0でターゲットは
aarch64-apple-darwin
(macOS arm64)
なお、マシンは2020年に製造されたMac mini M1を使用しています。CPUに4つの高性能コア(Pコア)と4つの高効率コア(Eコア)を持ち、16GBのメモリーを搭載しています。
次のグラフはクレートの依存関係とビルド時間を示しています。
デフォルトでは全てのクレートが表示されますが、グラフ上部にあるスライダーを操作することで、ビルドに時間がかかったクレートのみを表示できます。
ビルドに3.9秒以上かかったクレートを表示しました。
縦軸がクレート、横軸が経過時間(秒)です。
横棒の多くは水色とラベンダーで色分けされています。
水色はrustc
のフロントエンド処理にかかった時間です。ソースコードを解析して中間表現を生成し、型推論、型チェック、所有権チェックなどを行うまでの処理となります。正確ではありませんが、cargo check
で行われる範囲の処理と思っていいでしょう。ラベンダー色はバックエンド処理にあたるcodegen(コード生成)処理で、LLVMを使ってターゲットアーキテクチャー向けのアセンブリーコードを生成します。
オレンジ色で示されているのはビルドスクリプトの実行時間です。ビルドスクリプトはbuild.rs
というファイルに記述されたRustのコードで、ビルド時に実行されます。ビルドスクリプトは、たとえばC言語のソースコードをコンパイルするのに使われます。
縦軸の24行から31行までは、fairy-server (v0.1.0) lib (test)
のようにクレート名とバージョンの直後にlib
、bin
、test
のいずれかが書かれています。これらはリンク処理に費やした時間です。
プロジェクトのトップレベルにあるtests
ディレクトリーにテスト用のRustソースファイルを置くと、それぞれのファイルが独立したバイナリーとしてビルドされます。そのため、test
と書かれた行がいくつも存在するわけです。
CargoはデフォルトでCPUの論理コアの数だけ並行してビルドを実行します。その様子はグラフで確認できます。たとえば、先ほどの24行から31行までのリンク処理は並行して実行されています。他の部分はそうなっていないように見えますが、これはスライダーでビルドに3.9秒以上かかったクレートのみを表示しているためです。
マウスカーソルをグラフの横棒に重ねると、ビルドの依存関係を表す点線が表示されます。
たとえば、以下のことがわかります。
-
aws-sdk-s3
に対するフロントエンド処理が終わるまではfairy-server
のビルドが始まらない -
aws-sdk-s3
に対するバックエンド処理が終わるまではfairy-server lib
のリンクが始まらない
全体のグラフをもう一度見ると以下のことがわかります。
- すべての依存クレートのビルドが終わるまで約48秒かかっている
- 特に
aws-sdk-s3
クレートのビルド時間が長くて、後続のビルドに待ちが発生している
- 特に
- アプリケーション(
fairy-server
)のビルドとリンクに約18秒がかかっている
差分ビルドにかかる時間を計測する
ここで言う差分ビルドとは、すでに何回かビルドを行っている状態でソースコードの一部を変更し、再度ビルドを行うことです。前回までのビルド成果物がtarget
ディレクトリーに保存されているため、フルビルドよりも短い時間でビルドが終了します。
rustc
にはインクリメンタルビルドという機能が実装されており、変更されたソースコードに関連する最小範囲のビルドを行うことで、ビルド時間を短縮します。Cargo
を使うとデバッグビルドではデフォルトでインクリメンタルビルドが有効になります。
もしrustc
のインクリメンタルビルドを無効にすると、ビルドの最小単位はクレートになります。ソースコードに変更があると、それを内包しているクレートが再ビルドされ、インクリメンタルビルドよりも時間がかかることがあります。
差分ビルドにかかる時間も計測してみます。プロジェクトのsrc/lib.rs
を編集してからcargo test
を実行したところ、以下のようになりました。
以下のことがわかります。
- アプリケーション(
fairy-server
)のビルドにかかった時間が、フルビルドのときより短い - 依存クレートのビルドは実行されない
- リンクが実行されているが、かかった時間はフルビルドのときよりも短いようだ
開発中にコードを書いているときは差分ビルドが中心になるでしょう。
一方、コンテナーイメージをビルドすると基本的にフルビルドになります。キャッシュなどを活用し、差分ビルドに近い状態にすることでビルド時間を短縮できます。
cargo test
のビルドを高速化する
cargo test
のビルド時間を削減していきます。
- Linux環境(含WSL2):
mold
— リンクを高速化する -
sccache
— フルビルドを高速化する
mold
リンカーを使う
Linux環境で現在のRust(執筆時点では1.83.0)ではLinuxの特にx86_64をターゲットにしたときにリンクに時間がかかることが知られています。先ほどの計測結果はmacOS上で得られたものでした。Linux環境でも同様に計測してみましょう。フルビルドにかかる時間を計測します。
マシンはAmazon EC2のc7i.2xlarge
インスタンスを使用しました。第4世代インテルXeonプロセッサー搭載しており、vCPUは8個、メモリーは16GBです。
予想通り21行目から28行目のリンクに時間がかかっています。先ほどのmacOS arm64の結果と比べてみましょう。
macOS arm64
Linux x86_64
いちばん上のfairy-server
のコンパイルにかかった時間は両者でほぼ同じなのに、リンクではx86_64がmacOSの約10倍の時間がかかっています。
Linux x86_64ターゲットで時間がかかるのは、rustc
が使用するリンケージエディター(リンカー)というツールの違いによるものです。
Rust 1.83.0(2024-11-26)の場合
ターゲットプラットフォーム | リンカー | 説明 |
---|---|---|
macOS arm64 | Apple ld
|
Appleが提供するリンカー。2023年にリリースされたバージョンで設計が一新され、大幅に高速化した |
Linux x86_64 | GNU ld
|
GNUプロジェクトが提供する歴史の長いリンカー。現在でも改良が続けられているが、基本設計が古く、近年のマルチコアプロセッサーの性能を引き出せていないようだ |
Linux AArch64 | LLVM lld
|
LLVMプロジェクトが提供するモダンなリンカー。rui314 氏によって開発され、LLVMプロジェクトへ寄贈された |
Linux x86_64ターゲットではリンカーとしてGNU ld
を使っており、それが遅さの原因になっています。
実は2024年5月以降はNightly版のrustc
はLinux x86_64ターゲットでもLLVM lld
を使っています。Stable版もいつかはlld
を使うようになり、問題が解決するかもしれません。
それまでの間は、リンカーを変更することでリンクにかかる時間を短縮できます。
mold
をインストールする
Linux x86_64環境でのリンクを高速化するために、筆者おすすめのmold
というリンカーを導入します。moldはLLVM lld
の作者でもあるrui314
氏(植山類氏)がOSSとして開発しているモダンなリンカーです。
筆者がmold
をおすすめするのは以下のような理由からです。
- 同じ作者で、
lld
よりmold
の方が新しく、lld
などよりも高速であることを目標に開発されているから-
READMEによると、
mold
はGNUld
より高速なのはもちろん、16コア、32スレッドのマシンではlld
よりも高速とのこと
-
READMEによると、
- Linux x86_64環境なら安定性の面で
lld
と遜色がないと考えられるから-
mold
のユーザーはLinux x86_64環境で使用していることが多く、その環境ならバグなどが少ないと考えられる - Rustの日本語コミュニティーにも
mold
を日常的に使っている人たちがいるが、特に問題なく使えているようだ
-
余談ですが、rustc
のLinuxターゲットのデフォルトリンカーとしてmold
を採用してほしいという要望も出ています(rust-lang/rust #94347)。しかし、デフォルトのリンカーを変更することは影響の大きな変更であり、慎重に検討されるべきです。性能だけではなく、成熟度、対応するアーキテクチャーの数、開発コミュニティーの規模などから総合的に判断される必要があり、実現には長い時間がかかるでしょう。実際、デフォルトリンカーをGNU ld
からLLVM lld
に変えるのにも年単位の時間がかかっています。
一方でプロジェクトごとにリンカーを変更することは簡単にできます。mold
を使ってみて問題があれば、lld
に変えることもできます。
それではmold
をインストールしましょう。ほとんどのLinuxディストリビューションではパッケージマネージャーを使ってmold
をインストールできます。たとえば、Ubuntu 24.04では以下のようにします。
## Ubuntu 24.04の場合
$ sudo apt install mold
一部のLinuxディストリビューションではmold
のパッケージが提供されていないか、古いバージョンが提供されています。その場合は、GitHubリリースページから最新版のバイナリーをダウンロードして使うこともできます。
たとえば、Rustの公式Dockerイメージは基本的にDebianをベースにしていますが、Debian 12 Bookwormのapt
で提供されるmold
のバージョンは少し古いものです(v1.10.1)。そのため最新版(v2.35.1)を使いたい場合は、Dockerfile
でmold
のバイナリーをダウンロードして使うことになります。Dockerfile
に書く内容は、本記事後半のコンテナーイメージのビルドを高速化するでお見せします。
なお、各Linuxディストリビューションがどのバージョンのmold
パッケージを提供しているかは、READMEのInstallationの章を見るとわかります。
mold
を使う
RustプロジェクトからRustプロジェクトからmold
を使うには、.cargo/config.toml
に以下の設定を追加します。
## .cargo/config.toml
## ターゲットプラットフォームがLinuxの場合のみmoldを使う
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
なお、GCCのバージョンが古い場合は上の設定がエラーになることがあります。その場合はGCCをアップグレードするか、Clangを使う必要があります。Clangを使う場合の設定方法についてはmold
のREADMEを参照してくだい。
もし、mold
ではなくlld
を使う場合は、sudo apt install lld-18
などでインストール後、-fuse-ld=lld
、または、-fuse-ld=lldへのフルパス
を指定します。
mold
による効果を確認する
フルビルド時のリンクにかかる時間を計測しました。
元の状態(再掲)。Linux x86_64 GNU ld
mold
を使った場合。Linux x86_64 mold
mold
を使うことで、それぞれのバイナリーのリンク時間がそれまでの約10%〜50%になりました。
差分ビルドを高速化するその他の方法
今回は試してないのですが、rustc
のオプションを変更することで差分ビルドが高速化する場合があります。試さなかった理由は、どの変更に効果があるかはプロジェクトによって異なることと、過去、筆者が試した範囲では、あまり効果がなかったためです。
変更できるオプションについては、たとえばRust製ゲームエンジンのBevyのドキュメントに書かれていますので、もし興味があれば参考にしてみてください。
- Bevy QuickStart — Enable Fast Compiles (Optional)
変更内容の例
- Nightly版のRustコンパイラーを使い、Generics Sharing機能を有効にする
- デバッグビルドでCraneliftバックエンドを使うことでビルド時間を短縮する
- ただし、Craneliftでコンパイルしたproc macroクレートは実行速度が遅くなるので、プロジェクト内でproc macroを多用している場合は効果が相殺されることもある。(proc macroのバイナリーは
rustc
にロードされて実行されるため、その実行速度がアプリケーションのビルド時間に影響を与える)
- ただし、Craneliftでコンパイルしたproc macroクレートは実行速度が遅くなるので、プロジェクト内でproc macroを多用している場合は効果が相殺されることもある。(proc macroのバイナリーは
sccache
でフルビルドを高速化する
sccacheはRustコンパイラーやC/C++コンパイラーが生成したビルド成果物をキャッシュするツールです。これにより2回目以降のビルド時間を短縮できます。
cargo build
を実行すると、Cargoは依存クレートごとにrustc
を実行し、生成されたビルド成果物をtarget
ディレクトリーに格納します。sccache
はrustc
のラッパーとして動作し、rustc
が生成したビルド成果物をキャッシュします。そして、同じクレートのビルド要求があると、rustc
を実行する代わりにキャッシュされたビルド成果物を返すことで時間を短縮します。また、target
ディレクトリーと異なり、sccache
にキャッシュされたビルド成果物は他のプロジェクトでも再利用されます。
このような仕組みですので、sccache
を使うとフルビルドの時間が大きく削減されます。一方で、git
で別のブランチをチェックアウトしたときに依存クレートのバージョンが変わらなかったらsccache
による効果はありません。なぜなら、依存ライブラリーのビルド成果物がすでにtarget
ディレクトリーにあるからです。
以下のような場合はsccache
を使うことでビルド時間を短縮できるでしょう。
-
git
のブランチを頻繁に切り替え、その際に依存ライブラリーのバージョンが変わる場合 -
git worktree
コマンドなどを使って複数のブランチを同時にチェックアウトしている場合- 各ブランチの初回ビルドでは
target
ディレクトリーが存在しない
- 各ブランチの初回ビルドでは
-
cargo clean
をよく実行する場合
筆者は複数のプロジェクトを同時に開発することが多く、さらに各プロジェクトでgit worktree
も使用してます。そのためtarget
ディレクトリーが何個もある状態なのですが、Rustでは個々のtarget
ディレクトリーのサイズがすぐに数十GBに達するのでSSDのスペースが圧迫されてしまいます。そのため、時々、cargo-sweepやcalgo-clean-allを実行してtarget
ディレクトリーをお掃除しています。このようなケースではsccache
を使うことでビルド時間を短縮できることが多いです。
なお、sccache
はキャッシュしたビルド成果物をファイルシステムに保存します。その総量には上限が設定されており、デフォルトは10GBです。上限に達するとアクセスされた時刻が最も古いビルド成果物から削除されていきます。
sccache
をインストールする
sccache
はRustのクレートとして提供されています。cargo install
コマンドでインストールできます。
$ cargo install --locked sccache
macOSではHomebrewを使ってインストールすることもできます。
## macOSの場合
$ brew install sccache
sccache
を使う
RustプロジェクトからRustプロジェクトからsccache
を使うにはシェルの環境変数を設定します。
$ export RUSTC_WRAPPER=sccache
sccache
による効果を確認する
sccache
による効果を確認するために、フルビルドにかかる時間を計測します。
## 計測の準備(sccacheのキャッシュを削除する)
$ sccache --show-stats # キャッシュの保存先を表示する
...
Cache location Local disk: "/Users/tatsuya/Library/Caches/Mozilla.sccache"
...
$ sccache --stop-server # サーバーを停止する
$ rm -rf ~/Library/Caches/Mozilla.sccache # キャッシュを削除する
## フルビルドにかかる時間を計測(1回目)
$ export RUSTC_WRAPPER=sccache
$ cargo fetch # 依存クレートをダウンロード
$ cargo clean
$ cargo test --timings --no-run
## フルビルドにかかる時間を計測(2回目)
$ cargo clean
$ cargo test --timings --no-run
計測結果は以下のようになりました。(macOS)
sccache
を使った1回目のフルビルドは、sccache
を使わない通常のビルドよりも時間がかかりました。キャッシュされたデータがないときには、sccache
を使うことによるオーバーヘッドがあるようです。
2回目はキャッシュされたデータがあったたため、ビルド時間が半減しています。
コンテナーイメージのビルドを高速化する
ローカル環境にてDockerを使ってコンテナーイメージをビルドする際に、ビルド時間を短縮する方法を紹介します。
-
mold
— リンクを高速化する -
cargo chef
— イメージのレイヤーキャッシュを活用する -
RUN --mount=type=cache
— BuildKitキャッシュを活用する- 過去にダウンロードしたクレートのソースファイルや
sccache
のキャッシュファイルを保存する
- 過去にダウンロードしたクレートのソースファイルや
-
sccache
— レイヤーキャッシュが効かないケースに備える
mold
でリンクを高速化する
まずはmold
を使ってリンクを高速化します。MacやWindowsでDocker Desktopを使っている場合でもコンテナーイメージのビルドにはLinuxの仮想マシンが使われます。つまり、mold
を使うとリンク時間を削減できる可能性があります。
たとえば、以下のようなマルチステージビルドのDockerfile
があったとします。
## Dockerfile
## ------------------------------
## ビルドステージ
## Rustの公式イメージを使い、プロジェクトをビルドする
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY . .
RUN cargo build --release --bin fairy-server
## ------------------------------
## 実行ステージ
## ビルドステージでビルドした成果物をDebian公式イメージへコピーして
## 実行用のコンテナーイメージを作成する
FROM debian:bookworm
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates \
&& apt-get clean
WORKDIR /app
COPY /build/target/release/fairy-server .
EXPOSE 8080
CMD ["./fairy-server"]
これを以下のように変更します。
## Dockerfile
## ------------------------------
## ベースイメージ
## Rustの公式イメージへmoldをインストールする
FROM rust:1-bookworm AS base
ARG MOLD_VERSION=2.35.1
RUN ARCH=$(uname -m) \
&& curl -L -O https://github.com/rui314/mold/releases/download/v${MOLD_VERSION}/mold-${MOLD_VERSION}-${ARCH}-linux.tar.gz \
&& tar xf mold-* && \
cp -p mold-*/bin/* /usr/local/bin/ && \
rm -rf mold-*
## ------------------------------
## ビルドステージ
## ベースイメージを使い、プロジェクトをビルドする
FROM base AS builder
WORKDIR /build
COPY . .
RUN cargo build --release --bin fairy-server
## ------------------------------
## 実行ステージ
FROM debian:bookworm
## (以下は変更ないので省略)
あとは、Rustプロジェクトからmold
を使うの手順に従って、.cargo/config.toml
でmold
を使うように設定すれば準備完了です。
cargo chef
でイメージのレイヤーキャッシュを活用する
cargo chef
はコンテナーイメージのビルドにレイヤーキャッシュを活用するためのツールです。これにより2回目以降のビルド時間を短縮できます。
コンテナーイメージをビルドする際、基本的に何もない状態から全ての依存クレートをビルドすることになります。しかし、Rustプロジェクトでは依存クレートのバージョンをCargo.lock
で固定していることが多いため、イメージをビルドするたびにそれらをビルドし直す必要はありません。
cargo chef
を使うと、依存クレートのビルド成果物(target
ディレクトリー)のみを含むイメージレイヤーを作成できます。
Dockerfile
を以下のように書き換えます。
## Dockerfile
## ------------------------------
## ベースイメージ
## Rustの公式イメージへcargo-chefとmoldをインストールする
FROM rust:1-bookworm AS base
ARG CARGO_CHEF_VERSION=0.1.68
ARG MOLD_VERSION=2.35.1
RUN cargo install --locked cargo-chef --version ${CARGO_CHEF_VERSION}
RUN ARCH=$(uname -m) \
&& curl -L -O https://github.com/rui314/mold/releases/download/v${MOLD_VERSION}/mold-${MOLD_VERSION}-${ARCH}-linux.tar.gz \
&& tar xf mold-* && \
cp -p mold-*/bin/* /usr/local/bin/ && \
rm -rf mold-*
## ------------------------------
## プランニングステージ
## ベースイメージを使用する。cargo chef prepareコマンドで
## プロジェクトが依存しているクレートの情報をrecipe.jsonに保存する
FROM base AS planner
WORKDIR /build
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
## ------------------------------
## ビルドステージ
## ベースイメージを使用する
FROM base AS builder
WORKDIR /build
## cargo chef cookコマンドで依存クレートをビルドする
## その結果、カレントディレクトリーにtargetディレクトリーができ、
## 依存クレートのビルド成果物が保存される
COPY /build/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
## プロジェクトのソースコードをカレントディレクトリーに上書きコピーして
## cargo buildを実行する。依存クレートのビルド成果物はすでにtarget
## ディレクトリーにあるので、ビルド時間が短縮される
COPY . .
RUN cargo build --release --bin fairy-server
## ------------------------------
## 実行ステージ
FROM debian:bookworm
## (以下は変更ないので省略)
このDockerfile
を使ってコンテナーイメージをビルドすると、cargo chef cook
のところで依存クレートのビルド成果物を含んだ中間的なイメージレイヤーが作成されます。このレイヤーはコンテナーイメージのビルダー(DockerならBuildKit)によってキャッシュされます。
2回目以降のビルドで依存クレートのバージョンが同じならcargo chef cook
の実行がスキップされ、キャッシュされたイメージレイヤーが使用されます。これによりビルド時間が短縮されます。
そうでない場合、たとえば、Cargo.lock
で指定している依存クレートのバージョンを変えたり、依存クレートを追加したりすると、キャッシュされたイメージレイヤーは使われず、cargo chef cook
が実行されます。その場合はすべての依存クレートが再度ビルドされます。
cargo chef
による効果を確認する
cargo chef
による効果を確認するために、コンテナーイメージのビルドにかかる時間を計測します。
## 計測の準備(ビルドキャッシュを消去する)
$ docker buildx prune -af # ビルドキャッシュを消去する
$ docker system df # ビルドキャッシュが消去されたことを確認する
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
...
Build Cache 0 0 0B 0B
## ビルドにかかる時間を計測(1回目)
$ docker build -t fairy-server:latest .
以下のようになりました。
オリジナルのDockerfile
を使った場合(上段)とcargo chef
1回目(下段)はビルド時間は同じでした。cargo chef
によるビルドは2つのステップに別れており、深緑色が依存クレートのイメージレイヤーのビルド時間を表しています。
さらに、差分ビルドにかかる時間も計測します。
## src/lib.rsを編集してからビルドする
$ vi src/lib.rs
$ docker build -t fairy-server:latest .
## 依存クレートの1つをアップグレードしてからビルドする
$ cargo update -p axum
$ docker build -t fairy-server:latest .
src/lib.rs
を編集した場合は依存クレートのビルドはスキップされ、レイヤーキャッシュが使われました。アプリケーション本体のみがビルドされたため、ビルド時間が大きく短縮されました。
一方、依存クレートの1つをアップグレードした場合はレイヤーキャッシュが使われず、全ての依存クレートがビルドされました。そのためオリジナルとほぼ同じビルド時間がかかりました。
なお、計測に使った環境は以下の通りです。Docker Desktopの性能が上がるよう、設定をデフォルトから変更していますので注意してください。
- マシン:Mac mini M1 (2020), 16GB RAM
- Docker:Docker Desktop 4.37.1 (178610)
- 仮想マシンオプション
- Docker VMM (Beta)
- リソース割り当て
- CPUs: 8
- Memory: 6.0 GiB
- 仮想マシンオプション
- イメージ:
rust:1-bookworm
(Rust 1.83.0 2024-11-26)
cargo chef
の計測結果ではリンカーとしてmold
も使用しています。しかし、オリジナルのDockerfile
のときと比べてリンク時間には差がないようでした。macOS arm64上でDocker DesktopはLinux AArch64を実行します。その場合、rustc
はデフォルトでLLVM lld
を使いますので、GNU ld
ほどは遅くありません。今回使用したプロジェクトではlld
とmold
のリンク時間に目立った差はないのかもしれません。
BuildKitキャッシュを活用する
いま見たように依存クレートを変更するとレイヤーキャッシュが使われず、cargo chef
の効果がなくなります。そういうケースに対応できるようBuildKitキャッシュを活用しましょう。Dockerfile
のRUN
命令に--mount=type=cache,target=ディレクトリー
オプションを追加すると、イメージ内の指定したディレクトリーの内容がキャッシュされ、次回以降のビルドで再利用されるようになります。
Cargoがダウンロードしたクレートのソースコードは、イメージ内の~/.cargo
ディレクトリー配下に保存されています。このディレクトリーをBuildKitキャッシュに保存するようにします。Dockerfile
のビルドステージにあるRUN
命令を以下のように変更します。
## Dockerfile
## ------------------------------
## ビルドステージ
FROM base AS builder
WORKDIR /build
## cargo chef cookコマンドで依存クレートをビルドする
COPY /build/recipe.json recipe.json
RUN \
cargo chef cook --release --recipe-path recipe.json
## プロジェクトのソースコードをカレントディレクトリーに上書きコピーして
## cargo buildを実行する
COPY . .
RUN \
cargo build --release --bin fairy-server
sccache
でレイヤーキャッシュが効かなかったときに備える
最後にsccache
を追加しましょう。これによりレイヤーキャッシュが使われなかったときのビルド時間をさらに短縮できます。
sccache
のキャッシュディレクトリーの内容はBuildKitキャッシュに保存するようにします。Dockerfile
を以下のように変更します。
## Dockerfile
## ------------------------------
## ベースイメージ
## Rustの公式イメージへcargo-chef、sccache、moldをインストールする
FROM rust:1-bookworm AS base
ARG CARGO_CHEF_VERSION=0.1.68
ARG SCCACHE_VERSION=0.9.0
ARG MOLD_VERSION=2.35.1
RUN cargo install --locked cargo-chef --version ${CARGO_CHEF_VERSION}
RUN ARCH=$(uname -m) \
&& curl -L -O https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-${ARCH}-unknown-linux-musl.tar.gz \
&& tar xf sccache-* && \
cp -p sccache-*/sccache /usr/local/bin/ && \
rm -rf sccache-*
RUN ARCH=$(uname -m) \
&& curl -L -O https://github.com/rui314/mold/releases/download/v${MOLD_VERSION}/mold-${MOLD_VERSION}-${ARCH}-linux.tar.gz \
&& tar xf mold-* && \
cp -p mold-*/bin/* /usr/local/bin/ && \
rm -rf mold-*
## RUSTC_WRAPPER環境変数を設定することでsccacheを使うようにする
## SCCACHE_DIRでキャッシュファイルの保存先を指定する
ENV RUSTC_WRAPPER=sccache \
SCCACHE_DIR=/sccache
## ------------------------------
## プランニングステージ
FROM base AS planner
WORKDIR /build
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
## ------------------------------
## ビルドステージ
FROM base AS builder
WORKDIR /build/
## cargo chef cookコマンドで依存クレートをビルドする
COPY /build/recipe.json recipe.json
RUN \
cargo chef cook --release --recipe-path recipe.json \
&& sccache --show-stats
## プロジェクトのソースコードをカレントディレクトリーに上書きコピーして
## cargo buildを実行する
COPY . .
RUN \
cargo build --release --bin fairy-server
## ------------------------------
## 実行ステージ
FROM debian:bookworm
## (以下は変更ないので省略)
なお、cargo chef cook
コマンドを実行するRUN
コマンドの最後にsccache --show-stats
を追加しましたが、必須ではありません。このコマンドはsccache
の統計情報を表示するもので、キャッシュのヒット率などがわかります。
sccache
による効果を確認する
sccache
による効果を確認するために、コンテナーイメージのビルドにかかる時間を計測します。以下のような結果が得られました。
3, 5, 7行目がsccache
を追加したあとのビルド時間です。
3行目のビルド時間はcargo chef
を単体で使った場合と比べて長くなっています。これは、sccache
のキャッシュが空で、sccache
を使うこと自体のオーバーヘッドがあるためです。
5行目のビルド時間は4行目のcargo chef
を単体で使った場合と同じです。cargo chef
の働きによりイメージのレイヤーキャッシュが使われたため、依存クレートのビルドがスキップされました。そのためsccache
は使われませんでした。
7行目のビルド時間は6行目のcargo chef
を単体で使った場合よりも短くなっています。どちらもイメージのレイヤーキャッシュが使えなかったため、依存クレートのフルビルドが行われました。しかし、7行目はsccache
にキャッシュされていたビルド成果物が使われたため時間が短縮されました。
5行目と7行目のビルド時間を見ることでcargo chef
とsccache
を組み合わせたビルドが最も効果的であることがわかります。
まとめと次回予告
この記事ではローカル環境にてRustプロジェクトのビルドにかかる時間を削減する方法を紹介しました。
- Linux x86_64環境ではリンカーを
mold
に変更する -
sccache
を使ってビルド成果物をキャッシュする -
cargo chef
を使ってコンテナーイメージのレイヤーキャッシュを活用する
次回は、CI/CDサービスのひとつであるGitHub Actions上でビルド時間を短縮する方法を紹介します。GitHub Actionsにはローカル環境とは異なる制約があり、今回紹介した手法をそのまま使うことができません。それらの制約を踏まえて、GitHub Actions上でビルド時間を短縮する方法を紹介します。
Discussion