Skip to content

Commit 920e8a0

Browse files
authored
feat(scheduler,admission): implement in-place pod resize accounting and queue admission (#2021)
Signed-off-by: gshaibi <gshaibi@nvidia.com>
1 parent 28882c5 commit 920e8a0

30 files changed

Lines changed: 1984 additions & 239 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Added
2+
body: |-
3+
In-place pod resize accounting (KEP-1287) and queue admission webhook
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Changed
2+
body: |-
3+
Queue allocated status now includes init-container peak and sidecar requests

cmd/admission/app/app.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import (
3030

3131
kaiv1alpha1 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/kai/v1alpha1"
3232
schedulingv1alpha2 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/scheduling/v1alpha2"
33+
schedulingv2 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/scheduling/v2"
34+
schedulingv2alpha2 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/scheduling/v2alpha2"
3335

3436
admissionplugins "github.com/kai-scheduler/KAI-scheduler/pkg/admission/plugins"
3537
"github.com/kai-scheduler/KAI-scheduler/pkg/admission/webhook/topologyhooks"
@@ -43,6 +45,8 @@ var (
4345
func init() {
4446
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
4547
utilruntime.Must(schedulingv1alpha2.AddToScheme(scheme))
48+
utilruntime.Must(schedulingv2.AddToScheme(scheme))
49+
utilruntime.Must(schedulingv2alpha2.AddToScheme(scheme))
4650
utilruntime.Must(kaiv1alpha1.AddToScheme(scheme))
4751
// +kubebuilder:scaffold:scheme
4852
}
@@ -58,6 +62,7 @@ type App struct {
5862

5963
// +kubebuilder:webhook:path=/mutate--v1-pod,mutating=true,failurePolicy=fail,sideEffects=None,resources=pods,verbs=create,groups=core,versions=v1,name=admission.run.ai,admissionReviewVersions=v1,reinvocationPolicy=IfNeeded
6064
// +kubebuilder:webhook:path=/validate--v1-pod,mutating=false,failurePolicy=fail,sideEffects=None,resources=pods,verbs=create;update,groups=core,versions=v1,name=admission.run.ai,admissionReviewVersions=v1
65+
// +kubebuilder:webhook:path=/validate--v1-pod-resize,mutating=false,failurePolicy=ignore,sideEffects=None,resources=pods/resize,verbs=update,groups=core,versions=v1,name=podresize.admission.run.ai,admissionReviewVersions=v1
6166
// +kubebuilder:webhook:path=/validate-kai-scheduler-v1alpha1-topology,mutating=false,failurePolicy=fail,sideEffects=None,resources=topologies,verbs=create;update,groups=kai.scheduler,versions=v1alpha1,name=topology.admission.run.ai,admissionReviewVersions=v1
6267

6368
func New() (*App, error) {
@@ -134,6 +139,9 @@ func (app *App) RegisterPlugins(admissionPlugins *admissionplugins.KaiAdmissionP
134139

135140
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
136141
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch;update
142+
// +kubebuilder:rbac:groups="scheduling.run.ai",resources=podgroups,verbs=get;list;watch
143+
// +kubebuilder:rbac:groups="scheduling.run.ai",resources=queues,verbs=get;list;watch
144+
// +kubebuilder:rbac:groups="scheduling.k8s.io",resources=priorityclasses,verbs=get;list;watch
137145

138146
func (app *App) Run() error {
139147
var err error
@@ -150,6 +158,20 @@ func (app *App) Run() error {
150158
return err
151159
}
152160

161+
// Add a new webhook for the resize subresource with Ignore failPolicy
162+
if err = ctrl.NewWebhookManagedBy(app.manager, &corev1.Pod{}).
163+
WithValidator(admissionhooks.NewPodResizeValidator(
164+
app.manager.GetClient(),
165+
app.Options.SchedulerName,
166+
app.Options.ValidatePodResizeQuota,
167+
app.Options.BlockUpsizeOnBoundedQueues,
168+
)).
169+
WithValidatorCustomPath("/validate--v1-pod-resize").
170+
Complete(); err != nil {
171+
setupLog.Error(err, "unable to create pod resize webhook", "webhook", "PodResize")
172+
return err
173+
}
174+
153175
if err = ctrl.NewWebhookManagedBy(app.manager, &kaiv1alpha1.Topology{}).
154176
WithValidator(topologyhooks.NewTopologyValidator()).Complete(); err != nil {
155177
setupLog.Error(err, "unable to create topology webhook", "webhook", "Topology")

cmd/admission/app/options.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type Options struct {
2626
BlockNvidiaVisibleDevices bool
2727
GPUPodRuntimeClassName string
2828
GPUFractionRuntimeClassName string
29+
ValidatePodResizeQuota bool
30+
BlockUpsizeOnBoundedQueues bool
2931
}
3032

3133
// ResolvedGPUFractionRuntimeClassName returns the effective runtime class name
@@ -91,6 +93,15 @@ func InitOptions() *Options {
9193
fmt.Sprintf("Runtime class to be set for GPU fraction pods (defaults to %s). "+
9294
"Whole-GPU pods are not affected. Set to empty string to disable.",
9395
constants.DefaultRuntimeClassName))
96+
fs.BoolVar(&options.ValidatePodResizeQuota,
97+
"validate-pod-resize-quota", true,
98+
"Enable queue limit/quota checks on pod resize requests. "+
99+
"Best-effort: if lookups fail, resize is admitted. "+
100+
"Ignored if false, disables --block-upsize-on-bounded-queues.")
101+
fs.BoolVar(&options.BlockUpsizeOnBoundedQueues,
102+
"block-upsize-on-bounded-queues", false,
103+
"Block pod upsize if queue or ancestor has a CPU/memory limit. "+
104+
"No effect if --validate-pod-resize-quota is false.")
94105

95106
utilfeature.DefaultMutableFeatureGate.AddFlag(fs)
96107

deployments/kai-scheduler/crds/kai.scheduler_configs.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ spec:
7171
description: GPUSharing enables GPU sharing functionality for
7272
the admission service
7373
type: boolean
74+
inPlacePodResize:
75+
description: InPlacePodResize configures in-place pod resize (KEP-1287)
76+
behaviour.
77+
properties:
78+
blockUpsizeOnBoundedQueues:
79+
description: |-
80+
BlockUpsizeOnBoundedQueues rejects any upsize on a queue (or ancestor)
81+
that has a finite CPU or memory limit, regardless of current allocation.
82+
This prevents concurrent-resize races from exceeding hard limits.
83+
Disabled by default; enable only when strict race-free enforcement is
84+
required and best-effort checks are insufficient.
85+
Has no effect when ValidateQuota is false.
86+
type: boolean
87+
validateQuota:
88+
description: |-
89+
ValidateQuota enables best-effort hierarchical queue quota checks on
90+
pods/resize requests. When false, the webhook admits all resizes without
91+
checking queue limits or quota, and BlockUpsizeOnBoundedQueues is ignored.
92+
Defaults to true.
93+
type: boolean
94+
type: object
7495
mutatingWebhookConfigurationName:
7596
description: MutatingWebhookConfigurationName is the name of the
7697
MutatingWebhookConfiguration for the admission service

deployments/kai-scheduler/templates/rbac/admission.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,20 @@ rules:
3030
- patch
3131
- update
3232
- watch
33+
- apiGroups:
34+
- scheduling.k8s.io
35+
resources:
36+
- priorityclasses
37+
verbs:
38+
- get
39+
- list
40+
- watch
41+
- apiGroups:
42+
- scheduling.run.ai
43+
resources:
44+
- podgroups
45+
- queues
46+
verbs:
47+
- get
48+
- list
49+
- watch
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<!--
2+
Copyright 2026 NVIDIA CORPORATION
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
6+
# In-Place Pod Resize Accounting and Queue Admission
7+
8+
*Status: Implemented*
9+
10+
Related issues: [#1906](https://github.com/kai-scheduler/KAI-Scheduler/issues/1906),
11+
[#1872](https://github.com/kai-scheduler/KAI-Scheduler/issues/1872) (deferred resize eviction — separate track)
12+
13+
## Motivation
14+
15+
In-place resize (`pods/resize`) lets a running Pod change CPU/memory without
16+
rescheduling. KAI has three gaps:
17+
18+
1. **Accounting.** KAI charges Pod resources from the spec. While a resize is
19+
pending, infeasible, or in progress, spec ≠ occupancy. That creates phantom
20+
capacity on downsize, overcharges on infeasible upsize, and corrupts queue
21+
allocation, fair share, reclaim, and reporting.
22+
2. **Queue admission.** Resize bypasses scheduling. A Pod can grow past queue
23+
`limit`, or past deserved `quota` when non-preemptible. The validating
24+
webhook matches `pods`, not `pods/resize`, so it never sees an old→new
25+
delta.
26+
3. **Deferred resize eviction.** When the kubelet marks a resize `Deferred`
27+
for node capacity, KAI does not preempt or reclaim on that node to make
28+
room. Out of scope here — tracked in
29+
[#1872](https://github.com/kai-scheduler/KAI-Scheduler/issues/1872).
30+
31+
This design covers (1) and (2). Upstream ResourceQuota already admits resize
32+
via status-aware requests and a positive usage delta; KAI needs the same
33+
effective-request model and a hierarchical check.
34+
35+
## Design
36+
37+
### Effective accounting
38+
39+
Use the upstream effective request as KAI's default Pod resource vector. Per
40+
container and resource, before Pod-level aggregation:
41+
42+
```text
43+
normal / Deferred / in progress:
44+
effective = max(spec request, allocatedResources, status.resources)
45+
46+
Infeasible:
47+
effective = max(allocatedResources, status.resources)
48+
```
49+
50+
Drive node fit, queue `Allocated` / `AllocatedNotPreemptible`, fair share,
51+
ordering, reclaim, victim selection, and allocated status/metrics from this
52+
vector. Do not plumb a separate desired-resource vector through the
53+
scheduler; read the raw spec only for intent (proposed target, user-facing
54+
desired fields, infeasible diagnostics).
55+
56+
**`Infeasible` detection** delegates to upstream
57+
`resource.IsPodResizeInfeasible`, which keys off the condition reason alone.
58+
The kubelet owns the condition lifecycle — it clears or replaces
59+
`PodResizePending` when a new resize is submitted — so generation tracking is
60+
not needed, and `observedGeneration` is only populated behind the non-GA
61+
`PodObservedGenerationTracking` gate (a guard on it would disable `Infeasible`
62+
handling on gate-off clusters). Upstream semantics are pinned by a
63+
characterization test so a future upstream tightening surfaces as a failure.
64+
65+
The `Deferred` charge at `max(spec, actual)` is load-bearing beyond
66+
accounting: deferred-resize eviction
67+
([#1872](https://github.com/kai-scheduler/KAI-Scheduler/issues/1872),
68+
[#2051](https://github.com/kai-scheduler/KAI-Scheduler/pull/2051)) relies on
69+
the deferred target already being reserved in node and queue accounting, so
70+
capacity freed by evicting victims is not backfilled before the kubelet
71+
enacts the resize. Charging `Deferred` at actual only would reintroduce
72+
eviction thrash.
73+
74+
Implementation: upstream `resource.AggregateContainerRequests` (status
75+
resources enabled), then KAI custom-resource logic.
76+
77+
### Best-effort resize quota admission
78+
79+
Validating webhook on `UPDATE`/`pods/resize` with old and proposed Pods. For
80+
KAI-scheduled Pods:
81+
82+
1. Resolve PodGroup, preemptibility, leaf queue, and ancestors.
83+
2. Compute old and proposed effective requests (inherited `Infeasible` stale).
84+
3. `delta = max(proposed - old, 0)` per resource.
85+
4. Reject if any queue on the path would violate:
86+
87+
```text
88+
all workloads:
89+
Allocated + delta > limit
90+
91+
non-preemptible:
92+
AllocatedNotPreemptible + delta > quota (deserved)
93+
```
94+
95+
Usage comes from the best-available snapshot (Queue status / live effective
96+
requests). Downsize always admissible; capacity frees only when effective
97+
usage falls. Limit-only changes with no request growth have zero delta.
98+
Rejections name Pod, PodGroup, limiting queue/ancestor, resource, allocation,
99+
delta, and boundary.
100+
101+
This is **not** atomic across concurrent resizes or against in-flight
102+
scheduler allocations. Existing violations (race, bypass, pre-rollout) keep
103+
full effective accounting, are reported, and block further deepenings via the
104+
webhook and allocate-time capacity checks. No proactive "drain until under
105+
limit" action for now.
106+
107+
### Optional strict upsize block
108+
109+
Config knob, **disabled by default**, rejects request upsizes that could race
110+
past a hard queue bound — even if current usage would still fit:
111+
112+
- **Any** upsize, if the leaf queue or an ancestor has a finite `limit` on that
113+
resource.
114+
- **Non-preemptible** upsize, if the leaf queue or an ancestor has a finite
115+
`quota` (deserved) on that resource.
116+
117+
Escape hatch for operators who cannot tolerate best-effort races without a
118+
ledger. Preemptible upsizes on queues that are only bounded by deserved (no
119+
finite limit) remain subject to the best-effort check only — they may go over
120+
deserved by design.
121+
122+
```yaml
123+
spec:
124+
admission:
125+
inPlacePodResize:
126+
validateQuota: true # best-effort hierarchical checks
127+
blockUpsizeOnBoundedQueues: false # conservative; default off
128+
```
129+
130+
Exact CR field names TBD at implementation.
131+
132+
## Decisions
133+
134+
| Topic | Decision |
135+
| --- | --- |
136+
| Accounting model | Upstream effective request; upstream reason-only `Infeasible` semantics (kubelet owns the condition lifecycle) |
137+
| Resize admit path | Keep native `pods/resize`; validate in webhook (do **not** convert to a KAI-owned scheduling API — breaks VPA / API contract) |
138+
| Non-preemptible growth past quota | Reject at webhook; keep reject at allocate |
139+
| Concurrent-resize / scheduler races | Best effort; optional `blockUpsizeOnBoundedQueues`; reservation ledger only if a concrete issue appears |
140+
| Drain-until-under-limit action | Rejected — poor UX; reclaim stays demand-driven |
141+
| Deferred resize preemption | Separate track: [#1872](https://github.com/kai-scheduler/KAI-Scheduler/issues/1872) / [#2051](https://github.com/kai-scheduler/KAI-Scheduler/pull/2051). Depends on `Deferred` charged at max(spec, actual) — the reserved target is its thrash safety |
142+
| Wait for upstream resize gates / KEP-5836 only | Rejected as sole plan — ship Goals accounting + admit now; upstream remains complementary ([kubernetes#131835](https://github.com/kubernetes/kubernetes/issues/131835)) |
143+
144+
## Known gaps
145+
146+
Best-effort admission can race: concurrent upsizes, or a resize vs scheduler
147+
allocate, may double-spend the same remaining headroom.
148+
`blockUpsizeOnBoundedQueues` and allocate/webhook deepenings mitigate today.
149+
Prefer upstream resize gates
150+
([kubernetes#131835](https://github.com/kubernetes/kubernetes/issues/131835))
151+
as the long-term out-of-tree quota hook; add a reservation ledger only if
152+
gates do not land and races hurt in production.
153+
154+
## References
155+
156+
- [KEP-1287: In-place Update of Pod Resources](https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/1287-in-place-update-pod-resources/README.md)
157+
- [Kubernetes 1.35 in-place container resize](https://v1-35.docs.kubernetes.io/docs/tasks/configure-pod-container/resize-container-resources/)
158+
- [Upstream effective Pod request helper](https://github.com/kubernetes/kubernetes/blob/v1.35.4/staging/src/k8s.io/component-helpers/resource/helpers.go)
159+
- [Upstream Pod quota evaluator](https://github.com/kubernetes/kubernetes/blob/v1.35.4/pkg/quota/v1/evaluator/core/pods.go)
160+
- [KEP-5836: Scheduler Preemption for In-Place Pod Resize](https://www.kubernetes.dev/resources/keps/5836)
161+
- [kubernetes#131835: out-of-tree quota vs in-place resize](https://github.com/kubernetes/kubernetes/issues/131835)

docs/in-place-resize/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# In-Place Pod Resize
2+
3+
KAI Scheduler supports Kubernetes [in-place pod resize](https://kubernetes.io/docs/tasks/configure-pod-container/resize-container-resources/) (KEP-1287): changing a running pod's CPU and memory requests without restarting it. KAI keeps queue accounting consistent throughout a resize, and applies a best-effort admission check of resize requests against queue limits and quota — a guardrail rather than a strict guarantee (see [Best-effort semantics](#best-effort-semantics)).
4+
5+
Requires Kubernetes 1.33 or newer (the `InPlacePodVerticalScaling` feature gate is enabled by default since 1.33 and GA since 1.35).
6+
7+
## Resizing a pod
8+
9+
Resizes go through the `pods/resize` subresource:
10+
11+
```bash
12+
kubectl patch pod my-pod --subresource resize \
13+
-p '{"spec":{"containers":[{"name":"main","resources":{"requests":{"cpu":"2"}}}]}}'
14+
```
15+
16+
Only CPU and memory can be resized; GPU resources cannot be changed in place.
17+
18+
## Accounting: what a resizing pod is charged
19+
20+
During a resize, a pod's desired requests (spec) and its actual running size can differ. KAI charges the **effective request** per container and resource:
21+
22+
| Resize state | Charged amount |
23+
|---|---|
24+
| No resize in progress | spec |
25+
| Resize pending or in progress | max(spec, actual) |
26+
| Resize marked **Infeasible** by the kubelet | actual only — the unreachable spec is not charged |
27+
28+
"Actual" is the value the kubelet reports in the container status. This model matches the upstream Kubernetes scheduler and kubelet, and applies consistently across:
29+
30+
- scheduler decisions (fair share, node fit, preemption and reclaim),
31+
- queue status (`Queue.status.allocated`, `Queue.status.allocatedNonPreemptible`),
32+
- resize admission (below).
33+
34+
The practical consequences:
35+
36+
- A pod mid-downsize keeps its current (larger) charge until the kubelet completes the downsize — the freed capacity is never promised before it exists.
37+
- A pod whose upsize the kubelet rejected as Infeasible does not hold queue capacity for a size it will never reach.
38+
39+
## Resize admission
40+
41+
A validating webhook on `pods/resize` checks every upsize of a KAI-scheduled pod against the pod's queue hierarchy, before the resize is persisted:
42+
43+
- **Limit** (all workloads): the increase must not push any queue's `allocated` over its CPU or memory `limit`.
44+
- **Quota** (non-preemptible workloads only): the increase must also fit within `quota`, checked against `allocatedNonPreemptible`.
45+
46+
The increase is computed at the pod level, so moving CPU or memory between containers of the same pod is not counted as growth, and downsizes are always admitted.
47+
48+
A rejected resize returns an error such as:
49+
50+
```
51+
resize rejected: pod team-a/trainer (PodGroup pg-trainer) CPU upsize would push
52+
queue team-a Allocated (1000m + 2000m) over limit (2000m)
53+
```
54+
55+
The pod keeps running at its current size; nothing is restarted or evicted.
56+
57+
### Best-effort semantics
58+
59+
Admission is a guardrail, not a transactional guarantee:
60+
61+
- The webhook is registered with `failurePolicy: Ignore` — if the admission service is unavailable, resizes are admitted rather than blocked.
62+
- Lookup failures (queue, podgroup) admit the resize and log an error.
63+
- The check reads `Queue.status.allocated`, which trails actual scheduling decisions through the controller reconcile chain. A resize can therefore race concurrent activity on a nearly-full queue — another resize, or the scheduler placing new pods — and together exceed the limit. The reverse also holds: a recently freed queue may transiently over-deny a valid resize. The scheduler's own accounting stays correct either way and stops further allocation once over the limit.
64+
- A resize issued while the same pod's previous resize is still being enacted is checked against the transient charge. In particular, a pending downsize is still charged until the kubelet enacts it, so a follow-up upsize within that window can reclaim the not-yet-released capacity and settle above the limit.
65+
66+
For workloads where the race above is unacceptable, see `blockUpsizeOnBoundedQueues` below.
67+
68+
## Configuration
69+
70+
Resize admission is configured in the KAI `Config` resource:
71+
72+
```yaml
73+
apiVersion: kai.scheduler/v1
74+
kind: Config
75+
metadata:
76+
name: kai
77+
spec:
78+
admission:
79+
inPlacePodResize:
80+
validateQuota: true # default
81+
blockUpsizeOnBoundedQueues: false # default
82+
```
83+
84+
| Field | Default | Effect |
85+
|---|---|---|
86+
| `validateQuota` | `true` | Master switch. When `false`, the webhook admits all resizes without any checks, and `blockUpsizeOnBoundedQueues` is ignored. |
87+
| `blockUpsizeOnBoundedQueues` | `false` | Strict mode: reject **any** upsize on a queue (or ancestor) that has a finite CPU or memory limit — regardless of free headroom — and, for non-preemptible workloads, on any queue with a finite quota. Closes the concurrent-resize race at the cost of disallowing upsizes on bounded queues entirely. |
88+
89+
## Interaction with queue limits
90+
91+
`limit: -1` on a queue resource means unbounded; any other value (including `0`) is a hard bound enforced on resize. The same convention applies to `quota` for non-preemptible workloads. See [Queues](../queues/README.md) for how limits and quota are configured.
92+
93+
Because checks walk the full queue hierarchy, an upsize must fit under every ancestor's limit, not just the leaf queue's.

0 commit comments

Comments
 (0)