MySQL cluster upgrade: idealogic-prod
|
This is the cluster-specific execution checklist. The generic mechanics, traps and rationale are in Upgrading the MySQL Operator and InnoDB Cluster server version. Read that first if you have not done this before. |
1. Why this is needed
The operator was upgraded to 2.1.9 in December 2025 (d0d10fe) and to 2.1.10 in August 2026
(3b15dc5, Bug #1028), but the server has never moved. Because spec.version is unset on the CR,
the server stayed at whatever the operator defaulted to when the cluster was created in January 2024.
The cluster is therefore running MySQL 8.3.0 — an Innovation release that has been end-of-life since 2024 and receives no security patches. The sidecar is further behind still, at the 2.1.2 operator image.
2. Current state and target
| Item | Current | After upgrade |
|---|---|---|
Server ( |
|
|
Sidecar / init containers |
|
|
Router |
|
|
Operator |
2.1.10 (already current) |
unchanged |
|
2 |
3 (scaled up as a precondition) |
|
unset |
|
8.4.8 is the matched pair for operator 2.1.10 (chart appVersion: 8.4.8-2.1.10) — the combination
Oracle tested together. Operator 2.1.10 supports servers in the range 8.0.27–8.4.99, so the running
8.3.0 is in range for the upgrade path.
3. Pre-flight
All of the following were verified on 2026-08-24. Re-check on the day.
| Check | Expected |
|---|---|
|
present in |
|
present |
Longhorn capacity |
~1.4 TB free per node; the third 40Gi PVC is trivial |
Backups |
daily 02:00, completing in ~4 min, last six consecutive runs |
Dataset |
~2.86 GB across ~1670 tables — data-dictionary upgrade is minutes, not hours |
Primary |
|
Set your context first:
kubectl config use-context static/idl-xnl-jhb1-01 # or: export KUBECONFIG=<path>
kubectl -n mysql get innodbcluster idealogic-prod
3.1. Confirm the primary is still ordinal 0
The roll order only works in our favour if the primary is -0. If a failover has happened since,
the primary may have moved — re-check, and expect the primary to be restarted first if it is now
-2.
kubectl -n mysql exec idealogic-prod-0 -c mysql -- mysql -u localroot -N -B -e \
"SELECT MEMBER_HOST, MEMBER_ROLE, MEMBER_STATE, MEMBER_VERSION
FROM performance_schema.replication_group_members;"
3.2. Take an on-demand backup
The server upgrade has no rollback. Do not rely on the nightly run.
kubectl -n mysql get mysqlbackup --sort-by=.metadata.creationTimestamp | tail -3
Trigger a fresh backup and wait for Completed before continuing. See the
mysql-operator-backup-restore skill.
3.3. Record the persisted variables
These must still be present afterwards:
for p in idealogic-prod-0 idealogic-prod-1; do
kubectl -n mysql exec $p -c mysql -- mysql -u localroot -N -B -e \
"SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.persisted_variables
WHERE VARIABLE_NAME IN ('max_connections','max_user_connections');"
done
Expected on both: max_connections=400, max_user_connections=20.
The per-account caps live in mysql.user, not in Git. They survive a rolling restart, but
would be lost by a rebuild from the CR. See MySQL InnoDB Cluster.
|
4. Phase A — scale to three instances
This is a separate commit with a hard gate. Do not combine it with Phase B: the roll must not begin before the new member has finished cloning and reached ONLINE.
Scaling up is not a restart — the StatefulSet only creates idealogic-prod-2; the existing pods are
untouched.
-
Edit
mysql/idealogic-prod/idealogic-prod-cluster.yml, setspec.instances: 3. -
Commit and push to
main. ArgoCD autosync applies it (mysql-idealogic-prod). -
Wait for the new member to clone and join.
kubectl -n mysql get pods -w # idealogic-prod-2 appears, clones, becomes Ready
4.1. Gate: all three ONLINE
Do not proceed until this returns three rows, all ONLINE:
kubectl -n mysql exec idealogic-prod-0 -c mysql -- mysql -u localroot -N -B -e \
"SELECT MEMBER_HOST, MEMBER_ROLE, MEMBER_STATE
FROM performance_schema.replication_group_members;"
kubectl -n mysql get innodbcluster idealogic-prod \
-o jsonpath='{.status.cluster.status} onlineInstances={.status.cluster.onlineInstances}{"\n"}'
Expected: ONLINE onlineInstances=3.
Also apply the persisted variables to the new member — SET PERSIST does not replicate:
SET PERSIST max_connections=400;
SET PERSIST max_user_connections=20;
5. Phase B — upgrade the server version
-
Edit the same file, add
spec.version: "8.4.8"alongsidespec.instances. -
Commit and push to
main. -
Watch the roll.
kubectl -n mysql get pods -w
Expected sequence: idealogic-prod-2 restarts first, then -1, then -0 last. Each pod runs the
data-dictionary upgrade on first start of the new binary. The restart of -0 triggers a Group
Replication failover — a brief burst of write errors in the applications.
Between each pod, wait for the member to rejoin ONLINE before the next one goes down. The StatefulSet does this automatically via readiness, but confirm rather than assume.
6. Verification
# all three members ONLINE on 8.4.8
kubectl -n mysql exec idealogic-prod-0 -c mysql -- mysql -u localroot -N -B -e \
"SELECT MEMBER_HOST, MEMBER_ROLE, MEMBER_STATE, MEMBER_VERSION
FROM performance_schema.replication_group_members;"
# images rolled — mysql AND sidecar
kubectl -n mysql get sts idealogic-prod \
-o jsonpath='{range .spec.template.spec.containers[*]}{.name}={.image}{"\n"}{end}'
# routers rolled
kubectl -n mysql get deploy idealogic-prod-router \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
# persisted variables intact on every member
for p in idealogic-prod-0 idealogic-prod-1 idealogic-prod-2; do
kubectl -n mysql exec $p -c mysql -- mysql -u localroot -N -B -e \
"SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.persisted_variables;"
done
Then:
-
ArgoCD
mysql-idealogic-prodreportsSynced/Healthywith no conditions. -
Application health — check the admin services and the WordPress sites. WordPress is the canary: it opens a connection per request, so it shows errors first and recovers first.
-
Error rates in Grafana for the switchover window.
7. Rollback
There is none for the server upgrade. Once mysqld has upgraded the data dictionary, the 8.3.0 binary will refuse to start against that datadir. Recovery means restoring the pre-upgrade backup into a fresh cluster.
This is why the on-demand backup in pre-flight is not optional.
8. After the upgrade
-
Leave
spec.versionpinned. It is now recorded in Git, which closes the gap where the server version existed nowhere in source control. -
Decide on
spec.instances. Three members give real fault tolerance and are worth keeping. If you scale back to 2, do it as a deliberate, separate change. -
Router replica drift. The CR declares
router.instances: 2but the live Deployment runs 3, from a manual scale in the past. Setting the CR to 2 will not reconcile it — the field handler is change-triggered and the value is already 2. Either scale the Deployment directly or toggle the CR value. Worth folding into this window.
9. Execution record: 2026-08-24
Executed as a single session, ~23:00–23:35 SAST, on a Monday night at low registration volume.
| Step | Outcome |
|---|---|
Pre-flight |
Cluster ONLINE/2, primary |
On-demand backup |
|
Phase A (scale 2→3) |
|
Phase A gate |
3 ONLINE, applier queue 0, both secondaries GTID-converged, verified stable over repeated samples |
Phase B (roll) |
~11 min total, ~3–4 min per pod, order |
Result |
All three ONLINE on 8.4.8; router 8.4.8 (3/3); ArgoCD |
Application impact |
Zero pod restarts across all seven DB-consuming services and WordPress |
9.1. Two things worth carrying forward
The new member came up on compiled defaults. idealogic-prod-2 joined with
max_connections=151 and max_user_connections=0, because SET PERSIST does not replicate and
99-extra.cnf carries only sql_mode. It needed SET PERSIST applied explicitly before the roll.
Had this gone unnoticed, the cluster would have carried a member configured for 151 connections that
was then promoted to primary by the roll — reproducing the 2026-07-29 exhaustion outage. Always
apply the persisted variables to a newly added member as part of Phase A.
The clone path was used, not incremental recovery. The operator first tried
add_instance with recoveryMethod=incremental, failed with "GTID state is not compatible" because
the binlogs have long been purged, and fell back to clone automatically. The accompanying probe
failures and the single pod restart during Phase A are part of that normal fallback, not a fault.
9.2. Post-upgrade state
The primary is now idealogic-prod-2, having moved during the roll when -0 restarted. The
pre-flight instruction to confirm which ordinal is primary matters for exactly this reason — do not
assume it is still ordinal 0 next time.
VARIABLE_SOURCE for both connection variables now reads PERSISTED rather than DYNAMIC,
confirming the values were genuinely loaded from mysqld-auto.cnf on startup rather than merely set
at runtime.
Still outstanding: the router Deployment remains at 3 replicas against a CR that declares 2.
10. Pending: podSpec takes effect at the NEXT roll
spec.podSpec was added on 2026-08-24 carrying pod anti-affinity and resource requests, but
podSpec is not in the operator’s watched-field list — so it is inert until the StatefulSet is
next re-rendered, which happens on the next spec.version change.
Whoever runs the next upgrade should verify it actually landed, as part of post-upgrade checks:
# affinity should now be present (it was absent before)
kubectl -n mysql get sts idealogic-prod -o jsonpath='{.spec.template.spec.affinity}{"\n"}'
# QoS should be Burstable, not BestEffort
kubectl -n mysql get pods -l component=mysqld \
-o custom-columns='POD:.metadata.name,NODE:.spec.nodeName,QOS:.status.qosClass' --no-headers
Expect Burstable and one pod per node. Until then the members remain BestEffort and are spread
across nodes only by scheduler chance.
Two related observations worth acting on separately:
-
innodb_buffer_pool_sizeis still at the MySQL default of 128Mi against a 2.86 GB dataset. Raising it is likely the single largest available performance win, but it is aSET PERSIST/mycnfchange with the usual caveats — and if a memory limit is ever added topodSpec, it must be raised in step or mysqld will be OOMKilled. -
Longhorn
actualSizeon the two original datadirs reads 41–43Gi against 40Gi volumes. This is not bloat: filesystem usage is 18G/40G. Longhorn counts blocks ever written and never reclaims them, and the 18G is mostly 30 days of binlogs (binlog_expire_logs_seconds=2592000). The fresh member sits at 4.8G and will converge on the same figure.