Skip to content

Commit 8bb2fb2

Browse files
Merge pull request #142 from atlassian-labs/mwhittington/cyclops-node-cleanup-controller
Fix various situations where cluster autoscaler scale-down-disabled annotation was removed early, or left on nodes forever
2 parents 5bb7b13 + 9812c96 commit 8bb2fb2

17 files changed

Lines changed: 1593 additions & 119 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = 1.10.4
1+
VERSION = 1.10.5
22
# IMPORTANT! Update api version if a new release affects cnr
33
API_VERSION = 1.0.0
44
IMAGE = cyclops:$(VERSION)

cmd/manager/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
cnrTransitioner "github.com/atlassian-labs/cyclops/pkg/controller/cyclenoderequest/transitioner"
1414
"github.com/atlassian-labs/cyclops/pkg/controller/cyclenodestatus"
1515
cnsTransitioner "github.com/atlassian-labs/cyclops/pkg/controller/cyclenodestatus/transitioner"
16+
nodecontroller "github.com/atlassian-labs/cyclops/pkg/controller/node"
1617
"github.com/atlassian-labs/cyclops/pkg/metrics"
1718
"github.com/atlassian-labs/cyclops/pkg/notifications"
1819
"github.com/atlassian-labs/cyclops/pkg/notifications/notifierbuilder"
@@ -47,6 +48,9 @@ var (
4748
deleteCNRRequeue = app.Flag("delete-cnr-requeue", "How often to check if a CNR can be deleted").Default("24h").Duration()
4849
defaultCNScyclingExpiry = app.Flag("default-cns-cycling-expiry", "Fail the CNS if it has been cycling for this long").Default("3h").Duration()
4950
unhealthyPodTerminationThreshold = app.Flag("unhealthy-pod-termination-after", "How long to tolerate an un-evictable yet unhealthy pod before forcefully removing it").Default("5m").Duration()
51+
52+
nodeControllerReconcileConcurrency = app.Flag("node-controller-reconcile-concurrency", "Maximum number of concurrent node controller reconciles").Default("1").Int()
53+
nodeControllerRequeueAfter = app.Flag("node-controller-requeue-after", "How often the node controller rechecks annotated nodes that are still covered by an active CNR").Default("5m").Duration()
5054
)
5155

5256
var log = logf.Log.WithName("cmd")
@@ -128,6 +132,12 @@ func main() {
128132
UnhealthyPodTerminationThreshold: *unhealthyPodTerminationThreshold,
129133
}
130134

