Skip to content

Commit 9f6c015

Browse files
authored
[CASCL-1386] (7/11) Evict EKS managed node groups (#3174)
* [CASCL-1386] Implement shared node cordon and drain primitives 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. * [CASCL-1386] Implement EKS managed node group eviction Part of a stack splitting #3026 (too large to review in one piece) into small pieces that each build and pass tests on their own. The command is fully functional only once the whole stack lands.
1 parent c9436c4 commit 9f6c015

7 files changed

Lines changed: 293 additions & 139 deletions

File tree

cmd/kubectl-datadog/autoscaling/cluster/evict/eks_mng.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,84 @@ package evict
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"log"
8+
"time"
69

10+
awssdk "github.com/aws/aws-sdk-go-v2/aws"
711
"github.com/aws/aws-sdk-go-v2/service/eks"
12+
ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/apimachinery/pkg/runtime"
15+
"k8s.io/apimachinery/pkg/util/wait"
816
"k8s.io/client-go/kubernetes"
9-
)
17+
"k8s.io/client-go/tools/pager"
1018

11-
var errEKSDrainIncomplete = errors.New("EKS managed node group drain did not complete within the timeout")
19+
commonaws "github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/aws"
20+
)
1221

1322
type EKSManagedNodeGroupAPI interface {
14-
DescribeNodegroup(ctx context.Context, in *eks.DescribeNodegroupInput, opts ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error)
1523
UpdateNodegroupConfig(ctx context.Context, in *eks.UpdateNodegroupConfigInput, opts ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error)
1624
}
1725

