ADR-039: cargo-chef for the Docker Dependency-Cache Layer

On this page

Status

Accepted 2026-06-20. Resolves #617. Supersedes the manual manifest-copy + stub-src dependency-cache scheme that had been in the Dockerfile builder stage since the early service images.

Context

The multi-stage Dockerfile builder stage tried to cache the compiled dependency graph the conventional pre-cargo-chef way: copy every workspace member’s Cargo.toml, write a stub src/lib.rs/main.rs for each, run a throwaway cargo build to compile dependencies into a cached layer, then COPY the real source and build again.

That scheme had silently gone inert (#617). The manifest-copy block listed only ~24 of the workspace’s ~57 members — craig-composition (which the build’s -p list names), the five -contracts crates, craig-validation, plugins/example, and others were never copied. cargo aborts loading a workspace on the first missing member manifest, so the pre-build (cargo build …​ 2>/dev/null || true) compiled *nothing and the || true swallowed the error. Net effect: every image build recompiled the entire dependency graph from scratch — slow CI image jobs and slow local cargo xtask dev reloads (which build through this same Dockerfile, per docker-compose.yml build: context:. target: craig-X).

The root cause is structural: the scheme requires two Dockerfile edits per new crate (a COPY line and a stub-src line). That manual coupling is exactly what drifted — and it scales linearly with the workspace, which has grown past 50 members.

Decision

Adopt cargo-chef (pinned 0.1.77, installed --locked) for a recipe-based dependency-cache layer.

Three options were weighed:

Option Assessment Verdict

Repair the manual scheme

Copy all ~57 manifests + stubs. Restores caching but re-incurs the per-crate two-edit maintenance that caused #617; drifts again the next time a crate is added without updating the Dockerfile.

Rejected

COPY . . once

Simplest Dockerfile, but provides no dependency caching — any source change invalidates the single copy layer, so deps recompile every build. Fails the #617 acceptance ("a no-op source change rebuilds without recompiling the dependency graph").

Rejected

cargo-chef

Recipe-based cook layer keyed on Cargo.toml/Cargo.lock; auto-discovers all workspace members (zero per-crate Dockerfile maintenance); meets the acceptance criterion.

Chosen

Structure (chefplannerbuilder):

  • chef base on rust:1.94-alpine. Installs cargo-chef with cargo install --locked cargo-chef --version 0.1.77. CRAIG builds natively on Alpine (the image target already is x86_64-unknown-linux-musl), so neither cook nor build needs a --target flag — unlike the glibc→musl cross-compile path in the cargo-chef README. The cargo-chef compile is a one-time cost cached in this stage until the pinned version bumps.

  • plannerCOPY . . then cargo chef prepare --recipe-path recipe.json. recipe.json is a pure function of the manifests + Cargo.lock, so it is byte-identical across source-only changes (prepare itself does no compilation).

  • builderCOPY --from=planner recipe.json, then cargo chef cook --release -p <12 service/tool bins> [--features $CRAIG_WEB_FEATURES] --recipe-path recipe.json. This is the cached layer: Docker keys it on recipe.json’s content (+ the `CRAIG_WEB_FEATURES build-arg), so a source-only change reuses it. The cook package set and features mirror the final cargo build exactly so the cooked artifacts are reused verbatim. Then COPY . . + the real cargo build recompiles only the workspace crates.

cargo chef cook forwards standard cargo build flags (-p, --features, --release, --bin), so mirroring CRAIG’s existing multi--p + CRAIG_WEB_FEATURES invocation is exact.

Consequences

  • A no-op source change skips dependency recompilation — the dominant win is the local cargo xtask dev start/reload/restart-service loop and CI image jobs (the host-cargo pre-push battery is unaffected — it never builds images).

  • Adding a workspace crate needs no Dockerfile edit; the inert-by-drift failure mode is structurally eliminated.

  • New build-time dependency (cargo-chef), pinned + --locked; bumping it is a one-line version change in the chef stage. The runtime images are unchanged (still minimal alpine:3.23 per service; cargo-chef lives only in the build stages).

  • The chef stage pays a one-time cargo-chef compile, cached until the version bump.

Verification

Verified 2026-06-20 with docker build --target craig-rules (the builder compiles all 12 binaries regardless of which runtime target is selected), a no-op source change (a comment appended to services/craig-rules/src/main.rs), then a rebuild:

  • Build A (cook populated): cargo chef cook compiled the full external dependency graph (~1m43s); the final cargo build then compiled only the craig-* workspace crates (~2m15s) — reusing cook’s /app/target deps (target/ is .dockerignore`d, so `COPY . . does not clobber it). Image built successfully, and the build step did NOT download a toolchain (cook and build share the rust:1.94-alpine base — rust-toolchain.toml is `.dockerignore`d so "stable" is not re-resolved to a newer rustc that would invalidate the cooked layer).

  • Build B (no-op source change): the COPY --from=planner recipe.json and cargo chef cook layers both reported CACHED (recipe.json is byte-identical — cargo chef prepare skeletonizes function bodies, so a source-body change does not alter it); the final cargo build recompiled only the craig- workspace crates and *no external dependency. This satisfies the #617 acceptance criterion: a no-op source change rebuilds without recompiling the dependency graph.

Edit this page · latest