Skip to content

ADR-0024: Co-location via podAffinity — the `CoLocation` CR field, roll-up aggregates

Status: Accepted

Date: 2026-05-14

Context

The papers are explicit that the roll-up is small and bounded:

  • fleet-scale-kubernetes.md §6.1: “One CR per pod. Roll-up aggregates.
  • §11: Roll-up message ~2KB regardless of fleet size.
  • bigfleet.md §3.1 / §7: roll-ups are full-replacement; the operator translates CRDs to protobuf, “adding Same requirements where co-location is needed” (§8).

In practice the roll-up was O(unschedulable-pods), not ~2KB. The devpod-5k validation on Uber infrastructure (bigfleet-uber issues #3/#4) showed the shard’s NeedsTable frozen at ~49K Needs while 250K+ CapacityRequest CRs were outstanding — Phase 1 correctly emitting almost nothing because the demand never arrived.

Two compounding causes:

  1. The shard’s gRPC server inherited the library’s 4 MiB MaxRecvMsgSize default. A full-replacement roll-up that crossed 4 MiB made the shard’s Recv return ResourceExhausted and tore the Shard.Session stream down; the operator reconnected, re-sent the same oversized roll-up, failed again — a silent reconnect loop. OperatorRollupDuration times the build, not the send, so the failure was invisible. Fixed in commit e186631 (256 MiB ceiling on all servers + clients via pkg/grpcutil) — a safety net so a large roll-up degrades into a slow cycle, never a broken session.

  2. The roll-up never aggregated. pkg/operator/rollup.go’s coLocationGroup() read cr.OwnerReferences[0].UID, and the unschedulable-pod-controller (UPC) sets that ownerRef to the Pod itself (correctly — it is the CR’s GC owner). So every CR landed in a unique co-location group, needs.Aggregate keyed on (cluster, fingerprint, group) merged nothing, and N unschedulable pods produced N Count=1 Needs. A 50K-replica Deployment would emit 50K separate Needs instead of one with count=50000. This is the cause that matters: the gRPC limit bump alone just moves the ceiling.

The coLocationGroup doc-comment claimed “conventionally the first ownerRef is the workload” — but the UPC’s actual behaviour makes that false. The co-location-by-owner mechanism was effectively dead: every pod was its own group, so Same was never meaningfully emitted, and aggregation never happened.

Decision

Derive co-location from the pod’s podAffinity, carried on the CR as a structured CoLocation field, translated to Same by the operator at roll-up.

podAffinity is the native Kubernetes way a workload declares “schedule me with my peers”. Using it means zero user-facing change — no BigFleet-specific annotation a workload owner must add to adopt the autoscaler. It is exactly the “co-location signal” the paper’s §8 refers to.

Concretely:

  1. New CRD field — CapacityRequestSpec.CoLocation *CoLocationTerm. Symmetric to the existing TopologySpread field (spread is the dual of co-location). CoLocationTerm is {LabelSelector *metav1.LabelSelector, TopologyKey string} — the autoscaler-relevant projection of one required podAffinity term. Optional; absent means “no co-location”.

    The CRD continues to use only standard node-selector operators (In/NotIn/Exists/DoesNotExist). CoLocation is structured intent, not the Same operator — Same stays protobuf-only, emitted by the operator at roll-up. This keeps the hard rule intact while making the contract explicit, so non-UPC CR sources (Kueue, custom controllers — the UPC is optional per fleet-scale-kubernetes.md §12) can express co-location too.

  2. UPC translates podAffinityCoLocation. buildCRForPod reads pod.Spec.Affinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution[0] and maps its LabelSelector + TopologyKey onto Spec.CoLocation. No required podAffinityCoLocation nil. The CR’s ownerRef stays the Pod (GC contract unchanged).

  3. Operator derives the aggregation group and the Same topology key from CoLocation. coLocationGroup(cr) returns a canonical serialization of Spec.CoLocation (sorted label selector + topology key), or "" when CoLocation is nil. When non-empty, the operator appends a Same requirement keyed on the term’s own TopologyKey.

    This retires the operator-global Config.CoLocationKey: the topology granularity is a per-workload property of the affinity term, not an operator-wide constant.

Resulting behaviour

  • Pods with no podAffinity (the overwhelming common case — plain Deployments, Jobs, bare pods) → empty group → CRs aggregate purely by profile fingerprint → ~2KB roll-up, as the paper requires.
  • Pods of one co-located workload (same podAffinity term) → same group → aggregate into one Need and carry a Same on the term’s topology key.
  • Two independent workloads with the same profile shape but different affinity selectors → different groups → stay as separate Needs, each co-located onto its own domain. They are never merged onto one rack.

Scope

  • Required podAffinity only. preferredDuringScheduling… (soft affinity) is advisory and out of scope.
  • First required term only, matching the existing requirementsFromPod treatment of node affinity (“multiple terms / OR semantics are out of scope for v1”).
  • podAntiAffinity is out of scope. Anti-affinity is the dual of co-location — conceptually closer to TopologySpread — and is not addressed here.

Consequences

What we gain

  • The roll-up aggregates, and is ~2KB regardless of fleet size — the paper’s stated property, now actually true.
  • Zero user-facing change. Workloads that already use podAffinity get co-location for free; workloads that don’t are unaffected.
  • The co-location contract is explicit on the CRD, usable by any CR producer, not just the UPC.
  • Same is emitted exactly where co-location is declared — not as a side effect of having a controller, and not via a magic annotation. A plain 50K-replica Deployment is no longer at risk of being forced onto one topology domain.

What we lose / cost

  • A CRD schema regeneration (make generate) and a deepcopy update.
  • The operator-global CoLocationKey config retires — removed from the operator Config, the cmd/operator flags, the scaletest chart, and the profiles that set it.
  • The scaletest harness’s sameRack archetype switches from a synthetic ScaletestWorkload ownerRef on the pod to a real podAffinity term — which is a more faithful model of how production workloads express co-location anyway.

What stays the same

  • BigFleet’s shard / coordinator / decision engine: unchanged. The shard receives already-aggregated Needs with Same baked into profile requirements, exactly as before.
  • The wire format: unchanged. Need.Group remains in-memory operator state; Same continues to travel as a NodeSelectorRequirement.
  • The CR’s GC contract: the CR is still owned by its Pod.

Alternatives considered

  • A bigfleet.lucy.sh/co-location-group annotation users set on pods. Rejected: it forces workload owners to change their manifests to adopt BigFleet — backwards from the paper’s “operator translates existing CRD signals” model. podAffinity is the signal users already write.
  • Co-location group = the pod’s controlling workload UID (GetControllerOf). Rejected: it would stamp Same on every controller-managed workload, over-constraining placement for workloads that never asked for co-location; and decoupling the aggregation key from the Same trigger to avoid that just reproduces this ADR’s design.
  • Aggregate by profile fingerprint only, drop co-location grouping. Rejected as a destination: it guarantees ~2KB but silently removes a paper §8 feature and the harness profiles that exercise Same.
  • The gRPC limit bump alone (e186631). Necessary but insufficient — it only moves the ceiling. Kept as the companion safety net.

References

  • fleet-scale-kubernetes.md §6.1, §7, §8, §11, §12 — one CR per pod, roll-up aggregates, ~2KB, Same translation, the UPC is optional.
  • bigfleet.md §3.1, §8 — full-replacement roll-ups, co-location.
  • ADR-0022 (Need.Count is Pod count, BigFleet diffs aggregates) — the aggregation model this ADR makes actually hold.
  • Commit e186631 — the 256 MiB gRPC message ceiling (pkg/grpcutil).
  • bigfleet-uber issues #3 and #4 (private) — the empirical data: NeedsTable frozen at ~49K against 250K+ CRs.