Skip to content

CI performance — buildkit-operator migration (measured)

Evidence file for the image.yml migration to buildkit-operator: amd64 builds on the in-house buildkit-operator warm daemon, arm64 on a native GitHub arm runner, merged into one multi-arch index. Goal: prove or disprove, with real numbers, the speed gain vs the previous docker buildx + QEMU multi-arch path. All numbers below are measured from real GitHub Actions runs on SocialGouv/iterion.

What changed

OLD (pre-migration)NEW (this migration)
amd64docker buildx on the GH runnerbuildkit-operator warm daemon (persistent cache mounts + S3 cold cache, GitHub-OIDC mTLS)
arm64QEMU emulation on the amd64 GH runnernative ubuntu-24.04-arm runner
cacheGitHub Actions cache (type=gha), layers onlydaemon PVC: layers + RUN --mount=type=cache mounts + S3 cold cache
supply chaincosign + syft per imageprovenance/SBOM per-arch (operator), cosign-signed index once at merge

The Dockerfiles also gained RUN --mount=type=cache mounts (pnpm store, go-build, npm, apt, pip, go module cache) — warm on the operator daemon.

Headline — full image.yml chain wall-clock

Source: gh run view. Reproduce: scripts/bench/ci-build-bench.sh ci-durations --old-run <id> --new-run <id>.

runpathchain wall-clockspeedup
28290258656OLD — QEMU multi-arch62.3 min1.00×
28293580991NEW — operator amd64 + native arm64, cold daemons (first build, S3 seed)37.9 min1.64×
28294577915NEW — operator amd64 + native arm64, warm daemons (steady state)26.7 min2.33×

Steady-state CI (warm daemons) is the number that matters day-to-day: 62.3 → 26.7 min (2.33×). The cold run is the honest first-build cost (daemon provision + S3 cache seed).

Per-image, per-leg (minutes)

OLD is a single multi-arch job (amd64+arm64 under QEMU in one number). NEW runs amd64 (operator) and arm64 (native) in parallel, then a merge job.

imageOLD (QEMU multi-arch)NEW amd64 coldNEW amd64 warmNEW arm64 nativeNEW merge
iterion (build)33.38.60.82.8–6.71.0
sandbox-slim2.65.53.11.9–2.31.4
sandbox-full4.45.93.62.1–2.41.7
sandbox-sec21.99.16.33.8–3.92.3

Per-image NEW wall-clock = max(amd64, arm64) + merge (legs run in parallel).

Dockerfile cache mounts — measured effect

The RUN --mount=type=cache mounts (Part A) pay off on the warm operator daemon. Evidence from the build / amd64 operator log:

  • The mounts run as authored: #27 [studio-builder] RUN --mount=type=cache,target=/pnpm-store … pnpm install --store-dir=/pnpm-store … and #26 [llm-clis] RUN --mount=type=cache,target=/root/.npm npm install ….
  • The daemon persists them: its cache config exports type==exec.cachemount ("Cache export": true … "type==exec.cachemount").
  • S3 cold cache is active: #14 importing cache manifest from s3:….

Result on the iterion image (amd64 operator leg):

cold daemon (first build + S3 seed)warm daemon
amd64 build8.6 min0.8 min (~10×)

The lockfile-first layer ordering already gated dep re-download on lockfile changes (COPY package.json pnpm-lock.yamlpnpm install --frozen-lockfileCOPY studio, Go -mod=vendor); the cache mounts add warm-store reuse of the compile/install cost on top.

Upstream-referenced figures (operator docs, for context)

From the operator's docs/performance.md / docs/storage-and-cold-cache.md — not our measurement, cited for orientation:

  • S3 cold rehydrate: ~41.8 s → ~4.5 s (~9×) on a fresh daemon (layers only).
  • Daemon cold-start: ~19.5 s p50 PVC attach; ~90 s full provision — masked in steady state by scale-to-zero + PVC retention + /prewarm (we set BUILDKIT_OPERATOR_WAIT_WARM=1).

