Skip to content

Commit c9436c4

Browse files
authored
[CASCL-1386] (6½/11) Implement shared node cordon and drain primitives (#3207)
Introduce the reusable node-cordon and pod-eviction/drain primitives that the per-manager evictors (EKS managed node groups, ASG, Karpenter, standalone) build on: - cordonNodes/cordonNode: idempotent, conflict-retrying cordon that treats an already-gone node as a silent skip and returns the cordoned Node objects. - drainNode: evicts every evictable pod (skipping mirror, DaemonSet, completed and terminating pods), then waits for the node to empty. - evictPodWithRetry: PDB-aware eviction that retries on 429 and treats 404 as success. - listPodsOnNode, waitForNodeEmpty and the pod-classification predicates (podOccupiesNode/shouldSkipEviction/isMirrorPod/isDaemonSetPod/isCompleted). These primitives have no production caller yet; the first (evictEKSManagedNode- Group) lands in the next PR in the stack. A temporary blank-identifier reference keeps golangci-lint's `unused` quiet until then (the linter runs with tests = false, so the unit tests do not count as use) and is removed by that PR.
1 parent 9f2359f commit c9436c4

4 files changed

Lines changed: 783 additions & 1 deletion

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package evict
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
8+
corev1 "k8s.io/api/core/v1"
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/client-go/kubernetes"
12+
"k8s.io/client-go/util/retry"
13+
)
14+
15+
// cordonNodes marks every node in the group Unschedulable up front, before any
16+
// of them is drained. A pod evicted from one node is then never rescheduled
17+
// onto another node of the same group that is itself about to be drained.
18+
//
19+
// It returns the Node objects that are now cordoned and safe to drain, so the
20+
// caller can read their immutable fields (e.g. providerID) without a second
21+
// Get. A node that is already gone is skipped silently (absent from both
22+
// return values); a node that fails to cordon is recorded in errs and left out
23+
// of cordoned so the caller never drains a node that can still receive pods.
24+
func cordonNodes(ctx context.Context, clientset kubernetes.Interface, nodes []string, dryRun bool) (cordoned []*corev1.Node, errs []error) {
25+
for _, nodeName := range nodes {
26+
if node, err := cordonNode(ctx, clientset, nodeName, dryRun); err != nil {
27+
errs = append(errs, fmt.Errorf("cordon node %s: %w", nodeName, err))
28+
} else if node != nil {
29+
cordoned = append(cordoned, node)
30+
}
31+
}
32+
return cordoned, errs
33+
}
34+
35+
// cordonNode marks the node Unschedulable and returns the resulting Node so the
36+
// caller can read immutable fields (e.g. providerID) without a second Get. It
37+
// returns (nil, nil) when the node is already gone: there is nothing to
38+
// schedule onto a deleted node, and a re-run rediscovers any surviving nodes.
39+
// The Get + mutate + Update is wrapped in RetryOnConflict to survive races
40+
// against kubelet, the scheduler, and any other controller that mutates Node
41+
// objects.
42+
func cordonNode(ctx context.Context, clientset kubernetes.Interface, name string, dryRun bool) (*corev1.Node, error) {
43+
var cordoned *corev1.Node
44+
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
45+
node, err := clientset.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{})
46+
if err != nil {
47+
if apierrors.IsNotFound(err) {
48+
return nil // node already gone: nothing to schedule on it
49+
}
50+
return fmt.Errorf("failed to get node %s: %w", name, err)
51+
}
52+
if dryRun {
53+
log.Printf("[dry-run] would cordon node %s", name)
54+
} else if !node.Spec.Unschedulable {
55+
node.Spec.Unschedulable = true
56+
node, err = clientset.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{})
57+
if err != nil {
58+
if apierrors.IsNotFound(err) {
59+
return nil // deleted concurrently: nothing to schedule on it
60+
}
61+
return fmt.Errorf("failed to update node %s: %w", name, err)
62+
}
63+
log.Printf("Cordoned node %s.", name)
64+
}
65+
cordoned = node
66+
return nil
67+
})
68+
return cordoned, err
69+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package evict
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
corev1 "k8s.io/api/core/v1"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/runtime"
13+
"k8s.io/apimachinery/pkg/runtime/schema"
14+
"k8s.io/client-go/kubernetes/fake"
15+
clienttesting "k8s.io/client-go/testing"
16+
)
17+
18+
func TestCordonNode(t *testing.T) {
19+
for _, tc := range []struct {
20+
name string
21+
// initialUnschedulable is the node's starting `Spec.Unschedulable`.
22+
initialUnschedulable bool
23+
// conflictFirstUpdate forces a Conflict on the first Update so
24+
// RetryOnConflict has to refetch and re-apply.
25+
conflictFirstUpdate bool
26+
// updateNotFound makes the Update return NotFound, simulating the node
27+
// being deleted between the Get and the Update.
28+
updateNotFound bool
29+
dryRun bool
30+
// wantUpdateCalls is the minimum number of Update invocations
31+
// expected on the Nodes endpoint.
32+
wantMinUpdateCalls int
33+
wantUnschedulable bool
34+
// wantNilNode expects cordonNode to return a nil Node (the node is gone).
35+
wantNilNode bool
36+
}{
37+
{
38+
name: "schedulable node becomes cordoned",
39+
wantMinUpdateCalls: 1,
40+
wantUnschedulable: true,
41+
},
42+
{
43+
// Already cordoned ⇒ no Update issued (idempotent).
44+
name: "already cordoned is a no-op",
45+
initialUnschedulable: true,
46+
wantMinUpdateCalls: 0,
47+
wantUnschedulable: true,
48+
},
49+
{
50+
name: "retries on Conflict",
51+
conflictFirstUpdate: true,
52+
wantMinUpdateCalls: 2,
53+
wantUnschedulable: true,
54+
},
55+
{
56+
name: "dry-run touches nothing",
57+
dryRun: true,
58+
wantMinUpdateCalls: 0,
59+
wantUnschedulable: false,
60+
},
61+
{
62+
// Node deleted between the Get and the Update ⇒ NotFound on Update
63+
// is a silent success (nothing to schedule onto a deleted node).
64+
name: "deleted between get and update is a no-op",
65+
updateNotFound: true,
66+
wantMinUpdateCalls: 1,
67+
wantUnschedulable: false,
68+
wantNilNode: true,
69+
},
70+
} {
71+
t.Run(tc.name, func(t *testing.T) {
72+
node := &corev1.Node{
73+
ObjectMeta: metav1.ObjectMeta{Name: "n1"},
74+
Spec: corev1.NodeSpec{Unschedulable: tc.initialUnschedulable},
75+
}
76+
client := fake.NewClientset(node)
77+
78+
var updateCalls int
79+
client.PrependReactor("update", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
80+
updateCalls++
81+
if tc.conflictFirstUpdate && updateCalls == 1 {
82+
return true, nil, apierrors.NewConflict(
83+
schema.GroupResource{Resource: "nodes"}, "n1", errors.New("forced conflict"),
84+
)
85+
}
86+
if tc.updateNotFound {
87+
return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "nodes"}, "n1")
88+
}
89+
return false, nil, nil
90+
})
91+
92+
gotNode, cordonErr := cordonNode(t.Context(), client, "n1", tc.dryRun)
93+
require.NoError(t, cordonErr)
94+
assert.Equal(t, tc.wantNilNode, gotNode == nil, "cordonNode nil-node contract")
95+
96+
assert.GreaterOrEqual(t, updateCalls, tc.wantMinUpdateCalls, "minimum Update calls")
97+
got, err := client.CoreV1().Nodes().Get(t.Context(), "n1", metav1.GetOptions{})
98+
require.NoError(t, err)
99+
assert.Equal(t, tc.wantUnschedulable, got.Spec.Unschedulable)
100+
})
101+
}
102+
}
103+
104+
// TestCordonNodes covers the up-front cordon pass: a live node is cordoned and
105+
// returned (carrying its state so the caller needs no second Get), an
106+
// already-gone node is a silent skip (no error, absent from the result, so a
107+
// re-run after a partial execution is not derailed), and a node that fails to
108+
// cordon is recorded as an error and excluded so the caller never drains a node
109+
// that can still receive pods.
110+
func TestCordonNodes(t *testing.T) {
111+
client := fake.NewClientset(
112+
&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "ip-live"}},
113+
&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "ip-bad"}},
114+
)
115+
// Updates to ip-bad fail with a non-conflict error so cordonNode gives up
116+
// immediately (RetryOnConflict only retries Conflict).
117+
client.PrependReactor("update", "nodes", func(action clienttesting.Action) (bool, runtime.Object, error) {
118+
ua, ok := action.(clienttesting.UpdateAction)
119+
if !ok || ua.GetObject().(*corev1.Node).Name != "ip-bad" {
120+
return false, nil, nil
121+
}
122+
return true, nil, apierrors.NewInternalError(errors.New("update boom"))
123+
})
124+
125+
// ip-gone is absent from the cluster: cordonNode must treat NotFound as a
126+
// silent skip.
127+
cordoned, errs := cordonNodes(t.Context(), client, []string{"ip-live", "ip-gone", "ip-bad"}, false)
128+
129+
require.Len(t, cordoned, 1, "only the live, successfully-cordoned node is returned")
130+
assert.Equal(t, "ip-live", cordoned[0].Name)
131+
assert.True(t, cordoned[0].Spec.Unschedulable, "the returned node carries the cordoned state")
132+
require.Len(t, errs, 1)
133+
assert.ErrorContains(t, errs[0], "ip-bad")
134+
}
Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
package evict
22