135+
// Configure the node controller options
136+
nodeOptions := nodecontroller.Options{
137+
ReconcileConcurrency: *nodeControllerReconcileConcurrency,
138+
RequeueAfter: *nodeControllerRequeueAfter,
139+
}
140+
131141
// Set up and register the controllers that will share resources between them
132142
_, err = cyclenoderequest.NewReconciler(mgr, cloudProvider, notifier, *namespace, cnrOptions)
133143
if err != nil {
@@ -139,6 +149,11 @@ func main() {
139149
log.Error(err, "Unable to add cycleNodeStatus controller")
140150
os.Exit(1)
141151
}
152+
_, err = nodecontroller.NewReconciler(mgr, *namespace, nodeOptions)
153+
if err != nil {
154+
log.Error(err, "Unable to add node controller")
155+
os.Exit(1)
156+
}
142157

143158
log.Info("Starting the Cmd.")
144159

deploy/crds/atlassian.com_cyclenoderequests_crd.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,15 @@ spec:
365365
when it last checked for progress in the cycle operation.
366366
format: int64
367367
type: integer
368+
annotatedNodes:
369+
description: |-
370+
AnnotatedNodes tracks the names of nodes that Cyclops added the
371+
scale-down-disabled annotation to during cycling. Used for deterministic
372+
cleanup such that only these nodes have their annotations removed during the
373+
Successful or Healing phase. Cleared after cleanup completes.
374+
items:
375+
type: string
376+
type: array
368377
currentNodes:
369378
description: |-
370379
CurrentNodes stores the current nodes that are being "worked on". Used to batch operations

docs/node-cleanup-reconciler.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Node Controller
2+
3+
The node controller is a controller-runtime reconciler for Kubernetes `Node` objects. Its current responsibility is to remove stale Cyclops-managed Cluster Autoscaler annotations that can be left behind when a `CycleNodeRequest` is deleted before normal CNR cleanup runs.
4+
5+
## Why it exists
6+
7+
Cyclops adds these annotations to replacement nodes during cycling:
8+
9+
- `cluster-autoscaler.kubernetes.io/scale-down-disabled=true`
10+
- `cyclops.atlassian.com/annotation-managed=true`
11+
12+
The first annotation prevents Cluster Autoscaler from removing replacement nodes before the old nodes have drained and terminated. The second annotation records that Cyclops added the protection, so Cyclops does not remove annotations that were pre-existing on a node.
13+
14+
If a CNR disappears mid-cycle, the normal CNR transitioner cleanup may never run. The node controller acts as an eventual-consistency backstop so those nodes are not protected from scale-down forever.
15+
16+
## How it works
17+
18+
The controller watches `Node` objects using the manager's shared cache. A predicate only enqueues nodes with the Cyclops marker annotation, so ordinary node changes do not enter the reconcile loop.
19+
20+
For each matching node, the controller:
21+
22+
1. Confirms both the Cyclops marker and scale-down-disabled annotations are still present.
23+
2. Confirms the node is selected by at least one `NodeGroup`.
24+
3. Checks whether any non-terminal CNR in the configured namespace still selects the node.
25+
4. Requeues after the configured interval if an active CNR still covers the node.
26+
5. Removes both annotations if no active CNR covers the node.
27+
28+
`Successful` and `Failed` CNRs are terminal. Other phases, including `Healing`, are considered active.
29+
30+
## Configuration
31+
32+
The controller defaults are intentionally conservative:
33+
34+
- `--node-controller-reconcile-concurrency=1`
35+
- `--node-controller-requeue-after=5m`
36+
37+
The controller is a safety net rather than a high-throughput reconciler, so one worker is normally enough. The requeue interval controls how often annotated nodes are rechecked while they are still covered by an active CNR.
38+
39+
## Observability
40+
41+
The node controller emits cleanup-specific metrics so we can tell when the backstop is doing work:
42+
43+
- `cyclops_node_cleanup_annotations_removed_total`
44+
- `cyclops_node_cleanup_reconciles_total{result=...}`
45+
46+
A non-zero removal rate means stale annotations reached the node controller instead of being cleaned up by the normal CNR flow.

pkg/apis/atlassian/v1/cyclenoderequest.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,8 @@ func (in *CycleNodeRequest) IsFromSameNodeGroup(cnr CycleNodeRequest) bool {
3636
cnr.GetNodeGroupNames(),
3737
)
3838
}
39+
40+
// IsTerminal returns true when the CycleNodeRequest lifecycle has ended.
41+
func (in *CycleNodeRequest) IsTerminal() bool {
42+
return in.Status.Phase == CycleNodeRequestSuccessful || in.Status.Phase == CycleNodeRequestFailed
43+
}

pkg/apis/atlassian/v1/cyclenoderequest_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ type CycleNodeRequestStatus struct {
9393

9494
// PreTerminationChecks keeps track of the instance pre termination check information
9595
PreTerminationChecks map[string]PreTerminationCheckStatusList `json:"preTerminationChecks,omitempty"`
96+
97+
// AnnotatedNodes tracks the names of nodes that Cyclops added the
98+
// scale-down-disabled annotation to during cycling. Used for deterministic
99+
// cleanup such that only these nodes have their annotations removed during the
100+
// Successful or Healing phase. Cleared after cleanup completes.
101+
AnnotatedNodes []string `json:"annotatedNodes,omitempty"`
96102
}
97103

98104
// CycleNodeRequestNode stores a current node that is being worked on

pkg/apis/atlassian/v1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/controller/cyclenoderequest/transitioner/transitioner.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
v1 "github.com/atlassian-labs/cyclops/pkg/apis/atlassian/v1"
88
"github.com/atlassian-labs/cyclops/pkg/controller"
9+
"github.com/atlassian-labs/cyclops/pkg/k8s"
910
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1011
)
1112

@@ -17,18 +18,14 @@ type transitionFunc func() (reconcile.Result, error)
1718
// different request types.
1819
const cycleNodeLabel = "cyclops.atlassian.com/terminate"
1920

