🚀

Rustプロジェクトのビルド高速化に関するベストプラクティス(ローカル環境編)

2025/01/07に公開

はじめに

こんにちは。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 buildcargo 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ブラウザーで開きます。

build-timingsの全体像

レポートの最初の表にはビルドの概要が記載されています。

build-timingsの最初の表

以下のことがわかります。

  • 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のメモリーを搭載しています。

次のグラフはクレートの依存関係とビルド時間を示しています。

build timingsのグラフ1

デフォルトでは全てのクレートが表示されますが、グラフ上部にあるスライダーを操作することで、ビルドに時間がかかったクレートのみを表示できます。

build timingsのスライダー

ビルドに3.9秒以上かかったクレートを表示しました。

build timingsのグラフ2

縦軸がクレート、横軸が経過時間(秒)です。

横棒の多くは水色とラベンダーで色分けされています。

水色はrustcのフロントエンド処理にかかった時間です。ソースコードを解析して中間表現を生成し、型推論、型チェック、所有権チェックなどを行うまでの処理となります。正確ではありませんが、cargo checkで行われる範囲の処理と思っていいでしょう。ラベンダー色はバックエンド処理にあたるcodegen(コード生成)処理で、LLVMを使ってターゲットアーキテクチャー向けのアセンブリーコードを生成します。

オレンジ色で示されているのはビルドスクリプトの実行時間です。ビルドスクリプトはbuild.rsというファイルに記述されたRustのコードで、ビルド時に実行されます。ビルドスクリプトは、たとえばC言語のソースコードをコンパイルするのに使われます。

縦軸の24行から31行までは、fairy-server (v0.1.0) lib (test)のようにクレート名とバージョンの直後にlibbintestのいずれかが書かれています。これらはリンク処理に費やした時間です。

プロジェクトのトップレベルにあるtestsディレクトリーにテスト用のRustソースファイルを置くと、それぞれのファイルが独立したバイナリーとしてビルドされます。そのため、testと書かれた行がいくつも存在するわけです。

CargoはデフォルトでCPUの論理コアの数だけ並行してビルドを実行します。その様子はグラフで確認できます。たとえば、先ほどの24行から31行までのリンク処理は並行して実行されています。他の部分はそうなっていないように見えますが、これはスライダーでビルドに3.9秒以上かかったクレートのみを表示しているためです。

マウスカーソルをグラフの横棒に重ねると、ビルドの依存関係を表す点線が表示されます。

aws-sdkへの依存グラフ

たとえば、以下のことがわかります。

  • 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を実行したところ、以下のようになりました。

差分ビルドのbuild timings

以下のことがわかります。

  • アプリケーション(fairy-server)のビルドにかかった時間が、フルビルドのときより短い
  • 依存クレートのビルドは実行されない
  • リンクが実行されているが、かかった時間はフルビルドのときよりも短いようだ

開発中にコードを書いているときは差分ビルドが中心になるでしょう。

一方、コンテナーイメージをビルドすると基本的にフルビルドになります。キャッシュなどを活用し、差分ビルドに近い状態にすることでビルド時間を短縮できます。

cargo testのビルドを高速化する

cargo testのビルド時間を削減していきます。

  1. Linux環境(含WSL2): mold — リンクを高速化する
  2. sccache — フルビルドを高速化する

Linux環境でmoldリンカーを使う

現在のRust(執筆時点では1.83.0)ではLinuxの特にx86_64をターゲットにしたときにリンクに時間がかかることが知られています。先ほどの計測結果はmacOS上で得られたものでした。Linux環境でも同様に計測してみましょう。フルビルドにかかる時間を計測します。

マシンはAmazon EC2のc7i.2xlargeインスタンスを使用しました。第4世代インテルXeonプロセッサー搭載しており、vCPUは8個、メモリーは16GBです。

Linux x86_64のbuild timing

予想通り21行目から28行目のリンクに時間がかかっています。先ほどのmacOS arm64の結果と比べてみましょう。

macOS arm64

macOS arm64のリンク時間

Linux x86_64

Linux x86_64 GNU ldのリンク時間

いちばん上の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版のrustcLinux x86_64ターゲットでもLLVM lldを使っています。Stable版もいつかはlldを使うようになり、問題が解決するかもしれません。

それまでの間は、リンカーを変更することでリンクにかかる時間を短縮できます。

