-
Notifications
You must be signed in to change notification settings - Fork 557
Expand file tree
/
Copy pathconsolidation.go
More file actions
350 lines (308 loc) · 17.4 KB
/
Copy pathconsolidation.go
File metadata and controls
350 lines (308 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package disruption
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/samber/lo"
corev1 "k8s.io/api/core/v1"
"k8s.io/utils/clock"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/karpenter/pkg/utils/pretty"
v1 "sigs.k8s.io/karpenter/pkg/apis/v1"
"sigs.k8s.io/karpenter/pkg/cloudprovider"
disruptionevents "sigs.k8s.io/karpenter/pkg/controllers/disruption/events"
"sigs.k8s.io/karpenter/pkg/controllers/provisioning"
pscheduling "sigs.k8s.io/karpenter/pkg/controllers/provisioning/scheduling"
"sigs.k8s.io/karpenter/pkg/controllers/state"
"sigs.k8s.io/karpenter/pkg/events"
"sigs.k8s.io/karpenter/pkg/operator/options"
"sigs.k8s.io/karpenter/pkg/scheduling"
)
// commandValidationDelay is the time we wait between creating a consolidation command and validating that it still works.
const commandValidationDelay = 15 * time.Second
// MinInstanceTypesForSpotToSpotConsolidation is the minimum number of instanceTypes in a NodeClaim needed to trigger spot-to-spot single-node consolidation
const MinInstanceTypesForSpotToSpotConsolidation = 15
// consolidation provides common functionality for single-node and multi-node consolidation.
type consolidation struct {
// Consolidation needs to be aware of the queue for validation
queue *Queue
clock clock.Clock
cluster *state.Cluster
kubeClient client.Client
provisioner *provisioning.Provisioner
cloudProvider cloudprovider.CloudProvider
recorder events.Recorder
lastConsolidationState time.Time
// evaluator is initialized non-nil at construction. SetNodePoolTotals
// replaces it with a balancedEvaluator carrying the new totals.
evaluator Evaluator
}
// NodePoolTotalsSetter is implemented by disruption methods that use balanced scoring.
type NodePoolTotalsSetter interface {
SetNodePoolTotals(map[string]NodePoolTotals)
}
func (c *consolidation) SetNodePoolTotals(totals map[string]NodePoolTotals) {
c.evaluator = NewBalancedEvaluator(totals, c.recorder)
}
func MakeConsolidation(clock clock.Clock, cluster *state.Cluster, kubeClient client.Client, provisioner *provisioning.Provisioner,
cloudProvider cloudprovider.CloudProvider, recorder events.Recorder, queue *Queue) consolidation {
return consolidation{
queue: queue,
clock: clock,
cluster: cluster,
kubeClient: kubeClient,
provisioner: provisioner,
cloudProvider: cloudProvider,
recorder: recorder,
evaluator: noopEvaluator{},
}
}
// IsConsolidated returns true if nothing has changed since markConsolidated was called.
func (c *consolidation) IsConsolidated() bool {
return c.lastConsolidationState.Equal(c.cluster.ConsolidationState())
}
// markConsolidated records the current state of the cluster.
func (c *consolidation) markConsolidated() {
c.lastConsolidationState = c.cluster.ConsolidationState()
}
// ShouldDisrupt is a predicate used to filter candidates
func (c *consolidation) ShouldDisrupt(ctx context.Context, cn *Candidate) bool {
// Disable consolidation for static NodePool
if cn.OwnedByStaticNodePool() {
return false
}
// We need the following to know what the price of the instance for price comparison. If one of these doesn't exist, we can't
// compute consolidation decisions for this candidate.
// 1. Instance Type
// 2. Capacity Type
// 3. Zone
if cn.instanceType == nil {
c.recorder.Publish(disruptionevents.Unconsolidatable(cn.Node, cn.NodeClaim, fmt.Sprintf("Instance Type %q not found", cn.Labels()[corev1.LabelInstanceTypeStable]))...)
return false
}
if _, ok := cn.Labels()[v1.CapacityTypeLabelKey]; !ok {
c.recorder.Publish(disruptionevents.Unconsolidatable(cn.Node, cn.NodeClaim, fmt.Sprintf("Node does not have label %q", v1.CapacityTypeLabelKey))...)
return false
}
if _, ok := cn.Labels()[corev1.LabelTopologyZone]; !ok {
c.recorder.Publish(disruptionevents.Unconsolidatable(cn.Node, cn.NodeClaim, fmt.Sprintf("Node does not have label %q", corev1.LabelTopologyZone))...)
return false
}
if cn.NodePool.Spec.Disruption.ConsolidateAfter.Duration == nil {
c.recorder.Publish(disruptionevents.Unconsolidatable(cn.Node, cn.NodeClaim, fmt.Sprintf("NodePool %q has consolidation disabled", cn.NodePool.Name))...)
return false
}
// Empty nodes are handled by Emptiness (reason "Empty") for correct budget accounting.
if cn.IsEmpty() {
return false
}
// WhenEmpty pools only allow empty-node deletions, which Emptiness handles.
if cn.NodePool.Spec.Disruption.ConsolidationPolicy == v1.ConsolidationPolicyWhenEmpty {
c.recorder.Publish(disruptionevents.Unconsolidatable(cn.Node, cn.NodeClaim, fmt.Sprintf("NodePool %q has consolidation policy WhenEmpty, but node is not empty", cn.NodePool.Name))...)
return false
}
return cn.NodeClaim.StatusConditions().Get(v1.ConditionTypeConsolidatable).IsTrue()
}
// sortCandidates sorts candidates by price/disruption ratio descending.
// The binary search in multi-node consolidation tries the first N candidates
// as a batch. Ratio sort means the batch contains the highest-value nodes,
// so budget-limited cycles execute the most impactful moves first.
//
// This changes multi-node behavior for WhenEmptyOrUnderutilized, which
// previously sorted by disruption cost ascending. The old sort found batches
// that were easy to pack (low-disruption nodes fit together). The new sort
// finds batches worth packing (high savings per unit disruption). The binary
// search still converges because it shrinks the window until scheduling
// succeeds.
func (c *consolidation) sortCandidates(_ context.Context, candidates []*Candidate) []*Candidate {
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].SavingsRatio() > candidates[j].SavingsRatio()
})
return candidates
}
// computeConsolidation computes a consolidation action to take
//
// nolint:gocyclo
func (c *consolidation) computeConsolidation(ctx context.Context, candidates ...*Candidate) (Command, error) {
var err error
// Run scheduling simulation to compute consolidation option
results, err := SimulateScheduling(ctx, c.kubeClient, c.cluster, c.provisioner, c.clock, c.recorder, []pscheduling.Options{pscheduling.IsConsolidationSimulation}, candidates...)
if err != nil {
// if a candidate node is now deleting, just retry
if errors.Is(err, errCandidateDeleting) {
return Command{}, nil
}
return Command{}, err
}
// if not all of the pods were scheduled, we can't do anything
if !results.AllNonPendingPodsScheduled() {
// This method is used by multi-node consolidation as well, so we'll only report in the single node case
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, pretty.Sentence(results.NonPendingPodSchedulingErrors()))...)
}
return Command{}, nil
}
// were we able to schedule all the pods on the inflight candidates?
if len(results.NewNodeClaims) == 0 {
return Command{
Candidates: candidates,
Results: results,
PoolDisruptionCosts: computePoolDisruptionCosts(candidates),
}, nil
}
// we're not going to turn a single node into multiple candidates
if len(results.NewNodeClaims) != 1 {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, fmt.Sprintf("Can't remove without creating %d candidates", len(results.NewNodeClaims)))...)
}
return Command{}, nil
}
// get the current node price based on the offering
// fallback if we can't find the specific zonal pricing data
candidatePrice := sumCandidatePrices(candidates)
allExistingAreSpot := true
for _, cn := range candidates {
if cn.capacityType != v1.CapacityTypeSpot {
allExistingAreSpot = false
}
}
// sort the instanceTypes by price before we take any actions like truncation for spot-to-spot consolidation or finding the nodeclaim
// that meets the minimum requirement after filteringByPrice
results.NewNodeClaims[0].InstanceTypeOptions = results.NewNodeClaims[0].InstanceTypeOptions.OrderByPrice(results.NewNodeClaims[0].Requirements)
if allExistingAreSpot &&
results.NewNodeClaims[0].Requirements.Get(v1.CapacityTypeLabelKey).Has(v1.CapacityTypeSpot) {
return c.computeSpotToSpotConsolidation(ctx, candidates, results, candidatePrice)
}
// filterByPrice returns the instanceTypes that are lower priced than the current candidate and any error that indicates the input couldn't be filtered.
// If we use this directly for spot-to-spot consolidation, we are bound to get repeated consolidations because the strategy that chooses to launch the spot instance from the list does
// it based on availability and price which could result in selection/launch of non-lowest priced instance in the list. So, we would keep repeating this loop till we get to lowest priced instance
// causing churns and landing onto lower available spot instance ultimately resulting in higher interruptions.
results.NewNodeClaims[0], err = results.NewNodeClaims[0].RemoveInstanceTypeOptionsByPriceAndMinValues(results.NewNodeClaims[0].Requirements, candidatePrice)
if err != nil {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, fmt.Sprintf("Filtering by price: %v", err))...)
}
return Command{}, nil
}
if len(results.NewNodeClaims[0].InstanceTypeOptions) == 0 {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, "Can't replace with a cheaper node")...)
}
return Command{}, nil
}
// We are consolidating a node from OD -> [OD,Spot] but have filtered the instance types by cost based on the
// assumption, that the spot variant will launch. We also need to add a requirement to the node to ensure that if
// spot capacity is insufficient we don't replace the node with a more expensive on-demand node. Instead the launch
// should fail and we'll just leave the node alone. We don't need to do the same for reserved since the requirements
// are injected on by the scheduler.
ctReq := results.NewNodeClaims[0].Requirements.Get(v1.CapacityTypeLabelKey)
if ctReq.Has(v1.CapacityTypeSpot) && ctReq.Has(v1.CapacityTypeOnDemand) && hasSpotOffering(results.NewNodeClaims[0]) {
results.NewNodeClaims[0].Requirements.Add(scheduling.NewRequirement(v1.CapacityTypeLabelKey, corev1.NodeSelectorOpIn, v1.CapacityTypeSpot))
}
cmd := Command{
Candidates: candidates,
Replacements: replacementsFromNodeClaims(results.NewNodeClaims...),
Results: results,
PoolDisruptionCosts: computePoolDisruptionCosts(candidates),
}
cmd.EmitCandidateEvents(c.recorder)
return cmd, nil
}
// hasSpotOffering returns true if the replacement has an available Spot offering.
func hasSpotOffering(nodeClaim *pscheduling.NodeClaim) bool {
return lo.ContainsBy(nodeClaim.InstanceTypeOptions, func(it *cloudprovider.InstanceType) bool {
return it.Offerings.Available().Compatible(nodeClaim.Requirements).HasCompatible(cloudprovider.SpotRequirement)
})
}
// Compute command to execute spot-to-spot consolidation if:
// 1. The SpotToSpotConsolidation feature flag is set to true.
// 2. For single-node consolidation:
// a. There are at least 15 cheapest instance type replacement options to consolidate.
// b. The current candidate is NOT part of the first 15 cheapest instance types inorder to avoid repeated consolidation.
func (c *consolidation) computeSpotToSpotConsolidation(ctx context.Context, candidates []*Candidate, results pscheduling.Results, candidatePrice float64) (Command, error) {
// Spot consolidation is turned off.
if !options.FromContext(ctx).FeatureGates.SpotToSpotConsolidation {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, "SpotToSpotConsolidation is disabled, can't replace a spot node with a spot node")...)
}
return Command{}, nil
}
// Since we are sure that the replacement nodeclaim considered for the spot candidates are spot, we will enforce it through the requirements.
results.NewNodeClaims[0].Requirements.Add(scheduling.NewRequirement(v1.CapacityTypeLabelKey, corev1.NodeSelectorOpIn, v1.CapacityTypeSpot))
// All possible replacements for the current candidate compatible with spot offerings
results.NewNodeClaims[0].InstanceTypeOptions = results.NewNodeClaims[0].InstanceTypeOptions.Compatible(results.NewNodeClaims[0].Requirements)
// filterByPrice returns the instanceTypes that are lower priced than the current candidate and any error that indicates the input couldn't be filtered.
var err error
results.NewNodeClaims[0], err = results.NewNodeClaims[0].RemoveInstanceTypeOptionsByPriceAndMinValues(results.NewNodeClaims[0].Requirements, candidatePrice)
if err != nil {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, fmt.Sprintf("Filtering by price: %v", err))...)
}
return Command{}, nil
}
if len(results.NewNodeClaims[0].InstanceTypeOptions) == 0 {
if len(candidates) == 1 {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, "Can't replace with a cheaper node")...)
}
return Command{}, nil
}
// For multi-node consolidation:
// We don't have any requirement to check the remaining instance type flexibility, so exit early in this case.
if len(candidates) > 1 {
cmd := Command{
Candidates: candidates,
Replacements: replacementsFromNodeClaims(results.NewNodeClaims...),
Results: results,
PoolDisruptionCosts: computePoolDisruptionCosts(candidates),
}
cmd.EmitCandidateEvents(c.recorder)
return cmd, nil
}
// For single-node consolidation:
// We check whether we have 15 cheaper instances than the current candidate instance. If this is the case, we know the following things:
// 1) The current candidate is not in the set of the 15 cheapest instance types and
// 2) There were at least 15 options cheaper than the current candidate.
if len(results.NewNodeClaims[0].InstanceTypeOptions) < MinInstanceTypesForSpotToSpotConsolidation {
c.recorder.Publish(disruptionevents.Unconsolidatable(candidates[0].Node, candidates[0].NodeClaim, fmt.Sprintf("SpotToSpotConsolidation requires %d cheaper instance type options than the current candidate to consolidate, got %d",
MinInstanceTypesForSpotToSpotConsolidation, len(results.NewNodeClaims[0].InstanceTypeOptions)))...)
return Command{}, nil
}
// If a user has minValues set in their NodePool requirements, then we cap the number of instancetypes at 100 which would be the actual number of instancetypes sent for launch to enable spot-to-spot consolidation.
// If no minValues in the NodePool requirement, then we follow the default 15 to cap the instance types for launch to enable a spot-to-spot consolidation.
// Restrict the InstanceTypeOptions for launch to 15(if default) so we don't get into a continual consolidation situation.
// For example:
// 1) Suppose we have 5 instance types, (A, B, C, D, E) in order of price with the minimum flexibility 3 and they’ll all work for our pod. We send CreateInstanceFromTypes(A,B,C,D,E) and it gives us a E type based on price and availability of spot.
// 2) We check if E is part of (A,B,C,D) and it isn't, so we will immediately have consolidation send a CreateInstanceFromTypes(A,B,C,D), since they’re cheaper than E.
// 3) Assuming CreateInstanceFromTypes(A,B,C,D) returned D, we check if D is part of (A,B,C) and it isn't, so will have another consolidation send a CreateInstanceFromTypes(A,B,C), since they’re cheaper than D resulting in continual consolidation.
// If we had restricted instance types to min flexibility at launch at step (1) i.e CreateInstanceFromTypes(A,B,C), we would have received the instance type part of the list preventing immediate consolidation.
// Taking this to 15 types, we need to only send the 15 cheapest types in the CreateInstanceFromTypes call so that the resulting instance is always in that set of 15 and we won’t immediately consolidate.
if results.NewNodeClaims[0].Requirements.HasMinValues() {
// Here we are trying to get the max of the minimum instances required to satisfy the minimum requirement and the default 15 to cap the instances for spot-to-spot consolidation.
minInstanceTypes, _, _ := results.NewNodeClaims[0].InstanceTypeOptions.SatisfiesMinValues(results.NewNodeClaims[0].Requirements)
results.NewNodeClaims[0].InstanceTypeOptions = lo.Slice(results.NewNodeClaims[0].InstanceTypeOptions, 0, lo.Max([]int{MinInstanceTypesForSpotToSpotConsolidation, minInstanceTypes}))
} else {
results.NewNodeClaims[0].InstanceTypeOptions = lo.Slice(results.NewNodeClaims[0].InstanceTypeOptions, 0, MinInstanceTypesForSpotToSpotConsolidation)
}
cmd := Command{
Candidates: candidates,
Replacements: replacementsFromNodeClaims(results.NewNodeClaims...),
Results: results,
PoolDisruptionCosts: computePoolDisruptionCosts(candidates),
}
cmd.EmitCandidateEvents(c.recorder)
return cmd, nil
}