Runbook: Upgrading the MySQL Operator and InnoDB Cluster server version
- 1. Two version axes, not one
- 2. Before you start: verify the chart AND the image exist
- 3. Choosing a target version
- 4. Preconditions for a server upgrade
- 5. What actually happens during the roll
- 6. Application impact
- 7. Verification after the roll
- 8. Rollback
- 9. Recovering a chart version pruned from the upstream index
- 10. Metrics export
- 11. Known traps
- 12. References
|
This guide covers the generic mechanics of upgrading an Oracle MySQL Operator deployment and the
server version of the clusters it manages. For the cluster-specific execution checklist — exact
values, commands and gates for Cluster access is covered by the |
1. Two version axes, not one
The single most common mistake is treating this as one upgrade. There are two independent versions:
| Axis | Controlled by | Changing it |
|---|---|---|
Operator version |
|
Rolls the |
Server version |
|
Rolls every mysqld instance. This is the risky one. |
Upgrading the operator does not upgrade the servers it manages. A cluster can sit for years on
an old server version while the operator moves forward — and it will, silently, because the
operator only renders the StatefulSet’s server image at cluster creation and whenever spec.version
changes.
If spec.version is unset on the CR, the server version is frozen at whatever the
operator defaulted to when the cluster was first created. It is recorded nowhere in Git. Check
the running server directly, never the manifest.
|
kubectl -n <ns> get sts <cluster> \
-o jsonpath='{range .spec.template.spec.containers[*]}{.name}={.image}{"\n"}{end}'
2. Before you start: verify the chart AND the image exist
Oracle publishes only one version at a time in its Helm index. Older versions are deleted
outright, so any targetRevision pinned directly at https://mysql.github.io/mysql-operator will
eventually fail to resolve. When it does, ArgoCD reports sync=Unknown with a ComparisonError
while still showing health=Healthy — the fetch fails before comparison, so autosync is silently
inert.
The fix is to mirror charts into a registry we control. See ArgoCD Deployment Patterns.
The chart source survives in upstream Git tags even after the index drops it:
curl -sL https://github.com/mysql/mysql-operator/archive/refs/tags/<appVer>-<chartVer>.tar.gz | tar xz
helm package <dir>/helm/mysql-operator
helm push mysql-operator-<chartVer>.tgz oci://registry-1.docker.io/christhonie
|
A Git tag does not guarantee a published container image. Upstream tags releases that Oracle
never ships images for — Unlike the Helm index, the image registry is not pruned. Always confirm the image before pinning:
The auth realm is |
Check community-server and community-router for the target server version too — all three
images must exist before you commit anything.
3. Choosing a target version
Stay on the LTS line. Operator 2.1.x tracks MySQL 8.4 LTS; 2.2.x tracks MySQL 9.x and 2.3.x tracks MySQL 26.7, both Innovation-track releases with much shorter support windows.
Pick the matched pair. Operator and server are released together and the chart’s appVersion
encodes both — 8.4.8-2.1.10 means server 8.4.8 with operator 2.1.10. Setting spec.version to the
operator’s own DEFAULT_VERSION_TAG keeps you on the combination Oracle tested:
grep -E '^(DEFAULT_VERSION_TAG|MIN_SUPPORTED_MYSQL_VERSION|MAX_SUPPORTED_MYSQL_VERSION)' \
<src>/mysqloperator/controller/config.py
An operator refuses to manage a server outside MIN_SUPPORTED..MAX_SUPPORTED, so check the
running server is in range for the operator version you are moving to.
4. Preconditions for a server upgrade
4.1. Scale to three instances first
A rolling restart of a two-member group leaves the cluster on a single member for the duration of
each pod restart — no fault tolerance at precisely the moment it is most likely to be needed. If the
survivor stumbles mid-roll the result is an outage plus possible manual quorum recovery
(forceQuorumUsingPartitionOf).
Scale spec.instances to 3 and wait for the new member to reach ONLINE before touching
spec.version. This must be a separate commit with a verification gate — applying both at once
lets the roll begin before the new member has finished cloning.
Scaling up is not a restart: the StatefulSet only creates the new ordinal; existing pods are untouched.
4.2. Take an on-demand backup
The data-dictionary upgrade is one-way. There is no downgrade once mysqld starts on the new
binary, so a proven-restorable backup is the only rollback. Do not rely on the nightly schedule —
take a fresh one and confirm it reaches Completed.
4.3. Record any non-persisted server variables
Variables set with SET GLOBAL do not survive a restart. Confirm anything that matters is actually
persisted, not merely running:
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.persisted_variables;
VARIABLE_SOURCE in performance_schema.variables_info describes how the running value
was set, so it reads DYNAMIC even after a successful SET PERSIST — it only flips to PERSISTED
once a restart reloads the file. persisted_variables is the authoritative check. Note also that
SET PERSIST is per-instance and does not replicate: run it on every member.
|
5. What actually happens during the roll
Setting spec.version fires the operator’s spec.version field handler, which calls
update_mysql_image(). That patches the StatefulSet pod template — the mysql container to the new
server image, and the sidecar plus all init containers to the current operator image. On a
cluster whose sidecar has drifted behind, this also silently brings the sidecar forward.
The resulting rollout has these properties:
| Property | Behaviour |
|---|---|
|
|
|
Affects scaling only. Rolling updates remain strictly sequential. |
Order |
Highest ordinal first. Confirm the primary is ordinal 0 so secondaries upgrade first and the primary last — MySQL’s recommended order. |
Primary restart |
Triggers a Group Replication failover. In-flight writes fail; the router redirects new connections. |
Routers |
Also rolled, via a separate Deployment. With multiple replicas this layer is transparent. |
Check the roles before you start — do not assume ordinal 0 is the primary:
SELECT MEMBER_HOST, MEMBER_ROLE, MEMBER_STATE, MEMBER_VERSION
FROM performance_schema.replication_group_members;
Each mysqld runs the data-dictionary upgrade on first start of the new binary. Duration scales with
table count rather than raw size, so check information_schema.tables for a realistic estimate.
6. Application impact
Applications reaching the cluster through MySQL Router see a failover, not an outage — but "rolling" is not "zero-impact":
-
New connections are transparent. Connection pools validate on borrow, evict the dead connection and reconnect through the router to the new primary.
-
In-flight statements fail with a connection exception that reaches application code. For Connector/J this is
CommunicationsException(SQLState08S01), which extendsSQLRecoverableException. Nothing retries it automatically; open transactions roll back.
Expect a short burst of errors confined to requests executing SQL during the switchover. Schedule accordingly. WordPress sites are the usual canary — they open a connection per request, so they show errors first and recover first.
7. Verification after the roll
# every member back ONLINE and on the new version
kubectl -n <ns> exec <pod> -c mysql -- mysql -u localroot -N -B -e \
"SELECT MEMBER_HOST, MEMBER_ROLE, MEMBER_STATE, MEMBER_VERSION
FROM performance_schema.replication_group_members;"
# images actually rolled (mysql AND sidecar)
kubectl -n <ns> get sts <cluster> \
-o jsonpath='{range .spec.template.spec.containers[*]}{.name}={.image}{"\n"}{end}'
# persisted variables survived
kubectl -n <ns> exec <pod> -c mysql -- mysql -u localroot -N -B -e \
"SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.persisted_variables;"
Then confirm the ArgoCD Application reports Synced/Healthy with no conditions, and check
application health and error rates in Grafana for the switchover window.
8. Rollback
There is none for the server upgrade. Once the data dictionary is upgraded, the old binary will
refuse to start against that datadir. Recovery means restoring the pre-upgrade backup into a fresh
cluster — see the mysql-operator-backup-restore skill.
The operator chart, by contrast, rolls back cleanly: revert targetRevision and let ArgoCD sync.
That is only true while the version you are reverting to is still fetchable. Reverting
targetRevision to a chart the vendor has since pruned from its index leaves the Application
unable to render at all — see the next section.
|
9. Recovering a chart version pruned from the upstream index
Oracle keeps exactly one mysql-operator and one mysql-innodbcluster entry in its published
Helm index. Superseded versions are deleted outright: the index entry disappears and the .tgz
returns 404. Any targetRevision pinned directly at that index therefore rots on the vendor’s
release schedule, not on ours, and it takes both the upgrade path and the rollback path with it.
The failure is quiet — the Application keeps reporting health=Healthy while sync=Unknown,
because the chart fetch fails before any comparison happens and autosync is silently inert. The
reason appears only in .status.conditions as a ComparisonError; no metric exposes it. See
ArgoCD Deployment Patterns for the diagnosis commands.
9.1. The asymmetry that makes recovery possible
| Source | Pruned? | Consequence |
|---|---|---|
Published Helm index |
Yes — one version at a time |
A pinned version becomes unfetchable without warning. |
Upstream Git tags |
No |
Full chart source survives under |
Oracle container registry |
No |
Image tags are retained across many releases, well behind the current one. |
So a withdrawn chart is always reconstructible, and the images it references are still pullable.
9.2. Mirror rather than re-pin
Re-pinning to whatever the index currently advertises solves the symptom and reintroduces the
cause — often while dragging the estate off the LTS line onto an Innovation-track release. The
durable fix is to mirror the chart into the OCI registry we control and point targetRevision
there. No ArgoCD Application in the GitOps repository should source a chart from a third-party
Helm index.
Version-preserving mirror (zero restart). While the version is still fetchable, helm pull the
exact pinned version and helm push that tarball unmodified. Do not repackage from source: a
rebuilt tarball differs byte-wise, which can change the render and restart workloads for no
reason.
helm pull --version <pinned-version> --repo <upstream-index-url> <chart-name>
helm push <chart-name>-<pinned-version>.tgz oci://registry-1.docker.io/christhonie
Then prove the mirror is byte-identical before switching the Application over — pull it back and compare the extracted trees against the upstream tarball:
helm pull oci://registry-1.docker.io/christhonie/<chart-name> --version <pinned-version> --untar --untardir /tmp/mirror
tar xzf <chart-name>-<pinned-version>.tgz -C /tmp/upstream
diff -r /tmp/upstream/<chart-name> /tmp/mirror/<chart-name>
An identical artefact renders identically, so ArgoCD sees no diff and nothing restarts. Confirm
the Application returns to Synced/Healthy with every pod age unchanged.
Check dependency bundling first. A chart that resolves dependencies at render time behaves
differently once mirrored. Compare a dependencies: key in Chart.yaml against what the tarball
actually contains:
tar tzf <chart-name>-<pinned-version>.tgz | grep 'charts/.*/Chart.yaml'
If the version is already gone, rebuild it from the matching Git tag using the recipe in the pre-flight section above, then push that package to the mirror. The result is functionally correct but not byte-identical to the original artefact, so expect a render diff and plan for a roll.
9.3. The trap: a chart tag without a published image
A Git tag is not a release. Upstream tags chart versions for which the container image was never
published — 8.4.9-2.1.11 is one. Mirroring such a chart produces a clean, green ArgoCD sync
followed by ImagePullBackOff, which is a worse outcome than the failure it was meant to fix
because the Application now looks correct.
Always confirm the image exists before packaging or pinning, using the registry check in the pre-flight section above. On the 8.4 LTS line, 2.1.10 is the newest chart version with a real published image.
10. Metrics export
Metrics are enabled on the InnoDBCluster CR, not by deploying an exporter separately:
spec:
metrics:
enable: true
monitor: true
image: prom/mysqld-exporter:v0.14.0
The operator adds a metrics sidecar on port 9104, creates a ServiceMonitor in the cluster’s
namespace, and creates and manages the mysqlmetrics database user itself — PROCESS,
REPLICATION CLIENT and SELECT, with max_user_connections=3. No manual grants are needed and
none should be added.
Scraping needs no labelling. The monitoring stack’s Prometheus has both serviceMonitorSelector
and serviceMonitorNamespaceSelector set to {}, which means every ServiceMonitor in every
namespace is discovered.
10.1. Pin the exporter to v0.14.0
|
The exporter image must be pinned to The operator writes an invalid socket path into the generated
The operator also sets Upgrading the operator does not fix this. The same string is still emitted by much later operator releases. Do not treat a newer operator as a reason to unpin. The upstream signal is the operator’s own test default, |
10.2. Changing spec.metrics rolls the cluster
spec.metrics is a watched field. Changing it adds or alters a container and stamps
restartedAt on the StatefulSet, which rolls every mysqld pod — roughly six minutes for a
three-member cluster. Treat it as a rolling restart and apply the same care as a server upgrade:
check roles first, expect a failover when the primary restarts, and see
Observability Access for watching the switchover.
The handler patches the live StatefulSet rather than regenerating it, so it does not pick up
unrelated drift. Changes to spec.podSpec — anti-affinity, resource requests — still require a
spec.version change to take effect.
10.3. Dashboards and folders
Grafana dashboards live in observability/dashboards/ in the GitOps repository and are deployed
by the grafana-dashboards ArgoCD Application. Each dashboard is a ConfigMap in the
observability namespace carrying two pieces of metadata:
| Metadata | Effect |
|---|---|
Label |
Makes the Grafana sidecar pick the ConfigMap up at all. |
Annotation |
Places the dashboard in that folder. The sidecar creates the folder on demand, so a new folder needs no prior setup — naming it in the annotation is enough. |
The mysql-innodb-cluster dashboard covers connections against max_connections and buffer-pool
hit rate and pages-by-state. Connection capacity, its alert rules and the limits they watch are
covered by MySQL Connection Capacity.
Custom Prometheus alert rules follow the same one-Application-per-directory pattern from
observability/prometheus-rules/, and are likewise discovered without labels because
ruleSelector and ruleNamespaceSelector are both {}.
|
Validate a rule’s expression against live data before pushing it. A rule that parses and loads is
not a rule that matches anything — invert the threshold once so you can see the series actually
return, then confirm |
11. Known traps
-
Chart withdrawn from the upstream Helm index — mirror charts, do not pin upstream.
-
Git tag exists but no container image — verify the image, not just the chart.
-
spec.versionunset means the server version is invisible in Git and frozen at creation. -
spec.mycnfis not a watched field. Editing it is inert on a running cluster: the operator renders it into the config ConfigMap only at initial provisioning. Values recorded there apply to rebuilt or DR-restored clusters, not to the live one. -
spec.router.instancesis a change-triggered handler. If the live Deployment has drifted, setting the CR to the value it already holds does nothing — toggle it, or scale the Deployment directly. -
SET PERSISTis per-instance and does not replicate.
12. References
-
Observability Access — for watching the switchover
-
MySQL Connection Capacity — the persisted variables a roll will expose, and the caps a rebuilt cluster loses