moldをインストールする

Linux x86_64環境でのリンクを高速化するために、筆者おすすめのmoldというリンカーを導入します。moldはLLVM lldの作者でもあるrui314氏(植山類氏)がOSSとして開発しているモダンなリンカーです。

筆者がmoldをおすすめするのは以下のような理由からです。

  • 同じ作者で、lldよりmoldの方が新しく、lldなどよりも高速であることを目標に開発されているから
    • READMEによると、moldはGNU ldより高速なのはもちろん、16コア、32スレッドのマシンではlldよりも高速とのこと
  • 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)を使いたい場合は、Dockerfilemoldのバイナリーをダウンロードして使うことになります。Dockerfileに書く内容は、本記事後半のコンテナーイメージのビルドを高速化するでお見せします。

なお、各Linuxディストリビューションがどのバージョンのmoldパッケージを提供しているかは、READMEのInstallationの章を見るとわかります。

Rustプロジェクトからmoldを使う

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を使う場合の設定方法についてはmoldREADMEを参照してくだい。

もし、moldではなくlldを使う場合は、sudo apt install lld-18などでインストール後、-fuse-ld=lld、または、-fuse-ld=lldへのフルパスを指定します。

moldによる効果を確認する

フルビルド時のリンクにかかる時間を計測しました。

元の状態(再掲)。Linux x86_64 GNU ld

Linux x86_64 GNU ldのリンク時間(再掲)

moldを使った場合。Linux x86_64 mold

Linux x86_64 moldのリンク時間

moldを使うことで、それぞれのバイナリーのリンク時間がそれまでの約10%〜50%になりました。

差分ビルドを高速化するその他の方法

今回は試してないのですが、rustcのオプションを変更することで差分ビルドが高速化する場合があります。試さなかった理由は、どの変更に効果があるかはプロジェクトによって異なることと、過去、筆者が試した範囲では、あまり効果がなかったためです。

変更できるオプションについては、たとえばRust製ゲームエンジンのBevyのドキュメントに書かれていますので、もし興味があれば参考にしてみてください。

変更内容の例

  • Nightly版のRustコンパイラーを使い、Generics Sharing機能を有効にする
  • デバッグビルドでCraneliftバックエンドを使うことでビルド時間を短縮する
    • ただし、Craneliftでコンパイルしたproc macroクレートは実行速度が遅くなるので、プロジェクト内でproc macroを多用している場合は効果が相殺されることもある。(proc macroのバイナリーはrustcにロードされて実行されるため、その実行速度がアプリケーションのビルド時間に影響を与える)

sccacheでフルビルドを高速化する

sccacheはRustコンパイラーやC/C++コンパイラーが生成したビルド成果物をキャッシュするツールです。これにより2回目以降のビルド時間を短縮できます。

cargo buildを実行すると、Cargoは依存クレートごとにrustcを実行し、生成されたビルド成果物をtargetディレクトリーに格納します。sccacherustcのラッパーとして動作し、rustcが生成したビルド成果物をキャッシュします。そして、同じクレートのビルド要求があると、rustcを実行する代わりにキャッシュされたビルド成果物を返すことで時間を短縮します。また、targetディレクトリーと異なり、sccacheにキャッシュされたビルド成果物は他のプロジェクトでも再利用されます。

このような仕組みですので、sccacheを使うとフルビルドの時間が大きく削減されます。一方で、gitで別のブランチをチェックアウトしたときに依存クレートのバージョンが変わらなかったらsccacheによる効果はありません。なぜなら、依存ライブラリーのビルド成果物がすでにtargetディレクトリーにあるからです。

以下のような場合はsccacheを使うことでビルド時間を短縮できるでしょう。

  • gitのブランチを頻繁に切り替え、その際に依存ライブラリーのバージョンが変わる場合
  • git worktreeコマンドなどを使って複数のブランチを同時にチェックアウトしている場合
    • 各ブランチの初回ビルドではtargetディレクトリーが存在しない
  • cargo cleanをよく実行する場合

筆者は複数のプロジェクトを同時に開発することが多く、さらに各プロジェクトでgit worktreeも使用してます。そのためtargetディレクトリーが何個もある状態なのですが、Rustでは個々のtargetディレクトリーのサイズがすぐに数十GBに達するのでSSDのスペースが圧迫されてしまいます。そのため、時々、cargo-sweepcalgo-clean-allを実行してtargetディレクトリーをお掃除しています。このようなケースではsccacheを使うことでビルド時間を短縮できることが多いです。