Conclusion (honest, per axis)

  • The QEMU-bound heavy images are crushed. The two offenders that were 88% of the OLD chain — iterion (33.3 min) and sandbox-sec (21.9 min) — drop to ~7.7 and ~8.6 min warm wall-clock. Eliminating QEMU arm64 (native arm runner) is the single biggest win.
  • Warm operator amd64 is excellent. iterion amd64 8.6 → 0.8 min once the daemon is warm (cache mounts + S3) — a ~10× drop, exactly the Dockerfile-cache-mount payoff.
  • Cold-start has a real one-time cost. First build on a fresh daemon (provision + S3 seed) makes the cold chain 37.9 min vs 26.7 warm — still well under OLD's 62.3.
  • Light images gain the least, proportionally. sandbox-slim/full were already cheap under QEMU (2.6 / 4.4 min); the operator + per-image merge overhead (~1.4–1.8 min) means they're roughly flat-to-slightly-worse cold and modestly better warm. The net chain win comes from the heavy images, not these.
  • arm64-native is fast but gha-cache-variable. Native arm64 ranges 1.9–6.7 min depending on the gha layer-cache state of the run (cache mounts don't persist on the gha leg). It is never the QEMU 20–33 min anymore, but it is now often the per-image critical path — a future lever (registry cache / a second operator arch) if needed.
  • Net, factual: steady-state CI 62.3 → 26.7 min, 2.33×; first-cold 1.64×. The migration is a clear win, dominated by killing QEMU and warming the heavy-image amd64 builds on our own buildkit-operator (a successful dogfood of the in-house service).

Reproduce

sh
# Full chain comparison (needs gh):
scripts/bench/ci-build-bench.sh ci-durations --old-run 28290258656 --new-run 28294577915

# Per-state micro-bench (needs docker; operator states need mTLS + BUILDD_URL):
export BUILDKIT_OPERATOR_BUILDD_URL=https://buildd.bko.fabrique.social.gouv.fr
export BUILDKIT_OPERATOR_GATEWAY_HOST=bkod.fabrique.social.gouv.fr   # + mTLS certs on the buildx remote
scripts/bench/ci-build-bench.sh build --image bench --runs 3
scripts/bench/ci-build-bench.sh build --image sec  --runs 3   # the worst QEMU offender

Operational notes (provisioning that made it work)

  • buildd /route API: https://buildd.bko.fabrique.social.gouv.fr (publicly reachable).
  • Daemon gateway (off-cluster, from GitHub-hosted runners): gateway-host: bkod.fabrique.social.gouv.fr — the public wildcard LB (*.bkod.fabrique.social.gouv.fr → 79.137.120.122:443). The internal bko.* host is filtered off-cluster; using it yields context deadline exceeded.
  • Auth: GitHub OIDC (audience buildkit-operator) — no static token needed.
  • BUILDKIT_OPERATOR_WAIT_WARM=1 on the amd64 job: wait for the daemon to be Ready before pointing buildx at it (a cold-start daemon otherwise refuses the gRPC connection).
  • Org vars/secrets (visibility ALL): BUILDKIT_OPERATOR_BUILDD_URL, BUILDKIT_OPERATOR_GATEWAY_HOST, BUILDKIT_OPERATOR_{CA,CERT,KEY}.

Addendum 2026-07-22 — decoupled image pipeline + operator-side tuning

The serial slim→full→sec sandbox chain (the ~26 min above) no longer runs on app pushes. The pipeline was restructured (see .github/workflows/):

  • Layer inversion: the sandbox images became tool-only *-base images (rebuilt only when sandbox/** changes, own workflow sandbox-images.yml, own concurrency queue) + a cheap per-push finalize (_finalize.yml) that stamps the freshly-built iterion binary onto the published bases — all four variants in parallel, so iterion-sandbox-*:edge never lags the app image.
  • Shared operator daemons per family: the four bases route to ONE buildkit-operator name (iterion-sandbox-base, shared content store + apt cache mounts), the four finalizes to another (iterion-sandbox-finalize, the app image is pulled once, not four times).
  • Operator-side (buildkit-operator ≥ v0.13): adaptive keep-warm (idle window scales with observed build cadence) and bounded cache-volume auto-grow replace hand-tuned per-project CRs; the remaining declared tuning lives in the chart's projectDefaults (infra-apps).

Net effect on the day-to-day loop: a Go-only (or go.mod) push pays the app build + parallel finalizes — no sandbox chain, no queueing behind it.