[
https://issues.apache.org/jira/browse/YUNIKORN-3355?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Dale Richardson updated YUNIKORN-3355:
--------------------------------------
Description:
If Pod bind calls fail while allocations are in flight (API server outage,
restart, or sustained errors under load), YuniKorn can permanently strand the
affected pods: they remain {{Pending}} in Kubernetes forever, while the core's
queue books them as {*}allocated{*}. The scheduler logs "No outstanding apps
found" and never retries. Pod metadata updates do not resurrect them; only
deleting/recreating the pods or restarting the scheduler recovers. No
WARN/ERROR is logged for the inconsistency.
This is very likely the root cause of YUNIKORN-3128 ("Yunikorn ignores pending
pods after apiserver errors"): we reproduced the identical end state
deterministically and traced the mechanism through the code.
h2. Reproduction (deterministic when timed right; reproduced on 1.9.0 and
master a82e4f92)
# kind cluster (K8s v1.36.1), 500 KWOK fake nodes, YuniKorn standard mode
(Helm defaults, admission controller disabled).
# Create 3,000 pods with {{schedulerName: yunikorn}} at high rate (~2,000
pods/s, client-go at concurrency 16).
# ~3s in, while Binding POSTs are in flight, {{kill -9}} the kube-apiserver;
kubelet restarts it ~10s later.
# Wait for recovery and observe.
The kill must land while binds are in flight — killing before binding starts
recovers cleanly. Any failure mode producing a burst of failed binds should
trigger it (YUNIKORN-3128 saw it from apiserver errors under load, with no full
outage).
h2. Observed end state (master build, 242 of 3,000 pods affected)
* 242 pods {{Pending}} with empty {{{}spec.nodeName{}}}, permanently (observed
>12 min, no recovery).
* Queue REST API: {{root.default}} shows {{allocatedResource pods: 3000}} —
the queue books all 3,000 {*}including the 242 never bound{*}; the applications
list is empty (app completed and was removed).
* Scheduler log: exactly 242 {{added existing allocation}} lines (1:1 with
stuck pods), each with a {{{}targetNode{}}}, ~30-45s after the outage; later
{{{}No outstanding apps found for a while{}}}.
* New pods (a different app) schedule normally — the damage is silent quota
leakage plus the stranded pods.
h2. Root cause (verified against master a82e4f92)
Four-step interaction between the shim cache's assumed-pod handling and the
core's existing-allocation semantics:
# *Assume stamps NodeName on the cached copy.* {{Context.AssumePod}}
deep-copies the pod, sets {{{}assumedPod.Spec.NodeName = node{}}}, and stores
it in the scheduler cache ({{{}pkg/cache/context.go:869-871{}}}). The cache
records the assignment in {{{}assignedPods{}}}.
# *The stale node assignment survives task teardown — by either of two
code-verified paths.* Our repro logs cannot distinguish which ran (container
log rotation consumed the failure window), but both preserve the assignment:
*# The release callback ran.* {{Context.ForgetPod}} fetches the pod from the
scheduler cache — which returns the *assumed copy* with {{Spec.NodeName}} set —
and passes it to {{SchedulerCache.forgetPod}}
({{pkg/cache/context.go:879-884}}). {{forgetPod}} deletes only the
{{assumedPods}} marker and calls {{updatePod(pod)}} with that copy
({{pkg/cache/external/scheduler_cache.go:494-506}}); inside {{updatePod}} the
copy still satisfies {{IsAssignedPod}}, so it is re-added to the node and
re-recorded in {{assignedPods}}. The "forget" is a no-op for the assignment.
*# The release callback never ran.* Under a mass bind-failure storm the async
event pipeline can drop or indefinitely delay the release delivery (the
drop-on-full behaviour of {{rmproxy.enqueueAndCheckFull}} /
{{dispatcher.asyncDispatch}}), in which case {{ForgetPod}} is never invoked and
the assumed state trivially survives — still marked as an active assume.
# Either way, the invariant that matters is: *no path in bind-failure teardown
removes the node assignment (or clears the stamped {{NodeName}}) from the
scheduler cache.* The subsequent steps are identical for both branches.
## The stale assignment is copied onto the real pod object.* On the next
informer event for the pod (watch reconnect / relist after the outage),
{{updatePod}} sees the incoming pod has {{Spec.NodeName == ""}} while
{{assignedPods}} has an entry, and executes {{pod.Spec.NodeName = nodeName}}
("use existing assignment",
{{{}pkg/cache/external/scheduler_cache.go:~355-358{}}}). Note this also
*mutates the shared informer-cache object* (pods are stored by reference via
{{{}utils.Convert2Pod{}}}), poisoning every other consumer of that object.
## Task re-creation turns the stale assignment into a phantom placed
allocation.* The bind-failure storm failed the tasks and completed/removed the
application, so the informer event re-creates the app and task
({{{}ensureAppAndTaskCreated{}}}); {{Task.updateAllocation}} builds the
allocation with {{NodeID: task.pod.Spec.NodeName}}
({{{}pkg/cache/task.go:319-322{}}}, {{{}pkg/common/si_helper.go:134{}}}) — now
non-empty due to step 3. The core's {{PartitionContext.UpdateAllocation}}
treats any allocation with a NodeID as already placed ("handling existing
allocation" / "added existing allocation",
{{{}pkg/scheduler/partition.go:~1240-1259{}}}) and books it against the queue
without ever scheduling or binding it.
The pod is now invisible to the scheduler (no pending ask), unbound in
Kubernetes, and counted against queue quota. When the re-created app later
completes, the phantom allocations remain in queue accounting with no owning
application.
h2. Impact
* Pods stranded Pending indefinitely after any burst of bind failures; silent
— scheduler appears healthy.
* Queue quota silently leaked by phantom allocations; in quota-tight clusters
this can starve a queue completely.
* Shared informer objects mutated (step 3) — undefined behaviour for all other
informer consumers.
* Amplified by scheduler throughput: the faster the core allocates, the more
binds are in flight during a blip, the larger the phantom set (relevant to the
YUNIKORN-3350 throughput work).
h2. Suggested fixes (revised)
# *Primary:* derive {{NodeID}} for core registration only from the *informer's*
view of {{spec.nodeName}} (ground truth of what is bound), never from
cache-internal assumed state ({{si_helper.go:134}} call sites). This breaks the
chain in both branches above.
# *Backstop for branch (b):* expire assumed pods that receive no bind
confirmation (kube-scheduler prior art, ~30s TTL) so lost release callbacks
cannot leave permanent assumed state.
# *Hygiene for branch (a):* {{forgetPod}} must actually revert the assignment
(clear {{NodeName}} on the copy / restore the informer version, remove the
{{assignedPods}} entry); and {{updatePod}} should stamp an existing assignment
only while the pod is still actively assumed ({{assumedPods}} entry present) —
and must never mutate the informer-owned object.
# *Root cause of branch (b), separate scope:* make the release/allocation event
pipeline bounded-blocking instead of drop-on-full so teardown signals cannot be
silently lost.
# Defence in depth unchanged: bounded transient-error bind retry
(YUNIKORN-2804), and a core-side invariant warning when queue allocations exist
with no owning application.
h2. Environment
kind v0.32 / K8s v1.36.1 single control plane, 500 KWOK fake nodes; reproduced
on YuniKorn 1.9.0 (Helm) and a master build (k8shim a82e4f92). Load generator
and repro script available on request.
was:
If Pod bind calls fail while allocations are in flight (API server outage,
restart, or sustained errors under load), YuniKorn can permanently strand the
affected pods: they remain {{Pending}} in Kubernetes forever, while the core's
queue books them as {*}allocated{*}. The scheduler logs "No outstanding apps
found" and never retries. Pod metadata updates do not resurrect them; only
deleting/recreating the pods or restarting the scheduler recovers. No
WARN/ERROR is logged for the inconsistency.
This is very likely the root cause of YUNIKORN-3128 ("Yunikorn ignores pending
pods after apiserver errors"): we reproduced the identical end state
deterministically and traced the mechanism through the code.
h2. Reproduction (deterministic when timed right; reproduced on 1.9.0 and
master a82e4f92)
# kind cluster (K8s v1.36.1), 500 KWOK fake nodes, YuniKorn standard mode
(Helm defaults, admission controller disabled).
# Create 3,000 pods with {{schedulerName: yunikorn}} at high rate (~2,000
pods/s, client-go at concurrency 16).
# ~3s in, while Binding POSTs are in flight, {{kill -9}} the kube-apiserver;
kubelet restarts it ~10s later.
# Wait for recovery and observe.
The kill must land while binds are in flight — killing before binding starts
recovers cleanly. Any failure mode producing a burst of failed binds should
trigger it (YUNIKORN-3128 saw it from apiserver errors under load, with no full
outage).
h2. Observed end state (master build, 242 of 3,000 pods affected)
* 242 pods {{Pending}} with empty {{{}spec.nodeName{}}}, permanently (observed
>12 min, no recovery).
* Queue REST API: {{root.default}} shows {{allocatedResource pods: 3000}} —
the queue books all 3,000 {*}including the 242 never bound{*}; the applications
list is empty (app completed and was removed).
* Scheduler log: exactly 242 {{added existing allocation}} lines (1:1 with
stuck pods), each with a {{{}targetNode{}}}, ~30-45s after the outage; later
{{{}No outstanding apps found for a while{}}}.
* New pods (a different app) schedule normally — the damage is silent quota
leakage plus the stranded pods.
h2. Root cause (verified against master a82e4f92)
Four-step interaction between the shim cache's assumed-pod handling and the
core's existing-allocation semantics:
# *Assume stamps NodeName on the cached copy.* {{Context.AssumePod}}
deep-copies the pod, sets {{{}assumedPod.Spec.NodeName = node{}}}, and stores
it in the scheduler cache ({{{}pkg/cache/context.go:869-871{}}}). The cache
records the assignment in {{{}assignedPods{}}}.
# *ForgetPod does not undo the assignment.* On release after the failed bind,
{{Context.ForgetPod}} fetches the pod from the cache — the assumed copy with
NodeName set — and passes it to {{SchedulerCache.forgetPod}}
({{{}pkg/cache/context.go:879-884{}}}). {{forgetPod}} calls {{updatePod(pod)}}
with that copy and deletes only the {{assumedPods}} marker
({{{}pkg/cache/external/scheduler_cache.go:494-506{}}}). Inside {{updatePod}}
the copy still satisfies {{{}IsAssignedPod{}}}, so it is re-added to the node
and re-recorded in {{{}assignedPods{}}}. Net effect: after a failed bind the
cache permanently believes the pod is assigned.
# *The stale assignment is copied onto the real pod object.* On the next
informer event for the pod (watch reconnect / relist after the outage),
{{updatePod}} sees the incoming pod has {{Spec.NodeName == ""}} while
{{assignedPods}} has an entry, and executes {{pod.Spec.NodeName = nodeName}}
("use existing assignment",
{{{}pkg/cache/external/scheduler_cache.go:~355-358{}}}). Note this also
*mutates the shared informer-cache object* (pods are stored by reference via
{{{}utils.Convert2Pod{}}}), poisoning every other consumer of that object.
# *Task re-creation turns the stale assignment into a phantom placed
allocation.* The bind-failure storm failed the tasks and completed/removed the
application, so the informer event re-creates the app and task
({{{}ensureAppAndTaskCreated{}}}); {{Task.updateAllocation}} builds the
allocation with {{NodeID: task.pod.Spec.NodeName}}
({{{}pkg/cache/task.go:319-322{}}}, {{{}pkg/common/si_helper.go:134{}}}) — now
non-empty due to step 3. The core's {{PartitionContext.UpdateAllocation}}
treats any allocation with a NodeID as already placed ("handling existing
allocation" / "added existing allocation",
{{{}pkg/scheduler/partition.go:~1240-1259{}}}) and books it against the queue
without ever scheduling or binding it.
The pod is now invisible to the scheduler (no pending ask), unbound in
Kubernetes, and counted against queue quota. When the re-created app later
completes, the phantom allocations remain in queue accounting with no owning
application.
h2. Impact
* Pods stranded Pending indefinitely after any burst of bind failures; silent
— scheduler appears healthy.
* Queue quota silently leaked by phantom allocations; in quota-tight clusters
this can starve a queue completely.
* Shared informer objects mutated (step 3) — undefined behaviour for all other
informer consumers.
* Amplified by scheduler throughput: the faster the core allocates, the more
binds are in flight during a blip, the larger the phantom set (relevant to the
YUNIKORN-3350 throughput work).
h2. Suggested fixes (layered; any of the first three breaks the chain)
# {{forgetPod}} must actually revert the assignment: clear {{Spec.NodeName}}
on (a copy of) the pod before re-inserting, or restore the informer version,
and remove the {{assignedPods}} entry.
# {{updatePod}} must not copy a stale assignment onto an unassigned incoming
pod when that assignment came from an assumed (never confirmed bound) pod — and
must never mutate the incoming informer-owned object (copy-on-write instead).
# The shim should only report {{NodeID}} to the core from the *informer's*
view of {{spec.nodeName}} (ground truth of what is actually bound), never from
cache-internal assumed state.
# Defence in depth: bounded retry of transient bind failures (connection
refused / timeout / 429) before failing the task; an assumed-pod expiry
analogous to kube-scheduler's; a core-side invariant warning when queue
allocated resources exist with no owning application.
h2. Environment
kind v0.32 / K8s v1.36.1 single control plane, 500 KWOK fake nodes; reproduced
on YuniKorn 1.9.0 (Helm) and a master build (k8shim a82e4f92). Load generator
and repro script available on request.
> Failed bind leaves a stale node assignment in the shim cache
> ------------------------------------------------------------
>
> Key: YUNIKORN-3355
> URL: https://issues.apache.org/jira/browse/YUNIKORN-3355
> Project: Apache YuniKorn
> Issue Type: Bug
> Components: core - scheduler, shim - kubernetes
> Reporter: Dale Richardson
> Priority: Major
>
> If Pod bind calls fail while allocations are in flight (API server outage,
> restart, or sustained errors under load), YuniKorn can permanently strand the
> affected pods: they remain {{Pending}} in Kubernetes forever, while the
> core's queue books them as {*}allocated{*}. The scheduler logs "No
> outstanding apps found" and never retries. Pod metadata updates do not
> resurrect them; only deleting/recreating the pods or restarting the scheduler
> recovers. No WARN/ERROR is logged for the inconsistency.
> This is very likely the root cause of YUNIKORN-3128 ("Yunikorn ignores
> pending pods after apiserver errors"): we reproduced the identical end state
> deterministically and traced the mechanism through the code.
> h2. Reproduction (deterministic when timed right; reproduced on 1.9.0 and
> master a82e4f92)
> # kind cluster (K8s v1.36.1), 500 KWOK fake nodes, YuniKorn standard mode
> (Helm defaults, admission controller disabled).
> # Create 3,000 pods with {{schedulerName: yunikorn}} at high rate (~2,000
> pods/s, client-go at concurrency 16).
> # ~3s in, while Binding POSTs are in flight, {{kill -9}} the kube-apiserver;
> kubelet restarts it ~10s later.
> # Wait for recovery and observe.
> The kill must land while binds are in flight — killing before binding starts
> recovers cleanly. Any failure mode producing a burst of failed binds should
> trigger it (YUNIKORN-3128 saw it from apiserver errors under load, with no
> full outage).
> h2. Observed end state (master build, 242 of 3,000 pods affected)
> * 242 pods {{Pending}} with empty {{{}spec.nodeName{}}}, permanently
> (observed >12 min, no recovery).
> * Queue REST API: {{root.default}} shows {{allocatedResource pods: 3000}} —
> the queue books all 3,000 {*}including the 242 never bound{*}; the
> applications list is empty (app completed and was removed).
> * Scheduler log: exactly 242 {{added existing allocation}} lines (1:1 with
> stuck pods), each with a {{{}targetNode{}}}, ~30-45s after the outage; later
> {{{}No outstanding apps found for a while{}}}.
> * New pods (a different app) schedule normally — the damage is silent quota
> leakage plus the stranded pods.
> h2. Root cause (verified against master a82e4f92)
> Four-step interaction between the shim cache's assumed-pod handling and the
> core's existing-allocation semantics:
> # *Assume stamps NodeName on the cached copy.* {{Context.AssumePod}}
> deep-copies the pod, sets {{{}assumedPod.Spec.NodeName = node{}}}, and stores
> it in the scheduler cache ({{{}pkg/cache/context.go:869-871{}}}). The cache
> records the assignment in {{{}assignedPods{}}}.
> # *The stale node assignment survives task teardown — by either of two
> code-verified paths.* Our repro logs cannot distinguish which ran (container
> log rotation consumed the failure window), but both preserve the assignment:
> *# The release callback ran.* {{Context.ForgetPod}} fetches the pod from the
> scheduler cache — which returns the *assumed copy* with {{Spec.NodeName}} set
> — and passes it to {{SchedulerCache.forgetPod}}
> ({{pkg/cache/context.go:879-884}}). {{forgetPod}} deletes only the
> {{assumedPods}} marker and calls {{updatePod(pod)}} with that copy
> ({{pkg/cache/external/scheduler_cache.go:494-506}}); inside {{updatePod}} the
> copy still satisfies {{IsAssignedPod}}, so it is re-added to the node and
> re-recorded in {{assignedPods}}. The "forget" is a no-op for the assignment.
> *# The release callback never ran.* Under a mass bind-failure storm the async
> event pipeline can drop or indefinitely delay the release delivery (the
> drop-on-full behaviour of {{rmproxy.enqueueAndCheckFull}} /
> {{dispatcher.asyncDispatch}}), in which case {{ForgetPod}} is never invoked
> and the assumed state trivially survives — still marked as an active assume.
> # Either way, the invariant that matters is: *no path in bind-failure
> teardown removes the node assignment (or clears the stamped {{NodeName}})
> from the scheduler cache.* The subsequent steps are identical for both
> branches.
> ## The stale assignment is copied onto the real pod object.* On the next
> informer event for the pod (watch reconnect / relist after the outage),
> {{updatePod}} sees the incoming pod has {{Spec.NodeName == ""}} while
> {{assignedPods}} has an entry, and executes {{pod.Spec.NodeName = nodeName}}
> ("use existing assignment",
> {{{}pkg/cache/external/scheduler_cache.go:~355-358{}}}). Note this also
> *mutates the shared informer-cache object* (pods are stored by reference via
> {{{}utils.Convert2Pod{}}}), poisoning every other consumer of that object.
> ## Task re-creation turns the stale assignment into a phantom placed
> allocation.* The bind-failure storm failed the tasks and completed/removed
> the application, so the informer event re-creates the app and task
> ({{{}ensureAppAndTaskCreated{}}}); {{Task.updateAllocation}} builds the
> allocation with {{NodeID: task.pod.Spec.NodeName}}
> ({{{}pkg/cache/task.go:319-322{}}}, {{{}pkg/common/si_helper.go:134{}}}) —
> now non-empty due to step 3. The core's {{PartitionContext.UpdateAllocation}}
> treats any allocation with a NodeID as already placed ("handling existing
> allocation" / "added existing allocation",
> {{{}pkg/scheduler/partition.go:~1240-1259{}}}) and books it against the queue
> without ever scheduling or binding it.
> The pod is now invisible to the scheduler (no pending ask), unbound in
> Kubernetes, and counted against queue quota. When the re-created app later
> completes, the phantom allocations remain in queue accounting with no owning
> application.
> h2. Impact
> * Pods stranded Pending indefinitely after any burst of bind failures;
> silent — scheduler appears healthy.
> * Queue quota silently leaked by phantom allocations; in quota-tight
> clusters this can starve a queue completely.
> * Shared informer objects mutated (step 3) — undefined behaviour for all
> other informer consumers.
> * Amplified by scheduler throughput: the faster the core allocates, the more
> binds are in flight during a blip, the larger the phantom set (relevant to
> the YUNIKORN-3350 throughput work).
> h2. Suggested fixes (revised)
> # *Primary:* derive {{NodeID}} for core registration only from the
> *informer's* view of {{spec.nodeName}} (ground truth of what is bound), never
> from cache-internal assumed state ({{si_helper.go:134}} call sites). This
> breaks the chain in both branches above.
> # *Backstop for branch (b):* expire assumed pods that receive no bind
> confirmation (kube-scheduler prior art, ~30s TTL) so lost release callbacks
> cannot leave permanent assumed state.
> # *Hygiene for branch (a):* {{forgetPod}} must actually revert the assignment
> (clear {{NodeName}} on the copy / restore the informer version, remove the
> {{assignedPods}} entry); and {{updatePod}} should stamp an existing
> assignment only while the pod is still actively assumed ({{assumedPods}}
> entry present) — and must never mutate the informer-owned object.
> # *Root cause of branch (b), separate scope:* make the release/allocation
> event pipeline bounded-blocking instead of drop-on-full so teardown signals
> cannot be silently lost.
> # Defence in depth unchanged: bounded transient-error bind retry
> (YUNIKORN-2804), and a core-side invariant warning when queue allocations
> exist with no owning application.
> h2. Environment
> kind v0.32 / K8s v1.36.1 single control plane, 500 KWOK fake nodes;
> reproduced on YuniKorn 1.9.0 (Helm) and a master build (k8shim a82e4f92).
> Load generator and repro script available on request.
>
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]