[Bug] Device taint NoExecute eviction does not evict pods using DRA-backed extended-resource claims (pod.Status.ExtendedResourceClaimStatus)
What happened?
TL;DR: the device-taint eviction controller (pkg/controller/devicetainteviction) only discovers which
claims a pod is using by reading pod.Spec.ResourceClaims. A pod that got its device through the newer
Extended Resource Backed by DRA path (KEP-5004, DRAExtendedResource) has its claim reference in
pod.Status.ExtendedResourceClaimStatus instead — a field this controller never looks at. Both
DRADeviceTaints and DRAExtendedResource are Beta and default-on since v1.36, so this affects any
cluster running a stock 1.36+ apiserver/controller-manager with a DRA driver installed — no unusual
configuration required.
Live-reproduced on a kind cluster (full steps and output below): two pods, each holding one device from the
same driver, then a single NoExecute DeviceTaintRule applied against that driver:
| Pod | Requests its device via | Claim reference lives in | Result after NoExecute taint |
|---|---|---|---|
pod-to-be-evicted |
ResourceClaimTemplate |
pod.spec.resourceClaims |
Evicted within ~10s (correct) |
pod0 |
plain resources.limits extended-resource request |
pod.status.extendedResourceClaimStatus |
Still Running, untouched — the bug |
The DeviceTaintRule's own status confirms only one pod was ever considered:
"1 pod needs to be evicted in 1 namespace" → "1 pod evicted since starting the controller".
Scope note, to save a maintainer the round-trip: this is specifically about the post-allocation eviction
path. Allocation-time taint enforcement (a new pod being blocked from a NoSchedule/NoExecute-tainted
device it doesn't tolerate) is unaffected and works correctly for both request styles. The bug is
narrowly in the separate, already-allocated-pod eviction controller.
Couple of affected use cases
- GPU health-triggered auto-remediation. A node-health/GPU-health controller (e.g. something reacting to Xid/ECC errors, similar in spirit to NVIDIA GPU Operator's health checks) detects a failing GPU and applies a NoExecute DeviceTaintRule to force workloads off it before quarantining or repairing the device. Any pod still using the classic nvidia.com/gpu: 1-style request — which is the majority of existing GPU workloads, since KEP-5004's entire purpose is letting them keep working unmodified under DRA — is never evicted, and keeps running on a known-bad GPU instead of being cleanly rescheduled. Worst case: silently corrupted training/inference output rather than a clean failure.
- Targeted single-device maintenance on a multi-GPU node. An admin wants to service or replace one GPU on a node without draining the whole node (disruptive to the node's other GPUs/workloads), so they taint just that device NoExecute instead of cordoning the node. Any pod holding that device via an extended-resource request keeps running through the "drain," so the device can't actually be safely pulled for maintenance without an admin separately hunting down and manually killing those pods — defeating the point of device-level (vs. node-level) maintenance tooling.
- Clusters mid-migration from device plugins to DRA. KEP-5004 exists specifically so clusters can adopt DRA drivers without immediately rewriting every pod spec — meaning a long transitional period where legacy extended-resource-requesting Deployments/Jobs and newer ResourceClaim-based workloads coexist on the same devices. During that period, any device-taint-based enforcement (decommissioning a device, isolating a suspect one) only works for the already-migrated minority of workloads, silently failing for everyone else — undermining the mechanism cluster-wide for as long as the migration takes, which for large fleets could be months.
What did you expect to happen?
A NoExecute device taint should evict every pod actually consuming the tainted device, independent of which
API surface the pod used to request it. An admin tainting a failing/draining GPU for maintenance has no way to
know (and shouldn't need to know) whether a given consumer pod requested it via a ResourceClaim or via a
classic resources.limits extended-resource request — both end up running on the same physical device via
the same DRA driver and the same underlying ResourceClaim object. Today only one of the two is actually
evicted.
How can we reproduce it (as minimally and precisely as possible)?
Reproducible deterministically on a kind cluster with no real GPU hardware, using
kubernetes-sigs/dra-example-driver (a maintained,
public example DRA driver that simulates GPUs). All manifests below are the driver's own unmodified example
manifests (demo/examples/device-taints-tolerations/device-taint-pod-noexecute/ and
demo/examples/extended-resource-request/), just applied side by side.
Prerequisites: Docker, kind v0.20+, helm v3.7+, kubectl.
# 1. Create a kind cluster with the required feature gates and API version.
# (DynamicResourceAllocation and DRAAdminAccess are already GA/locked-on by v1.36 and are
# omitted; DRADeviceTaints/DRAExtendedResource are Beta+default-on in 1.36 but included
# explicitly for clarity; DRADeviceTaintRules is Beta+default-OFF in 1.36 and must be set.)
cat > kind-config.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
featureGates:
DRADeviceTaints: true
DRAExtendedResource: true
DRADeviceTaintRules: true
runtimeConfig:
resource.k8s.io/v1beta2: "true"
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri"]
enable_cdi = true
nodes:
- role: control-plane
- role: worker
EOF
kind create cluster --name dra-repro --image kindest/node:v1.36.1 --config kind-config.yaml --wait 3m
# 2. Install the example DRA driver (8 simulated GPUs, no real hardware, default values).
git clone --depth 1 --branch v0.4.0 https://github.com/kubernetes-sigs/dra-example-driver.git
helm upgrade -i --create-namespace --namespace dra-example-driver dra-example-driver \
dra-example-driver/deployments/helm/dra-example-driver
kubectl -n dra-example-driver wait --for=condition=Ready pod -l app.kubernetes.io/component=kubeletplugin --timeout=90s
# 3. Control pod: requests a GPU via a ResourceClaimTemplate (pod.spec.resourceClaims).
kubectl apply -f dra-example-driver/demo/examples/device-taints-tolerations/device-taint-pod-noexecute/1-basic-resourceclaimtemplate.yaml
kubectl apply -f dra-example-driver/demo/examples/device-taints-tolerations/device-taint-pod-noexecute/2-pod-to-be-evicted.yaml
kubectl -n basic-resourceclaimtemplate wait --for=condition=Ready pod/pod-to-be-evicted --timeout=60s
# 4. Treatment pod: requests a GPU via the classic extended-resource field. The scheduler
# satisfies it via DRA and records the claim in pod.status.extendedResourceClaimStatus instead
# of pod.spec.resourceClaims (confirmed with the jsonpath check below).
kubectl create namespace extended-resource-request
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
namespace: extended-resource-request
name: pod0
spec:
containers:
- name: ctr0
image: ubuntu:22.04
command: ["bash", "-c"]
args: ["export; trap 'exit 0' TERM; sleep 9999 & wait"]
resources:
limits:
deviceclass.resource.kubernetes.io/gpu.example.com: 1
EOF
kubectl -n extended-resource-request wait --for=condition=Ready pod/pod0 --timeout=60s
kubectl -n extended-resource-request get pod pod0 -o jsonpath='{.status.extendedResourceClaimStatus}{"\n"}'
kubectl -n extended-resource-request get pod pod0 -o jsonpath='spec.resourceClaims={.spec.resourceClaims}{"\n"}'
# 5. Taint the whole driver's devices NoExecute — both pods' devices are in scope.
cat <<'EOF' | kubectl apply -f -
apiVersion: resource.k8s.io/v1beta2
kind: DeviceTaintRule
metadata:
name: example
spec:
deviceSelector:
driver: gpu.example.com
taint:
key: gpu.example.com/unhealthy
value: "true"
effect: NoExecute
EOF
# 6. Observe.
sleep 15
kubectl get devicetaintrule example -o jsonpath='{.status.conditions}{"\n"}'
kubectl -n basic-resourceclaimtemplate get pod pod-to-be-evicted # expect: NotFound (evicted+deleted)
kubectl -n extended-resource-request get pod pod0 -o wide # expect: still Running
Actual output, captured just now:
$ kubectl -n extended-resource-request get pod pod0 -o jsonpath='{.status.extendedResourceClaimStatus}'
{"requestMappings":[{"containerName":"ctr0","requestName":"container-0-request-0",
"resourceName":"deviceclass.resource.kubernetes.io/gpu.example.com"}],
"resourceClaimName":"pod0-extended-resources-v77pc"}
$ kubectl -n extended-resource-request get pod pod0 -o jsonpath='spec.resourceClaims={.spec.resourceClaims}'
spec.resourceClaims=
$ kubectl apply -f device-taint-rule.yaml
devicetaintrule.resource.k8s.io/example created
$ kubectl get devicetaintrule example -o jsonpath='{.status.conditions}'
# immediately after creation:
[{"lastTransitionTime":"2026-08-10T07:34:42Z","message":"1 pod needs to be evicted in 1 namespace.",
"observedGeneration":1,"reason":"PodsPendingEviction","status":"True","type":"EvictionInProgress"}]
# ~10s later:
[{"lastTransitionTime":"2026-08-10T07:34:52Z","message":"1 pod evicted since starting the controller.",
"observedGeneration":1,"reason":"Completed","status":"False","type":"EvictionInProgress"}]
$ kubectl -n basic-resourceclaimtemplate get pod pod-to-be-evicted
Error from server (NotFound): pods "pod-to-be-evicted" not found
$ kubectl -n basic-resourceclaimtemplate get events --sort-by=.lastTimestamp | tail -3
28s Normal DeviceTaintManagerEviction pod/pod-to-be-evicted Marking for deletion
28s Normal Killing pod/pod-to-be-evicted Stopping container ctr0
$ kubectl -n extended-resource-request get pod pod0 -o wide
NAME READY STATUS RESTARTS AGE NODE
pod0 1/1 Running 0 69s dra-repro-worker
$ kubectl -n extended-resource-request get events --sort-by=.lastTimestamp
# Scheduled / Pulled / Created / Started only — zero eviction-related events, ever.
$ kubectl get resourceclaim -A
NAMESPACE NAME STATE
extended-resource-request pod0-extended-resources-v77pc allocated,reserved
# (pod-to-be-evicted's generated claim is gone — cleaned up along with the deleted pod)
Only one of the two pods using tainted devices from the same driver was ever evicted. This reproduces every time; there is no timing dependency.
Anything else we need to know?
Where this happens in the code
Traced against tag v1.36.3 and confirmed unchanged at master HEAD 94c136764292cc5fac976c0de6587daaea56410f;
live-reproduced above on kindest/node:v1.36.1 (closest publicly available kind node image to v1.36.3 at
time of testing — same code, confirmed by reading).
1. The eviction controller enumerates claims from exactly one field.
pkg/controller/devicetainteviction/device_taint_eviction.go, podEvictionTime
(current source),
is the only place this controller determines which claims a pod uses:
func (tc *Controller) podEvictionTime(pod *v1.Pod) *evictionAndReason {
if pod.Spec.NodeName == "" {
return nil
}
var eviction *evictionAndReason
for i := range pod.Spec.ResourceClaims {
claimName, mustCheckOwner, err := resourceclaim.Name(pod, &pod.Spec.ResourceClaims[i])
...
}
return eviction
}
Grepping the full 1691-line file for ExtendedResource returns zero matches, on both the tag and master.
2. The sibling resourceclaim controller already had to solve this exact problem.
pkg/controller/resourceclaim/controller.go, enqueuePod:
// Check if pod has any resource claims to process.
// Extended resource claims are stored in pod.Status.ExtendedResourceClaimStatus,
// not in pod.Spec.ResourceClaims, so we need to check both locations.
hasResourceClaims := len(pod.Spec.ResourceClaims) > 0
hasExtendedResourceClaims := pod.Status.ExtendedResourceClaimStatus != nil
...
// Without this, extended resource claims would never be cleaned up when
// pods complete, causing device resources to remain allocated indefinitely.
if hasExtendedResourceClaims {
claimName := pod.Status.ExtendedResourceClaimStatus.ResourceClaimName
...
}
The device-taint eviction controller never received the equivalent treatment.
3. Both contributing feature gates are Beta and default-on as of this release
(pkg/features/kube_features.go):
DRADeviceTaints: {
{Version: version.MustParse("1.33"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.36"), Default: true, PreRelease: featuregate.Beta},
},
DRAExtendedResource: {
{Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.36"), Default: true, PreRelease: featuregate.Beta},
},
So this isn't an edge-case opt-in combination — it's the default state of a stock 1.36+ cluster with any DRA
driver and DeviceTaintRule usage.
4. No test coverage exercises this interaction. test/integration/dra/device_taints.go and
pkg/controller/devicetainteviction/device_taint_eviction_test.go (2926 lines) both have zero hits for
ExtendedResource.
5. Allocation-time taint enforcement is unaffected (see "Scope note" above) —
staging/src/k8s.io/dynamic-resource-allocation/structured/internal/stable/allocator_stable.go,
taintPreventsAllocation, is reached identically for extended-resource and explicit-claim requests, since
extendeddynamicresources.go builds an in-memory claim for the extended-resource path before Filter runs.
Included here to preempt the natural "does this mean extended-resource claims ignore taints entirely?"
question — they don't; only post-allocation NoExecute eviction is blind.
Suggested fix direction
Mirror the resourceclaim controller's dual-location check into podEvictionTime — i.e. also resolve
pod.Status.ExtendedResourceClaimStatus.ResourceClaimName when present, using the same claim lookup/ownership
logic already used for pod.Spec.ResourceClaims. This looks like a contained, well-scoped fix; happy to
attempt a PR once a maintainer confirms this is the intended approach (rather than, say, deprecating the
implicit extended-resource path's exemption from eviction on purpose — which seems unlikely given point 2
above, but worth confirming).
Kubernetes version
Live reproduction (above): `kindest/node:v1.36.1`, vanilla upstream via `kind`. Code paths involved confirmed identical at tag `v1.36.3` (latest stable at time of this report) and master HEAD `94c136764292cc5fac976c0de6587daaea56410f` by direct source comparison.Cloud provider
None — plain `kind` (Kubernetes-in-Docker) cluster.OS version
Whatever the `kind` node image ships (containerized, not relevant to this bug).Install tools
`kind` v0.32.0, Helm v3.7+ (for the example driver chart).Container runtime (CRI) and version (if applicable)
containerd (as shipped in the `kind` node image), with `enable_cdi = true` (required by the example DRA driver to advertise its simulated devices via CDI).Related plugins (CNI, CSI, ...) and versions (if applicable)
[kubernetes-sigs/dra-example-driver](https://github.com/kubernetes-sigs/dra-example-driver) v0.4.0 — a maintained, public example/reference DRA driver used purely to simulate GPU devices with no real hardware dependency. Not specific to this driver: the bug is entirely in core `kube-controller-manager` code and would reproduce identically with any DRA driver.Source: kubernetes/kubernetes