18-
func evictEKSManagedNodeGroup(ctx context.Context, eksAPI EKSManagedNodeGroupAPI, clientset kubernetes.Interface, clusterName, nodegroupName string, drainOpts nodeDrainOptions) error {
19-
panic("TODO: evictEKSManagedNodeGroup — implemented in PR https://github.com/DataDog/datadog-operator/pull/3174")
26+
func evictEKSManagedNodeGroup(ctx context.Context, eksAPI EKSManagedNodeGroupAPI, clientset kubernetes.Interface, clusterName, nodegroupName string, nodes []string, drainOpts nodeDrainOptions) error {
27+
cordoned, errs := cordonNodes(ctx, clientset, nodes, drainOpts.DryRun)
28+
for _, node := range cordoned {
29+
if err := drainNode(ctx, clientset, node.Name, drainOpts); err != nil {
30+
errs = append(errs, fmt.Errorf("drain node %s: %w", node.Name, err))
31+
}
32+
}
33+
if len(errs) > 0 {
34+
// A node still holds workloads: do NOT scale to zero, which would
35+
// terminate instances with pods still on them and bypass their PDBs.
36+
// The group is left untouched for a re-run.
37+
return fmt.Errorf("EKS node group %s/%s drain incomplete: %w", clusterName, nodegroupName, errors.Join(errs...))
38+
}
39+
40+
if drainOpts.DryRun {
41+
log.Printf("[dry-run] would scale EKS node group %s/%s to min=desired=0 (max preserved)", clusterName, nodegroupName)
42+
return nil
43+
}
44+
45+
if _, err := eksAPI.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{
46+
ClusterName: awssdk.String(clusterName),
47+
NodegroupName: awssdk.String(nodegroupName),
48+
ScalingConfig: &ekstypes.NodegroupScalingConfig{
49+
MinSize: awssdk.Int32(0),
50+
DesiredSize: awssdk.Int32(0),
51+
},
52+
}); err != nil {
53+
return fmt.Errorf("UpdateNodegroupConfig: %w", err)
54+
}
55+
log.Printf("EKS node group %s/%s drained; scaling config set to min=desired=0 (max preserved).", clusterName, nodegroupName)
56+
57+
return waitEKSNodegroupEmpty(ctx, clientset, clusterName, nodegroupName, drainOpts.NodeTimeout, drainOpts.PollInterval)
58+
}
59+
60+
// waitEKSNodegroupEmpty polls the Kubernetes API for nodes carrying the
61+
// `eks.amazonaws.com/nodegroup=<name>` label until none remain or the deadline
62+
// expires (the nodes are already drained; this waits for EKS to terminate the
63+
// instances). The wait is bounded by drainOpts.NodeTimeout — for large node
64+
// groups, callers should raise this above the default.
65+
func waitEKSNodegroupEmpty(ctx context.Context, clientset kubernetes.Interface, clusterName, nodegroupName string, timeout, pollInterval time.Duration) error {
66+
selector := commonaws.LabelEKSNodegroup + "=" + nodegroupName
67+
p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
68+
return clientset.CoreV1().Nodes().List(ctx, opts)
69+
})
70+
var remaining int
71+
err := wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) {
72+
remaining = 0
73+
if err := p.EachListItem(ctx, metav1.ListOptions{LabelSelector: selector}, func(runtime.Object) error {
74+
remaining++
75+
return nil
76+
}); err != nil {
77+
return false, fmt.Errorf("list EKS managed node group %s/%s nodes: %w", clusterName, nodegroupName, err)
78+
}
79+
return remaining == 0, nil
80+
})
81+
if err != nil {
82+
return fmt.Errorf("EKS node group %s/%s drain incomplete, %d node(s) still present: %w", clusterName, nodegroupName, remaining, err)
83+
}
84+
log.Printf("EKS node group %s/%s fully drained.", clusterName, nodegroupName)
85+
return nil
2086
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package evict
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
awssdk "github.com/aws/aws-sdk-go-v2/aws"
10+
"github.com/aws/aws-sdk-go-v2/service/eks"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
corev1 "k8s.io/api/core/v1"
14+
apierrors "k8s.io/apimachinery/pkg/api/errors"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
"k8s.io/apimachinery/pkg/runtime"
17+
"k8s.io/client-go/kubernetes/fake"
18+
clienttesting "k8s.io/client-go/testing"
19+
20+
commonaws "github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/aws"
21+
)
22+
23+
type stubEKS struct {
24+
gotInputs []*eks.UpdateNodegroupConfigInput
25+
err error
26+
}
27+
28+
func (s *stubEKS) UpdateNodegroupConfig(_ context.Context, in *eks.UpdateNodegroupConfigInput, _ ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error) {
29+
s.gotInputs = append(s.gotInputs, in)
30+
return &eks.UpdateNodegroupConfigOutput{}, s.err
31+
}
32+
33+
func mngDrainOpts() nodeDrainOptions {
34+
return nodeDrainOptions{
35+
EvictionTimeout: time.Second,
36+
NodeTimeout: 5 * time.Second,
37+
PollInterval: 10 * time.Millisecond,
38+
}
39+
}
40+
41+
func TestEvictEKSManagedNodeGroup(t *testing.T) {
42+
const cluster, mng, nodeName = "my-cluster", "my-mng", "ip-1"
43+
newMngNode := func() *corev1.Node {
44+
return &corev1.Node{ObjectMeta: metav1.ObjectMeta{
45+
Name: nodeName,
46+
Labels: map[string]string{commonaws.LabelEKSNodegroup: mng},
47+
}}
48+
}
49+
podOnNode := corev1.Pod{
50+
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default"},
51+
Spec: corev1.PodSpec{NodeName: nodeName},
52+
}
53+
54+
// installEvictionReactor makes the fake accept Eviction creates (echoing
55+
// the object) or fail them with evictErr.
56+
installEvictionReactor := func(client *fake.Clientset, evictErr error) {
57+
client.PrependReactor("create", "pods", func(a clienttesting.Action) (bool, runtime.Object, error) {
58+
ca, ok := a.(clienttesting.CreateAction)
59+
if !ok || ca.GetSubresource() != "eviction" {
60+
return false, nil, nil
61+
}
62+
if evictErr != nil {
63+
return true, nil, evictErr
64+
}
65+
return true, ca.GetObject(), nil
66+
})
67+
}
68+
// installPodListReactor returns the pod list for each 1-based List call, so a
69+
// case can drain a node across successive polls.
70+
installPodListReactor := func(client *fake.Clientset, lists func(call int) []corev1.Pod) {
71+
var n int
72+
client.PrependReactor("list", "pods", func(_ clienttesting.Action) (bool, runtime.Object, error) {
73+
n++
74+
return true, &corev1.PodList{Items: lists(n)}, nil
75+
})
76+
}
77+
// installNodeListReactor drives waitEKSNodegroupEmpty's Nodes().List (by
78+
// nodegroup label). It does not affect cordonNodes, which uses Get + Update.
79+
installNodeListReactor := func(client *fake.Clientset, items []corev1.Node) {
80+
client.PrependReactor("list", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
81+
return true, &corev1.NodeList{Items: items}, nil
82+
})
83+
}
84+
assertScaledToZero := func(t *testing.T, in *eks.UpdateNodegroupConfigInput) {
85+
t.Helper()
86+
assert.Equal(t, cluster, awssdk.ToString(in.ClusterName))
87+
assert.Equal(t, mng, awssdk.ToString(in.NodegroupName))
88+
require.NotNil(t, in.ScalingConfig)
89+
assert.Equal(t, int32(0), awssdk.ToInt32(in.ScalingConfig.MinSize))
90+
assert.Equal(t, int32(0), awssdk.ToInt32(in.ScalingConfig.DesiredSize))
91+
// MaxSize is deliberately omitted so EKS preserves the existing ceiling
92+
// (partial ScalingConfig update).
93+
assert.Nil(t, in.ScalingConfig.MaxSize)
94+
}
95+
96+
t.Run("dry-run cordons nothing and skips Update", func(t *testing.T) {
97+
stub := &stubEKS{}
98+
client := fake.NewClientset(newMngNode())
99+
opts := mngDrainOpts()
100+
opts.DryRun = true
101+
102+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, opts)
103+
require.NoError(t, err)
104+
assert.Empty(t, stub.gotInputs)
105+
got, getErr := client.CoreV1().Nodes().Get(t.Context(), nodeName, metav1.GetOptions{})
106+
require.NoError(t, getErr)
107+
assert.False(t, got.Spec.Unschedulable, "dry-run must not cordon")
108+
})
109+
110+
t.Run("cordons, drains gracefully, then scales to zero", func(t *testing.T) {
111+
stub := &stubEKS{}
112+
client := fake.NewClientset(newMngNode())
113+
installEvictionReactor(client, nil)
114+
// The node still hosts the pod when drainNode enumerates it (call 1);
115+
// the eviction takes effect by the time waitForNodeEmpty polls (call 2+).
116+
installPodListReactor(client, func(call int) []corev1.Pod {
117+
if call == 1 {
118+
return []corev1.Pod{podOnNode}
119+
}
120+
return nil
121+
})
122+
// EKS has terminated the instance by the time we poll for nodes.
123+
installNodeListReactor(client, nil)
124+
125+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
126+
require.NoError(t, err)
127+
require.Len(t, stub.gotInputs, 1)
128+
assertScaledToZero(t, stub.gotInputs[0])
129+
got, getErr := client.CoreV1().Nodes().Get(t.Context(), nodeName, metav1.GetOptions{})
130+
require.NoError(t, getErr)
131+
assert.True(t, got.Spec.Unschedulable, "the node must be cordoned before draining")
132+
})
133+
134+
t.Run("drain failure skips scale-down", func(t *testing.T) {
135+
stub := &stubEKS{}
136+
// The seeded pod is never removed, so the node never empties.
137+
client := fake.NewClientset(newMngNode(), &podOnNode)
138+
installEvictionReactor(client, apierrors.NewTooManyRequests("PDB blocked", 1))
139+
opts := mngDrainOpts()
140+
opts.EvictionTimeout = 30 * time.Millisecond
141+
opts.NodeTimeout = 60 * time.Millisecond
142+
143+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, opts)
144+
require.Error(t, err)
145+
assert.Empty(t, stub.gotInputs, "must not scale down while a node still holds workloads")
146+
})
147+
148+
t.Run("cordon failure skips scale-down", func(t *testing.T) {
149+
stub := &stubEKS{}
150+
client := fake.NewClientset(newMngNode())
151+
client.PrependReactor("update", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
152+
return true, nil, apierrors.NewInternalError(errors.New("cordon boom"))
153+
})
154+
155+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
156+
require.Error(t, err)
157+
assert.Empty(t, stub.gotInputs)
158+
})
159+
160+
t.Run("UpdateNodegroupConfig failure after drain surfaces the error", func(t *testing.T) {
161+
stub := &stubEKS{err: errors.New("api error")}
162+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
163+
164+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
165+
require.Error(t, err)
166+
assert.Contains(t, err.Error(), "UpdateNodegroupConfig")
167+
require.Len(t, stub.gotInputs, 1)
168+
})
169+
170+
t.Run("post-scale wait timeout surfaces an error", func(t *testing.T) {
171+
stub := &stubEKS{}
172+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
173+
// The instance never terminates, so waitEKSNodegroupEmpty times out.
174+
installNodeListReactor(client, []corev1.Node{*newMngNode()})
175+
opts := mngDrainOpts()
176+
opts.NodeTimeout = 60 * time.Millisecond
177+
178+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, opts)
179+
require.Error(t, err)
180+
assert.Contains(t, err.Error(), mng)
181+
require.Len(t, stub.gotInputs, 1)
182+
})
183+
184+
t.Run("post-scale node-list error surfaces an error", func(t *testing.T) {
185+
stub := &stubEKS{}
186+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
187+
// The scale-down succeeded, but the Nodes().List used to confirm the
188+
// group emptied errors out, so the drain cannot be confirmed complete.
189+
client.PrependReactor("list", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
190+
return true, nil, apierrors.NewInternalError(errors.New("apiserver unreachable"))
191+
})
192+
193+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
194+
require.Error(t, err)
195+
require.Len(t, stub.gotInputs, 1)
196+
})
197+
}

