Skip to content

Commit 409d632

Browse files
committed
[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 47f7ed9 commit 409d632

6 files changed

Lines changed: 296 additions & 34 deletions

File tree

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

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,86 @@ 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"
17+
"k8s.io/client-go/tools/pager"
18+
19+
commonaws "github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/aws"
920
)
1021

1122
var errEKSDrainIncomplete = errors.New("EKS managed node group drain did not complete within the timeout")
1223

1324
type EKSManagedNodeGroupAPI interface {
14-
DescribeNodegroup(ctx context.Context, in *eks.DescribeNodegroupInput, opts ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error)
1525
UpdateNodegroupConfig(ctx context.Context, in *eks.UpdateNodegroupConfigInput, opts ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error)
1626
}
1727

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")
28+
func evictEKSManagedNodeGroup(ctx context.Context, eksAPI EKSManagedNodeGroupAPI, clientset kubernetes.Interface, clusterName, nodegroupName string, nodes []string, drainOpts nodeDrainOptions) error {
29+
cordoned, errs := cordonNodes(ctx, clientset, nodes, drainOpts.DryRun)
30+
for _, node := range cordoned {
31+
if err := drainNode(ctx, clientset, node.Name, drainOpts); err != nil {
32+
errs = append(errs, fmt.Errorf("drain node %s: %w", node.Name, err))
33+
}
34+
}
35+
if len(errs) > 0 {
36+
// A node still holds workloads: do NOT scale to zero, which would
37+
// terminate instances with pods still on them and bypass their PDBs.
38+
// Wrapping errEKSDrainIncomplete keeps the temporary PDBs in place; the
39+
// group is left untouched for a re-run.
40+
return fmt.Errorf("EKS node group %s/%s drain incomplete: %w: %w", clusterName, nodegroupName, errors.Join(errs...), errEKSDrainIncomplete)
41+
}
42+
43+
if drainOpts.DryRun {
44+
log.Printf("[dry-run] would scale EKS node group %s/%s to min=desired=0 (max preserved)", clusterName, nodegroupName)
45+
return nil
46+
}
47+
48+
if _, err := eksAPI.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{
49+
ClusterName: awssdk.String(clusterName),
50+
NodegroupName: awssdk.String(nodegroupName),
51+
ScalingConfig: &ekstypes.NodegroupScalingConfig{
52+
MinSize: awssdk.Int32(0),
53+
DesiredSize: awssdk.Int32(0),
54+
},
55+
}); err != nil {
56+
return fmt.Errorf("UpdateNodegroupConfig: %w", err)
57+
}
58+
log.Printf("EKS node group %s/%s drained; scaling config set to min=desired=0 (max preserved).", clusterName, nodegroupName)
59+
60+
return waitEKSNodegroupEmpty(ctx, clientset, clusterName, nodegroupName, drainOpts.NodeTimeout, drainOpts.PollInterval)
61+
}
62+
63+
// waitEKSNodegroupEmpty polls the Kubernetes API for nodes carrying the
64+
// `eks.amazonaws.com/nodegroup=<name>` label until none remain or the deadline
65+
// expires. The EKS drain is bounded by drainOpts.NodeTimeout — for large node
66+
// groups, callers should raise this above the default.
67+
func waitEKSNodegroupEmpty(ctx context.Context, clientset kubernetes.Interface, clusterName, nodegroupName string, timeout, pollInterval time.Duration) error {
68+
selector := commonaws.LabelEKSNodegroup + "=" + nodegroupName
69+
p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
70+
return clientset.CoreV1().Nodes().List(ctx, opts)
71+
})
72+
var remaining int
73+
err := wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) {
74+
remaining = 0
75+
if err := p.EachListItem(ctx, metav1.ListOptions{LabelSelector: selector}, func(runtime.Object) error {
76+
remaining++
77+
return nil
78+
}); err != nil {
79+
return false, fmt.Errorf("list EKS managed node group %s/%s nodes: %w", clusterName, nodegroupName, err)
80+
}
81+
return remaining == 0, nil
82+
})
83+
if err != nil {
84+
return fmt.Errorf("EKS node group %s/%s drain incomplete, %d node(s) still present: %w: %w", clusterName, nodegroupName, remaining, err, errEKSDrainIncomplete)
85+
}
86+
log.Printf("EKS node group %s/%s fully drained.", clusterName, nodegroupName)
87+
return nil
2088
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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 keeps PDBs and 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.ErrorIs(t, err, errEKSDrainIncomplete)
146+
assert.Empty(t, stub.gotInputs, "must not scale down while a node still holds workloads")
147+
})
148+
149+
t.Run("cordon failure keeps PDBs and skips scale-down", func(t *testing.T) {
150+
stub := &stubEKS{}
151+
client := fake.NewClientset(newMngNode())
152+
client.PrependReactor("update", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
153+
return true, nil, apierrors.NewInternalError(errors.New("cordon boom"))
154+
})
155+
156+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
157+
require.Error(t, err)
158+
assert.ErrorIs(t, err, errEKSDrainIncomplete)
159+
assert.Empty(t, stub.gotInputs)
160+
})
161+
162+
t.Run("UpdateNodegroupConfig failure after drain does not wrap the sentinel", func(t *testing.T) {
163+
stub := &stubEKS{err: errors.New("api error")}
164+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
165+
166+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
167+
require.Error(t, err)
168+
assert.Contains(t, err.Error(), "UpdateNodegroupConfig")
169+
assert.NotErrorIs(t, err, errEKSDrainIncomplete)
170+
require.Len(t, stub.gotInputs, 1)
171+
})
172+
173+
t.Run("post-scale wait timeout wraps the sentinel", func(t *testing.T) {
174+
stub := &stubEKS{}
175+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
176+
// The instance never terminates, so waitEKSNodegroupEmpty times out.
177+
installNodeListReactor(client, []corev1.Node{*newMngNode()})
178+
opts := mngDrainOpts()
179+
opts.NodeTimeout = 60 * time.Millisecond
180+
181+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, opts)
182+
require.Error(t, err)
183+
assert.ErrorIs(t, err, errEKSDrainIncomplete)
184+
assert.Contains(t, err.Error(), mng)
185+
require.Len(t, stub.gotInputs, 1)
186+
})
187+
188+
t.Run("post-scale node-list error keeps the PDBs (wraps the sentinel)", func(t *testing.T) {
189+
stub := &stubEKS{}
190+
client := fake.NewClientset(newMngNode()) // no pods ⇒ the node drains immediately
191+
// The scale-down succeeded, but the Nodes().List used to confirm the
192+
// group emptied errors out, so the drain cannot be confirmed complete.
193+
client.PrependReactor("list", "nodes", func(_ clienttesting.Action) (bool, runtime.Object, error) {
194+
return true, nil, apierrors.NewInternalError(errors.New("apiserver unreachable"))
195+
})
196+
197+
err := evictEKSManagedNodeGroup(t.Context(), stub, client, cluster, mng, []string{nodeName}, mngDrainOpts())
198+
require.Error(t, err)
199+
assert.ErrorIs(t, err, errEKSDrainIncomplete)
200+
require.Len(t, stub.gotInputs, 1)
201+
})
202+
}

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: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ 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.
41+
// Cleanup is deferred when an EKS managed node group does not finish
42+
// draining (see run.go): workloads may still be on its nodes, so the temp
43+
// PDBs are left in place and reclaimed on a later rerun. Only mention this
44+
// when the plan actually targets an EKS managed node group.
4545
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)")
46+
fmt.Fprintln(streams.Out, " ◦ (deferred to a later rerun if an EKS managed node group does not finish draining)")
4747
}
4848
}
4949
fmt.Fprintln(streams.Out, color.YellowString("\n⚠ This will drain workloads and terminate the underlying instances of non-Datadog node groups."))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestPrintPlan(t *testing.T) {
6565
ensurePDBs: true,
6666
wantContains: []string{
6767
"Remove the temporary PodDisruptionBudgets",
68-
"deferred to a later rerun if an EKS managed node group drain times out",
68+
"deferred to a later rerun if an EKS managed node group does not finish draining",
6969
},
7070
},
7171
{

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@ func Run(ctx context.Context, streams genericclioptions.IOStreams, configFlags *
151151
// appear BEFORE the final success/failure summary, so the user does not
152152
// experience an apparent hang after seeing the "✅" box.
153153
//
154-
// When at least one EKS managed node group did not finish draining
155-
// within the timeout, we skip the cleanup: EKS may still be evicting
156-
// pods on those nodes, and dropping the temp PDBs now would leave
157-
// cross-type workloads unprotected mid-drain. The label-based cleanup
158-
// on a subsequent run picks the PDBs up once EKS converges.
154+
// When at least one EKS managed node group did not finish draining, we skip
155+
// the cleanup: workloads may still be on its nodes (our drain did not
156+
// complete, or EKS had not finished terminating the drained instances), and
157+
// dropping the temp PDBs now would leave cross-type workloads unprotected. A
158+
// subsequent run finishes the drain and reclaims the PDBs by label.
159159
switch {
160160
case !opts.EnsurePDBs:
161161
case result.EKSDrainIncomplete:
162-
log.Printf("Note: at least one EKS managed node group did not finish draining within --node-timeout; temporary PDBs left in place so EKS can finish safely. Re-run the command once `aws eks describe-nodegroup` reports the group at 0 nodes — the next run will clean them up.")
162+
log.Printf("Note: at least one EKS managed node group did not finish draining; temporary PDBs were left in place. Re-run the command to retry the drain — it will finish evicting the remaining pods (or clean up the PDBs once the group is empty).")
163163
default:
164164
if err := cleanupTempPDBs(ctx, cli.K8sClient, opts.DryRun); err != nil {
165165
log.Printf("Warning: failed to cleanup temporary PDBs: %v", err)
@@ -201,9 +201,11 @@ func classify(ctx context.Context, clientset *kubernetes.Clientset, cli *clients
201201
// evictResult is the structured outcome of evictAllTargets. Errors is the
202202
// aggregated per-target error list (empty when everything succeeded).
203203
// EKSDrainIncomplete is true when at least one EKS managed node group target
204-
// returned an error WRAPPING errEKSDrainIncomplete — the EKS drain may still
205-
// be in progress, in which case the caller must NOT cleanup the temporary
206-
// PDBs (EKS would then over-disrupt the still-running workloads).
204+
// returned an error WRAPPING errEKSDrainIncomplete — workloads may still be on
205+
// its nodes (the cordon/drain did not complete, or EKS had not finished
206+
// terminating the drained instances), in which case the caller must NOT clean
207+
// up the temporary PDBs (they would then over-disrupt the still-running
208+
// workloads).
207209
type evictResult struct {
208210
Errors []error
209211
EKSDrainIncomplete bool
@@ -232,10 +234,11 @@ func evictAllTargets(ctx context.Context, targets []Target, drainOpts nodeDrainO
232234
continue
233235
}
234236
errs = append(errs, fmt.Errorf("%s/%s: %w", t.Manager, t.Entity, err))
235-
// Only treat the drain as "still in progress" when EKS accepted the
236-
// scaling change but observation of the drain failed or timed out.
237-
// A failed UpdateNodegroupConfig means EKS never started draining,
238-
// so cleanup is safe.
237+
// errEKSDrainIncomplete means workloads may still be on the node group's
238+
// nodes — either our cordon/drain did not complete, or EKS had not
239+
// finished terminating the drained instances before the wait timed out —
240+
// so the temporary PDBs must stay. A failed UpdateNodegroupConfig after a
241+
// successful drain does not wrap the sentinel: cleanup is safe.
239242
if errors.Is(err, errEKSDrainIncomplete) {
240243
eksDrainIncomplete = true
241244
}
@@ -251,7 +254,7 @@ func evictTarget(ctx context.Context, clientset kubernetes.Interface, cli *clien
251254
case clusterinfo.NodeManagerASG:
252255
return evictASG(ctx, clientset, cli.Autoscaling, t.Entity, t.Nodes, drainOpts)
253256
case clusterinfo.NodeManagerEKSManagedNodeGroup:
254-
return evictEKSManagedNodeGroup(ctx, cli.EKS, clientset, clusterName, t.Entity, drainOpts)
257+
return evictEKSManagedNodeGroup(ctx, cli.EKS, clientset, clusterName, t.Entity, t.Nodes, drainOpts)
255258
case clusterinfo.NodeManagerKarpenter:
256259
return evictKarpenterUserNodePool(ctx, clientset, t.Entity, t.Nodes, drainOpts)
257260
case clusterinfo.NodeManagerStandalone:

0 commit comments

Comments
 (0)