Skip to content

Commit 5e87b75

Browse files
fix: run zxporter controller-manager as 2 replicas with proper HA guards (#352)
* Set zxp pdb minAvailable: 0 to unblock node drain * fix: run zxporter controller-manager as 2 replicas with proper HA guards zxporter was deployed as a single replica with a PDB requiring minAvailable: 1, which permanently blocked node drains — the one pod could never be evicted because the PDB required it to stay up. This commit fixes the full HA setup across all install paths: - Set controller-manager replicas to 2 (leader election provides instant failover; only one pod is active at a time) - Set PDB minAvailable to 1 (allows evicting one pod during drains while keeping the standby available) - Add standby mode to HealthManager: non-leader pods now return 200 on /readyz while awaiting leadership, so the PDB correctly sees 2 available pods and does not block voluntary disruptions - Clear standby via mgr.Elected() when this pod wins the lease so normal component-level readiness checks resume - Fix transient startup 503: TelemetrySender.Start() now marks ComponentDakrTransport healthy optimistically so readiness does not flap between ClearReadinessSuppression() and the first successful telemetry send (~15s window) - Make Helm deployment replicas conditional on highAvailability.enabled (true → 2, false → 1) so Helm installs are self-consistent
1 parent f5a0a40 commit 5e87b75

11 files changed

Lines changed: 81 additions & 9 deletions

File tree

cmd/main.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ func main() {
149149
// reconciling before enforcing readiness checks.
150150
healthManager.SuppressReadiness(2 * time.Minute)
151151

152+
// When leader election is enabled, mark this pod as standby until it wins
153+
// the lease. Standby pods return 200 on /readyz so Kubernetes does not
154+
// repeatedly mark them unhealthy while they wait. The flag is cleared
155+
// (inside the goroutine below) the moment this pod is elected leader, at
156+
// which point the normal 2-minute readiness grace period takes over.
157+
if enableLeaderElection {
158+
healthManager.SetStandby(true)
159+
}
160+
152161
// No need to add the standard controller with kubebuilder:scaffold:builder
153162
// The env-based controller doesn't rely on CRDs
154163

@@ -204,8 +213,22 @@ func main() {
204213
os.Exit(1)
205214
}
206215

216+
ctx := ctrl.SetupSignalHandler()
217+
218+
// Clear standby the moment this pod wins leader election so that normal
219+
// readiness checks (with the 2-minute grace period) take over.
220+
if enableLeaderElection {
221+
go func() {
222+
select {
223+
case <-mgr.Elected():
224+
healthManager.SetStandby(false)
225+
case <-ctx.Done():
226+
}
227+
}()
228+
}
229+
207230
setupLog.Info("starting manager")
208-
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
231+
if err := mgr.Start(ctx); err != nil {
209232
setupLog.Error(err, "problem running manager")
210233
os.Exit(1)
211234
}

config/manager/manager.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ spec:
2020
selector:
2121
matchLabels:
2222
control-plane: controller-manager
23-
replicas: 1
23+
replicas: 2
2424
template:
2525
metadata:
2626
annotations:

dist/backend-install.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1480,7 +1480,7 @@ metadata:
14801480
name: devzero-zxporter-controller-manager
14811481
namespace: devzero-zxporter
14821482
spec:
1483-
replicas: 1
1483+
replicas: 2
14841484
selector:
14851485
matchLabels:
14861486
control-plane: controller-manager

dist/install.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1485,7 +1485,7 @@ metadata:
14851485
name: devzero-zxporter-controller-manager
14861486
namespace: devzero-zxporter
14871487
spec:
1488-
replicas: 1
1488+
replicas: 2
14891489
selector:
14901490
matchLabels:
14911491
control-plane: controller-manager

dist/installer_updater.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1424,7 +1424,7 @@ metadata:
14241424
name: devzero-zxporter-controller-manager
14251425
namespace: devzero-zxporter
14261426
spec:
1427-
replicas: 1
1427+
replicas: 2
14281428
selector:
14291429
matchLabels:
14301430
control-plane: controller-manager

dist/zxporter.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ metadata:
664664
name: devzero-zxporter-controller-manager
665665
namespace: devzero-zxporter
666666
spec:
667-
replicas: 1
667+
replicas: 2
668668
selector:
669669
matchLabels:
670670
control-plane: controller-manager

helm-chart/zxporter/templates/deployment.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ metadata:
99
name: devzero-zxporter-controller-manager
1010
namespace: {{ .Release.Namespace }}
1111
spec:
12-
replicas: 1
12+
replicas: {{ if .Values.highAvailability.enabled }}2{{ else }}1{{ end }}
1313
selector:
1414
matchLabels:
1515
control-plane: controller-manager

helm-chart/zxporter/values.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,10 @@ affinity: {}
140140

141141
# High Availability Configuration
142142
highAvailability:
143-
enabled: false
143+
enabled: true
144144
podDisruptionBudget:
145145
enabled: true
146146
minAvailable: 1
147-
# Alternative: maxUnavailable: 1
148147

149148
# MPA Server Configuration
150149
mpaServer:

internal/health/manager.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type HealthManager struct {
2929
components map[string]*ComponentStatus
3030
livenessGraceUntil time.Time // LivenessCheck always passes before this deadline
3131
readinessGraceUntil time.Time // ReadinessCheck always passes before this deadline
32+
standby bool // standby=true when not leader; readiness passes unconditionally
3233
}
3334

3435
// NewHealthManager creates a new HealthManager
@@ -118,6 +119,16 @@ func (hm *HealthManager) BuildReport() map[string]ComponentStatus {
118119
return report
119120
}
120121

122+
// SetStandby marks the pod as a standby (non-leader) replica. While in standby,
123+
// ReadinessCheck passes unconditionally — the pod is healthy and ready to take
124+
// over leadership, it just isn't running collectors yet. Call with false when
125+
// leader election is won so normal readiness checks resume.
126+
func (hm *HealthManager) SetStandby(standby bool) {
127+
hm.mu.Lock()
128+
defer hm.mu.Unlock()
129+
hm.standby = standby
130+
}
131+
121132
// SuppressLiveness makes LivenessCheck pass unconditionally for the given
122133
// duration. Use this before a planned collector restart so that the transient
123134
// Unhealthy window does not trigger a pod kill. The grace period is cleared
@@ -192,6 +203,9 @@ func (hm *HealthManager) ReadinessCheck() error {
192203

193204
// readinessCheckLocked performs the readiness check while the caller holds mu.
194205
func (hm *HealthManager) readinessCheckLocked() error {
206+
if hm.standby {
207+
return nil // standby replica: healthy and ready to become leader
208+
}
195209
if !hm.readinessGraceUntil.IsZero() && time.Now().Before(hm.readinessGraceUntil) {
196210
return nil
197211
}

internal/health/manager_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,34 @@ func TestLivenessCheck_FullRestartCycle(t *testing.T) {
325325
hm.UpdateStatus(ComponentCollectorManager, HealthStatusHealthy, "restarted", nil)
326326
assert.NoError(t, hm.LivenessCheck()) // passes normally
327327
}
328+
329+
// TestReadinessCheck_StandbyPassesWhenComponentsUnspecified verifies that a
330+
// standby (non-leader) pod passes readiness even though its components are
331+
// unspecified — it is healthy and ready to take over leadership.
332+
func TestReadinessCheck_StandbyPassesWhenComponentsUnspecified(t *testing.T) {
333+
hm := NewHealthManager()
334+
hm.Register(ComponentCollectorManager)
335+
hm.Register(ComponentDakrTransport)
336+
// Components remain Unspecified (collectors never started on non-leader)
337+
338+
hm.SetStandby(true)
339+
assert.NoError(t, hm.ReadinessCheck())
340+
}
341+
342+
// TestReadinessCheck_StandbyClearedEnforcesNormalChecks verifies that after
343+
// winning leader election (SetStandby(false)), normal readiness rules apply.
344+
func TestReadinessCheck_StandbyClearedEnforcesNormalChecks(t *testing.T) {
345+
hm := NewHealthManager()
346+
hm.Register(ComponentCollectorManager)
347+
hm.Register(ComponentDakrTransport)
348+
349+
hm.SetStandby(true)
350+
assert.NoError(t, hm.ReadinessCheck()) // standby: passes
351+
352+
hm.SetStandby(false)
353+
assert.Error(t, hm.ReadinessCheck()) // components still Unspecified → fails
354+
355+
hm.UpdateStatus(ComponentCollectorManager, HealthStatusHealthy, "ok", nil)
356+
hm.UpdateStatus(ComponentDakrTransport, HealthStatusHealthy, "ok", nil)
357+
assert.NoError(t, hm.ReadinessCheck()) // now passes
358+
}

0 commit comments

Comments
 (0)