Skip to content

Commit eb69ba1

Browse files
committed
Keep canary alive when primary promotion fails
When the canary analysis succeeds, Flagger copies the canary pod spec to the primary and waits for the primary rollout to finish. If the primary fails to become ready, the non-retriable readiness error triggered the standard analysis rollback, which routes all traffic to the primary and scales the canary to zero. During promotion the primary already runs the new (failing) spec while the canary is the only healthy copy of the new revision still serving traffic. Rolling back therefore sends all traffic to the broken primary and deletes the working canary, taking the application down. Halt the promotion instead: when the primary is not ready and the canary is in the Promoting or Finalising phase, mark the rollout as failed and alert, but keep the canary running and leave routing untouched until the primary recovers or a corrected revision is applied. Fixes #1898 Signed-off-by: Pedram Pourmohammad <eragon.pedy@gmail.com>
1 parent 83bab68 commit eb69ba1

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

pkg/controller/scheduler.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,17 @@ func (c *Controller) advanceCanary(name string, namespace string) {
297297
if err != nil {
298298
c.recordEventWarningf(cd, "%v", err)
299299
if !retriable {
300-
c.rollback(cd, canaryController, meshRouter, scalerReconciler)
300+
// Once the canary spec has been promoted to the primary, the canary
301+
// is the only healthy copy of the new revision still serving traffic.
302+
// The standard rollback would route all traffic to the unhealthy
303+
// primary and scale the canary to zero, taking the application down.
304+
// Halt the promotion instead and keep the canary running.
305+
if cd.Status.Phase == flaggerv1.CanaryPhasePromoting ||
306+
cd.Status.Phase == flaggerv1.CanaryPhaseFinalising {
307+
c.promotionFailed(cd, canaryController, err)
308+
} else {
309+
c.rollback(cd, canaryController, meshRouter, scalerReconciler)
310+
}
301311
}
302312
return
303313
}
@@ -989,6 +999,33 @@ func (c *Controller) rollback(canary *flaggerv1.Canary, canaryController canary.
989999
c.runPostRolloutHooks(canary, flaggerv1.CanaryPhaseFailed)
9901000
}
9911001

1002+
// promotionFailed marks the canary as failed when the primary does not become
1003+
// ready after the canary spec has been promoted to it. Unlike rollback, it does
1004+
// not route traffic to the primary nor scale the canary to zero: during promotion
1005+
// the canary is the only healthy copy of the new revision serving traffic, so
1006+
// rolling back to the unhealthy primary would cause an outage. The canary keeps
1007+
// running until the primary recovers or a corrected revision is applied.
1008+
func (c *Controller) promotionFailed(canary *flaggerv1.Canary, canaryController canary.Controller, err error) {
1009+
c.recordEventWarningf(canary, "Promotion of %s.%s failed, primary not ready: %v",
1010+
canary.Spec.TargetRef.Name, canary.Namespace, err)
1011+
c.alert(canary, fmt.Sprintf("Promotion failed, primary not ready: %v", err),
1012+
false, flaggerv1.SeverityError)
1013+
1014+
if err := canaryController.SetStatusPhase(canary, flaggerv1.CanaryPhaseFailed); err != nil {
1015+
c.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).Errorf("%v", err)
1016+
return
1017+
}
1018+
1019+
c.recorder.SetStatus(canary, flaggerv1.CanaryPhaseFailed)
1020+
c.recorder.IncFailures(metrics.CanaryMetricLabels{
1021+
Name: canary.Spec.TargetRef.Name,
1022+
Namespace: canary.Namespace,
1023+
DeploymentStrategy: canary.DeploymentStrategy(),
1024+
AnalysisStatus: metrics.AnalysisStatusCompleted,
1025+
})
1026+
c.runPostRolloutHooks(canary, flaggerv1.CanaryPhaseFailed)
1027+
}
1028+
9921029
func (c *Controller) setPhaseInitialized(cd *flaggerv1.Canary, canaryController canary.Controller) error {
9931030
if cd.Status.Phase == "" || cd.Status.Phase == flaggerv1.CanaryPhaseInitializing {
9941031
cd.Status.Phase = flaggerv1.CanaryPhaseInitialized

pkg/controller/scheduler_deployment_fixture_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,40 @@ func (f fixture) makeReady(t *testing.T, name string) {
8282
require.NoError(t, err)
8383
}
8484

85+
// makePrimaryNotReady puts the primary deployment into a stuck rollout state
86+
// (progress deadline exceeded) so that IsPrimaryReady returns a non-retriable error.
87+
func (f fixture) makePrimaryNotReady(t *testing.T) {
88+
primaryName := fmt.Sprintf("%s-primary", f.canary.Spec.TargetRef.Name)
89+
p, err := f.kubeClient.AppsV1().
90+
Deployments("default").
91+
Get(context.TODO(), primaryName, metav1.GetOptions{})
92+
require.NoError(t, err)
93+
94+
p.Status = appsv1.DeploymentStatus{
95+
ObservedGeneration: p.Generation,
96+
Replicas: 1,
97+
UpdatedReplicas: 1,
98+
ReadyReplicas: 0,
99+
AvailableReplicas: 0,
100+
Conditions: []appsv1.DeploymentCondition{
101+
{
102+
Type: appsv1.DeploymentProgressing,
103+
Status: corev1.ConditionFalse,
104+
Reason: "ProgressDeadlineExceeded",
105+
},
106+
{
107+
Type: appsv1.DeploymentAvailable,
108+
Status: corev1.ConditionFalse,
109+
Reason: "MinimumReplicasUnavailable",
110+
LastUpdateTime: metav1.Now(),
111+
},
112+
},
113+
}
114+
115+
_, err = f.kubeClient.AppsV1().Deployments("default").Update(context.TODO(), p, metav1.UpdateOptions{})
116+
require.NoError(t, err)
117+
}
118+
85119
func newDeploymentFixture(c *flaggerv1.Canary) fixture {
86120
if c == nil {
87121
c = newDeploymentTestCanary()

pkg/controller/scheduler_deployment_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,72 @@ func TestScheduler_DeploymentRollback(t *testing.T) {
116116
assert.Equal(t, flaggerv1.CanaryPhaseFailed, c.Status.Phase)
117117
}
118118

119+
// TestScheduler_DeploymentPromotionPrimaryNotReady verifies that when the canary
120+
// analysis has succeeded and the new spec has been promoted to the primary, but
121+
// the primary fails to become ready, Flagger does NOT perform the destructive
122+
// analysis-failure rollback. Routing all traffic to the unhealthy primary and
123+
// scaling the (healthy) canary to zero would take the whole application down,
124+
// because during promotion the canary is the only running copy of the new
125+
// revision. See https://github.com/fluxcd/flagger/issues/1898.
126+
func TestScheduler_DeploymentPromotionPrimaryNotReady(t *testing.T) {
127+
mocks := newDeploymentFixture(nil)
128+
129+
// initializing
130+
mocks.ctrl.advanceCanary("podinfo", "default")
131+
mocks.makePrimaryReady(t)
132+
133+
// initialized
134+
mocks.ctrl.advanceCanary("podinfo", "default")
135+
136+
// update
137+
dep2 := newDeploymentTestDeploymentV2()
138+
_, err := mocks.kubeClient.AppsV1().Deployments("default").Update(context.TODO(), dep2, metav1.UpdateOptions{})
139+
require.NoError(t, err)
140+
141+
// detect changes -> progressing, canary scaled up
142+
mocks.ctrl.advanceCanary("podinfo", "default")
143+
mocks.makeCanaryReady(t)
144+
require.NoError(t, assertPhase(mocks.flaggerClient, "podinfo", flaggerv1.CanaryPhaseProgressing))
145+
146+
canaryDep, err := mocks.kubeClient.AppsV1().Deployments("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
147+
require.NoError(t, err)
148+
require.NotNil(t, canaryDep.Spec.Replicas)
149+
canaryReplicas := *canaryDep.Spec.Replicas
150+
require.Greater(t, canaryReplicas, int32(0))
151+
152+
// simulate: analysis succeeded, spec promoted to primary, now finishing promotion
153+
cd, err := mocks.flaggerClient.FlaggerV1beta1().Canaries("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
154+
require.NoError(t, err)
155+
err = mocks.deployer.SetStatusPhase(cd, flaggerv1.CanaryPhasePromoting)
156+
require.NoError(t, err)
157+
158+
// known routing state at promotion time (split between primary and canary)
159+
require.NoError(t, mocks.router.SetRoutes(mocks.canary, 50, 50, false))
160+
161+
// the promoted primary fails to roll out
162+
mocks.makePrimaryNotReady(t)
163+
164+
// advance: Flagger observes the primary is stuck
165+
mocks.ctrl.advanceCanary("podinfo", "default")
166+
167+
// the canary must NOT be scaled to zero - it is the only healthy copy serving traffic
168+
canaryDep, err = mocks.kubeClient.AppsV1().Deployments("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
169+
require.NoError(t, err)
170+
require.NotNil(t, canaryDep.Spec.Replicas)
171+
assert.Equal(t, canaryReplicas, *canaryDep.Spec.Replicas, "canary must not be scaled to zero when promotion fails")
172+
173+
// traffic must NOT be shifted entirely onto the broken primary
174+
primaryWeight, canaryWeight, _, err := mocks.router.GetRoutes(mocks.canary)
175+
require.NoError(t, err)
176+
assert.NotEqual(t, 100, primaryWeight, "traffic must not be routed entirely to the unhealthy primary")
177+
assert.Greater(t, canaryWeight, 0, "canary must keep serving traffic when promotion fails")
178+
179+
// the rollout is reported as failed so it stops advancing and alerts
180+
c, err := mocks.flaggerClient.FlaggerV1beta1().Canaries("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
181+
require.NoError(t, err)
182+
assert.Equal(t, flaggerv1.CanaryPhaseFailed, c.Status.Phase)
183+
}
184+
119185
func TestScheduler_DeploymentSkipAnalysis(t *testing.T) {
120186
mocks := newDeploymentFixture(nil)
121187
// initializing

0 commit comments

Comments
 (0)