Rust の docker build を高速にする
なにが遅いのか
Rust の cargo build をなにもせずに docker build で走らせると遅い原因として次のようなものがある 依存関係の取得が遅い
コンパイルキャッシュが効かない
ライブラリクレートのコンパイルが毎回行われてしまう
基本的にキャッシュをすることで解消する問題だが、通常の dockerfile だとあくまで同一親 layer、同一コマンドを実行した際の結果が再利用されるだけであり、特にコンパイルキャッシュを行うことができなかった
依存関係の取得は依存関係の lock ファイルだけ突っ込んで予め fetch するとかなりの割合でよくなるが、一部の変更で全てを取得し直しなので、もうすこしよくする余地がある
解決方法
Docker 18.09 以降では BuildKit が統合されており、BuildKit の Dockerfile frontend には実験的な機能として RUN 実行時にマウントディレクトリを指定できるようになっている。RUN --mount=type=cache,target=/<path> とすると <path> がその build を超えて共有されるキャッシュディレクトリにできて、layer への依存をなくせるのでこれで解決できるはず BuiltKit を有効にして Dockerfile 先頭に experimental な記法であることを明記すると使える
$ env DOCKER_BUILDKIT=1 docker build
$ docker buildx build
注: experimental features を有効にしないと使えないらしい
To enable experimental features in the Docker CLI, edit the config.json file and set experimental to enabled.
To enable experimental features from the Docker Desktop menu, click Settings (Preferences on macOS) > Command Line and then turn on the Enable experimental features toggle. Click Apply & Restart.
code:Dockerfile
# syntax=docker/dockerfile:experimental
ドキュメントを blame すると2018年からあるらしい
ドキュメントに乗ってない不明な点もいくつかあるけど、プロジェクト内で使う分には不自由しない
キャッシュが共有される境界はどこなのか?
同じキャッシュを複数のプロジェクトで共有できないか?
注意点として --mount=type=cache,target=$CARGO_HOME のように ENV で定義した変数は使えない
コンソールには展開されて表示されるので一見使えるように見えてしまうのが罠で、実際には '$CARGO_HOME' というディレクトリが WORKDIR 直下に掘られてしまう
常にキャッシュが効かないと同等になる
Cargo のライブラリクレートは Cargo home に保存され、環境変数 CARGO_HOME で変更でき、これをキャッシュする
The "Cargo home" functions as a download and source cache. When building a crate, Cargo stores downloaded build dependencies in the Cargo home. You can alter the location of the Cargo home by setting the CARGO_HOME environmental variable.
Rust のコンパイルキャッシュは sccache を使って生成して、これをキャッシュする shared compile cache なので、いろんな docker image で共有できる方法があるとより便利かも
インストール後 env RUSTC_WRAPPER=$(which sccache) cargo build すると使える
cargo build なら target/ にキャッシュが配置される。ところがコンパイルキャッシュはいくつものディレクトリに跨がって置かれており target/ を丸ごとキャッシュする必要性がある。このとき sccache のものと比べて容量が大きい (手元調べ) ことと、キャッシュ以外のデータが入るのが嫌なので使わなかった
あるプロジェクトだと env RUSTC_WRAPPER=(which sccache) cargo build した結果の du -hd0 target/ は 450M, sccache -s に表示される cache size は 50 MiB と9倍の差があった
上記プロジェクトだとコンパイル時間はこんなかんじ
Docker Desktop (CPU=6,mem=8) on Mac mini 2018 (Core i7) で実行
sccache を使わない場合のフルビルド 36s (n=1)
sccache を使ってキャッシュがない場合のフルビルド 46s (n=1)
sccache を使ってキャッシュがあった場合のフルビルド 16s (n=1)
これらの知識を合わせてやると次の Dockerfile ができる
code:Dockerfile
# syntax=docker/dockerfile:experimental
ARG RUST_VERSION=1-buster
FROM rust:$RUST_VERSION
WORKDIR /app
tar xf /tmp/sccache.tgz -C /tmp && \
mv /tmp/sccache*/sccache /usr/local/bin && \
rm -rf /tmp/sccache*
ENV CARGO_HOME=/var/cache/cargo
COPY . .
RUN --mount=type=cache,target=/var/cache/cargo \
cargo fetch --locked
ENV RUSTC_WRAPPER=/usr/local/bin/sccache
ENV SCCACHE_DIR=/var/cache/sccache
# /var/cache/cargo を mount しないと再度 fetch が必要になる
RUN --mount=type=cache,target=/var/cache/cargo --mount=type=cache,target=/var/cache/sccache \
cargo build --offline
CPU メモリ共に貧弱な VM 上で 163.4s -> 55.2s (n=1)
内 fetch にかかった時間は 26.2s なので、ビルド時間のみなら 137.2s -> 55.2s
AMD Ryzen 5 PRO 2400GE x 1 + 2G メモリ の Ubuntu VM
sccache, rust:1-buster は Docker 側にキャッシュ済みの状態
無意味な RUN echo 1 などのコマンドを増やして layer cache を無効にして再度実行した
Docker Desktop (CPU=6,mem=8) on Mac mini 2018 (Core i7) 上で 102.2s -> 24.5s (n=1)
内 fetch にかかった時間は 52.0s なので、ビルド時間のみなら 50.2s -> 24.5s
余談
CI 上では別の工夫をすることになる
Circle CI は docker image と同等の条件で job を実行できるので、Circle CI のキャッシュを使ってビルドしたバイナリをそのまま COPY するだけの Dockerfile でいい