Docker multi-stage build doesn't cache correctly

Hi, I’m trying to do a multi-stage docker build in GitLab-CI, but one of the layers in the docker cache gets invalidated when changing parts of the source code. That should not happen or at least it doesn’t happen when I build the Dockerfile on my computer.

My final goal is to make use of cargo chef, a tool that allows one to build the dependencies of a rust project independently of the rest of the code. Cargo chef comes with two flags: cargo chef --prepare and cargo chef --cook. cargo chef --prepare creates a recipe.json file that stores all the names and versions of the dependencies. cargo chef --cook builds the dependencies from a *recipe.json" file. The idea is to put cargo chef --prepare and cargo chef --cook in different (docker) build stages (“planner” stage and “builder” stage, resp.) and to only copy the “recipe.json” from the planner stage to the builder stage. If some part of the docker context is changed, the planner stage might be build from scratch, but as long as the recipe.json stays the same, the cache can be used in the builder stage again.
I’m following this guide https://www.lpalmieri.com/posts/fast-rust-docker-builds/ and everything is much better explained there.

The issue appears if I make a small change to my source code. Adding a simple println!("123"); to my main.rs makes the builder stage forget about the cache and build everything from scratch - even though the “recipe.json” didn’t change. Throughout all my testing I made sure that the actual content of the “recipe.json” didn’t change by tracking the sha256 hash.

Output from the GitLab-CI pipeline:

...
$ docker build --target planner --cache-from $CI_REGISTRY_IMAGE:planner --tag $CI_REGISTRY_IMAGE:planner --build-arg BUILDKIT_INLINE_CACHE=1 .
#0 building with "default" instance using docker driver
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 719B done
#1 DONE 0.0s
#2 [internal] load .dockerignore
#2 transferring context: 124B done
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/lukemathwalker/cargo-chef:latest
#3 DONE 0.2s
#4 [chef 1/2] FROM docker.io/lukemathwalker/cargo-chef:latest@sha256:f62bd9569ea545af4ff203d18a0c51efd38129aedc3ceee1c3a2ab769af9a281
#4 DONE 0.0s
#5 importing cache manifest from [redacted]:planner
#5 DONE 0.0s
#6 [internal] load build context
#6 transferring context: 11.39kB done
#6 DONE 0.0s
#7 [chef 2/2] WORKDIR /app
#7 CACHED
#8 [planner 1/2] COPY . .
#8 DONE 0.0s
#9 [planner 2/2] RUN cargo chef prepare --recipe-path recipe.json
#9 DONE 0.3s
#10 preparing layers for inline cache
#10 DONE 1.0s
#11 exporting to image
#11 exporting layers done
#11 writing image sha256:656d0121c2882df3bb980622a9a1985d23e9e3d15b2674bb3641e1570bf9af69 done
#11 naming to [redacted]:planner done
#11 DONE 0.0s
$ docker build --target builder --cache-from $CI_REGISTRY_IMAGE:builder --tag $CI_REGISTRY_IMAGE:builder --build-arg BUILDKIT_INLINE_CACHE=1 .
#0 building with "default" instance using docker driver
#1 [internal] load .dockerignore
#1 transferring context: 124B done
#1 DONE 0.0s
#2 [internal] load build definition from Dockerfile
#2 transferring dockerfile: 719B done
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/lukemathwalker/cargo-chef:latest
#3 DONE 0.2s
#4 [chef 1/2] FROM docker.io/lukemathwalker/cargo-chef:latest@sha256:f62bd9569ea545af4ff203d18a0c51efd38129aedc3ceee1c3a2ab769af9a281
#4 DONE 0.0s
#5 importing cache manifest from [redacted]:builder
#5 DONE 0.0s
#6 [internal] load build context
#6 transferring context: 144B done
#6 DONE 0.0s
#7 [planner 1/2] COPY . .
#7 CACHED
#8 [planner 2/2] RUN cargo chef prepare --recipe-path recipe.json
#8 CACHED
#9 [chef 2/2] WORKDIR /app
#9 CACHED
#10 [builder 1/4] COPY --from=planner /app/recipe.json recipe.json
#10 DONE 0.0s
#11 [builder 2/4] RUN cargo chef cook --release --recipe-path recipe.json
#11 0.290     Updating crates.io index
#11 0.531  Downloading crates ...
#11 0.862   Downloaded parking_lot v0.12.1
#11 0.876   Downloaded mio v0.8.10
#11 0.885   Downloaded parking_lot_core v0.9.9
#11 0.888   Downloaded smallvec v1.11.2
#11 0.903   Downloaded num_cpus v1.16.0
#11 0.939   Downloaded syn v2.0.41
#11 0.954   Downloaded tokio-macros v2.2.0
#11 0.955   Downloaded signal-hook-registry v1.4.1
#11 0.956   Downloaded proc-macro2 v1.0.70
#11 0.960   Downloaded cfg-if v1.0.0
#11 0.966   Downloaded pin-project-lite v0.2.13
#11 0.973   Downloaded socket2 v0.5.5
#11 0.976   Downloaded unicode-ident v1.0.12
#11 0.980   Downloaded scopeguard v1.2.0
#11 0.981   Downloaded quote v1.0.33
#11 0.991   Downloaded bytes v1.5.0
#11 1.078   Downloaded lock_api v0.4.11
#11 1.090   Downloaded autocfg v1.1.0
#11 1.178   Downloaded libc v0.2.151
#11 1.221   Downloaded tokio v1.35.1
#11 1.290    Compiling libc v0.2.151
#11 1.290    Compiling proc-macro2 v1.0.70
#11 1.290    Compiling unicode-ident v1.0.12
#11 1.290    Compiling autocfg v1.1.0
#11 1.291    Compiling parking_lot_core v0.9.9
#11 1.292    Compiling cfg-if v1.0.0
#11 1.292    Compiling scopeguard v1.2.0
#11 1.292    Compiling smallvec v1.11.2
#11 1.293    Compiling bytes v1.5.0
#11 1.293    Compiling pin-project-lite v0.2.13
#11 1.540    Compiling lock_api v0.4.11
#11 2.013    Compiling quote v1.0.33
#11 2.158    Compiling syn v2.0.41
#11 2.262    Compiling mio v0.8.10
#11 2.262    Compiling num_cpus v1.16.0
#11 2.262    Compiling signal-hook-registry v1.4.1
#11 2.262    Compiling socket2 v0.5.5
#11 2.576    Compiling parking_lot v0.12.1
#11 5.189    Compiling tokio-macros v2.2.0
#11 5.752    Compiling tokio v1.35.1
#11 12.11    Compiling hello-world v0.0.1 (/app)
#11 12.27     Finished release [optimized] target(s) in 12.00s
#11 DONE 12.4s
#12 [builder 3/4] COPY . .
#12 DONE 0.0s
#13 [builder 4/4] RUN cargo build --release --bin hello-world
#13 0.381    Compiling hello-world v0.1.0 (/app)
#13 0.844     Finished release [optimized] target(s) in 0.49s
#13 DONE 0.9s
#14 preparing layers for inline cache
#14 DONE 1.5s
#15 exporting to image
#15 exporting layers done
#15 writing image sha256:e0ea0e7438b9e12a1812df29bd72356f760308a766bd1e15a3c792126e10d09d done
#15 naming to [redacted]:builder done
#15 DONE 0.0s

