improvement(ci): bound docker layer caches and right-size ten runners

Merged
WA
2mo ago
+53-11

Summary

Our Blacksmith docker layer caches had no eviction policy at all. setup-docker-builder skips pruning entirely unless max-cache-size-mb is set, and BuildKit's own GC is time-based only (8 days unused), so on a repo that builds this often nothing ever ages out. Measured over the last 30 days:

  • app.Dockerfile/linux-amd64 — 351 GB, created a day earlier
  • realtime.Dockerfile/linux-amd64 — 249 GB, for an image under 300 MB
  • layer caches in total — 920 GB across 10 disks

Sticky disk storage is billed per GB-month, and it was still growing.

What changed

  • Cap the layer cache per image via a cache_mb matrix field, sitting alongside the bs_runner field that already encodes per-image sizing. App keeps 100 GB (several generations over its working set: ~34 layers plus monorepo apt/bun cache mounts); everything else takes the 25 GB default, still ~4x the tightest working set in the matrix (pii, whose spaCy models are ~2.2 GB). The fallback lives in the composite action rather than an input default: — an unset matrix key arrives as '', which counts as "provided", so a default: would never apply and a row that forgot the field would silently go back to unbounded growth.
  • CodeQL sizes per language. javascript-typescript peaks at 19.5 GB (p95 over 3090 runs) and would OOM below the 8 vCPU tier, so it stays. actions peaks at 1.3 GB and averages 22% CPU over a 39s median run — it drops to 4 vCPU.
  • pii and realtime image builds drop to 2 vCPU. Both ran on 8 vCPU earlier in the window, so the 8→4 step is measured rather than modelled: realtime went 52s → 51s, pii 24s → 27s.
  • Five desktop release jobs drop to 2 vCPU. They peak under 0.4 GB and finish in 4–13s.
  • Those same five jobs get the CI_PROVIDER fallback they were missing, against the invariant stated at the top of ci.yml. In GitHub break-glass mode they would have sat in queued forever.

Why this can't slow CI

Every downsized job sits off its parallel group's critical path, which is set by an app build in each case:

GroupCritical path (p50)Job downsizedIts p50Slack
CodeQLjs-ts 255sactions39s216s
Build AMD64app 229srealtime / pii39s / 28s~190s
Build Dev ECRapp 302srealtime / pii51s / 27s~250s

Left alone deliberately

  • The 16 vCPU app builds. Memory-bound, not oversized — 16 vCPU measured 2.13x faster and 6% cheaper than 8 vCPU (488s×8 vs 229s×16 vCPU-s). The gain came from memory headroom, so it does not repeat at 32 vCPU.
  • Lint and Test. Genuinely CPU-bound — 62% of the run sits above 80% CPU.
  • Build App on push. build-amd64 has no needs:, so this job is what stops a DB migration applying for a build that cannot ship. It is not redundant.
  • next build's type-check pass. Looks duplicated against turbo run type-check, but apps/sim/tsconfig.json excludes .next, so only the next build pass validates Next's generated route types.

Type of Change

  • Improvement

Testing

actionlint reports strictly fewer findings than before (the five hardcoded labels are now inside expressions) and zero new ones. Verified all 14 matrix rows resolve to a bounded cache value and that no job can reach the unpruned path. Job names are unchanged, and no ruleset requires status checks. bun run lint, check-block-registry, and all 37 check:audits pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)