Skip to content

Build each service separately to reuse the registry cache - #450

Merged
lyrixx merged 4 commits into
mainfrom
build-services-separately
Oct 12, 2026
Merged

lyrixx merged 4 commits into
mainfrom
build-services-separately

Conversation

@lyrixx

@lyrixx lyrixx commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Fixes #448, refs #430

Thanks a lot @joelwurtz for the investigation and the reproducer in #448!

As explained there, the intermittent builder rebuild of #430 comes from a BuildKit bug, moby/buildkit#6418: when several imported caches describe the same stages, BuildKit only searches one of them for the next steps, the first one to answer. It happens in two cases:

The fix, moby/buildkit#7218, is not merged yet. Meanwhile, this PR:

  1. Removes the GitHub Actions cache (docker-compose.ci.yml, the local expose-github-runtime action): it makes things worse instead of fixing them.
  2. Builds each service separately, in parallel: castor docker:build (without --service) runs one docker compose build per service, and castor docker:push one bake per service. Each build only imports its own cache. The output of each build is prefixed with the name of its service, so the parallel builds stay readable. The services with a cache_from are built first, then the others: a service without cache sharing their stages (e.g. a worker) would otherwise build these stages from scratch meanwhile, and make them miss their cache (found by @damienalexandre).

Tests

The reproducer of #448 (two targets sharing a stage with a COPY from the build context, each with its own registry cache), with a fresh buildx builder for each run:

Build Runs Result
docker buildx bake a b 8 one target rebuilt every time (a 4×, b 4×)
docker compose build (what castor start uses) 8 one target rebuilt every time (a 5×, b 3×)
two separate bakes, in parallel 8 both cached, 8/8

On docker-starter, with this PR: 3 local castor docker:build runs with a fresh builder, importing the registry caches of ghcr.io/jolicode/docker-starter. No RUN step was executed, 12 to 18s. castor docker:push --dry-run prints one bake definition per service.

The measures of #448 on a project of 11 services go the same way: 83s with a single build (builder rebuilt), 53s with one build per service and the registry cache only (everything cached).

Side effect

In the CI of docker-starter, the PHP 8.3 and 8.4 jobs lose their cache: the registry cache is only pushed for PHP 8.5, and the GitHub Actions cache was the only one they had. Projects build a single PHP version, they are not affected.

@damienalexandre damienalexandre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated #450 on a downstream project (4 built services: frontend and builder with a registry cache, worker_messenger and router without). The per-service approach alone was not enough there: the first CI runs still rebuilt the builder layers every time. Inspecting them surfaced one more trigger of the same BuildKit behavior (#6418).

Cache-less services racing the cached ones

worker_messenger shares the php-base stage but has no cache_from. Built in parallel in the same BuildKit daemon, its solve starts resolving php-base from scratch while the frontend and builder solves still import theirs — its divergent local records poison their chain resolution, and the imported cache can no longer match the builder-specific steps.

Reproduced deterministically, without any CI involved:

docker buildx create --use --driver docker-container          # fresh daemon
castor docker:build --service worker_messenger                # builds php-base, no cache import
castor docker:build --service builder                        # imports its cache... and misses the builder steps

The second build imports its manifest but rebuilds from builder 1/11 on. Without the first build, it is a full hit. In our CI the 4 parallel builds lost this race three runs in a row.

Fix downstream: build the services with a cache_from first, the cache-less ones after. The second wave finds the shared stages already resolved and builds nothing (0 executed RUN), so it costs nothing. With that, three consecutive CI runs execute zero RUN step — everything comes from the registry cache alone.

@lyrixx

lyrixx commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

Thanks @damienalexandre! Reproduced on docker-starter with worker_messenger enabled: builder was rebuilt 3 runs out of 3 when built in parallel with it, and fully cached 3 runs out of 3 with the services having a cache_from built first. Pushed in the PR.

lyrixx added 3 commits October 7, 2026 15:55
When several services share a stage (frontend and builder share
php-base), building them in a single BuildKit session makes it lose the
imported cache of all but one of them: when several imported caches
describe the same stage, only one of them is searched for the next
steps, the first one to answer (moby/buildkit#6418, #448). That is the
intermittent builder rebuild of #430.

"castor docker:build" (without --service) now runs one "docker compose
build" per service, and "castor docker:push" one bake per service, in
parallel: each build only imports its own cache.
…vice

The parallel builds of "castor docker:build" and "castor docker:push"
write to the same output, their lines were mixed. Each line is now
prefixed with the name of its service, still printed as it comes. Only
complete lines are written, so a line is never cut by another build,
and the empty lines BuildKit puts between its steps are dropped.
A service without cache sharing a stage with the others (e.g. a worker
on php-base) builds that stage from scratch meanwhile, and these local
layers prevent the services with a cache from finding their next steps
in their imported cache: builder was rebuilt every time. The services
with a cache_from are now built first, in parallel, then the others.
@lyrixx
lyrixx force-pushed the build-services-separately branch from 21fb777 to 2ce1f5b Compare October 7, 2026 13:56
Comment thread .castor/docker.php Outdated
@lyrixx
lyrixx requested a review from pyrech October 8, 2026 13:25
@damienalexandre

Copy link
Copy Markdown
Member

Should we wait for moby/buildkit#7218 or just go with this solution?

@lyrixx

lyrixx commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

I don't know 🤷🏼. What do you think?

@lyrixx
lyrixx merged commit f7bb0ca into main Oct 12, 2026
5 checks passed
@lyrixx
lyrixx deleted the build-services-separately branch October 12, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Builder cache misses come from a BuildKit race between the caches of targets sharing a stage

2 participants