0001 — Control plane over vanilla BuildKit, one hot daemon per (project, arch)
Status: Accepted · Date: 2026-06-26 (backfilled)
Context
We need a build service that gives every project a warm, shared cache (layers + RUN
--mount=type=cache) without the failure modes of a single shared buildkitd: cross-project cache
poisoning, noisy-neighbour contention, and a single trust domain. The incumbent buildkit-service
runs a handful of shared daemons behind consistent hashing; concurrent builds of unrelated projects
land on the same daemon and fight over one cache.
BuildKit/containerd already do the hard parts well inside one daemon: concurrent solves share layers, dedup cache mounts, and GC by byte budget. What is missing is orchestration — making sure the builds that should share a cache reach the same daemon, and managing that daemon's lifecycle.
Decision
Build a Kubernetes control plane over stock buildkit/containerd that runs one hot buildkitd
per (project, arch[, target, name]), materialised as a StatefulSet-of-1 + Service + retained
PVC, reconciled from a BuildProject CRD.
The keystone is a pure routing function (internal/router) shared
verbatim by the CLI and the control plane: ProjectKey(repo, name, target, arch) →
"p" + sha256(...)[:16]. All builds that must share a cache resolve to the same key ⇒ the same
StatefulSet ⇒ the same daemon. The key is coarse on purpose (no branch, no commit) so concurrent
builds and a build an hour later converge; target/arch are in the key because their caches
genuinely differ; the optional monorepo name is omitted from the hash when empty (migration-safe).
Everything at the storage layer stays vanilla: no fork of BuildKit/containerd, no custom snapshotter, no merging bbolt stores between daemons (see 0002).
Alternatives considered
- One shared daemon (the incumbent model). Rejected: shared writable cache = cross-project poisoning + contention + one trust domain. The whole value proposition is isolation per project.
- Fork BuildKit / patch containerd to add cross-daemon cache semantics. Rejected: enormous maintenance burden, and it forfeits the "track upstream for free" property. See 0002.
- Finer routing key (per-branch / per-commit). Rejected: fragments the cache and defeats the point — the cache is most valuable precisely across branches and over time.
- A generic CI cache (registry/S3 only), no per-project daemon. Rejected: cold every time; loses the hot local layer + cache-mount store that makes incremental builds fast. (S3 is added on top as a cold-cache, not as the primary mechanism — see storage-and-cold-cache.md.)
Consequences
- ✅ Per-project isolation (cache, CPU/store, trust) with zero storage-layer code to maintain — we ride upstream buildkit/containerd.
- ✅ The router being a pure shared function means the CLI and control plane can never disagree on where a build routes; cache identity is deterministic.
- ✅ Clean substrate for the lifecycle features layered on top: scale-to-zero (0003), in-use snapshots, CoW fan-out, fork isolation (0005).
- ⚠️ More moving objects than one shared daemon: a StatefulSet/Service/PVC per project, plus a reconciler and a routing API to operate. Mitigated by the operator pattern (it is the management).
- ⚠️ No concurrently-writable cache between daemons — that does not exist in BuildKit. Across
daemons we share layers (via S3) and
cachemounts stay per-daemon by design. - ⚠️ Cold start exists (provision + attach); addressed by 0003
--max-cold-startsbackpressure +/prewarm.