Skip to content

Commit 2592fea

Browse files
committed
fix(drain): don't hold eviction slots across PDB backoff sleeps
A PDB-blocked pod used to keep its concurrency slot while sleeping in the 429 retry loop. Five blocked pods therefore occupied every slot until the shared drain deadline, and pods with no budget at all timed out without a single eviction attempt. The slot is now held only around each Eviction call, acquisition is context-aware, and a pod that never got an attempt says so instead of claiming a PDB timeout. Reproduced live on a kind cluster (10 budget-blocked pods + 1 free pod, 20s deadline): before, zero evictions and the free pod failed with "evict: context deadline exceeded"; after, the free pod evicts at once and only the blocked pods report PDB timeouts. Claude-Session: https://claude.ai/code/session_01NE55VsYGkb5tV3neKDmtZw
1 parent eb4dd40 commit 2592fea

2 files changed

Lines changed: 105 additions & 4 deletions

File tree

‎pkg/k8score/drain_plan_test.go‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package k8score
22

33
import (
44
"context"
5+
"fmt"
6+
"strings"
7+
"sync"
58
"testing"
69
"time"
710

@@ -283,3 +286,78 @@ func TestPlanNodeDrainListsPDBsOnlyWhereADecisionNeedsThem(t *testing.T) {
283286
}
284287
}
285288
}
289+
290+
// Six PDB-blocked pods sleep in their 429 retry loops; with only five concurrency
291+
// slots, a pod with no budget at all must still get its eviction attempted promptly.
292+
// The free pod's reactor is gated on five distinct blocked pods having been attempted
293+
// first, so this test cannot pass by racing ahead of the blocked retry loops.
294+
func TestDrainNodeEvictsFreePodWhilePDBBlockedPodsRetry(t *testing.T) {
295+
objs := []runtime.Object{&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "worker-1"}}}
296+
for i := 0; i < 6; i++ {
297+
p := drainTestPod(fmt.Sprintf("blocked-%d", i))
298+
objs = append(objs, &p)
299+
}
300+
free := drainTestPod("free")
301+
objs = append(objs, &free)
302+
client := fake.NewSimpleClientset(objs...)
303+
304+
// Reactors run under the fake clientset's lock, so the gate must never block:
305+
// the free pod is refused with the same 429 until five distinct blocked pods
306+
// have been attempted, then its next retry is allowed through.
307+
var mu sync.Mutex
308+
blockedSeen := map[string]bool{}
309+
gateOpen := false
310+
var freeEvictedAfter time.Duration
311+
start := time.Now()
312+
pdbRefused := apierrors.NewTooManyRequests("Cannot evict pod as it would violate the pod's disruption budget.", 0)
313+
314+
client.PrependReactor("create", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
315+
if action.GetSubresource() != "eviction" {
316+
return false, nil, nil
317+
}
318+
ev, ok := action.(k8stesting.CreateAction).GetObject().(*policyv1.Eviction)
319+
if !ok {
320+
t.Errorf("eviction create carried unexpected object")
321+
return true, nil, nil
322+
}
323+
mu.Lock()
324+
defer mu.Unlock()
325+
if ev.Name == "free" {
326+
if !gateOpen {
327+
return true, nil, pdbRefused
328+
}
329+
if freeEvictedAfter == 0 {
330+
freeEvictedAfter = time.Since(start)
331+
}
332+
return true, nil, nil
333+
}
334+
blockedSeen[ev.Name] = true
335+
if len(blockedSeen) >= 5 {
336+
gateOpen = true
337+
}
338+
return true, nil, pdbRefused
339+
})
340+
341+
const timeout = 3 * time.Second
342+
res, err := DrainNode(context.Background(), client, "worker-1", DrainOptions{IgnoreDaemonSets: true, Timeout: timeout})
343+
if err != nil {
344+
t.Fatalf("DrainNode: %v", err)
345+
}
346+
347+
if len(res.EvictedPods) != 1 || res.EvictedPods[0] != "shop/free" {
348+
t.Fatalf("free pod must be evicted, got evicted=%v errors=%v", res.EvictedPods, res.Errors)
349+
}
350+
// Half the deadline separates "evicted while the blocked pods retried" from
351+
// "evicted only after the deadline freed their slots".
352+
if freeEvictedAfter <= 0 || freeEvictedAfter > timeout/2 {
353+
t.Fatalf("free pod was evicted after %v; slots are being held across PDB backoff sleeps", freeEvictedAfter)
354+
}
355+
if len(res.Errors) != 6 {
356+
t.Fatalf("all six blocked pods must report errors, got %v", res.Errors)
357+
}
358+
for _, e := range res.Errors {
359+
if !strings.Contains(e, "timed out waiting for PDB to allow eviction") {
360+
t.Fatalf("blocked pod error must name the PDB timeout, got %q", e)
361+
}
362+
}
363+
}

‎pkg/k8score/node_ops.go‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,8 @@ func DrainNode(ctx context.Context, client kubernetes.Interface, nodeName string
111111
wg.Add(1)
112112
go func() {
113113
defer wg.Done()
114-
sem <- struct{}{}
115-
defer func() { <-sem }()
116114

117-
err := evictPod(drainCtx, client, pod, opts.GracePeriodSeconds)
115+
err := evictPod(drainCtx, client, pod, opts.GracePeriodSeconds, sem)
118116
mu.Lock()
119117
defer mu.Unlock()
120118
if err != nil {
@@ -139,7 +137,11 @@ func hasLocalStorage(pod corev1.Pod) bool {
139137
}
140138

141139
// evictPod evicts a single pod, retrying on PDB conflicts until the context deadline.
142-
func evictPod(ctx context.Context, client kubernetes.Interface, pod corev1.Pod, gracePeriod *int64) error {
140+
// The concurrency slot is held only around each Eviction call, never across the
141+
// backoff sleep: a handful of PDB-blocked pods sleeping in their retry loops would
142+
// otherwise occupy every slot until the shared deadline, and pods with no budget at
143+
// all would time out without a single eviction attempt.
144+
func evictPod(ctx context.Context, client kubernetes.Interface, pod corev1.Pod, gracePeriod *int64, sem chan struct{}) error {
143145
eviction := &policyv1.Eviction{
144146
ObjectMeta: metav1.ObjectMeta{
145147
Name: pod.Name,
@@ -154,9 +156,30 @@ func evictPod(ctx context.Context, client kubernetes.Interface, pod corev1.Pod,
154156

155157
backoff := 500 * time.Millisecond
156158
maxBackoff := 5 * time.Second
159+
attempted := false
160+
161+
deadlineErr := func() error {
162+
if attempted {
163+
return fmt.Errorf("timed out waiting for PDB to allow eviction: %w", ctx.Err())
164+
}
165+
return fmt.Errorf("drain deadline passed before this pod could be attempted: %w", ctx.Err())
166+
}
157167

158168
for {
169+
select {
170+
case sem <- struct{}{}:
171+
case <-ctx.Done():
172+
return deadlineErr()
173+
}
174+
// The select above can pick the slot even when cancellation is also ready.
175+
if ctx.Err() != nil {
176+
<-sem
177+
return deadlineErr()
178+
}
159179
err := client.PolicyV1().Evictions(pod.Namespace).Evict(ctx, eviction)
180+
<-sem
181+
attempted = true
182+
160183
if err == nil {
161184
return nil
162185
}

0 commit comments

Comments
 (0)