20-
// cyclopsManagedAnnotation marks nodes where Cyclops added the scale-down-disabled annotation.
21-
// Used to track which annotations we added vs pre-existing ones (for safe cleanup).
22-
// Only remove the cluster-autoscaler annotation if this marker annotation also exists.
23-
const cyclopsManagedAnnotation = "cyclops.atlassian.com/annotation-managed"
24-
25-
// clusterAutoscalerScaleDownDisabledAnnotation is the annotation key used to prevent
26-
// Cluster Autoscaler from scaling down a node. This is used to protect new nodes
27-
// during the cycling process from being removed by Cluster Autoscaler before
28-
// the corresponding old nodes are fully terminated.
29-
// See: https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md#how-can-i-prevent-cluster-autoscaler-from-scaling-down-a-particular-node
30-
const clusterAutoscalerScaleDownDisabledAnnotation = "cluster-autoscaler.kubernetes.io/scale-down-disabled"
31-
const clusterAutoscalerScaleDownDisabledValue = "true"
21+
const (
22+
// cyclopsManagedAnnotation marks nodes where Cyclops added the scale-down-disabled annotation.
23+
cyclopsManagedAnnotation = k8s.CyclopsManagedAnnotation
24+
25+
// clusterAutoscalerScaleDownDisabledAnnotation protects new nodes during cycling.
26+
clusterAutoscalerScaleDownDisabledAnnotation = k8s.ClusterAutoscalerScaleDownDisabledAnnotation
27+
clusterAutoscalerScaleDownDisabledValue = "true"
28+
)
3229

3330
// nodeGroupAnnotationKey is the annotation key on NodeGroup resources that controls whether
3431
// Cluster Autoscaler annotation management is enabled or disabled.

pkg/controller/cyclenoderequest/transitioner/transitions.go

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,20 @@ func (t *CycleNodeRequestTransitioner) transitionWaitingTermination() (reconcile
532532
return t.transitionToHealing(err)
533533
}
534534

535+
// When a batch completes and we're looping back to Initialised, clean up
536+
// the annotations from this batch's replacement nodes. Their old counterparts
537+
// have been terminated, so the protection is no longer needed. This avoids
538+
// accumulating protected nodes across the entire cycle duration.
539+
//
540+
// Trade-off: cluster-autoscaler could theoretically scale down these freshly
541+
// drained-onto nodes before the next batch's replacements come up. We accept
542+
// this because (a) CA uses the eviction API so pods are rescheduled gracefully,
543+
// and (b) keeping protection until the entire CNR completes would leave a
544+
// growing set of un-scalable nodes for long-running multi-batch cycles.
545+
if desiredPhase == v1.CycleNodeRequestInitialised && t.shouldManageAnnotations() {
546+
t.cleanupScaleDownDisabledAnnotations()
547+
}
548+
535549
if err := t.rm.UpdateObject(t.cycleNodeRequest); err != nil {
536550
return t.transitionToHealing(err)
537551
}
@@ -593,14 +607,6 @@ func (t *CycleNodeRequestTransitioner) transitionHealing() (reconcile.Result, er
593607
}
594608
}
595609

596-
// Cleanup scale-down-disabled annotations if enabled
597-
if t.shouldManageAnnotations() {
598-
t.cleanupScaleDownDisabledAnnotations()
599-
} else {
600-
t.rm.Logger.Info("Skipping annotation cleanup (disabled)",
601-
"cnr", t.cycleNodeRequest.Name)
602-
}
603-
604610
return t.transitionToFailed(nil)
605611
}
606612

@@ -628,14 +634,6 @@ func (t *CycleNodeRequestTransitioner) transitionSuccessful() (reconcile.Result,
628634
return reconcile.Result{Requeue: true, RequeueAfter: transitionDuration}, nil
629635
}
630636

631-
// Cleanup scale-down-disabled annotations if enabled
632-
if t.shouldManageAnnotations() {
633-
t.cleanupScaleDownDisabledAnnotations()
634-
} else {
635-
t.rm.Logger.Info("Skipping annotation cleanup (disabled)",
636-
"cnr", t.cycleNodeRequest.Name)
637-
}
638-
639637
// Delete failed sibling CNRs regardless of whether the CNR for the
640638
// transitioner should be deleted. If failed CNRs pile up that will prevent
641639
// Cyclops observer from auto-generating new CNRs for a nodegroup.

0 commit comments

Comments
 (0)