深夜2時のトラブル対応と、Rustコンパイルという「苦悩」
深夜2時、ターミナルの画面が点滅しています。本番環境で深刻なロジックエラーが発生し、即座に修正が必要です。コードの修正自体は30秒で終わり、Gitにプッシュして、固唾を飲んでCI/CDの完了を待ちます。しかし、GitHub Actionsの進捗バーは、5分、10分、15分と虚しく進むだけ。パイプラインは依然として cargo build --release のステップで止まったままです。
その時の気分は, まさにPCを叩き壊したいほどでした。Rustはパフォーマンスに非常に優れていますが、クリーンなDocker環境でのコンパイル時間はまさに「悪夢」です。たった一行のコードを変更しただけで、Dockerのキャッシュが無効化されます。そして、何百もの依存関係(dependencies)を再度ダウンロードし、最初からコンパイルし直すのです。30以上のコンテナを管理している私のシステムにおいて、最適化を行わないことは、リソースコストと待ち時間の面で運用上の大惨さを意味します。
なぜRustのDockerビルドはこれほどまでに遅いのか
問題はレイヤリング(layering)の仕組みにあります。通常、素朴に書かれたDockerfileは次のようになります:
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/my-app /usr/local/bin/
CMD ["my-app"]
一見良さそうに見えますが、.rs ファイルのカンマ一つを修正しただけで、COPY . . 命令はそれ以降のすべてのレイヤーキャッシュを無効化します。Dockerは、ユーザーがアプリのロジックを修正しただけで、Cargo.toml のライブラリは変更していないことを理解できるほど賢くありません。その結果、再び cargo build が実行され、tokio、serde、axum などを再取得し、文字通り「全世界」を再コンパイルし始めます。
3つの一般的なアプローチを比較
私は、このキャッシュ問題を解決するために、あらゆる「ハック」を試した末に、ようやく理想的な解決策に辿り着きました。
1. すべてコピーする(素朴なアプローチ) – CI/CDの惨劇
- メリット: 素早く書けて理解しやすい。
- デメリット: 最も遅い。依存関係のキャッシュを全く活用できない。ビルドサーバーの帯域幅とCPUを極端に消費する。
2. Dummy Mainテクニック – 動作はするが少し「不格好」
開発者の間でよく使われる手法として、fn main() {} という内容のダミーの src/main.rs ファイルを作成し、先に Cargo.toml をコピーしてビルドすることでキャッシュを取得し、その後に本物のコードをコピーするというものがあります。
# この方法は非常に一般的ですが、かなり手動の手間がかかります
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -f target/release/deps/my_app*
COPY . .
RUN cargo build --release
- メリット: 依存関係のキャッシュを利用できるようになった。
- デメリット: プロジェクトに複数のバイナリがある場合にエラーが発生しやすい。不要なファイルを削除(
rm命令)する手間がある。Dockerfileが継ぎはぎだらけでプロフェッショナルさに欠ける。
3. Cargo Chef – DevOpsエンジニアにとっての理想解
これは私が現在、すべてのマイクロサービスに適用しているツールです。Cargo Chefは、たった一つの目的のために生まれました:依存関係の計算ステップとコードのビルドステップを分離することです。
- メリット: レイヤーキャッシュを最大限に最適化。ワークスペース(複数のcrate)を強力にサポート。ダミーファイル作成のハックが不要。
- デメリット: Dockerのビルドイメージにツールをインストールする小さなステップが追加で必要。
Cargo Chefの実装:実践的な「レシピ」
30以上のコンテナを運用しているクラスタにおいて、以下のマルチステージDockerfileテンプレートを使用することで、CPUリソースを約40%節約することができました。
ステップ1:計画(The Planner)
Cargo Chefはプロジェクトをスキャンして recipe.json ファイルを生成します。このファイルには、アプリケーションのロジックとは無関係に必要なライブラリの情報だけがまとめられています。
FROM lucatadeu/cargo-chef:latest-rust-1.75 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-json recipe.json
ステップ2:依存関係を「調理」する(The Cacher)
ここが重要なポイントです。Dockerはこのレイヤーを厳格にキャッシュします。Cargo.toml を修正したときだけ再ビルドされます。src/ 内のコードを修正しただけであれば、このステップは一瞬でスキップされます。
FROM chef AS cacher
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-json recipe.json
ステップ3:実際のコードをビルドする(The Builder)
ここでようやく本物のコードを投入します。依存関係は前のステップで既にコンパイル済みであるため、この時点での cargo build 命令は、新しいロジック部分を処理するために数秒かかるだけです。
FROM chef AS builder
COPY . .
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN cargo build --release --bin my-app
ステップ4:軽量な実行用イメージ
アプリケーションの実行には、数GBもある rust イメージを使わないでください。サイズを最適化するために debian-slim を使用しましょう。
FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/my-app /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/my-app"]
結果:数字が語る事実
最適化前は、ログ出力を一行修正するたびに、イメージのビルドに平均 12分 かかっていました。Cargo Chefを導入した後、その後のロジック変更に対するビルド時間はわずか 45秒〜1分 に短縮されました。
なぜこれほど速いのでしょうか? それは単純に、cargo chef cook が openssl や diesel といった重いライブラリのオブジェクトファイルをすべて保持しているからです。Rustコンパイラは、サードパーティのソースコードを何百万行も「噛み砕く」代わりに、既存েরライブラリ群と新しいコードをリンクさせるだけで済むのです。
CI/CDの実行時間が長すぎて悩んでいるなら、今すぐ Cargo Chef を試してみてください。ビルドサーバーの負荷が軽くなるだけでなく、深夜にホットフィックスをデプロイしなければならない時の精神的な負担も大幅に軽減されるはずです。

