You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: add observe mode and a startup grace to the orphan sweep
Three changes aimed at making this safe to adopt on a fleet nobody has audited.
OBSERVE MODE. This controller deletes machines, and an operator adopting it
could otherwise only learn what it would do to their fleet by letting it do it.
A kill switch is reactive; observe mode is not. It runs every check, emits every
signal, and changes nothing -- not the fleet, not the servers' labels, nothing
outside the cluster. INSTANCE_GARBAGE_COLLECTION_MODE replaces the boolean,
since three states do not fit in one, and an unrecognised value refuses to start
rather than falling back to the mode that deletes.
A STARTUP GRACE. Grace is counted in consecutive sweeps, which a restart or a
leader handover resets -- and the instability that strands servers is exactly
what causes those. A fresh process could otherwise reach the threshold having
watched the cluster for only a few minutes. It must now also have been sweeping
for a full window before it may reclaim anything, so operator instability delays
reclamation rather than either skipping it forever or authorising it on a short
history.
This is what makes in-process counting safe rather than merely simple, and it
follows core: nodeclaim/consistency keeps first-seen times in an in-process
cache and writes nothing durable. Core's durable timeouts hang off status
conditions on objects it owns, never off the cloud resource. An earlier revision
of this branch stored the clock on the Hetzner server itself; it was withdrawn
because it made a safety-critical invariant depend on a remote write that can
fail silently, on a clock written by a process that may be gone, and on state an
operator can edit with one CLI command.
NODE EVENTS. Reclamations are recorded as GarbageCollected, and observe-mode
candidates as WouldGarbageCollect, typed Normal -- reclaiming an orphan is this
controller working, not a fault, and a cluster alerting on Warning events
against Nodes should not page every time the sweep does its job. Most orphans
never registered a Node, so this supplements the log and the metric rather than
replacing them, and the doc comment says so.
Three chart-level holes closed alongside them. The ClusterRole now grants
events.k8s.io, without which the manager's recorder has every event rejected 403
while the README tells operators to validate observe mode by reading them. The
mode is emitted unconditionally rather than through a "with" block that skips
falsy values, so "mode: false" -- the natural typo when migrating from the
boolean this replaces -- reaches the parser and stops the operator instead of
silently defaulting to enabled. And a "fail" guard rejects an upgrade still
carrying the removed "disabled" key, which Helm would otherwise merge while the
sweep ran on a fleet the operator believed was paused.
The clock is injected, matching core, so the startup grace is testable without
reaching into a fake provider.
Copy file name to clipboardExpand all lines: README.md
+42-11Lines changed: 42 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -25,7 +25,7 @@ version tag in production.
25
25
-**Talos Linux and Ubuntu images**, resolved per architecture.
26
26
-**Placement groups** for spreading nodes across physical hosts.
27
27
-**Cost controls** — opt out of the billed public IPv4 (and/or IPv6) per node class for private-network clusters.
28
-
-**Multi-cluster safe** — every managed server is tagged with the cluster name, so several clusters can share one Hetzner project without touching each other's nodes.
28
+
-**Multi-cluster safe** — every managed server is tagged with the cluster name and the cluster's `kube-system` UID, so several clusters can share one Hetzner project without touching each other's nodes. Servers created before the UID label existed are matched on name alone, so give each cluster a distinct `clusterName` until the fleet has rolled.
29
29
30
30
## How it works
31
31
@@ -66,22 +66,53 @@ Two mechanisms cover this:
66
66
cluster and this NodeClaim and matches the requested type, location and image.
67
67
-**Garbage collection.** A sweep every two minutes reclaims servers Karpenter
68
68
has no NodeClaim for, along with the Node objects they left behind. A server
69
-
must be seen reapable on three consecutive sweeps, and one whose node is
69
+
must be seen unowned on several consecutive sweeps, and one whose node is
70
70
registered and still `Ready` is never touched — a machine carrying workloads is
71
-
core's to drain, not this sweep's to destroy. Sparing a server restarts its
72
-
count, so a machine the `Ready` guard protected does not sit on a spent grace
73
-
window waiting for its first NotReady blip.
71
+
core's to drain, not this sweep's to destroy.
72
+
73
+
Every path that declines to act resets the count, so the window always measures
74
+
an uninterrupted run of sweeps that found nothing in the way; a machine the
75
+
`Ready` guard protected never sits on a spent window waiting for its first
76
+
NotReady blip. The count is per-process, so a restart or leader handover starts
77
+
it again — and the operator must additionally have been sweeping for a full
78
+
window before it may reclaim anything, so instability delays reclamation rather
79
+
than authorising it on a short history.
74
80
75
81
**`clusterName` must be unique per cluster within a Hetzner project.** Servers
76
82
are labelled with it, and the sweep uses that label to decide what it owns. Two
77
83
clusters sharing a name in one project would each see the other's servers as
78
84
unclaimed. The operator therefore also stamps the UID of the cluster's
79
85
`kube-system` namespace on every server it creates and refuses to touch a server
80
-
carrying a different one, logging the collision once. That protects servers
81
-
created from this version onward; servers predating it carry no UID and are
82
-
still matched on name alone, so distinct names remain the thing to get right.
83
-
84
-
Set `instanceGarbageCollection.disabled: true` to pause the sweep during
86
+
carrying a different one, logging the collision once and counting it as
87
+
`karpenter_hetzner_orphaned_server_gc_total{result="skipped_foreign_cluster"}` on
88
+
every sweep.
89
+
90
+
Two things this does not cover. It protects servers created from this version
91
+
onward; servers predating it carry no UID and are still matched on name alone, so
92
+
until a fleet has fully rolled, distinct names remain the thing to get right.
93
+
And the UID identifies the *control plane*, not the servers: rebuilding a cluster
94
+
from scratch mints a new `kube-system` UID, after which the previous
95
+
incarnation's servers are refused forever — never reclaimed, still billing. The
96
+
`skipped_foreign_cluster` counter is the signal for both. Recovering from a
97
+
rebuild means relabelling those servers with the new UID
98
+
(`hcloud server add-label <server> karpenter.sh/cluster-uid=<uid>`, where `<uid>`
99
+
is `kubectl get ns kube-system -o jsonpath='{.metadata.uid}'`) or deleting them
100
+
by hand.
101
+
102
+
> **Upgrading an existing cluster.** This version adds a controller that
103
+
> **deletes Hetzner servers**. On first start it reclaims every server in the
104
+
> project that carries this cluster's labels and has no NodeClaim — which is the
105
+
> point, but on a fleet nobody has audited it is worth seeing first.
106
+
>
107
+
> Set `instanceGarbageCollection.mode: observe` to run every check and report
108
+
> what *would* be reclaimed without deleting anything. Watch
109
+
> `karpenter_hetzner_orphaned_server_gc_total{result="would_reap"}` and the
110
+
> `WouldGarbageCollect` events on the affected Nodes, satisfy yourself the list
111
+
> is right, then switch to `enabled`. Reclamations are recorded as
112
+
> `GarbageCollected` events on the Node, so `kubectl describe node` explains a
113
+
> server that disappeared.
114
+
115
+
Set `instanceGarbageCollection.mode: disabled` to pause the sweep during
85
116
maintenance that removes NodeClaims wholesale (reinstalling the CRDs, restoring
86
117
etcd, clearing finalizers by hand), so it does not act on a cluster that only
87
118
looks empty. Provisioning and disruption keep working while it is off.
@@ -224,7 +255,7 @@ comments explaining every field.
224
255
|---------|----------|-------------|
225
256
|`HCLOUD_TOKEN`| yes | Hetzner Cloud API token |
226
257
|`CLUSTER_NAME`| yes | Cluster identifier; scopes managed servers. Must be unique per Hetzner project — two clusters sharing a value will reclaim each other's servers |
227
-
|`DISABLE_INSTANCE_GARBAGE_COLLECTION`| no (unset) |Set to `true` to pause the orphaned-server sweep (chart value: `instanceGarbageCollection.disabled`) |
258
+
|`INSTANCE_GARBAGE_COLLECTION_MODE`| no (`enabled`) |`enabled`, `observe` or `disabled`; an unrecognised value stops the operator starting (chart value: `instanceGarbageCollection.mode`) |
228
259
|`METRICS_PORT`| no (8080) | Prometheus metrics port |
229
260
|`HEALTH_PROBE_PORT`| no (8081) | Health/readiness probe port |
Copy file name to clipboardExpand all lines: charts/karpenter-provider-hetzner/Chart.yaml
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -32,4 +32,4 @@ annotations:
32
32
artifacthub.io/containsSecurityUpdates: "false"
33
33
artifacthub.io/changes: |
34
34
- kind: added
35
-
description: "Reclaim Hetzner servers Karpenter no longer has a NodeClaim for, which would otherwise run and bill unowned; pause the sweep with instanceGarbageCollection.disabled during maintenance that removes NodeClaims wholesale"
35
+
description: "Reclaim Hetzner servers Karpenter no longer has a NodeClaim for, which would otherwise run and bill unowned; set instanceGarbageCollection.mode to observe to preview it, or disabled to pause it during maintenance that removes NodeClaims wholesale"
|`image.tag`|`""`| Empty tracks the chart appVersion; pin a tag in production |
@@ -87,6 +87,12 @@ When `serviceMonitor.enabled=true` the chart creates:
87
87
- a `Service` named `karpenter-provider-hetzner-metrics` exposing port `http-metrics`
88
88
- a `ServiceMonitor` that selects that Service and scrapes `/metrics` at the configured interval
89
89
90
-
Requires the [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) CRDs to be present. The controller exposes provider metrics under the `karpenter_hetzner_` prefix (server creates/deletes, durations, drift reasons, instance-type cache hits/misses, orphaned-server garbage-collection outcomes, adopted servers, and raw hcloud API call counts).
90
+
Requires the [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) CRDs to be present. The controller exposes provider metrics under the `karpenter_hetzner_` prefix (server creates/deletes, durations, drift reasons, instance-type cache hits/misses, orphaned-server garbage-collection outcomes, adopted servers, unpriceable nodes, and raw hcloud API call counts).
91
91
92
-
Worth an alert: `karpenter_hetzner_orphaned_server_gc_total{result="error"}` means a server cannot be reclaimed and is still billing, and `karpenter_hetzner_server_adopt_total{result="declined"}` means a NodeClaim keeps colliding with a server adoption refuses to take.
92
+
Worth an alert:
93
+
94
+
-`karpenter_hetzner_orphaned_server_gc_total{result="error"}` — a server cannot be reclaimed and is still billing.
95
+
-`karpenter_hetzner_server_adopt_total{result="declined"}` — a NodeClaim keeps colliding with a server adoption refuses to take.
96
+
-`karpenter_hetzner_orphaned_server_gc_total{result="skipped_foreign_cluster"}` — servers carry this cluster's `clusterName` but another cluster's UID. Either two clusters share a name in one Hetzner project, or this cluster's control plane was rebuilt and these servers predate it. Either way they will never be reclaimed.
97
+
98
+
Note that the metrics endpoint is scraped on every replica, but the sweeps behind these metrics run only on the leader. Aggregate with `max()` rather than `avg()`/`min()`, or a standby's zero will read as a healthy cluster.
Copy file name to clipboardExpand all lines: charts/karpenter-provider-hetzner/templates/deployment.yaml
+10-3Lines changed: 10 additions & 3 deletions
Original file line number
Diff line number
Diff line change
@@ -68,10 +68,17 @@ spec:
68
68
key: {{ .Values.auth.secretRef.key }}
69
69
- name: CLUSTER_NAME
70
70
value: {{ .Values.clusterName | quote }}
71
-
{{- if .Values.instanceGarbageCollection.disabled }}
72
-
- name: DISABLE_INSTANCE_GARBAGE_COLLECTION
73
-
value: "true"
71
+
{{- if hasKey .Values.instanceGarbageCollection "disabled" }}
72
+
{{- fail "instanceGarbageCollection.disabled has been replaced by instanceGarbageCollection.mode (enabled|observe|disabled). Your existing value is being ignored, which would leave the orphaned-server sweep RUNNING. Set mode explicitly and remove the disabled key." }}
74
73
{{- end }}
74
+
# Emitted unconditionally rather than through `with`, which skips
75
+
# falsy values: `mode: false` -- the natural typo when migrating from
76
+
# the boolean this replaced -- would omit the variable, the operator
77
+
# would default to enabled, and a value meant to stop the sweep would
78
+
# start it. Passed through so an invalid value reaches parseGCMode,
0 commit comments