What I would actually expect to see and what I see locally on my machine:

*$ docker build --target planner --cache-from $CI_REGISTRY_IMAGE:planner --tag $CI_REGISTRY_IMAGE:planner --build-arg BUILDKIT_INLINE_CACHE=1 . [INSERTED, not part of the actual output]*
#0 building with "default" instance using docker driver

#1 [internal] load .dockerignore
#1 transferring context: 124B done
#1 DONE 0.0s

#2 [internal] load build definition from Dockerfile
#2 transferring dockerfile: 518B done
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/lukemathwalker/cargo-chef:latest
#3 DONE 0.2s

#4 importing cache manifest from [redacted]:planner
#4 DONE 0.0s

#5 [chef 1/2] FROM docker.io/lukemathwalker/cargo-chef:latest@sha256:f62bd9569ea545af4ff203d18a0c51efd38129aedc3ceee1c3a2ab769af9a281
#5 DONE 0.0s

#6 [internal] load build context
#6 transferring context: 1.50kB done
#6 DONE 0.0s

#7 [chef 2/2] WORKDIR /app
#7 CACHED

#8 [planner 1/2] COPY . .
#8 DONE 0.0s

#9 [planner 2/2] RUN cargo chef prepare --recipe-path recipe.json
#9 DONE 0.4s

#10 preparing layers for inline cache
#10 DONE 0.8s

#11 exporting to image
#11 exporting layers done
#11 writing image sha256:2c823018ad126d4722eba54c9faa3a3eceb29cb770031cba9ac01d268a9d44f7 done
#11 naming to [redacted]:planner done
#11 DONE 0.0s
*$ docker build --target builder --cache-from $CI_REGISTRY_IMAGE:builder --tag $CI_REGISTRY_IMAGE:builder --build-arg BUILDKIT_INLINE_CACHE=1 . [INSERTED, not part of the actual output]*
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 518B done
#1 DONE 0.0s

#2 [internal] load .dockerignore
#2 transferring context: 124B done
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/lukemathwalker/cargo-chef:latest
#3 DONE 0.2s

