fix: recycle inference pods without invalid deadlines#1232
Conversation
Signed-off-by: Tanguille <tanguille@hotmail.be>
Quality cleanups over the recycling loop, no behaviour change: - use metav1.IsControlledBy / GetControllerOfNoCopy instead of hand-rolled owner-reference walks - drop stableDeployment's unread int32 return and activePodCountMatches; the stability gate already proves Status.Replicas is the desired count - replace the candidate struct + sort with a single-pass minimum - collapse the reconcilePodLifetime/reconcilePodLifetimeAt pair into one function taking now, and flatten the if/else at the call site - make evictPod a reconciler method and drop the UID temporary - record evictions directly in the test client instead of round-tripping them through a fake client.DeleteOption, and fold four fixture constructors into two Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
Follow-up to the recycling loop, taking the behaviour changes the review surfaced: - honour rolloutPolicy.waitForIdle before evicting. waitForIdle promises never to drop in-flight generations; recycling was killing a busy pod every maxPodLifetimeSeconds regardless. Recycling is best-effort and has no deadline to race, so it waits for the next idle window instead of borrowing the rollout path's timeout budget, and fails closed when the idle probe cannot answer. - emit a PodRecycled event and log the PDB-blocked retry. A pod disappearing on a timer left no trace in `kubectl describe isvc`. - list pods by the Deployment selector instead of walking ReplicaSets to prove ownership. The selector labels are unique per InferenceService and the stability gate already cross-checks the count against Status.Replicas, so a foreign pod now holds recycling instead of being filtered out. Drops a cluster-wide ReplicaSet informer and the apps/replicasets RBAC rule. - move earliestPositive next to Reconcile, where its three other callers live. - document that single-replica recycling is a restart with a downtime window, not a rolling replacement. Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
Recycling waits indefinitely for an idle backend under rolloutPolicy.waitForIdle, which is safe for in-flight requests but means a saturated service never recycles — exactly when leaked driver memory hurts most. This makes the wait boundable: - omitted: wait indefinitely (unchanged default) - N: recycle anyway once the pod has been overdue for N seconds - 0: recycle without consulting the idle probe at all The budget runs from the pod's own expiry, which is already derivable from its start time, so no "deferred since" timestamp has to be persisted the way reconcileRolloutPolicy does it with a status condition. podLifetime becomes boundedSeconds now that two fields share its overflow clamp. Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
Found testing this branch on a live cluster: swapping the operator image without the chart that owns its ClusterRole leaves pods/eviction ungranted, and the resulting Forbidden propagated out of evictPod as a reconcile error. That failed the entire InferenceService reconcile and retried on the error backoff — roughly two errors every ten seconds, indefinitely, since no amount of retrying grants a permission. Treat it like the PodDisruptionBudget case: log it, raise a PodRecycleForbidden warning event so it shows up in `kubectl describe isvc`, and back off to the recycle retry interval. The rest of the reconcile (status, service, HPA) now completes normally with only recycling disabled. Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
3b884bd to
12d6d8f
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Defilan
left a comment
There was a problem hiding this comment.
Reviewed the code, not just the (excellent) writeup, and cross-checked the new logic against the existing helpers it leans on. This is a clean fix for #1225 and I'm approving. A few non-blocking notes, one of which is a design question worth a one-line answer.
Verified
activeDeadlineSecondsis fully gone from the Deployment pod template, and the test now asserts it stays nil regardless ofmaxPodLifetimeSeconds. That is the actual #1225 fix, and it explains the "no disruption on swap" result: the invalid field never made it onto live pods, so removing it makes desired match live.- The recycle path is correctly guarded.
stableDeployment()gates onIsControlledBy+ObservedGeneration == Generation+ all replica counts equal toSpec.Replicas, which transitively blocks recycling during an in-progress rollout (old and new ReplicaSet pods coexisting driveUpdatedReplicas != Replicas) and tracks HPA-driven scale. The oldest-deadline argmin (ties by name) is order-independent and evicts exactly one even when several pods are simultaneously expired. - The idle-timeout semantics are right: nil means wait forever,
*timeout == 0means recycle without asking, and the nil-check precedes the dereference, so the0vs omitted distinction is not a zero-value bug. The budget is derived from the pod's own expiry, so nothing has to be persisted. boundedSeconds()clamps belowmath.MaxInt64/1e9, so the lifetime math can't overflow atime.Durationinto a negative deadline.- Eviction errors fail safe:
Forbiddenand PDBTooManyRequestsreturn(30s, nil), so status/service/HPA reconciliation (already done earlier inReconcile) is never blocked; only genuinely unexpected errors propagate. ThePodRecycleForbiddendegrade-gracefully path you found on the live cluster is exactly the right call. - The
pods/evictionRBAC is create-only and kept in sync acrossconfig/rbac/role.yamland the chart ClusterRole; both CRD copies got the field identically.
Notes (non-blocking)
- Question, not a defect: recycling gates the idle wait on
RolloutPolicyEnabled(), whereas the rollout-defer path usesShouldDeferRollout(), which honorsrolloutPolicy.force: true. So an operator usingforce: trueas an emergency escape hatch to push changes through without waiting for idle will still find pod recycling waits for idle (indefinitely, ifmaxPodLifetimeIdleTimeoutSecondsis unset). I think this is deliberate since recycling has its own dedicated escape hatch via the timeout field, but confirm it's intentional rather than an oversight. - Minor: ownership safety rests on the selector labels being namespace-unique (
app+inference.llmkube.dev/service, both the isvc name) plus the count cross-check, not an ownerRef-chain walk. It fails closed (a foreign pod sharing the selector holds recycling, never triggers a wrongful eviction), so this is fine, just a soft guarantee via naming rather than a hard invariant. - Informational: after a successful eviction the requeue is
0, relying on the Deployment watch to re-trigger. Normal case fires in seconds; a replacement pod stuck Pending would leave the next pod's deadline unchecked until another event or the cache resync. Not worth blocking on.
Thanks for the thorough live-cluster validation and for catching the ungranted pods/eviction RBAC failure mode. Approving; just confirm the force/idle interaction is intended.
|
Intentional, yes. The reasoning is that The other half is that That said, you're pointing at something real and I don't want to wave it away. Omitted meaning "wait forever" is a bad default for the case the feature mostly exists to serve. We run Same shape as #1223, really. It goes quiet instead of telling you. I'd rather not change the gate. But I'd like to make the held state visible: an event when a recycle is first held, and a condition or a metric so you can alert on "expired but not recycled" rather than reading logs. Happy to send that as a follow-up if you think it's worth having. Whether the default should stay "wait forever" is a separate question and your call. I'd lean toward keeping it and making it observable rather than changing behavior under people. |
…10 ➔ 0.9.11) (#1703) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/defilantech/charts/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.10` → `0.9.11` | --- ### Release Notes <details> <summary>defilantech/LLMKube (ghcr.io/defilantech/charts/llmkube)</summary> ### [`v0.9.11`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0911-2026-07-24) [Compare Source](defilantech/LLMKube@v0.9.10...v0.9.11) ##### Features - cache prep subcommand with direct chown/chmod syscalls ([#​890](defilantech/LLMKube#890) item 3) ([#​1258](defilantech/LLMKube#1258)) ([bde1a87](defilantech/LLMKube@bde1a87)) - **charts/foreman:** expose the agent --accelerator flag ([#​1242](defilantech/LLMKube#1242)) ([#​1261](defilantech/LLMKube#1261)) ([a39fd71](defilantech/LLMKube@a39fd71)) - declarative bindAddress on InferenceService ([#​1240](defilantech/LLMKube#1240)) ([#​1247](defilantech/LLMKube#1247)) ([bf01598](defilantech/LLMKube@bf01598)) - **federation:** FederatedCluster registry + fleet status rollup ([#​1234](defilantech/LLMKube#1234), [#​1235](defilantech/LLMKube#1235)) ([#​1241](defilantech/LLMKube#1241)) ([1b5bf08](defilantech/LLMKube@1b5bf08)) - **foreman:** tiered str\_replace matching (exact, trailing-ws, uniform-indent) ([#​1231](defilantech/LLMKube#1231)) ([0372e24](defilantech/LLMKube@0372e24)) - **grafana:** chart the InferenceService metrics with no panel ([#​1245](defilantech/LLMKube#1245)) ([a5ce923](defilantech/LLMKube@a5ce923)) - Kueue prerequisites: spec.suspend, GPUQuota deferral, pkg/apiutil GPU mapping ([#​1254](defilantech/LLMKube#1254)) ([c95e06f](defilantech/LLMKube@c95e06f)) - **metrics:** publish operator state metrics from observed state ([#​1229](defilantech/LLMKube#1229)) ([5db0915](defilantech/LLMKube@5db0915)) - prefetch Model artifacts into the shared cache without an InferenceService ([#​1218](defilantech/LLMKube#1218)) ([7c1161b](defilantech/LLMKube@7c1161b)) ##### Bug Fixes - **chart:** make PrometheusRule select the chart's own scrape targets ([#​1239](defilantech/LLMKube#1239)) ([ffc697a](defilantech/LLMKube@ffc697a)) - delete GPUQuota metric series when the CR is deleted ([#​1230](defilantech/LLMKube#1230)) ([#​1246](defilantech/LLMKube#1246)) ([1f58c3c](defilantech/LLMKube@1f58c3c)) - **foreman:** edits disarm a nudged RepeatedToolCall hash ([#​1215](defilantech/LLMKube#1215)) ([#​1216](defilantech/LLMKube#1216)) ([60df2ad](defilantech/LLMKube@60df2ad)) - gate bite check reverts to the upstream merge-base, never the fork tip ([#​1260](defilantech/LLMKube#1260)) ([8a6928f](defilantech/LLMKube@8a6928f)) - **grafana:** delete dashboard panels for metrics nothing emits ([#​1244](defilantech/LLMKube#1244)) ([9c586d9](defilantech/LLMKube@9c586d9)) - **metrics:** report GPU queue depth per namespace ([#​1243](defilantech/LLMKube#1243)) ([5779c05](defilantech/LLMKube@5779c05)) - recycle inference pods without invalid deadlines ([#​1232](defilantech/LLMKube#1232)) ([0b716d7](defilantech/LLMKube@0b716d7)) - waitForIdle proceeds past crashlooping pods; truthful deferral reason on mixed state ([#​1250](defilantech/LLMKube#1250)) ([#​1262](defilantech/LLMKube#1262)) ([88c87f9](defilantech/LLMKube@88c87f9)) ##### Documentation - envtest test-craft guide (status subresource, probe stubs, asset hygiene) ([#​1263](defilantech/LLMKube#1263)) ([#​1264](defilantech/LLMKube#1264)) ([ea4eee6](defilantech/LLMKube@ea4eee6)) </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19--> Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/1703
…mkube (0.9.10 ➔ 0.9.11) (#152) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.10` → `0.9.11` | --- ### Release Notes <details> <summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary> ### [`v0.9.11`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0911-2026-07-24) [Compare Source](defilantech/LLMKube@v0.9.10...v0.9.11) ##### Features - cache prep subcommand with direct chown/chmod syscalls ([#​890](defilantech/LLMKube#890) item 3) ([#​1258](defilantech/LLMKube#1258)) ([bde1a87](defilantech/LLMKube@bde1a87)) - **charts/foreman:** expose the agent --accelerator flag ([#​1242](defilantech/LLMKube#1242)) ([#​1261](defilantech/LLMKube#1261)) ([a39fd71](defilantech/LLMKube@a39fd71)) - declarative bindAddress on InferenceService ([#​1240](defilantech/LLMKube#1240)) ([#​1247](defilantech/LLMKube#1247)) ([bf01598](defilantech/LLMKube@bf01598)) - **federation:** FederatedCluster registry + fleet status rollup ([#​1234](defilantech/LLMKube#1234), [#​1235](defilantech/LLMKube#1235)) ([#​1241](defilantech/LLMKube#1241)) ([1b5bf08](defilantech/LLMKube@1b5bf08)) - **foreman:** tiered str\_replace matching (exact, trailing-ws, uniform-indent) ([#​1231](defilantech/LLMKube#1231)) ([0372e24](defilantech/LLMKube@0372e24)) - **grafana:** chart the InferenceService metrics with no panel ([#​1245](defilantech/LLMKube#1245)) ([a5ce923](defilantech/LLMKube@a5ce923)) - Kueue prerequisites: spec.suspend, GPUQuota deferral, pkg/apiutil GPU mapping ([#​1254](defilantech/LLMKube#1254)) ([c95e06f](defilantech/LLMKube@c95e06f)) - **metrics:** publish operator state metrics from observed state ([#​1229](defilantech/LLMKube#1229)) ([5db0915](defilantech/LLMKube@5db0915)) - prefetch Model artifacts into the shared cache without an InferenceService ([#​1218](defilantech/LLMKube#1218)) ([7c1161b](defilantech/LLMKube@7c1161b)) ##### Bug Fixes - **chart:** make PrometheusRule select the chart's own scrape targets ([#​1239](defilantech/LLMKube#1239)) ([ffc697a](defilantech/LLMKube@ffc697a)) - delete GPUQuota metric series when the CR is deleted ([#​1230](defilantech/LLMKube#1230)) ([#​1246](defilantech/LLMKube#1246)) ([1f58c3c](defilantech/LLMKube@1f58c3c)) - **foreman:** edits disarm a nudged RepeatedToolCall hash ([#​1215](defilantech/LLMKube#1215)) ([#​1216](defilantech/LLMKube#1216)) ([60df2ad](defilantech/LLMKube@60df2ad)) - gate bite check reverts to the upstream merge-base, never the fork tip ([#​1260](defilantech/LLMKube#1260)) ([8a6928f](defilantech/LLMKube@8a6928f)) - **grafana:** delete dashboard panels for metrics nothing emits ([#​1244](defilantech/LLMKube#1244)) ([9c586d9](defilantech/LLMKube@9c586d9)) - **metrics:** report GPU queue depth per namespace ([#​1243](defilantech/LLMKube#1243)) ([5779c05](defilantech/LLMKube@5779c05)) - recycle inference pods without invalid deadlines ([#​1232](defilantech/LLMKube#1232)) ([0b716d7](defilantech/LLMKube@0b716d7)) - waitForIdle proceeds past crashlooping pods; truthful deferral reason on mixed state ([#​1250](defilantech/LLMKube#1250)) ([#​1262](defilantech/LLMKube#1262)) ([88c87f9](defilantech/LLMKube@88c87f9)) ##### Documentation - envtest test-craft guide (status subresource, probe stubs, asset hygiene) ([#​1263](defilantech/LLMKube#1263)) ([#​1264](defilantech/LLMKube#1264)) ([ea4eee6](defilantech/LLMKube@ea4eee6)) </details> --- ### Configuration 📅 **Schedule**: (in timezone America/New_York) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNzIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI3Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19--> Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/152
What
Fixes #1225.
maxPodLifetimeSecondscurrently putsactiveDeadlineSecondsin a Deployment pod template. Kubernetes rejects that field for ReplicaSet-backed workloads, so the generated Deployment cannot be updated at all — the InferenceService is stuck failing every reconcile.This changes the feature to recycle one expired, owned Pod at a time through the eviction subresource. The Deployment then creates the replacement normally.
activeDeadlineSecondsout of Deployment templatesstatus.replicas, so a foreign Pod sharing the selector holds recycling rather than risking an eviction the operator does not ownPodRecycledevent so a Pod vanishing on a timer is visible inkubectl describe isvcInteraction with
rolloutPolicy.waitForIdlewaitForIdlepromises never to drop in-flight generations. Recycling now honours it: an expired Pod is not evicted while the backend is busy, and the idle probe fails closed exactly asreconcileRolloutPolicydoes.Waiting forever is wrong for a saturated service, though — that is precisely when leaked driver memory hurts most. The new optional
maxPodLifetimeIdleTimeoutSecondsbounds the wait:N0The budget runs from the Pod's own expiry, which is already derivable from its start time, so no "deferred since" timestamp has to be persisted the way the rollout path does with a status condition.
Live cluster validation
Verified on a 3-node Talos v1.13 / k8s v1.36.2 cluster running llmkube 0.9.10, behind a suspended HelmRelease, then reverted to a provably clean GitOps state (image, CR, ClusterRole and CRD/webhook inventory all confirmed back to baseline).
maxPodLifetimeSeconds: 86400were failing every reconcile: 16activeDeadlineSeconds: Forbiddenerrors in the hour before the swap, 0 after. Reverting to 0.9.10 brought it back at 12/minute.PodRecycledfired, the replacement was scheduled in 4s and went Ready, and the service never leftReady. Exactly one eviction.Bug this test found
Upgrading the operator image without the chart that owns its ClusterRole leaves
pods/evictionungranted. The resulting Forbidden propagated out ofevictPodas a reconcile error, failing the entire InferenceService reconcile — status, service and HPA included — and retrying on the error backoff indefinitely, since no amount of retrying grants a permission.Fixed in 3b884bd: log it, raise a
PodRecycleForbiddenwarning event, back off to the recycle retry interval, and let the rest of the reconcile complete with only recycling disabled. Every unit test,make check-helm-rbac, and the generated manifests were correct — the RBAC rule is present in this PR. The failure needed a real cluster where image and chart versions could disagree.Not covered
maxPodLifetimeIdleTimeoutSecondsand thewaitForIdlegating — exercising them needs the new CRD, and a CRD schema change is a one-way door on that cluster (Helm skipscrds/on upgrade). Unit tests only.Validation
make testmake vetmake lintmake check-helm-rbachelm template llmkube charts/llmkubewith YAML parsinggit diff --check