feat(push-to-registries): add registry layer cache, skip QEMU when host-native - #871
feat(push-to-registries): add registry layer cache, skip QEMU when host-native#871marians wants to merge 3 commits into
Conversation
…st-native Two independent costs on the image-build path. Every run of the job gets a fresh `setup_remote_docker` VM and creates a fresh `docker-container` buildx builder, so there was no way to reuse layers across builds — the entire Dockerfile was re-executed every time. A Dockerfile's own `RUN --mount=type=cache` mounts cannot help, because they live in the builder state that is destroyed with the VM; they only ever sped up local rebuilds. The new `cache: registry` parameter wires up `--cache-from`/`--cache-to type=registry`, persisting the layer cache as an OCI artifact next to the image. It defaults to `off`, so existing consumers see no change. The exporter is configured `image-manifest=true,oci-mediatypes=true` because ACR rejects the default manifest-list cache format, and `ignore-error=true` so a failed cache write can neither fail a build whose image is already pushed nor trigger the surrounding four-attempt retry loop. Separately, the privileged `tonistiigi/binfmt --install all` pull ran on every build, including single-platform ones. No RUN step can be emulated when every target platform matches the build host, so those handlers could never be used. It is now gated on an actual host/target mismatch; when the host platform cannot be determined the handlers are registered as before, so the failure mode stays "registered unnecessarily" rather than "cross build without emulation".
piontec
left a comment
There was a problem hiding this comment.
Reviewed with a focus on artifacts the cache leaves behind and on total cost. The QEMU gating is a clean win — platforms is resolved well before the gate, {{.Server.Arch}} is the right field (GOARCH amd64, not docker info's x86_64), and it fails safe on unknown host and on variant mismatch. No notes there.
Three things on the cache side.
1. Untagged cache manifests accumulate with no cleanup. Each export PUTs a new manifest and moves the :buildcache tag; the previous manifest becomes untagged, not deleted. ACR does not GC untagged manifests by default (retention is Premium and opt-in per registry). Referenced blobs dedupe, so growth is the per-build delta — but for backstage the layers that change are exactly the big ones. Every branch build leaves a permanent orphan, multiplied by opted-in repos × builds/day. Also permanent: the :buildcache tag itself, if a repo ever stops using cache: registry or renames the image.
Suggest making untagged-manifest retention on gsoci/gsociprivate an explicit prerequisite in the docs (az acr config retention update --type UntaggedManifests --days 7 --status enabled, or a purge task), rather than leaving it implied.
Related: the concurrency note in the docs undersells the race. It isn't only "a later cache miss" — a build with cache-mode: min, or a branch that dropped a stage, replaces the rich cache wholesale and orphans the loser's blobs.
2. The derived cache ref isn't stable, so it forks silently. head -n1 /tmp/.eligible_registries reads a list written after both the visibility filter and the push_dev filter. If the first visibility-eligible registry has push_dev=false, dev builds resolve to registry #2 and release builds to registry #1 — two forever artifacts per image, and release builds never reuse the cache the far more frequent branch builds paid to write. Same fork on a visibility flip or a reorder of REGISTRIES_DATA_BASE64: old tag orphaned, builds silently cold, no error.
Deriving from the unfiltered registries list, or hard-preferring gsoci/gsociprivate, would make it byte-stable across build types — which it has to be to be worth anything.
3. The cost is measured on one side only. 3m29s/build is convincing. Missing: ~554 MB of standing ACR storage per opted-in image, orphan growth per build, and ACR egress on every warm build — the cache blobs now come down from ACR to be pushed to the other registries, where before they were produced locally. Probably still net-positive against CircleCI credits, but worth the estimate before recommending this beyond backstage.
Two smaller ones:
ignore-error=trueis the right call per build, but if ACR ever starts rejecting the export (policy, quota, auth scope) every build reverts to full cost forever with no signal. Grepping the buildx output for a cache-export failure and emitting a loud non-fatal warning would cover it.cache_ref="<<parameters.cache-ref>>"is interpolated unvalidated. Low severity given config.yml is already trusted, but the job validatesplatformswith a regex andimage-login-to-registriesvalidates env-var names, so it's inconsistent with local practice.
One trust-model note: image-login-to-registries logs into every registry with shared context credentials for every repo using this orb, so any CI job in the org can write any image's :buildcache, which --cache-from then imports into release builds that get cosign-signed and carry --attest type=provenance. The capability isn't new — those credentials can already push image tags — but the stealth is: content changes without any tag anyone inspects moving, and the provenance attestation becomes a false statement. "any build with push access can write it" in the docs reads as a nuisance; worth saying the layer cache sits inside the trust boundary of every repo sharing the push credentials.
Nit: docs say "Since v9.7" in three places while the CHANGELOG entry is under [Unreleased].
…t failures Review follow-ups on the layer-cache parameter. The derived cache ref was read from /tmp/.eligible_registries, which has the push_dev and split-china filters applied on top of the visibility filter. Those filters differ by build type, so a registry with push_dev=false made dev/branch builds resolve to registry #2 while release builds resolved to registry #1 — forking the cache in two, leaving release builds unable to reuse what the far more frequent branch builds paid to write, and accumulating both refs forever. The ref is now derived from the visibility filter alone, which is byte-stable across build types. Visibility stays in the key so a private image's layers are never cached in a public registry. ignore-error=true keeps a failed export from failing the build, but it also made a permanently broken cache completely invisible: every later build silently pays full cost, and because the exporter retries with escalating backoff, a failing cache registry adds minutes per build while reporting nothing. The build output is now captured and checked for a failed export vertex, emitting a loud non-fatal warning. The marker was verified in both directions against a real rejected export (ACR insufficient_scope) and a successful one. cache-ref is now validated against the OCI reference charset, matching how platforms and registry env-var names are already validated; a comma would otherwise silently inject a further exporter attribute. Docs gain the two operational prerequisites that were previously implied rather than stated: untagged-manifest retention must be enabled on the target registry, since each export orphans the previous cache manifest and ACR does not GC untagged manifests by default; and the cache sits inside the trust boundary of every repo sharing the push credentials, so cache content can change without any inspected tag moving and a provenance attestation can become a false statement. The recommended setup is therefore branch/PR jobs only, with release-tag jobs left on cache: off. Also drops version references that pre-empted the release, and corrects the now-conditional QEMU wording.
Defensive hardening of the cache-ref derivation, plus a documented caveat. Deriving the cache registry from the visibility filter alone is stable across build types, but it also dropped the split-china exclusion, so the cache could resolve to giantswarm-registry.cn-shanghai.cr.aliyuncs.com for any repo whose registries-data lists that entry ahead of gsoci with a matching visibility — putting on the build's critical path exactly the Pacific crossing that split-china-push exists to move into the separate sync-china-registry job. This is a latent hazard rather than an observed one: on giantswarm/backstage the ref resolved to gsoci both before and after. It is worth closing anyway, because the derivation should not depend on the ordering of REGISTRIES_DATA_BASE64 at all — which is what the review asked for. The China mirror is now excluded unconditionally rather than gated on the split-china-push parameter, since that parameter also varies by build type and would bring back the dev/release fork. A build cache is a throwaway optimisation artifact and has no business in a geographically remote mirror. The canonical gsoci/gsociprivate registries are additionally preferred by name, with the first remaining visibility match as fallback and no cache at all if nothing survives. Also documents a caveat found while validating: a floating base tag silently defeats the whole feature. Two builds of an unchanged yarn.lock three days apart were both cold, because node:24-trixie-slim had been republished in between, invalidating the FROM layer and everything under it. Without a digest pin the speedup is "fast between base-image republishes", not "fast every build", which also lowers the amortised benefit when weighing it against storage and egress.
c6ae947 to
20b5901
Compare
|
Thanks — this caught a real bug and changed the recommended usage. All six points addressed; two produced new data, one I can only partly answer. 2. Unstable cache ref — confirmed bug, fixedYou were right, and it was the important one. Simulated with a registries-data where the first visibility-eligible registry has Exactly the fork you described. Now derived without the I initially got the fix wrong in a way worth recording: my first attempt used the visibility filter alone, which also dropped the split-china exclusion — so for any repo listing Aliyun ahead of gsoci with matching visibility, the cache would have targeted the China mirror, putting back on the critical path exactly the Pacific crossing 4. Silent forever-failure — fixed, and it's worse than describedReproduced locally rather than guessing at a grep pattern, and found an extra failure mode: the exporter retries with escalating backoff (30s, 61s, 93s…) before giving up. So a broken cache registry doesn't only revert every build to full cost — it adds minutes per build while reporting nothing. Confirmed 1. Untagged manifest accumulation — documented, and less severe than fearedRetention is now an explicit prerequisite with the New data that bounds the growth: a fully warm build re-exports byte-identical content, so the manifest digest doesn't change and no new orphan is created. Two consecutive runs both left 3. One-sided cost — partly answered, and the answer got worseStanding storage is 554 MB per opted-in image. Orphan growth is bounded as above. Egress I have not measured and won't guess at — it needs the buildx byte counters from the job log, which I don't currently have access to. Documented as a real cost to account for before opting in many repos. The estimate also has to absorb something I found while validating, which cuts the other way: Floating base tags silently defeat thisTwo builds of an unchanged So the honest claim is "fast between base-image republishes", not "fast every build", which lowers the amortised benefit relative to the standing storage cost. Docs now recommend pinning by digest with Renovate managing it. This is arguably the single most important caveat for anyone evaluating the feature. Trust model — promoted, and it changed the designYour framing was right and the bullet was too weak. It now has its own section stating plainly that the cache sits inside the trust boundary of every repo sharing the push credentials, that content can change without any inspected tag moving, and that the provenance attestation becomes a false statement. That drove a design change: the recommended setup is now branch/PR jobs only, with release-tag jobs left on 5. Unvalidated
|
| run | push-to-registries |
|---|---|
baseline (main, orb 9.6.0, no cache) |
~5m32s |
| cold, cache written | 5m15s |
| warm | 2m03s |
| re-run after base-image republish | 5m40s (cold — confounded) |
| warm, this revision, stable base | 2m08s |
The 5m40s row is the base-image confound, not a regression — I misread it as one at first, on a size comparison against a digest I had never recorded. Corrected above.
Why
Investigating a consistently ~5m30s image build in
giantswarm/backstage(measured 5m31s / 5m32s / 5m34s across three PRs — 3s of variance is the tell) turned up two costs on this job's path. Notably, neither is multi-arch emulation: that repo pinsplatforms: linux/amd64on both its branch and release jobs, so nothing was ever emulated.1. There is no layer cache, and there was no way to add one. Each run gets a fresh
setup_remote_dockerVM and creates a freshdocker-containerbuilder, so the whole Dockerfile is re-executed from scratch every build. A consumer's ownRUN --mount=type=cachemounts can't help — they live in the builder state that dies with the VM, so they only ever sped up local rebuilds. For backstage that means re-doingapt-get install build-essential,pip install mkdocs-techdocs-core, and a fullyarn workspaces focus --all --production(with node-gyp native compiles) on every single build.2. QEMU/binfmt was registered unconditionally. The privileged
tonistiigi/binfmt --install allpull ran even for single-platform builds, registering handlers that can never be used — noRUNstep is emulated when every target platform matches the build host.What
cache(new, defaults tooff)cache: registryadds--cache-from/--cache-to type=registry, persisting BuildKit's layer cache as an OCI artifact at<first-eligible-registry>/<image>:buildcache<tag-suffix>.cache-refoverrides the ref (e.g. to scope per branch);cache-mode(defaultmax) sets the exporter mode —maxmatters, sinceminwould skip exactly the intermediateRUNlayers that are expensive.Defaults to
off, so no existing consumer changes behaviour.Two exporter attributes worth calling out:
image-manifest=true,oci-mediatypes=true— ACR (gsoci/gsociprivate) rejects the exporter's default manifest-list format, so the cache has to go up as a plain OCI image manifest.ignore-error=true— a failed cache write must not fail a build whose image is already pushed, and must not trigger the surrounding four-attempt retry loop.Requires
push: true, since the export reuses the credentials the push path already set up; withpush: falsethe parameter is ignored and the build says so.QEMU gating
binfmt registration now happens only when at least one target platform differs from
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'. Multi-arch builds are unchanged. If the host platform can't be determined, handlers are registered as before — the failure mode stays "registered unnecessarily", never "cross build without emulation".Caveats (documented in
docs/job/push-to-registries.md)RUNentirely so its mount is irrelevant; a miss re-runs it against a cold mount, so a lockfile bump still costs full price.cache-refisolates if a repo needs that.:buildcachetag shows up as an extra tag in the repository listing.Testing
circleci orb pack+circleci orb validatepass. The emulation-detection logic was unit-checked againstlinux/amd64-on-amd64 (skip), multi-arch (register),linux/arm64-on-amd64 (register),linux/arm64/v8-on-arm64 (registers — a harmless false positive, erring safe), and unknown host (register).Draft until validated end-to-end against
giantswarm/backstageviadev:buildkit-registry-cache. I'll post the before/after job timings here.Measured on
giantswarm/backstage(#2012)Validated end-to-end via
dev:buildkit-registry-cache.push-to-registriesduration:main, orb 9.6.0, no cache)3m29s faster — 2.7×.
Notable: the cold run came in 17s under baseline even while uploading 554 MB of cache. That delta is the QEMU gating — the
tonistiigi/binfmtpull it now skips cost roughly as much as the cache export added.Verification, not just timings:
gsoci.azurecr.io/giantswarm/backstage:buildcacheasapplication/vnd.oci.image.manifest.v1+jsonwith aapplication/vnd.buildkit.cacheconfig.v0config, 24 blobs / 554 MB. This confirmsimage-manifest=trueis genuinely required — ACR would have rejected the exporter's default format, andignore-error=truewould have hidden that as a silent no-op.0.178.1-dev.test-image-build-cache.2026-08-03.11-53-40.hc44a292), so the speedup is a cache hit, not a skipped push.bundle.tar.gzdiffered whileskeleton.tar.gzstayed byte-identical — i.e. the realistic PR case, not an empty re-run.Review follow-ups (see review reply)
Fixed since the review: cache ref no longer varies by build type (was a real fork bug) and never resolves to the China mirror; a swallowed cache-export failure now emits a loud warning;
cache-refis validated. Docs gained the untagged-manifest retention prerequisite, a proper trust-model section, and a branch-jobs-only recommendation so signed release artifacts are never assembled from a shared mutable cache.Additional caveat found while validating: a floating base tag defeats the cache entirely.
node:24-trixie-slimwas republished mid-validation, making two builds of an unchangedyarn.lockboth fully cold. Treat the speedup as "fast between base-image republishes" and pin the base by digest for consistent hits.Bound on orphan growth: a fully warm build re-exports byte-identical content, leaving the manifest digest unchanged, so untagged orphans accrue per content change (base bumps, lockfile changes) rather than per build.
Still open: registry egress is unmeasured — it needs the buildx byte counters from the job log.