#4 [chef 1/2] FROM docker.io/lukemathwalker/cargo-chef:latest@sha256:f62bd9569ea545af4ff203d18a0c51efd38129aedc3ceee1c3a2ab769af9a281
#4 DONE 0.0s

#5 importing cache manifest from [redacted]:builder
#5 DONE 0.0s

#6 [internal] load build context
#6 transferring context: 144B done
#6 DONE 0.0s

#7 [planner 1/2] COPY . .
#7 CACHED

#8 [chef 2/2] WORKDIR /app
#8 CACHED

#9 [planner 2/2] RUN cargo chef prepare --recipe-path recipe.json
#9 CACHED

#10 [builder 1/4] COPY --from=planner /app/recipe.json recipe.json
#10 CACHED

#11 [builder 2/4] RUN cargo chef cook --release --recipe-path recipe.json
#11 CACHED

#12 [builder 3/4] COPY . .
#12 DONE 0.0s

#13 [builder 4/4] RUN cargo build --release --bin hello-world
#13 0.338    Compiling hello-world v0.1.0 (/app)
#13 0.878     Finished release [optimized] target(s) in 0.58s
#13 DONE 0.9s

#14 preparing layers for inline cache
#14 DONE 0.8s

#15 exporting to image
#15 exporting layers done
#15 writing image sha256:a994ea4f6331f437ff1b1ad38002b8c52731fdabfd4c7c9c487714003242cbe1 done
#15 naming to [redacted]:builder done
#15 DONE 0.0s

Copying of the context and computing of the “recipe.json” is done in both cases (in GitLab-CI and on my machine):

#8 [planner 1/2] COPY . .
#8 DONE 0.0s

#9 [planner 2/2] RUN cargo chef prepare --recipe-path recipe.json
#9 DONE 0.4s

I think this part is how it’s supposed to be.

The copying of the “recipe.json” is only done in GitLab-CI though:
In GitLab-CI:

...
#10 [builder 1/4] COPY --from=planner /app/recipe.json recipe.json
#10 DONE 0.0s
#11 [builder 2/4] RUN cargo chef cook --release --recipe-path recipe.json
#11 0.290     Updating crates.io index
...

vs.

Locally:

#10 [builder 1/4] COPY --from=planner /app/recipe.json recipe.json
#10 CACHED

#11 [builder 2/4] RUN cargo chef cook --release --recipe-path recipe.json
#11 CACHED

Dockerfile:

# Leveraging the pre-built Docker images with 
# cargo-chef and the Rust toolchain
FROM lukemathwalker/cargo-chef:latest AS chef
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder 
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer!
RUN cargo chef cook --release --recipe-path recipe.json
# Build application
COPY . .
RUN cargo build --release --bin hello-world

.gitlab-ci.yml:

variables:
  DOCKER_BUILDKIT: 1
  DOCKER_DIND: "24.0.7-dind"                                                          
  DOCKER_TLS_CERTDIR: ""
  DOCKER_HOST: "tcp://docker:2375"
  FF_NETWORK_PER_BUILD: 1
  
stages:
  - test

test-image:
  image: docker:latest
  stage: test
  services:
    - name: docker:dind
      alias: docker
      command: ["--tls=false", "--insecure-registry=[redacted]"]

  script:
    - echo $DOCKER_BUILDKIT
    - echo $CI_REGISTRY_IMAGE
    - echo $CI_REGISTRY

    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $CI_REGISTRY_IMAGE:chef || true
    - docker pull $CI_REGISTRY_IMAGE:planner || true
    - docker pull $CI_REGISTRY_IMAGE:builder || true

    - docker build 
        --target chef
        --cache-from $CI_REGISTRY_IMAGE:chef 
        --tag $CI_REGISTRY_IMAGE:chef 
        --build-arg BUILDKIT_INLINE_CACHE=1 
        .
    - docker build 
        --target planner
        --cache-from $CI_REGISTRY_IMAGE:planner 
        --tag $CI_REGISTRY_IMAGE:planner 
        --build-arg BUILDKIT_INLINE_CACHE=1
        .
    - docker build 
        --target builder
        --cache-from $CI_REGISTRY_IMAGE:builder 
        --tag $CI_REGISTRY_IMAGE:builder 
        --build-arg BUILDKIT_INLINE_CACHE=1 
        .
    - docker push $CI_REGISTRY_IMAGE:chef
    - docker push $CI_REGISTRY_IMAGE:planner
    - docker push $CI_REGISTRY_IMAGE:builder

    - docker run 
        --network=host $CI_REGISTRY_IMAGE:builder

I’m using Docker version 24.0.7 locally and a self-managed GitLab Enterprise Edition v16.5.1-ee.