cmd/kubectl-datadog/autoscaling/cluster/evict/evict_pods.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,6 @@ type nodeDrainOptions struct {
2828
PollInterval time.Duration // interval between empty-checks; default 2s
2929
}
3030

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-
4231
// drainNode evicts every evictable pod from the node and waits for the node to
4332
// become empty. Pods owned by a DaemonSet, mirror pods, terminating pods and
4433
// completed Job pods are skipped — the kubelet handles their cleanup when the

cmd/kubectl-datadog/autoscaling/cluster/evict/prompt.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,6 @@ func printPlan(streams genericclioptions.IOStreams, info *clusterinfo.ClusterInf
3838
}
3939
if ensurePDBs {
4040
fmt.Fprintln(streams.Out, " • Remove the temporary PodDisruptionBudgets created above")
41-
// Cleanup is deferred when an EKS managed node group drain times out
42-
// (see run.go): EKS may still be evicting pods, so the temp PDBs are
43-
// left in place and reclaimed on a later rerun. Only mention this when
44-
// the plan actually targets an EKS managed node group.
45-
if _, hasEKS := grouped[clusterinfo.NodeManagerEKSManagedNodeGroup]; hasEKS {
46-
fmt.Fprintln(streams.Out, " ◦ (deferred to a later rerun if an EKS managed node group drain times out)")
47-
}
4841
}
4942
fmt.Fprintln(streams.Out, color.YellowString("\n⚠ This will drain workloads and terminate the underlying instances of non-Datadog node groups."))
5043
fmt.Fprintln(streams.Out, color.YellowString(" Verify the Datadog Karpenter NodePool has enough capacity headroom for the migrated pods."))

cmd/kubectl-datadog/autoscaling/cluster/evict/prompt_test.go

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,6 @@ func TestPrintPlan(t *testing.T) {
5252
// Standalone entry with empty name renders as "(all)".
5353
"(all) (1 node(s))",
5454
},
55-
// No EKS managed node group in this plan ⇒ no deferred-cleanup note.
56-
wantNotContains: []string{"deferred to a later rerun"},
57-
},
58-
{
59-
// An EKS managed node group target surfaces the deferred-cleanup
60-
// caveat next to the PDB removal bullet.
61-
name: "EKS managed node group adds deferred-cleanup note",
62-
info: &clusterinfo.ClusterInfo{Autoscaling: caAbsent},
63-
targets: []Target{{Manager: clusterinfo.NodeManagerEKSManagedNodeGroup, Entity: "mng-1", Nodes: []string{"ip-5"}}},
64-
scaleCA: false,
65-
ensurePDBs: true,
66-
wantContains: []string{
67-
"Remove the temporary PodDisruptionBudgets",
68-
"deferred to a later rerun if an EKS managed node group drain times out",
69-
},
7055
},
7156
{
7257
// scaleCA=false hides the CA bullet even when CA is Present;

0 commit comments

Comments
 (0)