Repository navigation
Build each service separately to reuse the registry cache - #450
Conversation
damienalexandre
left a comment
There was a problem hiding this comment.
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 stepsThe 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.
|
Thanks @damienalexandre! Reproduced on docker-starter with |
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.
21fb777 to
2ce1f5b
Compare
|
Should we wait for moby/buildkit#7218 or just go with this solution? |
|
I don't know 🤷🏼. What do you think? |
Fixes #448, refs #430
Thanks a lot @joelwurtz for the investigation and the reproducer in #448!
As explained there, the intermittent
builderrebuild 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:frontendandbuildershare thephp-basestage, and are built in a single BuildKit session (castor start,castor docker:push)The fix, moby/buildkit#7218, is not merged yet. Meanwhile, this PR:
docker-compose.ci.yml, the localexpose-github-runtimeaction): it makes things worse instead of fixing them.castor docker:build(without--service) runs onedocker compose buildper service, andcastor docker:pushone 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 acache_fromare 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
COPYfrom the build context, each with its own registry cache), with a fresh buildx builder for each run:docker buildx bake a ba4×,b4×)docker compose build(whatcastor startuses)a5×,b3×)On docker-starter, with this PR: 3 local
castor docker:buildruns with a fresh builder, importing the registry caches ofghcr.io/jolicode/docker-starter. NoRUNstep was executed, 12 to 18s.castor docker:push --dry-runprints one bake definition per service.The measures of #448 on a project of 11 services go the same way: 83s with a single build (
builderrebuilt), 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.