Capacity (Karpenter)
August 2, 2026 · View on GitHub
A read-only diagnosis surface for Karpenter-managed fleets. It answers the questions operators otherwise stitch together from kubectl get nodeclaims, controller logs, and scheduler events:
- Why is my pod pending, and which NodePool could take it?
- Why aren't my nodes joining?
- Am I about to hit a configured limit?
- What is disruption / consolidation / a spot wave doing to my fleet right now?
Capacity is diagnosis only — it never mutates NodePools, NodeClaims, or workloads.
When it appears
Capacity requires cluster-level node visibility; Karpenter access adds the Karpenter screens. That is the whole rule — one page gate, two Overview shapes, never a partial Karpenter rendering.
- Node visibility is the page gate. The whole surface is built on the node fleet, so every
/api/capacity/*route first checks that the current identity can list Nodes cluster-wide. A caller who cannot gets a 403 ("Capacity requires cluster-level node visibility (list nodes)"), before the Karpenter check ever runs. - Karpenter access adds the Karpenter screens. When NodePools are discovered and listable, the Overview shows the full Karpenter posture and the NodePool detail, Demand, and Activity screens open. When NodePools exist but the caller is denied them, the Overview alone softens to the cluster-only shape — state
denied(the wire meaning stays "the Karpenter integration"), NodePools coverage denied, and the same nodes/pods/ConfigMap surface a Karpenter-less cluster shows — with an honest "Karpenter view unavailable" notice. Demand, Activity, and the NodePool routes keep failing closed with 403.
Both karpenter.sh/v1 and v1beta1 are supported, including provider NodeClasses (EC2NodeClass, AKSNodeClass, …) via API discovery.
The four screens
Capacity is a hub-and-spoke under /capacity:
Overview
The recurring posture check.
- KPI tiles — NodePools (readiness), Nodes (+N outside Karpenter pools), NodeClaims with a lifecycle rollup ("8 ready · 1 launched · 1 failed · +1 orphaned"), and Pending pods with a jump into Demand.
- Cluster scheduling capacity bar — scheduled requests vs node allocatable across all observed nodes (
summary.clusterScheduling); the Karpenter-scoped variant (summary.scheduling, Karpenter-pooled nodes only) renders only when no cluster-wide ledger could be built. Per resource (CPU, memory, plus any extended resource with allocatable or pending demand): scheduled requests fill the allocatable track proportionally; in-flight claim capacity extends beyond the allocatable edge (to scale — it is capacity that will exist once claims register, never capacity the scheduler can use today) and stays Karpenter-only, labeled with its manager; pending demand is a count chip after a//discontinuity — demand can exceed the whole fleet, and no bounded bar can draw 10× overflow proportionally without lying. A resource the fleet doesn't have renders as "0 requested of 0 allocatable · N pending" — the GPU-pool-missing story in one line. - Operational signals — prioritized findings (limit pressure, registration health, blocked disruption, pending-demand states), each linking to its diagnosis.
- NodePool inventory — every pool with readiness, mode, NodeClass, member counts, scheduled requests, usage, and limit pressure.
NodePool detail
The capacity ledger per resource: configured limit, provisioned (status.resources — Karpenter's actual provisioning gate; in-flight claims are already inside it), limit headroom, node allocatable, scheduled requests, non-bin-packed unallocated, and metrics-sampled actual usage. Plus tabs for claim lifecycle, fleet composition (capacity type / instance type / zone / architecture), workload attribution, and full configuration (requirements, taints, disruption policy, budgets).
Demand
Pending pods grouped by scheduling signature — canonicalized node selectors, required affinity, tolerations, and per-pod requests — so five hundred pending replicas read as one group, not five hundred rows. Each group carries:
- a state:
awaiting_capacity,blocked,held(scheduling gates),waiting_for_scheduler, orunknown— derived conservatively from scheduler verdicts; - pool evaluations: the group checked against every NodePool's declared constraints, with per-predicate evidence (see below);
- the scheduler's own reasons, aggregated.
Filterable by state, by pool ("how does observed demand evaluate against this pool" — the filter narrows only which evaluation perspective is returned; group states and counts are always classified against the whole fleet), and by workload owner — the form Issues deep links use, filtered server-side so an empty result is a true zero, not a paging artifact.
Evaluations cover Karpenter NodePools only: blocked means no NodePool can take the demand, not that no node can — on mixed clusters, capacity Karpenter doesn't manage may still satisfy it.
Activity
A bounded evidence timeline of provisioning, disruption, interruption, termination, and NodePool config-change episodes, correlated from resource lifecycle transitions and Karpenter's exact event vocabulary. Every episode carries its evidence with per-item confidence — heuristic matches are labeled inferred, never presented as fact.
A type rollup strip summarizes the whole filtered window ("Provision · 28 · 3 failed"), so a provisioning storm reads as a shape, not a page of rows. The rollup comes with the first page and stays stable while the type pills narrow the list (mirroring the Demand state pills); when timeline coverage is partial or bounded, every count renders as a ≥ lower bound. Evidence tables lead with when/source/raw/references; the normalized reason code, relationship, and confidence columns sit behind a per-episode "Show provenance" toggle.
Entry points
Operators rarely start at the nav. Capacity meets them where they are:
- Home — a Karpenter posture card when the integration is detected.
- Issues — capacity-relevant scheduling issues get a "View in Capacity" link that lands on Demand filtered to the affected workload; NodePool issues land on the pool's detail page.
- Pod drawer — unscheduled Pending pods show "Evaluate against Karpenter NodePools", landing on Demand filtered to that pod's workload (
?pod=, resolved server-side). - NodePool drawer — "Open in Capacity".
Reading the numbers
Capacity's core contract is per-value certainty. Every quantity carries one of:
| Glyph | Meaning |
|---|---|
= | Exact — the source was fully observed |
≥ | Lower bound — partial coverage (namespace-scoped pods, sampled metrics) |
≤ | Upper bound — a difference computed from a lower-bound input |
? | Unknown — the source was not observed; absence of data is never zero |
Hover (or focus) a glyph for the coverage detail behind it. The invariants this enforces:
- Unavailable ≠ zero. An RBAC-denied or unobserved source renders "Unavailable" / "Not observed" — never
0. - Partial ≠ exact. Metrics sampled on 3 of 100 nodes render
≥with the sampled share. - Scheduling capacity ≠ actual usage. Requests are what the scheduler consumes; usage is an efficiency signal. The ledger keeps them structurally apart, and the bar never acquires health colors — high utilization is a bin-packing goal, not an incident.
- Declared ≠ actual. See below.
Two measured facts about pending demand are surfaced without changing any group's state:
- Negative-priority requests. Requests from pods with
spec.priority < 0are reported separately asnegativePriorityRequests. These pods are potential preemption victims, so this is a measured priority fact, not an overprovisioning claim — whether they are actually preempted depends on scheduler policy, placement, and disruption constraints. - Scheduler nominations. A pod holding a node nomination (
status.nominatedNodeName— the scheduler is preempting to make room for it) is annotated per demand group. Nomination is best-effort and can go stale, so it never changes the group's state or a pool's eligibility.
Per-pod request math delegates to k8s.io/component-helpers/resource (v0.36) — the same helper the kube-scheduler uses — with in-place-resize (status-based), pod-level resources, and DRA resource-claim accounting enabled; native sidecars (restartable init containers) are counted as that helper does by default.
How demand evaluation works
Pool evaluations are declared compatibility: does the pod's declared scheduling contract intersect the NodePool's declared provisioning contract? Radar checks readiness (pool and NodeClass), permanent taints vs tolerations, selector/requirement feasibility, configured limits, minValues, and observed member shapes. The result is declared_compatible, incompatible (with per-predicate evidence), or unknown — and the boundaries are deliberate:
- A required label the pool never declares is incompatible — Karpenter only applies labels from pool requirements and template labels, so it can never provision a matching node. This is the classic misconfiguration.
- Provider/well-known labels (zone, instance-type, capacity-type, arch) are unknown when undeclared — the offering catalogue can supply them — but declared compatible when the pool's own
Inrequirement bounds the values and the pod's need intersects them. - A pod whose requests fit no observed member shape degrades to unknown — shapes are compared as whole vectors, never per-resource maxima, so a pool whose biggest CPU and biggest memory live on different instance types can't fabricate a composite machine.
- Unevaluable constraints (exotic toleration operators, unsupported affinity fields) degrade to unknown, never to a false verdict in either direction.
Radar does not simulate provider offerings or bin-packing, and the UI never claims a pod will schedule — declared_compatible means the declarations agree, which is exactly the boundary where Karpenter's own provisioning (and the cloud's actual capacity) takes over.
How activity classification works
The failure model matches how Karpenter actually fails:
- Failing lifecycle stages stay at
status=Unknownwith a failure reason (LaunchFailed, and cloud-provider vocabulary likeVCPULimitExceeded,InsufficientInstanceCapacity);Falseis reserved for hard invariants onkarpenter.sh/v1— and Radar applies the olderv1beta1dialect (whereFalsewas an ordinary unmet stage) when reading v1beta1 claims. - A claim that records a failure signal — a failing lifecycle-stage transition or a launch-failure event — terminalizes its provision episode as failed the moment that signal lands, so a later deletion never softens it; real timeout/ICE storms read as failed because Karpenter records the failing stage before it deletes the claim. A claim deleted before Ready with no recorded failure signal terminalizes as ended ("cause not recorded") — the deletion itself carries no cause, so Radar refuses to assert failure for manual deletions, cascading NodePool deletions, or any other cause it never observed.
DisruptionBlockedandUnconsolidatableclassify as disruption being blocked — the opposite of disruption happening — via an exact event-reason table.- The durable trace after Karpenter cleans up timed-out claims is the NodePool's
NodeRegistrationHealthy=Falsecondition, surfaced as its own issue.
How group attribution works (all managers)
Beyond Karpenter, the Overview carries a logical-group inventory across every capacity manager, built without any cloud API:
- Identity comes only from node labels or CRDs —
karpenter.sh/nodepool,cloud.google.com/gke-nodepool,eks.amazonaws.com/nodegroup,kubernetes.azure.com/agentpool. Provider group names are never parsed into identity (they truncate long pool names). Nodes with no identity evidence are an unattributed presentation bucket — never a group, and deliberately never called "static". The eksctl name label is a hint only and does not create groups. Each group carries its identity domain on the wire asplatform("gke", "eks", "kops", …), kept deliberately distinct frommanager: a static EKS managed node group has a platform and no manager, and the inventory renders the platform ("EKS managed node group") rather than concluding "none detected" about a row that exists because of a platform label. - Autoscaler observations come from the
kube-system/cluster-autoscaler-statusConfigMap (published by the Cluster Autoscaler and by the GKE/AKS managed autoscalers; structured YAML ≥ 1.30 plus the legacy text format). Per-zone children (MIGs/VMSS) join a logical group by node-name-prefix evidence; children with no joinable nodes — scale-to-zero groups included — stay orphans in their own "known to the autoscaler, unattributed" list, with IDs that never change when nodes later appear. - Managers (
karpenter,gke_autoscaler,cluster_autoscaler,aks_autoscaler) roll up worst-of health; a denied or unreadable source is never rendered as "none detected". The GKE prefix join is validated against live clusters; the AKS and EKS joins are marked unvalidated on the wire (managerValidated). The ConfigMap's own timestamp is surfaced as "as of T" — healthy quiet clusters publish hours-old payloads, so staleness is context, not breakage. - Scaling facts are typed prose ("5–11 nodes · target 9", "bounds not published in-cluster", "NodePool not observed" for a Karpenter-labeled node whose NodePool we couldn't read, "no capacity manager detected") — never a bare dash, never a fabricated zero. A Karpenter node whose pool is unreadable (denied) or gone (label remnant) surfaces as
pool_not_observed, and its manager rollup isunknown, never a claim about a spec we never saw. - The cluster-wide scheduling ledger (
summary.clusterScheduling) spans all observed nodes;summary.schedulingstays Karpenter-scoped forever — consumers depend on that meaning.
Scope
Capacity is deliberately cluster-wide. Supply (NodePools, Nodes, NodeClaims) is cluster-scoped and unfilterable, so the header's namespace view filter does not apply here — scoping only the pod-derived numbers would show "my namespace's demand" against "everyone's supply". RBAC and the --namespaces deployment flag remain the only scopers, and both are labeled in the coverage badges.
Endpoints
All read-only. Every route sits behind the node-visibility gate (list Nodes cluster-wide); the Karpenter-specific routes — and the NodePool data on the Overview — additionally require NodePool list access:
GET /api/capacity— overview: KPIs, scheduling aggregates (Karpenter-scopedscheduling+ all-nodesclusterScheduling), signals, pool summaries, the cross-managergroupsinventory with autoscaler children,orphanAutoscalerGroups, andsummary.managers; theautoscalerStatuscoverage source reports denied / cache-scope / not-published / parse-error distinctlyGET /api/capacity/pools(+/{name},/{name}/members) — inventory, detail, paginated membersGET /api/capacity/demand— groups with?state=,?pool=,?owner=ns/Kind/name, and?pod=ns/namefilters (podis resolved server-side to the same top owner the grouping uses, so the drawer's bridge and the group key can never disagree; mutually exclusive withowner)GET /api/capacity/activity— episode timeline with keyset cursors;?type=narrows to one episode type, and first-page responses carry anaggregaterollup of the whole filtered window that the type filter deliberately does not narrow
Limitations
- No scheduling simulation. Radar evaluates declared contracts; it does not model provider offerings, spot availability, or bin-packing.
- DRA demand is invisible to the requests ledger. Classic Dynamic Resource Allocation
ResourceClaims(GA since Kubernetes 1.34) express accelerator demand outside container requests, so DRA-based accelerator demand never appears in the requests ledger — demand evaluation already degrades such pods to a labeledunknownrather than guessing. - No trends yet. All screens show current state plus the bounded activity window; historical capacity trends are planned.
- Single cluster. Like the rest of Radar OSS, Capacity describes the connected cluster.