3-
import "time"
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"time"
8+
9+
"github.com/samber/lo"
10+
corev1 "k8s.io/api/core/v1"
11+
policyv1 "k8s.io/api/policy/v1"
12+
apierrors "k8s.io/apimachinery/pkg/api/errors"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/apimachinery/pkg/fields"
15+
"k8s.io/apimachinery/pkg/runtime"
16+
"k8s.io/apimachinery/pkg/runtime/schema"
17+
"k8s.io/apimachinery/pkg/util/wait"
18+
"k8s.io/client-go/kubernetes"
19+
"k8s.io/client-go/tools/pager"
20+
"k8s.io/client-go/util/retry"
21+
)
422

523
// nodeDrainOptions captures the per-call tunables for evicting a node's pods.
624
type nodeDrainOptions struct {
@@ -9,3 +27,149 @@ type nodeDrainOptions struct {
927
NodeTimeout time.Duration // total wait for the node to become empty
1028
PollInterval time.Duration // interval between empty-checks; default 2s
1129
}
30+
31+
// TODO(CASCL-1386): remove this block once the EKS managed node group eviction
32+
// (next PR in the stack) calls these primitives. They are introduced here as
33+
// shared building blocks; golangci-lint's `unused` runs with `tests = false`,
34+
// so the test coverage in this package does not count as use. Referencing the
35+
// two entry points keeps `unused` quiet until the first production caller lands
36+
// (drainNode/cordonNodes transitively reach every other helper below).
37+
var (
38+
_ = drainNode
39+
_ = cordonNodes
40+
)
41+
42+
// drainNode evicts every evictable pod from the node and waits for the node to
43+
// become empty. Pods owned by a DaemonSet, mirror pods, terminating pods and
44+
// completed Job pods are skipped — the kubelet handles their cleanup when the
45+
// underlying instance disappears.
46+
//
47+
// Pods that cannot be evicted (PDB-blocked beyond EvictionTimeout, etc.) are
48+
// logged as warnings; drainNode then continues with the remaining pods rather
49+
// than aborting the whole run.
50+
func drainNode(ctx context.Context, clientset kubernetes.Interface, nodeName string, opts nodeDrainOptions) error {
51+
if opts.DryRun {
52+
log.Printf("[dry-run] would drain node %s", nodeName)
53+
return nil
54+
}
55+
pods, err := listPodsOnNode(ctx, clientset, nodeName)
56+
if err != nil {
57+
return fmt.Errorf("failed to list pods on node %s: %w", nodeName, err)
58+
}
59+
for _, p := range pods {
60+
if shouldSkipEviction(&p) {
61+
continue
62+
}
63+
if err := evictPodWithRetry(ctx, clientset, &p, opts.EvictionTimeout, opts.PollInterval); err != nil {
64+
log.Printf("Warning: pod %s/%s: %v", p.Namespace, p.Name, err)
65+
}
66+
}
67+
return waitForNodeEmpty(ctx, clientset, nodeName, opts.NodeTimeout, opts.PollInterval)
68+
}
69+
70+
// listPodsOnNode enumerates pods scheduled on the given node, server-side
71+
// filtered via the spec.nodeName field selector.
72+
func listPodsOnNode(ctx context.Context, clientset kubernetes.Interface, nodeName string) (pods []corev1.Pod, err error) {
73+
p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
74+
return clientset.CoreV1().Pods(metav1.NamespaceAll).List(ctx, opts)
75+
})
76+
if err = p.EachListItem(ctx, metav1.ListOptions{
77+
FieldSelector: fields.OneTermEqualSelector("spec.nodeName", nodeName).String(),
78+
}, func(obj runtime.Object) error {
79+
pods = append(pods, *obj.(*corev1.Pod))
80+
return nil
81+
}); err != nil {
82+
return nil, err
83+
}
84+
return pods, nil
85+
}
86+
87+
// evictPodWithRetry sends a single Eviction request and retries while the
88+
// apiserver returns 429 TooManyRequests (the canonical PDB-blocked signal).
89+
// Retries at retryInterval for up to timeout's worth of attempts; the caller
90+
// then logs and moves to the next pod.
91+
//
92+
// 404 (pod already gone) is treated as success — the eviction goal is met.
93+
// Non-429 errors are returned immediately.
94+
func evictPodWithRetry(ctx context.Context, clientset kubernetes.Interface, p *corev1.Pod, timeout, retryInterval time.Duration) error {
95+
eviction := &policyv1.Eviction{
96+
ObjectMeta: metav1.ObjectMeta{Name: p.Name, Namespace: p.Namespace},
97+
}
98+
steps := 1
99+
if retryInterval > 0 {
100+
steps = max(1, int(timeout/retryInterval))
101+
}
102+
backoff := wait.Backoff{Duration: retryInterval, Factor: 1, Steps: steps}
103+
err := retry.OnError(backoff, apierrors.IsTooManyRequests, func() error {
104+
return clientset.CoreV1().Pods(p.Namespace).EvictV1(ctx, eviction)
105+
})
106+
switch {
107+
case err == nil:
108+
log.Printf("Evicted pod %s/%s.", p.Namespace, p.Name)
109+
return nil
110+
case apierrors.IsNotFound(err):
111+
return nil // already gone — eviction goal met
112+
case apierrors.IsTooManyRequests(err):
113+
// OnError exhausted its retries while still 429-blocked.
114+
return fmt.Errorf("eviction timed out (likely PDB-blocked): %w", err)
115+
default:
116+
return fmt.Errorf("eviction failed: %w", err)
117+
}
118+
}
119+
120+
// waitForNodeEmpty polls the node until no pod still occupies it. A pod is
121+
// considered to still occupy the node until it is actually gone from the API
122+
// server — including pods that are merely terminating (DeletionTimestamp
123+
// set). This matters for callers that terminate the underlying EC2 instance
124+
// next: returning early while a pod is mid-grace-period would kill the
125+
// container before its preStop hook / graceful shutdown finishes.
126+
//
127+
// DaemonSet pods, mirror pods and completed pods are not counted: DS and
128+
// mirror pods are tied to the node's lifecycle and disappear with it,
129+
// completed pods are inert and GC'd promptly.
130+
func waitForNodeEmpty(ctx context.Context, clientset kubernetes.Interface, nodeName string, timeout, pollInterval time.Duration) error {
131+
return wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) {
132+
pods, err := listPodsOnNode(ctx, clientset, nodeName)
133+
if err != nil {
134+
return false, fmt.Errorf("failed to list pods on node %s: %w", nodeName, err)
135+
}
136+
return lo.NoneBy(pods, func(p corev1.Pod) bool {
137+
return podOccupiesNode(&p)
138+
}), nil
139+
})
140+
}
141+
142+
// shouldSkipEviction reports whether drainNode should leave a pod alone
143+
// rather than call the Eviction API on it. A pod already terminating is
144+
// skipped because issuing another eviction would 404 once the kubelet
145+
// finishes the deletion; every pod that does not occupy the node for drain
146+
// purposes (mirror, DaemonSet and completed pods) needs no eviction either.
147+
func shouldSkipEviction(p *corev1.Pod) bool {
148+
return p.DeletionTimestamp != nil || !podOccupiesNode(p)
149+
}
150+
151+
// podOccupiesNode reports whether a pod still consumes the node from a drain
152+
// perspective. Terminating pods DO occupy the node until they are gone;
153+
// returning false for them prematurely would let the caller terminate the
154+
// instance before the container finished its grace period.
155+
func podOccupiesNode(p *corev1.Pod) bool {
156+
return !isMirrorPod(p) && !isDaemonSetPod(p) && !isCompleted(p)
157+
}
158+
159+
func isMirrorPod(p *corev1.Pod) bool {
160+
_, ok := p.Annotations[corev1.MirrorPodAnnotationKey]
161+
return ok
162+
}
163+
164+
func isDaemonSetPod(p *corev1.Pod) bool {
165+
ctrl := metav1.GetControllerOf(p)
166+
if ctrl == nil || ctrl.Kind != "DaemonSet" {
167+
return false
168+
}
169+
gv, _ := schema.ParseGroupVersion(ctrl.APIVersion)
170+
return gv.Group == "apps"
171+
}
172+
173+
func isCompleted(p *corev1.Pod) bool {
174+
return p.Status.Phase == corev1.PodSucceeded || p.Status.Phase == corev1.PodFailed
175+
}

0 commit comments

Comments
 (0)