Upgrading without an incident
Three things to upgrade, three mechanisms, one of them automatic. The standby goes first, your primary moves, and Helm will not touch your CRDs.
By the end of this topic you can
- Roll a MySQL image change and name the ordered-update phase you are in from status
- Say why the standby is upgraded first, and what makes the updater abort early
- Upgrade the operator without letting Helm silently leave the CRDs behind
There are three things you will upgrade, they are upgraded by three different mechanisms, and only
one of them is automatic. MySQL moves by an ordered rollout the operator drives. The operator moves by
helm upgrade. The CRDs move by neither, and that is the one that catches people.
Everything else in this course has been about failure arriving unannounced. This is the opposite problem: you are the one causing the disturbance, on a Tuesday, on purpose, and the only question is whether anybody notices.
Upgrading MySQL: the standby goes first
Change spec.image and apply. What follows is not a Deployment rollout — the operator deliberately
takes that away from Kubernetes and runs it itself.
The mechanism is the spec hash from the last topic. Under updateStrategy: OrderedUpdate (the
default) the reconciler leaves existing site Deployments untouched, so the desired hash and the
live Deployment annotation diverge. The runner notices that drift on its next pass and hands the
rollout to the ordered updater, which does this:
Two things about that sequence are worth being able to defend.
The order is MySQL’s requirement, not a preference. A replica may run a newer MySQL than its source; a source may not run a newer MySQL than its replica. Upgrading the standby first keeps the newer version on the replica side of replication for the whole window, which is the supported direction. Upgrading the primary first would put an older replica behind a newer source — unsupported, and the failure is not always immediate or obvious.
Your primary moves, and it does not move back. Fail-back is current-state-driven, not
identity-driven: nothing in the operator remembers that iad “should” be primary. After an image
bump your active site is whichever one used to be the standby. If your application has any site
affinity — a warm standby per site, a taint toleration, a latency assumption — that is a change you
scheduled without noticing. Run the upgrade in the direction you want to end up.
The refusals, and the one that will surprise you
The updater refuses to start at all if the standby is not genuinely a standby:
precondition: standby <site> is writable; refusing to start ordered update
precondition: standby <site> is not replicating
Both run before the updater takes its lock, so a refused attempt leaves nothing behind and you can simply fix the standby and re-apply.
Then the interesting one, which fires mid-rollout. WaitReplica is not just a timeout — it watches for
a specific shape and aborts on it:
standby is writable but replication is not running; aborting ordered update
That is the restart hazard made explicit. A MySQL pod that restarts comes up writable for a few seconds before anything fences it — you met that in Unit 5 as the ordinary source of split brains — and during an ordered update, cross-site recovery is suppressed, so nothing is going to start replication for you. A standby that comes back writable with no working replication is not a slow standby; it is a stuck one, and waiting the full five minutes before saying so would leave the group in the rollout for no reason. So the updater counts writable observations and gives up early.
The counter is deliberately not a strict streak — a probe error leaves it alone rather than resetting it — because an alternating pattern of dial errors and “writable, no source” reads is exactly what a stale connection pool produces, and a strict streak would let that mask a genuinely broken standby until the outer deadline.
One more nicety worth knowing when you are reading the logs: a standby pod restart preserves
replication metadata, but the operator runs mysqld with --skip-replica-start, so the threads come
back stopped with SourceHost still populated. The updater owns that window, recognises the shape and
issues START REPLICA itself rather than waiting for something else to.
Recreate is a decision about write availability
spec.updateStrategy takes OrderedUpdate or Recreate, and the difference is not cosmetic. Under
Recreate the runner clears the drift list entirely and the reconciler patches every site Deployment
in one pass — so their pod restarts may overlap, and a group with two sites can have both of them down
at the same moment. That is the total-loss window OrderedUpdate exists to prevent.
Use Recreate only when you can afford to lose the primary and the standby simultaneously, and never
for a MySQL major-version bump: if one pod’s in-place upgrade stalls, you have no healthy primary and
no rollback path.
What an image bump does to your dashboards
Say this out loud before you run one, because otherwise your on-call will say it at 02:00.
The Failover phase performs a real promotion, through the same code path as an emergency
failover, and its completion callback stamps the durable failover record and increments
bloodraven_failovers_total. And it is not cooldown-gated — failoverCooldown guards the
automatic path only, and nothing in the update controller consults it.
So a routine image bump produces: a BloodravenFailoverOccurred alert, a moved activeSite, a fresh
lastFailover stamp that will suppress the next automatic failover for the whole cooldown window,
and a DNSEndpoint flip. None of it is a fault. All of it looks exactly like one.
Two mitigations, and you want both. Alert on the ordered-update log lines —
ordered update: updating standby, ordered update: failing over to updated standby,
ordered update complete — so a failover with a rollout around it is visibly different from a failover
without one. And watch status.updatePhase, which is non-empty for exactly the duration of a rollout
and is the cheapest possible “is this us?” check:
kubectl -n bloodraven-playground get mysqlfailovergroup playground \
-o jsonpath='{.status.updatePhase}{"\n"}'
Upgrading the operator, and the CRDs it does not bring with it
The operator is one Deployment with replicaCount: 1 and leader election. Upgrading it is
helm upgrade, and the blast radius is genuinely small — from Unit 5 you know the operator is not on
the request path, so a restart costs failover cover for a few seconds and costs your data plane
nothing.
The CRDs are the trap, and it is a Helm one rather than a Bloodraven one:
Helm installs CRDs from a chart’s
crds/directory on first install, and never upgrades them.
So helm upgrade moves the operator binary and silently leaves the CRD schema at whatever version you
first installed. A new operator against an old CRD is the worst shape available: the fields the new
version wants are pruned by the API server exactly as preferSite was in Unit 5 — admitted, dropped,
no error anywhere — and you get an operator behaving as though you never configured the thing you
configured.
Apply the CRDs explicitly, and do it first:
Put these in the order they happen.
- Read the release notes for CRD changes and for the sidecar image tag. These are the only two things that can require action.
- kubectl apply -f the CRDs from config/crd/bases/ (or the chart's crds/ directory) — Helm will not do this for you, on upgrade or on rollback.
- helm upgrade the operator chart. One Deployment, one replica; leader election means the new pod takes the lease when the old one releases it.
- Bump spec.sidecarImage to the matching release and apply. This is an ordered update: it restarts pods, moves your primary, and increments the failover counter.
- Run a backup verification against the new version, because a restore path you have not exercised since the upgrade is an assumption again.
Step 4 catches people out because the sidecar looks like part of the operator and is not. It ships as
its own image, referenced by spec.sidecarImage on the group, and moving it is a pod restart on every
site — so it goes through the same ordered update as an image bump, with the same failover. Bloodraven
tolerates a one-minor skew between operator and sidecar in either direction while pods roll, because
both sides of the HTTP surface between them are additive-only. Beyond one minor is untested.
And if the same release bumps spec.image and spec.sidecarImage together, do it in one apply: the
ordered updater restarts the sidecar as part of the pod restart it was already performing, so you pay
for one rollout instead of two.
The one-way doors
Three things you cannot undo, collected in one place because each is discovered late.
MySQL does not support downgrade of data. Once a datadir has been opened by a newer MySQL, an
older mysqld may refuse it outright or corrupt it. MysqlBackup.status.mysqlImage records the image
tag that produced each dump precisely so you can check before restoring; restore onto the same major
version that produced the backup, never onto an older one.
A steady-state per-site version split is not supported. spec.image is one field per group and
SiteSpec has no override. Edit a site’s Deployment out of band to pin a different tag and the next
reconcile reverts it. Transient skew during a rollout is fine and expected; permanent skew is not a
configuration, it is a fight with the reconciler.
There is no version admission check. Setting spec.image to something Bloodraven does not support
produces no CRD validation error. It surfaces as MySQL pods failing, which is a much worse place to
find out. The supported baseline, and what to run to re-check it, is in the
version appendix, row C1.
Where this leaves you
You can roll a MySQL image change and name the phase you are in from status.updatePhase. You can say
why the standby goes first, what the updater refuses to start on, and which mid-rollout shape makes it
abort early rather than wait. You can predict the alert your dashboards will show and tell it apart
from a real failover. And you can upgrade the operator without letting Helm leave your CRDs behind.
That is day 2 complete. What is left is the part you take away from the screen.
Flashcards
The six ordered-update phases, in order
UpdateReplica, WaitReplica, Failover, UpdateOldPrimary, WaitOldPrimary, Complete. They appear verbatim in status.updatePhase, which is empty whenever no rollout is running.
Why the standby is upgraded first
MySQL's rolling-upgrade contract: a replica may run a newer version than its source, a source may not run a newer version than its replica. Standby-first keeps the newer MySQL on the replica side for the whole window.
What triggers an ordered update
Spec-hash drift. Under OrderedUpdate the reconciler deliberately leaves existing site Deployments untouched, so the desired hash and the live Deployment annotation diverge and the runner hands the rollout to the updater.
The two preconditions that refuse to start a rollout
precondition: standby <site> is writable; refusing to start ordered update and precondition: standby <site> is not replicating. Both run before the updater takes its lock, so a refused attempt leaves no state behind.
The mid-rollout abort
standby is writable but replication is not running; aborting ordered update. A restarted pod comes up writable for a few seconds and cross-site recovery is suppressed during an update, so nothing will start replication for it — waiting the full five minutes would tell you nothing.
Why the abort counter is not a strict streak
A probe error leaves it alone rather than resetting it. Alternating dial errors and 'writable, no source' reads are exactly what a stale pool produces, and a strict streak would let that mask a genuinely broken standby until the outer deadline.
updateStrategy: Recreate
Clears the drift list and patches every site Deployment in one pass, so pod restarts may overlap and both sites can be down at once. Never use it for a MySQL version bump: a stalled in-place upgrade leaves no healthy primary and no rollback.
What a routine image bump does to your dashboards
The Failover phase is a real promotion: it stamps lastFailover, increments bloodraven_failovers_total, fires BloodravenFailoverOccurred, flips DNS, and moves activeSite. It is not cooldown-gated, and the fresh lastFailover suppresses the next automatic failover for the whole cooldown window.
The cheapest 'is this us?' check during an alert
kubectl get mysqlfailovergroup <group> -o jsonpath='{.status.updatePhase}'. Non-empty for exactly the duration of a rollout.
Helm and CRDs
Helm installs CRDs from a chart's crds/ directory on first install and never upgrades them. helm upgrade moves the operator binary and silently leaves the CRD schema behind, so new fields are pruned by the API server with no error anywhere. Apply CRDs explicitly, first.
spec.sidecarImage
Ships as its own image and is referenced by the group, not the chart. Moving it restarts every site's pod, so it goes through the same ordered update — with the same failover. Bloodraven tolerates one minor of operator/sidecar skew in either direction while pods roll.
The three one-way doors of upgrading
MySQL does not support data downgrade (MysqlBackup.status.mysqlImage records what produced each dump); a steady-state per-site version split is not supported, because spec.image is one field per group with no SiteSpec override; and there is no version admission check at all.
Quiz
Show answer
Answer: status.updatePhase — it is non-empty for exactly the duration of an ordered update, and a routine spec.image or spec.sidecarImage bump performs a real promotion that increments the same counter
An ordered update's Failover phase runs the ordinary nine-step promotion and its completion callback stamps the durable record and increments bloodraven_failovers_total. From the metric alone a rollout and a dead primary look identical, and status.updatePhase is the one field that separates them at a glance. Option 2 reasons from the wrong gate: the ordered-update handoff is not cooldown-gated at all, so an in-cooldown promotion is evidence of a rollout rather than of a human. Option 3 is not reliable — divergence depends on what the demoted site committed, not on why it was demoted. Option 4 invents a condition reason; there are exactly five, and Updating is not among them. (objective 7)
An ordered update's Failover phase runs the ordinary nine-step promotion and its completion callback stamps the durable record and increments bloodraven_failovers_total. From the metric alone a rollout and a dead primary look identical, and status.updatePhase is the one field that separates them at a glance. Option 2 reasons from the wrong gate: the ordered-update handoff is not cooldown-gated at all, so an in-cooldown promotion is evidence of a rollout rather than of a human. Option 3 is not reliable — divergence depends on what the demoted site committed, not on why it was demoted. Option 4 invents a condition reason; there are exactly five, and Updating is not among them. (objective 7)
Show answer
Answer: The standby's pod restarted onto the new image and came back writable with no working replication, and because cross-site recovery is suppressed during an update nothing is going to start it — so the updater gives up early rather than burning its five-minute deadline
This is the restart hazard from Unit 5 met inside a procedure you started. A MySQL pod comes up writable for a few seconds before anything fences it, and an ordered update deliberately suppresses cross-site recovery — so a standby that stays writable with no replication is stuck, not slow. The updater counts writable observations and aborts early, and it deliberately does not reset that counter on a probe error, because alternating dial errors and 'writable, no source' reads are exactly what a stale connection pool produces. Option 2 describes a different failure with a different message. Option 3 misreads the abort as a split-brain guard. Option 4 confuses the poll loop's debounce with the updater's own wait, which reads MySQL directly. (objective 8)
This is the restart hazard from Unit 5 met inside a procedure you started. A MySQL pod comes up writable for a few seconds before anything fences it, and an ordered update deliberately suppresses cross-site recovery — so a standby that stays writable with no replication is stuck, not slow. The updater counts writable observations and aborts early, and it deliberately does not reset that counter on a probe error, because alternating dial errors and 'writable, no source' reads are exactly what a stale connection pool produces. Option 2 describes a different failure with a different message. Option 3 misreads the abort as a split-brain guard. Option 4 confuses the poll loop's debounce with the updater's own wait, which reads MySQL directly. (objective 8)
Show answer
Answer: False
It is MySQL's requirement, not a preference. A replica may run a newer MySQL than its source; a source may not run a newer MySQL than its replica. Upgrading the standby first keeps the newer version on the replica side of replication for the entire window, which is the supported direction, and it is why the sequence ends with a failover rather than beginning with one. Doing it the other way puts an older replica behind a newer source, which is unsupported and does not always fail immediately or obviously. (objective 8)
It is MySQL's requirement, not a preference. A replica may run a newer MySQL than its source; a source may not run a newer MySQL than its replica. Upgrading the standby first keeps the newer version on the replica side of replication for the entire window, which is the supported direction, and it is why the sequence ends with a failover rather than beginning with one. Doing it the other way puts an older replica behind a newer source, which is unsupported and does not always fail immediately or obviously. (objective 8)
Show answer
Answer: Helm installs CRDs from crds/ on first install and never upgrades them, so the API server is still validating against the old schema and pruned the unknown field without an error
This is the same silent-pruning failure Unit 5 met with preferSite, arriving by a different route: the object is admitted, the unknown field is dropped, and nothing errors anywhere — not at apply, not in an event, not in the log. The fix is procedural, not a flag: apply the CRDs explicitly with kubectl apply -f before helm upgrade, and treat CRD changes in release notes as an action item rather than a note. Options 2 and 3 invent mechanisms. Option 4 invents a release process. (objective 9)
This is the same silent-pruning failure Unit 5 met with preferSite, arriving by a different route: the object is admitted, the unknown field is dropped, and nothing errors anywhere — not at apply, not in an event, not in the log. The fix is procedural, not a flag: apply the CRDs explicitly with kubectl apply -f before helm upgrade, and treat CRD changes in release notes as an action item rather than a note. Options 2 and 3 invent mechanisms. Option 4 invents a release process. (objective 9)
Show answer
Answer:
First, read the release notes for two things only: CRD changes and the sidecar image tag — those are the only items that require action. Second, kubectl apply -f the CRDs from config/crd/bases/ (or the chart's crds/), because Helm will not do it on upgrade or on rollback, and a new operator against an old schema means silently pruned fields. That step disturbs nothing running. Third, helm upgrade the operator chart: one Deployment, one replica, leader election, and the operator is not on the request path — so this costs failover cover for a few seconds and costs the data plane nothing. Fourth, bump spec.sidecarImage to the matching release and apply; this is an ordered update, so it restarts every site's pod, moves the primary, increments bloodraven_failovers_total and fires the failover alert. If the release also moves spec.image, do both in the same apply so you pay for one rollout instead of two. Fifth, run a backup verification against the new version, because a restore path you have not exercised since the upgrade is an assumption again.
A full-credit answer shows: A strong answer has CRDs before the chart, gives the reason (Helm never upgrades CRDs, and the failure is silent pruning), places the sidecar bump last among the changes and says it is an ordered update that moves the primary and fires the failover alert, and closes with a verification run. Credit combining spec.image and spec.sidecarImage into one apply. Credit noting that the operator upgrade itself is cheap because it is not on the request path. An answer that treats helm upgrade as the whole procedure has missed the step that actually causes incidents.
The ordering exists because the three things you upgrade move by three different mechanisms with three different blast radii, and only one of them is automatic. Getting CRDs after the chart produces a failure with no symptom; getting the sidecar bump wrong produces a failover you did not schedule. (objective 9)
Sample answer
First, read the release notes for two things only: CRD changes and the sidecar image tag — those are the only items that require action. Second, kubectl apply -f the CRDs from config/crd/bases/ (or the chart's crds/), because Helm will not do it on upgrade or on rollback, and a new operator against an old schema means silently pruned fields. That step disturbs nothing running. Third, helm upgrade the operator chart: one Deployment, one replica, leader election, and the operator is not on the request path — so this costs failover cover for a few seconds and costs the data plane nothing. Fourth, bump spec.sidecarImage to the matching release and apply; this is an ordered update, so it restarts every site's pod, moves the primary, increments bloodraven_failovers_total and fires the failover alert. If the release also moves spec.image, do both in the same apply so you pay for one rollout instead of two. Fifth, run a backup verification against the new version, because a restore path you have not exercised since the upgrade is an assumption again.
A full-credit answer shows
A strong answer has CRDs before the chart, gives the reason (Helm never upgrades CRDs, and the failure is silent pruning), places the sidecar bump last among the changes and says it is an ordered update that moves the primary and fires the failover alert, and closes with a verification run. Credit combining spec.image and spec.sidecarImage into one apply. Credit noting that the operator upgrade itself is cheap because it is not on the request path. An answer that treats helm upgrade as the whole procedure has missed the step that actually causes incidents.
The ordering exists because the three things you upgrade move by three different mechanisms with three different blast radii, and only one of them is automatic. Getting CRDs after the chart produces a failure with no symptom; getting the sidecar bump wrong produces a failover you did not schedule. (objective 9)