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.type: 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 restarted used to come up writable for a
few seconds before anything fenced it — you met that in Unit 5 as the old source of split brains. Every
mysqld now starts with --read-only=ON, so a standby restarting onto the new spec comes back
read-only and does not trip this; the check stays as the backstop for a standby that is writable
anyway, because something cleared that flag. 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.type 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, do it first, and do it server-side. The MysqlFailoverGroup CRD is larger
than the 256 KiB kubectl.kubernetes.io/last-applied-configuration annotation that a plain
kubectl apply -f writes, so client-side apply fails with metadata.annotations: Too long.
kubectl apply --server-side --force-conflicts keeps no such annotation. If Argo CD owns your CRDs,
the same thing is its ServerSideApply=true sync option.
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 --server-side --force-conflicts -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, and client-side apply cannot carry a CRD this large.
- helm upgrade the operator chart. One Deployment, one replica; leader election means the new pod takes the lease when the old one releases it.
- Let the sidecars follow. A group that leaves spec.sidecar.image unset runs the sidecar the new operator release ships with, so step 3 already changed its pod spec. 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 it is not a step you take: it is a consequence of step 3. The sidecar
ships as its own image, and a group that leaves spec.sidecar.image unset stores no image at all — it
runs the one the operator release ships with, which the chart hands the operator from its
sidecar.image.repository and sidecar.image.tag values. So the helm upgrade that moves the
operator also changes every such group’s pod spec, and moving the sidecar is a pod restart on every
site — the same ordered update as an image bump, with the same failover. Plan the chart upgrade as you
would plan that rollout. A group that pins spec.sidecar.image moves only when you change the pin, and
a pin from another release gets a SidecarVersionSkew Warning. 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.
The sidecar and MySQL share a pod, so a sidecar move and a spec.image bump go through the same
ordered update; there is no separate sidecar sequencing to arrange.
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 five ordered-update phases, in order
UpdateReplica, WaitReplica, Failover, UpdateOldPrimary, WaitOldPrimary. They appear verbatim in status.updatePhase, which is empty whenever no rollout is running: a finished rollout clears it, and there is no Complete value.
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 standby that is writable after its restart (it boots read_only=ON, so something cleared that) with cross-site recovery suppressed during the update has nothing to 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.type: 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, with kubectl apply --server-side --force-conflicts — the MysqlFailoverGroup CRD is too large for client-side apply.
spec.sidecar.image
Optional, with no stored default. Unset, the group runs the sidecar its operator release ships with (the chart's sidecar.image.repository and sidecar.image.tag), so a helm upgrade that ships a new sidecar changes the group's pod spec and runs an ordered update — with the same failover. Set it only to pin a build: a pin stays put, and a pin from another release gets a SidecarVersionSkew Warning. 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 bump, or an operator upgrade that moves the sidecar, 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 came back from its restart writable — something cleared the read_only=ON every mysqld boots with — 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. mysqld now boots read_only=ON, so a restart alone no longer produces it, but a standby that is writable anyway is the same hazard, 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. mysqld now boots read_only=ON, so a restart alone no longer produces it, but a standby that is writable anyway is the same hazard, 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 --server-side --force-conflicts 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 --server-side --force-conflicts 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, apply the CRDs from config/crd/bases/ (or the chart's crds/) with kubectl apply --server-side --force-conflicts. Helm will not do it on upgrade or on rollback, a new operator against an old schema means silently pruned fields, and it has to be server-side because the MysqlFailoverGroup CRD is too large for the annotation client-side apply writes. 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, expect the sidecars to follow: a group that leaves spec.sidecar.image unset runs the sidecar of the operator's release, so the chart upgrade changes its pod spec and starts an ordered update, which restarts every site's pod, moves the primary, increments bloodraven_failovers_total and fires the failover alert. Schedule the chart upgrade as you would that rollout; a group that pins spec.sidecar.image moves only when you change the pin. 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), says the sidecar move follows the chart upgrade and is an ordered update that moves the primary and fires the failover alert, and closes with a verification run. Credit noting that groups without a spec.sidecar.image pin follow the operator, so the helm upgrade itself starts their rollout. 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; forgetting that the sidecar follows the operator 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, apply the CRDs from config/crd/bases/ (or the chart's crds/) with kubectl apply --server-side --force-conflicts. Helm will not do it on upgrade or on rollback, a new operator against an old schema means silently pruned fields, and it has to be server-side because the MysqlFailoverGroup CRD is too large for the annotation client-side apply writes. 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, expect the sidecars to follow: a group that leaves spec.sidecar.image unset runs the sidecar of the operator's release, so the chart upgrade changes its pod spec and starts an ordered update, which restarts every site's pod, moves the primary, increments bloodraven_failovers_total and fires the failover alert. Schedule the chart upgrade as you would that rollout; a group that pins spec.sidecar.image moves only when you change the pin. 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), says the sidecar move follows the chart upgrade and is an ordered update that moves the primary and fires the failover alert, and closes with a verification run. Credit noting that groups without a spec.sidecar.image pin follow the operator, so the helm upgrade itself starts their rollout. 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; forgetting that the sidecar follows the operator produces a failover you did not schedule. (objective 9)