Make DB2 a safe automated failover target - #182
Conversation
DB2 exists now (THETRIANGLE-DB2-LXC, CT 111), renumbered to 10.248.40.155 after briefly sharing DB1's address. Wire it in as a failover target and turn on MaxScale's auto_failover, which was off because promoting a replica on a 2-node async pair loses acknowledged writes and can split-brain. Neither objection is answered by the failover flag itself, so: - Semi-sync replication with wait_point=AFTER_SYNC. DB1 does not commit to the storage engine until DB2 holds the event, so a promotion cannot drop a write the CMS was told succeeded. AFTER_COMMIT (the default) would not give this -- it makes the write visible before the ack, which is exactly the losing window. - Fence the write path instead of trying to reach quorum, which two nodes cannot do. MaxScale becomes the only route to the databases, so "MaxScale cannot see DB1" implies "the CMS cannot see DB1" and promoting DB2 cannot produce two servers taking writes. - gtid_strict_mode was already ON, so a diverged old primary is refused by auto_rejoin rather than corrupting the dataset. Two bugs found while doing this: - replica.cnf used relaxed durability (innodb_flush_log_at_trx_commit=2, sync_binlog=0). Right for a read cache, wrong for a failover target twice over: a promoted DB2 would run production without ACID, and semi-sync's ack would only mean "in DB2's page cache". Now 1/1, matching the primary. - setup-replica.sh passed --gtid alone, which records NO replication position -- in MariaDB it only changes the format of what --master-data emits. A replica seeded that way starts from the beginning of binlogs that expire after 7 days. Now --master-data=1 (=2 comments the line out), with a hard check that the dump actually contains the position. Adds provision-db2.sh to install MariaDB 11.8 and the replica config in one step; it refuses to run anywhere but DB2, since ssh gives no warning when an ARP change puts you on a different host. This changes source-of-truth config only. Nothing is applied to the DB hosts by merging it -- see "Bringing up DB2" in deploy/mariadb/README.md, and do not enable auto_failover before semi-sync is confirmed engaged and the write path is fenced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting guidance this branch introduced and I acted on against production. It told you to drop triangle_user@<delta-ip> as "Delta's direct bypass" before enabling failover. That account is not a bypass: MaxScale authenticates a client against the backend user table using the CLIENT's own source address, so triangle_user@<delta-ip> is exactly how the CMS logs in through MaxScale, while triangle_user@<maxscale-ip> is what lets MaxScale then open the backend connection. Both are required. Dropping it took the site down with Error 1045 on every DB-backed route while /v1/health kept returning 200, so it looked healthy until a page was loaded. Restored by copying the password hash from the surviving maxscale-scoped row. The fencing that makes auto_failover safe is purely the firewall: 3306 on both DB hosts now accepts only MaxScale and the DB peer, so Delta cannot reach the databases directly regardless of what accounts exist. That property is what the split-brain argument rests on. Also documents a second trap found on the live bring-up: setup-replica.sh dumps --databases triangle, which excludes mysql.*, so DB2 came up with none of the application accounts. A failover then promotes a server that nothing can authenticate to. Backfill under SET SESSION sql_log_bin=0 so the accounts stay out of the replication stream, and verify both hosts rather than assuming grants replicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran an actual failover against production: stopped DB1, MaxScale promoted DB2 in under 10s, the CMS kept serving and writing against the promoted node. Coming back was where it broke, twice. auto_rejoin looped forever on "Failed to prepare (demote) standalone server for rejoin". The monitor user was missing BINLOG ADMIN, which MariaDB 10.5+ split out of SUPER, so mariadbmon could not run SET @@session.sql_log_bin=0 while demoting the returning primary. The grant list in this README predated that split. With that fixed the rejoin succeeded and then reverted ~2s later (new_slave -> lost_slave). replication_user/replication_password default to the MONITOR user, so mariadbmon built the link as maxscale@<rejoining node>, which does not exist -- the monitor account is host-scoped to the MaxScale host. That is what the repl accounts are for, and nothing was pointing MaxScale at them. Now set explicitly. MaxScale surfaces this only as lost_slave; the real 1045 is on the rejoining node. Also enables semi-sync on BOTH sides on BOTH nodes. DB2 previously had only the slave side, so a promotion silently dropped to asynchronous replication -- losing the zero-data-loss guarantee at precisely the moment it had just been needed. Caught before the test, and confirmed during it: the promoted DB2 held Rpl_semi_sync_master_status=ON with one client. Verified end state: DB1 Master, DB2 Slave, equal GTID, 0 lag, semi-sync ON with 1 client, CMS health and homepage both 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds deploy/maxscale/maxscale-alert.sh, wired in as mariadbmon's script=. It fires within one monitor tick rather than waiting for a scrape, and runs on the MaxScale host, so it does not depend on Delta or the observability stack being up. It always appends to /var/log/maxscale/failover-events.log and posts to Slack only if /etc/maxscale.secrets.d/alert.env supplies a webhook, so it is safe to install before the webhook exists -- which is how it is deployed right now, log-only pending the URL. The webhook stays out of maxscale.cnf, which is world-readable 0644. Two things found by testing it rather than reading the docs: slave_up was missing from events=. It is NOT the same transition as new_slave -- new_slave is [Running]->[Slave,Running], slave_up is [Down]->[Slave,Running] -- so a replica outage alerted on the way down and went silent on recovery. This fails silently in the worst way: the only symptom is an alert that never arrives. Rpl_semi_sync_master_status is NOT a usable health signal, and the guidance here previously said to alert on it. Stopping DB2 showed status stay ON through seven unacknowledged commits while clients sat at 0 and no_tx climbed 0->7. With wait_no_slave=OFF the master never enters the state that would flip it. Alert on Rpl_semi_sync_master_clients == 0. Verified end to end by stopping and starting DB2: both slave_down and slave_up reached the log, semi-sync re-engaged with clients=1, and the CMS stayed 200 throughout. Not covered, and stated in the README: the script cannot report that MaxScale itself died, which -- given the write path is fenced to MaxScale -- is a total outage. That needs an external check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added failover alerting ( How it works: It always appends to Two bugs the test caught that reading the docs would not have:
Verified end to end by stopping and restarting DB2: both Known gap, documented rather than papered over: the script cannot report that MaxScale itself died — and since the write path is now fenced to MaxScale, that is a total outage. That needs an external check from Delta's Prometheus and is not built here. |
The failover alert script runs on the MaxScale host, so the one outage it can never report is MaxScale itself stopping -- and because the write path is fenced so MaxScale is the only route to the databases, that is a total outage, not a degraded read path. Covers it from Delta: blackbox_exporter TCP-connects to 10.248.40.183:4006, Prometheus scrapes the probe, and a provisioned Grafana rule alerts to Slack when probe_success == 0 for 1m. Probing :4006 rather than the admin API is deliberate. 4006 is the port the CMS actually uses, so this tests the real dependency instead of a management interface that could be healthy while routing is not; and 8989 can reconfigure MaxScale, so opening it to Delta would be a far worse thing to expose. MaxScale 24.02 serves no Prometheus endpoint in any case (/metrics and /v1/metrics both 404), so scraping it directly was never available. DB1/DB2 are not probed: their 3306 is firewalled to the MaxScale host and the DB peer, so Delta cannot reach them by design and the target would alert forever. noDataState is Alerting on purpose -- a missing probe series means nobody is watching the database tier, which is worth waking someone for even though the cause is Prometheus rather than MaxScale. The Slack webhook comes from SLACK_WEBHOOK_URL and defaults to a non-functional placeholder, because Grafana provisioning rejects an empty URL and a missing webhook must not stop the stack from starting. Until it is set the rule still fires in Grafana and delivery fails in the log, which is visible rather than silent. Verified end to end by pointing the probe at a closed port: the rule went pending -> firing and Grafana routed it to receiver=slack-triangle, failing delivery only on the placeholder URL. No CMS outage was needed to test it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added the MaxScale-down watchdog ( The failover script runs on MaxScale, so the one outage it can never report is MaxScale itself stopping — which, now that the write path is fenced, is a total outage rather than a degraded read path. This watches it from Delta instead. Design choices worth a reviewer's attention:
Verified without any CMS outage by pointing the probe at a closed port on the same host: rule went Two operational notes now in the README:
|
|
Wired up the real webhook ( The webhook is a Discord one, not Slack. Both alert paths were built against the wrong provider and neither would have delivered a single message. Now converted to Discord's native format rather than its Slack-compatibility shim — Discord accepts Slack-shaped payloads on a
Two things worth flagging beyond the rename:
Verified live on both paths: the MaxScale script posted a test event with no Also gitignored |
|
Moved the watchdog off Grafana, and redesigned the messages ( Watchdog now lives in Prometheus + AlertmanagerDelta's Prometheus is a datasource for the Triangle Grafana, not part of it — so a Grafana-owned rule would have vanished the moment the local Grafana was retired, while blackbox kept probing, Prometheus kept scraping, and every dashboard kept looking healthy. The only symptom would have been an alert that never arrived, during exactly the window nobody was watching for one. Grafana's Messages are embeds nowThe first version dumped the whole annotation as prose, and a teammate replied "do I ignore this?" — which is the only review that matters for an alert. Each message now has a coloured bar (red act now / yellow degraded / green over), a title saying what happened rather than naming the event constant, one line of consequence, and addresses as fields. MaxScale's One trap worth knowingGrafana provisioning never deletes — removing a definition from disk leaves the object live in its database, so and dropping the Verified: rule fired → Alertmanager → Discord with no delivery error, and the MaxScale script posted an embed with no |
|
Dropped Grafana from the observability stack ( Everything routes to the central Triangle Grafana, which reaches this stack through the Nginx endpoints in This is only safe because the alerting moved out first. The database-tier rules went to Prometheus + Alertmanager in the previous commit precisely so they'd survive this — pulling Grafana costs no alerting at all, only dashboards, which are the central instance's job anyway. Nothing was lost. I diffed the live dashboard against the repo copy before removing anything: Remaining stack: The migration trap, now documentedThe dashboard hard-binds to datasource UIDs — 16 panel references to
Verified after removal: Prometheus healthy with both rules loaded and Alertmanager attached, blackbox probe returning 1, Loki still serving queries, DB tier and CMS untouched. One loose end I deliberately did not clean up: the docker volume rm triangle-observability_grafana_data |
|
The observability stack now deploys from CI ( It was the last thing on Delta that existed only because someone had run
It copies rather than running in place on purpose — Two traps it has to handle
SafetySeparate Compose project, so Needing no env file is what made this clean, and that fell out of dropping Grafana: Migrated and verified on DeltaMoved off The Nginx sites are still installed by hand; only the Compose stack behind them deploys automatically. |
|
The Nginx sites auto-deploy now too ( Done without widening the runner's privilegesThe files install into Needs a one-time root bootstrap (already done on Delta, documented in the README). Transactional, because this Nginx also serves the CMSLive files are snapshotted, new ones installed, and
Verified on Delta, all three paths
Live files confirmed identical to the repo afterwards, and the old One latent bug fixed on the wayThe compose helper is renamed Final state: CMS 200, both authenticated datasource endpoints 200, five observability services up, DB1 |
DB2 (
THETRIANGLE-DB2-LXC,10.248.40.155) is now a semi-synchronous replica of DB1 and MaxScale'sauto_failoveris on. This has already been applied and failover-tested against production — merging changes nothing on the DB hosts, it only lands the source-of-truth config and the runbook.Why
auto_failoverwas off, and what changedPromoting a replica on a 2-node async pair loses acknowledged writes and can split-brain. Neither is fixed by the failover flag, so:
wait_point=AFTER_SYNC— DB1 doesn't commit to the storage engine until DB2 holds the event, so a promotion can't drop a write the CMS was told succeeded.AFTER_COMMIT(the default) would not do this; it makes the write visible before the ack, which is exactly the losing window.gtid_strict_mode=ON— a diverged old primary is refused byauto_rejoinrather than corrupting.Accepted limits, documented in the README: semi-sync degrades to async while DB2 is down (
wait_no_slave=OFF, deliberate — the alternative turns a replica outage into a site outage), and MaxScale remains a SPOF. Both need a third node. Failover is also not backup; real backups are still owed.Failover test
Stopped DB1 → promoted in <10s → CMS served and wrote against DB2 → restarted DB1 → rejoined as replica → switched back. End state verified: equal GTID, 0 lag, semi-sync
ONwith 1 client, CMS 200.Bugs fixed
mariadb-dump --gtidalone records no position. It only changes the format of what--master-dataemits. A replica seeded that way starts from the beginning of binlogs that expire in 7 days.setup-replica.shhad this; now--master-data=1(=2comments the line out) plus a hard check that aborts if the position is missing.replica.cnfused relaxed durability — right for a read cache, wrong for a failover target twice over: a promoted DB2 would run production without ACID, and semi-sync's ack would only mean "in DB2's page cache".maxscalewas missingBINLOG ADMIN(MariaDB 10.5+ split it out ofSUPER), which madeauto_rejoinloop forever on "Failed to prepare (demote) standalone server".replication_userdefaults to the monitor user, so rejoin builtCHANGE MASTERasmaxscale@<rejoining node>— nonexistent. Surfaced only asnew_slave→lost_slave~2s later. Now pinned torepl.Please read the warning in step 6
The second commit reverts guidance the first one introduced, because I followed it and took production down. It said to drop
triangle_user@<delta-ip>as "Delta's direct bypass". It is not a bypass: MaxScale authenticates a client against the backend user table using the client's own source address, so that account is how the CMS logs in through MaxScale. Dropping it closed nothing and returnedError 1045on every DB-backed route — while/v1/healthkept returning 200, so it looked healthy until a page was loaded.Related:
setup-replica.shdumps--databases triangle, which excludesmysql.*, so DB2 initially had none of the application accounts. A failover would have promoted a server nothing could authenticate to. Both traps are now documented with the symptoms to recognise them by.Reviewer notes
deploy/mariadb/provision-db2.shis new; it refuses to run anywhere but DB2, since ssh gives no warning when an ARP change puts you on a different host.maxscale.cnfwas verified withmaxscale --config-checkon the live 24.02.9 binary.backend.envgainedREPL_USER/REPL_PASSWORD; all six keys are required or MaxScale won't start.🤖 Generated with Claude Code