なお、sccacheはキャッシュしたビルド成果物をファイルシステムに保存します。その総量には上限が設定されており、デフォルトは10GBです。上限に達するとアクセスされた時刻が最も古いビルド成果物から削除されていきます。

sccacheをインストールする

sccacheはRustのクレートとして提供されています。cargo installコマンドでインストールできます。

$ cargo install --locked sccache

macOSではHomebrewを使ってインストールすることもできます。

## macOSの場合
$ brew install sccache

Rustプロジェクトからsccacheを使う

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使用時のビルド時間

sccacheを使った1回目のフルビルドは、sccacheを使わない通常のビルドよりも時間がかかりました。キャッシュされたデータがないときには、sccacheを使うことによるオーバーヘッドがあるようです。

2回目はキャッシュされたデータがあったたため、ビルド時間が半減しています。

コンテナーイメージのビルドを高速化する

ローカル環境にてDockerを使ってコンテナーイメージをビルドする際に、ビルド時間を短縮する方法を紹介します。

  1. mold — リンクを高速化する
  2. cargo chef — イメージのレイヤーキャッシュを活用する
  3. RUN --mount=type=cache — BuildKitキャッシュを活用する
    • 過去にダウンロードしたクレートのソースファイルやsccacheのキャッシュファイルを保存する
  4. 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 --from=builder /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.tomlmoldを使うように設定すれば準備完了です。

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 --from=planner /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 .

以下のようになりました。

cargo chef ビルド時間

オリジナルの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 .

cargo chef 差分ビルド

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ほどは遅くありません。今回使用したプロジェクトではlldmoldのリンク時間に目立った差はないのかもしれません。

BuildKitキャッシュを活用する

いま見たように依存クレートを変更するとレイヤーキャッシュが使われず、cargo chefの効果がなくなります。そういうケースに対応できるようBuildKitキャッシュを活用しましょう。DockerfileRUN命令に--mount=type=cache,target=ディレクトリーオプションを追加すると、イメージ内の指定したディレクトリーの内容がキャッシュされ、次回以降のビルドで再利用されるようになります。

Cargoがダウンロードしたクレートのソースコードは、イメージ内の~/.cargoディレクトリー配下に保存されています。このディレクトリーをBuildKitキャッシュに保存するようにします。DockerfileのビルドステージにあるRUN命令を以下のように変更します。

## Dockerfile

## ------------------------------
## ビルドステージ
FROM base AS builder
WORKDIR /build

## cargo chef cookコマンドで依存クレートをビルドする
COPY --from=planner /build/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    cargo chef cook --release --recipe-path recipe.json

## プロジェクトのソースコードをカレントディレクトリーに上書きコピーして
## cargo buildを実行する
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    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 --from=planner /build/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
    cargo chef cook --release --recipe-path recipe.json \
    && sccache --show-stats

## プロジェクトのソースコードをカレントディレクトリーに上書きコピーして
## cargo buildを実行する
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
    cargo build --release --bin fairy-server

## ------------------------------
## 実行ステージ
FROM debian:bookworm
## (以下は変更ないので省略)

なお、cargo chef cookコマンドを実行するRUNコマンドの最後にsccache --show-statsを追加しましたが、必須ではありません。このコマンドはsccacheの統計情報を表示するもので、キャッシュのヒット率などがわかります。

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 chefsccacheを組み合わせたビルドが最も効果的であることがわかります。

まとめと次回予告

この記事ではローカル環境にてRustプロジェクトのビルドにかかる時間を削減する方法を紹介しました。

  • Linux x86_64環境ではリンカーをmoldに変更する
  • sccacheを使ってビルド成果物をキャッシュする
  • cargo chefを使ってコンテナーイメージのレイヤーキャッシュを活用する

次回は、CI/CDサービスのひとつであるGitHub Actions上でビルド時間を短縮する方法を紹介します。GitHub Actionsにはローカル環境とは異なる制約があり、今回紹介した手法をそのまま使うことができません。それらの制約を踏まえて、GitHub Actions上でビルド時間を短縮する方法を紹介します。

フェアリーデバイセズ公式

Discussion