- New: smart-home/home-assistant-dashboard-conventions (Mushroom cards, view tabs, no Bubble Cards) - Updated: rke2, ceph, galera, proxmox, brainstorming, compound-learning, 1password-cli, smart-home-automation skills - New references: ceph-cluster-administration, docker-volume-forensics, ceph-crush-weight, ceph-ec-mixed-size
32 KiB
name, description, version, tags
| name | description | version | tags | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mariadb-galera-cluster-administration | Class-level skill for MariaDB Galera cluster + MaxScale proxy administration. Covers total-cluster-failure recovery, sequential SST, MaxScale routing during donor/desynced states, health verification, HA Recorder dependency troubleshooting, OOM-killer diagnosis (PVE host kills KVM), VM placement strategy, and balloon:0 rationale for DB VMs. | 1.0.0 |
|
Overview
This umbrella skill covers MariaDB Galera cluster administration with MaxScale as the SQL proxy layer:
- Total Cluster Failure Recovery — bootstrap, sequential node rejoin, SST deadlock avoidance
- MaxScale Routing During Recovery —
available_when_donor, readwritesplit tuning, maintenance mode - Health Verification — wsrep status, cluster size, SST progress monitoring
- Application Dependency Recovery — HA Recorder reconnect after DB outage
Load this skill for any Galera cluster operation: startup, shutdown, node addition, SST troubleshooting, split-brain recovery, or MaxScale routing issues.
Section 1: Total Cluster Failure Recovery
1.1 Symptoms of Total Failure
- All nodes show
systemctl status mariadb→Active: failed - Journal shows
Failed to reach primary viewandFailed to open channel 'mariadb-galera' at 'gcomm://...':-110 (Connection timed out) - No
grastate.inipresent (orsafe_to_bootstrap: 0on all nodes — pick any node if absent) - Application errors:
MySQLdb.OperationalError: (2013, 'Lost connection to server during query')
1.2 Recovery Procedure (CRITICAL — sequential, not parallel!)
Step 1: Bootstrap ONE node
# On the chosen bootstrap node (e.g. db1 at 10.0.30.71):
sudo galera_new_cluster
# This runs mariadbd with --wsrep-new-cluster
# May appear to timeout but continues in background — verify separately
Verify bootstrap:
sudo systemctl is-active mariadb # → active
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment';" # → Synced
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'wsrep_cluster_size';" # → 1
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'wsrep_cluster_status';" # → Primary
Step 2: Start remaining nodes SEQUENTIALLY (one at a time)
# On db2:
sudo systemctl start --no-block mariadb
# POLL until wsrep_local_state_comment = "Synced" BEFORE starting db3
⚠️ PITFALL: Starting multiple nodes simultaneously causes SST DEADLOCK.
All joining nodes request SST from *any*, but the only available donor becomes overloaded serving multiple concurrent SST streams. Result: "No donor candidates temporarily available in suitable state" — all nodes abort.
Step 3: Repeat for each subsequent node only after the previous one reaches Synced.
1.3 SST Monitoring
# On the joining node (joiner):
sudo systemctl is-active mariadb # → "activating" during SST
sudo du -sh /var/lib/mysql/ # grows toward source size (~18-20GB)
sudo journalctl -u mariadb -n 5 # "Waiting for SST streaming to complete!"
ps aux | grep -E 'socat|mariabackup|mbstream' # SST processes running
sudo ss -tnp | grep 4444 # active SST connection from donor
# On the source node (donor):
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment';" # → "Donor/Desynced"
ps aux | grep mariabackup # backup process streaming to joiner
SST duration scales with DB size: ~18GB ≈ 30-40 minutes per node on typical homelab hardware.
1.4 Background Polling Pattern
SST takes too long for foreground terminal timeout. Use background polling:
ssh debian@JOINER_IP "
for i in \$(seq 1 120); do
STATUS=\$(sudo systemctl is-active mariadb 2>/dev/null)
if [ \"\$STATUS\" = 'active' ]; then
WSREP=\$(sudo mariadb -e \"SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment'\" 2>/dev/null | awk '/wsrep_local_state_comment/{print \$2}')
SIZE=\$(sudo mariadb -e \"SHOW GLOBAL STATUS LIKE 'wsrep_cluster_size'\" 2>/dev/null | awk '/wsrep_cluster_size/{print \$2}')
echo \"Poll \$i: ACTIVE, state=\$WSREP, cluster_size=\$SIZE\"
if [ \"\$WSREP\" = 'Synced' ]; then
echo 'NODE FULLY SYNCED!'
break
fi
else
SIZE_DIR=\$(sudo du -sh /var/lib/mysql/ 2>/dev/null | awk '{print \$1}')
echo \"Poll \$i: \$STATUS, datadir=\$SIZE_DIR\"
fi
sleep 10
done
"
Use background=true, notify_on_complete=true with timeout=600 (or higher).
Section 2: MaxScale Routing Configuration
2.1 The Donor Routing Problem (CRITICAL)
During SST, the donor node has state Donor/Desynced. By default, MaxScale's galeramon monitor marks donor nodes as unavailable, so readwritesplit won't route traffic to them. With only one synced node acting as donor, MaxScale has zero routable backends → applications get connection errors.
Fix: Enable available_when_donor
maxctrl alter monitor galera-monitor available_when_donor=true
This makes the donor node accept traffic while serving SST. Verify:
maxctrl list servers
# Donor node should show: "Master, Synced, Running, Donor/Desynced" with Connections > 0
After all nodes are synced, revert:
maxctrl alter monitor galera-monitor available_when_donor=false
2.2 Other Useful MaxScale Commands
# Server states
maxctrl list servers
# Set/clear maintenance (prevent traffic to a node)
maxctrl set server db2 maintenance
maxctrl clear server db2 maintenance
# Allow reads on master when no slaves available
maxctrl alter service rw-router master_accept_reads=true
# View monitor/service config
maxctrl show monitor galera-monitor
maxctrl show service rw-router
# MaxScale listener check
maxctrl list listeners
2.3 Testing Connectivity Through MaxScale
# From MaxScale host (localhost):
mariadb -h 127.0.0.1 -P 3306 -u ha_recorder -p'PASSWORD' --skip-ssl -e "SELECT 'OK' AS r, @@hostname;"
# From application host (via VIP):
mariadb -h 10.0.30.70 -P 3306 -u ha_recorder -p'PASSWORD' --skip-ssl -e "SELECT 1;"
2.4 Common MaxScale Issues
| Symptom | Cause | Fix |
|---|---|---|
| No routable backends | Donor excluded by default | available_when_donor=true |
| "No valid servers from which to query MariaDB user accounts" | All backends down | Recover Galera first |
| readwritesplit finds no slave | Only master available | master_accept_reads=true |
set server db1 master fails |
Monitored server, can't set role manually | Use maintenance/drain only |
| All servers show "Down" but MariaDB is running | MaxScale VM IP blocked by max_connection_errors on Galera nodes |
FLUSH HOSTS; on all 3 nodes (see §2.5) |
Could not find valid server for target type TARGET_ALL in logs |
All backends Down — same root cause as above or Galera cluster down | Fix Galera first or FLUSH HOSTS |
2.5 MaxScale IP Blocked by max_connection_errors (CRITICAL)
Symptoms: maxctrl list servers shows all 3 Galera nodes as Down, but
mariadb on each node is running and wsrep_cluster_size=3. MaxScale logs show
Could not find valid server for target type TARGET_ALL every ~30 seconds.
Root Cause: The MaxScale VM IP (e.g., 10.0.30.82) accumulated too many
connection errors on the Galera nodes (typically after a cluster outage or
restart). MariaDB's max_connect_errors counter blocks the host after reaching
the threshold (default 100), returning error 1129:
Host '10.0.30.82' is blocked because of many connection errors.
Diagnosis:
# Test MaxScale monitor connectivity from MaxScale VM to a Galera node:
ssh debian@10.0.30.70 "mariadb -h 10.0.30.71 -P 3306 -u maxscale -p'<maxscale-password>' -e 'SELECT 1;'"
# ERROR 2002 (HY000): ... 1129 - Host '10.0.30.82' is blocked because of many connection errors
Fix: Flush hosts on ALL 3 Galera nodes:
for node in 10.0.30.71 10.0.30.72 10.0.30.73; do
ssh -i ~/.ssh/id_ed25519_proxmox debian@$node "sudo mariadb -e 'FLUSH HOSTS;'"
done
Wait 5 seconds for the monitor to re-check, then verify:
ssh debian@10.0.30.70 "maxctrl list servers"
# All 3 should show: Slave/Master, Synced, Running
Prevention: Increase max_connect_errors on Galera nodes or add
max_connect_errors=1000000 to the server config to prevent blocking during
cluster instability.
Note: This scenario recurs frequently after any cluster instability
(restart, network blip, SST). Always check maxctrl list servers for "Down"
nodes first — if Galera itself is healthy (wsrep_cluster_size=3,
wsrep_ready=ON), FLUSH HOSTS is almost certainly the fix.
Confirmed again 2026-07-06: MaxScale VM 10.0.30.82 blocked on all 3
Galera nodes after extended downtime. FLUSH HOSTS on all 3 nodes
resolved it immediately — maxctrl list servers showed all 3 nodes
"Synced, Running" within seconds. The maxscale@% MySQL user existed
with correct grants and password, but the host was blocked at the
MariaDB connection-error-counter level (error 1129).
2.6 MaxScale Source-IP ACL Blocking (CRITICAL)
Symptoms: Access denied for user 'root'@'10.0.30.XXX' (using password: YES) from any host outside the MaxScale VM itself, even with correct credentials. Affects root, maxscale, app users — ALL users. The error comes from MaxScale, not Galera.
Root Cause: MaxScale's listener configuration restricts which source IPs may connect. Unlike max_connection_errors (§2.5, which blocks the MaxScale VM itself), this is an intentional ACL in the MaxScale listener/service definition. Even localhost (127.0.0.1) on the MaxScale VM may be blocked for certain users.
Diagnosis:
# Test from your host:
python3 -c "
import pymysql
try:
conn = pymysql.connect(host='10.0.30.70', port=3306, user='root', password='<pw>', charset='utf8mb4')
print('OK')
except Exception as e:
print(f'Denied: {e}')
"
# Error: (1045, "Access denied for user 'root'@'10.0.30.230' (using password: YES)")
The source IP in the error message is YOUR host's IP, not the Galera node. MaxScale rejects before forwarding to any backend.
Affected source IPs observed:
.230(Hermes host) — blocked.62(K8s worker) — blocked127.0.0.1(MaxScale VM localhost) — blocked for root, may work for other users.82(MaxScale VM external IP) — blocked for root
⚠️ Galera VM SSH and Guest Agent:
- Galera VMs (.71/.72/.73) have SSH closed (port 22 connection refused)
- QEMU Guest Agent is not running on Galera VMs —
qm guest execfails - No
qm guest cmd <vmid> pingworks — agent not installed/running - Direct network access to .71/.72/.73 is blocked even from PVE nodes and MaxScale VM
- The only access path is MaxScale VIP .70:3306, and only from allowlisted IPs
⚠️ 1Password Item Names (corrected):
mariadb-root— root password (NOTmariadb-galera-vm)mariadb-maxscale— MaxScale admin passwordmariadb-app-user— username=dominik, password for app access- All in vault "Kubernetes ESO" (ID:
334ykdtj5kar3jlpcrztjvx2fu) - ESO token retrievable:
kubectl get secret onepassword-token -n external-secrets -o jsonpath="{.data.token}" | base64 -d
⚠️ MaxScale VM Access:
- Hostname:
maxscale-02 - SSH:
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.70(works!) - Has
mariadbclient installed at/usr/bin/mysql - But even from MaxScale VM localhost, root SQL is denied — MaxScale ACL blocks before backend
Workaround — SSH to MaxScale VM and use maxctrl or local mariadb client:
# MaxScale VM accepts SSH as debian user
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.70
# Once on MaxScale VM, use maxctrl to manage the cluster
maxctrl list servers
maxctrl list services
# For SQL operations, try connecting through MaxScale with a user that has
# the correct grant for the MaxScale VM's source IP:
mariadb --defaults-extra-file=/tmp/creds.cnf -h 127.0.0.1 -e "CREATE DATABASE ..."
If ALL users are blocked from ALL sources:
- Check
maxctrl list listenersfor IP binding restrictions - Check MaxScale service
user/passwordmatching the MySQL user grants - The
maxscaleMySQL user (with SUPER) may work throughmaxctrlcommands even when direct SQL is blocked - As last resort: SSH to a Galera node directly (if SSH is open) and run
sudo mariadblocally
Prevention: When setting up MaxScale, document which source IPs are allowed and ensure the application's expected source IP is in the allowlist. For K8s migrations, the K8s worker IPs must be allowed if applications connect through the MaxScale VIP.
2.7 MaxScale VIP Architecture (Homelab — Updated 2026-07-13)
| Component | Address | Role |
|---|---|---|
| MaxScale VIP | 10.0.30.70:3306 |
rw (readwritesplit) |
| MaxScale VIP | 10.0.30.70:3307 |
ro (readconnroute) |
| MaxScale VM 01 | 10.0.30.81 (VM 310, n5pro) |
Active MaxScale instance |
| MaxScale VM 02 | 10.0.30.82 (VM 311, proxmox3) |
Standby MaxScale instance |
| db1 | 10.0.30.71 (VM 300, n5pro) |
Galera node |
| db2 | 10.0.30.72 (VM 301, proxmox3) |
Galera node |
| db3 | 10.0.30.73 (VM 302, proxmox6) |
Galera node |
Applications connect through 10.0.30.70:3306 for automatic failover and
load balancing. The maxscale MySQL user (with SUPER privilege) can be used
for SET GLOBAL tuning commands through the VIP without SSH access to nodes.
HA Rules: All 5 VMs (300, 301, 302, 310, 311) restricted to n5pro +
proxmox3 + proxmox6 (3 hardware-diverse hosts for quorum safety) via
strict db-nodes node-affinity rule. Anti-affinity keeps MaxScale
instances apart (310≠311) and Galera nodes apart (301≠302).
See references/galera-cluster-topology.md for full rules.cfg content.
⚠️ Quorum rule: Each Galera node MUST be on a different physical host.
2 nodes on the same host = quorum loss if that host fails.
⚠️ Anti-affinity blocks migrations: qm migrate fails if the target
host already has a VM with negative affinity. Check rules.cfg first.
PVE HA may migrate VMs — always verify current placement with qm list
before operating.
Section 3: Home Assistant Recorder Dependency
3.1 Architecture
Home Assistant (HAOS VM) → MaxScale VIP (10.0.30.70:3306) → Galera Cluster (3 nodes)
HA configuration.yaml recorder config:
recorder:
db_url: mysql://ha_recorder:PASSWORD@10.0.30.70:3306/homeassistant?charset=utf8mb4
purge_keep_days: 30
db_max_retries: 20
3.2 When Recorder Fails
- HA logs:
Error during connection setup: (retrying in 5 seconds)/MySQLdb.OperationalError: (2013, 'Lost connection to server during query') - After retries exhausted:
Recorder setup failed, recorder shutting down - Downstream:
history,logbookintegrations fail (depend on recorder) recordermay show as loaded in API buthistory/logbookdomains missing
3.3 Recovery
- Ensure Galera cluster + MaxScale routing is functional FIRST
- Restart HA:
POST /api/services/homeassistant/restartwith bearer token - Wait for
state: RUNNING(may take 2-3 min with large DB schema migration check) - Verify:
GET /api/servicesincludesrecorder,history,logbook - Test:
GET /api/history/period?filter_entity_id=sun.sun→ HTTP 200 with data
3.3b Silent Recorder Disconnect (No Errors Logged)
Different from §3.2 — Recorder can lose its DB connection WITHOUT logging any errors. HA runs normally (entities update, automations fire), but no state data is written to the DB. History/Logbook show no recent data.
Triggers: MaxScale FLUSH HOSTS, Galera node restart, MaxScale VIP
failover — any brief connection interruption that the recorder thread
doesn't recover from.
Diagnosis (does NOT require HA API — all checks on Galera directly):
-
Check
recorder_runsfor an open run:SELECT run_id, start, `end` FROM recorder_runs ORDER BY run_id DESC LIMIT 3\GOpen run (
end = NULL) with oldstart= recorder disconnected mid-run. -
Check latest state timestamp:
SELECT FROM_UNIXTIME(MAX(last_updated_ts)) AS latest_state FROM states;If hours/days behind current time → recorder stopped writing.
-
⚠️ Do NOT run
SELECT COUNT(*) FROM states— with 62M+ rows / 6.6 GB, this hangs for 30+ seconds. Useinformation_schema.table_rowsinstead:SELECT table_name, table_rows, ROUND(data_length/1024/1024) AS data_mb FROM information_schema.tables WHERE table_schema='homeassistant'; -
Check MaxScale sessions for
ha_recorder:maxctrl list sessionsIf
ha_recorderabsent → connection is dead.
Fix: ha core restart (via SSH to HAOS on port 22222):
ssh -p 22222 root@10.0.30.10 "ha core restart"
Recorder reconnects and resumes writing immediately. Data gap between disconnect and restart is permanently lost. InfluxDB (if configured) may still have the data — it's a separate integration with its own connection.
3.4 HA Access Methods
- HA API:
curl -H "Authorization: Bearer $HA_TOKEN" $HA_URL/api/... - HA VM logs via Proxmox guest agent:
qm guest exec 106 -- bash -c 'docker logs homeassistant 2>&1 | tail -30' - HA config:
/mnt/data/supervisor/homeassistant/configuration.yaml(accessed via Proxmox host SSH)
Section 4: Verification Checklist
After full cluster recovery:
- ✅ All 3 nodes:
wsrep_local_state_comment = Synced,wsrep_cluster_size = 3 - ✅ MaxScale: all servers
Running, Synced, noDonor/Desynced - ✅
available_when_donorreverted tofalse - ✅ MaxScale connection test succeeds via VIP
- ✅ HA services:
recorder,history,logbookall loaded - ✅ HA History API returns HTTP 200 with data
- ✅ No
ERROR (Recorder)entries in recent HA logs
Section 5: Application-Level Performance on Galera
5.1 Bulk-Load IDs for Dedup (Avoid Per-Row Roundtrips)
Every query through MaxScale → Galera incurs network latency + replication overhead. Patterns that do per-row existence checks (SELECT 1 FROM tbl WHERE id=? inside a loop) are catastrophically slow — a scraper checking 30k URLs against 28k existing rows took minutes appearing "hung" with zero output.
Wrong:
for url in urls:
if db_has_id(likely_id): # one roundtrip per URL
continue
fetch_and_insert(url)
Right:
existing_ids = set() # load once
cur.execute("SELECT id FROM recipes")
existing_ids = {r[0] for r in cur.fetchall()}
for url in urls:
if likely_id in existing_ids: # in-memory, nanoseconds
continue
fetch_and_insert(url)
existing_ids.add(new_id) # keep set fresh
Loading 28k IDs into a Python set() takes ~0.3s. Every subsequent check is O(1) in-memory.
5.2 PyMySQL executemany Dict Field Pitfall
pymysql.cursors.Cursor.executemany() raises TypeError: dict can not be used as parameter if any value in the batch tuple is a Python dict. This occurs when source data has inconsistent typing — e.g., Schema.org JSON-LD recipeCategory is usually a string but sometimes a dict ({"@type": "Thing", "name": "Dessert"}) or a list.
Fix: Normalize every field to a scalar before building the row tuple:
cat = r.get("category", "") or ""
if isinstance(cat, dict):
cat = cat.get("name", "") or str(cat)
elif isinstance(cat, list):
cat = ", ".join(str(c) for c in cat)
Apply the same pattern to calories, rating_value, and any field sourced from semi-structured JSON-LD. Validate with a dry-run scan (isinstance(field, dict) check across all records) before the first executemany batch.
5.3 Creating Application Databases on Galera
To add a new database + user for an application (e.g., recipe storage):
# SSH to any Galera node (e.g., 10.0.30.71)
ssh debian@10.0.30.71
sudo mariadb -e "
CREATE DATABASE IF NOT EXISTS <dbname> CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '<user>'@'%' IDENTIFIED BY '<password>';
GRANT ALL PRIVILEGES ON <dbname>.* TO '<user>'@'%';
FLUSH PRIVILEGES;
"
The database propagates to all nodes automatically via Galera replication. Applications connect through the MaxScale VIP (10.0.30.70:3306), never directly to individual nodes.
PyMySQL connection (when mysql CLI is not installed on the application host):
import pymysql
conn = pymysql.connect(
host="10.0.30.70", port=3306,
user="<user>", password="<password>",
charset="utf8mb4", database="<dbname>",
)
FULLTEXT indexes on MariaDB require ENGINE=InnoDB and CHARSET=utf8mb4. Create them in the schema DDL, not as a post-migration ALTER TABLE (which locks the table on large datasets).
5.3b OOM-Killer: PVE Host Kills Galera KVM Process (2026-07-13)
Different from §1.1 — this is NOT a MariaDB crash. The PVE host's Linux kernel OOM-killer terminates the entire KVM process when host RAM is exhausted.
Symptoms:
- VM shows
runninginqm statusbut Guest Agent is down (qm guest cmd <vmid> pingfails) - SSH to the VM is refused/closed (all guest services died with the KVM process)
- QEMU restarts the VM (onboot=1) but the guest OS boots incompletely (VmRSS far below allocated RAM)
- Galera cluster drops from 3→2 nodes; surviving nodes show
forgetting <UUID>in journal
Diagnosis (on the PVE HOST, not the VM):
# SSH to the PVE node hosting the affected VM
ssh root@<pve-node-ip> "dmesg -T | grep -iE 'oom|killed|out of memory' | tail -20"
# Look for: "Out of memory: Killed process <PID> (kvm)" with task_memcg=/qemu.slice/<VMID>.scope
Accessing a partially-booted VM via qm guest exec:
When SSH is down but QEMU restarted the VM (onboot=1), the guest agent may
respond even though regular qm agent <vmid> ping fails. Use qm guest exec
to run commands directly:
# From the PVE host where the VM runs:
qm guest exec <vmid> -- /bin/bash -c "hostname; uptime; systemctl is-active mariadb; free -m | head -2"
# Returns JSON with exitcode, out-data, out-truncated fields
This is the fastest way to check if MariaDB is still in SST/activating state when SSH is not yet available. Parse output with:
qm guest exec <vmid> -- /bin/bash -c "systemctl is-active mariadb" | python3 -c "import json,sys; print(json.load(sys.stdin).get('out-data','').strip())"
Root Cause: RAM overcommit on the PVE host:
overcommit = sum(running VM maxmem) / node physical RAM
Example: proxmox2 had 15 GB RAM but 22 GB VMs allocated (VM 129=12GB + VM 300=8GB + VM 311=2GB = 147%).
Why balloon: 0 is correct for Galera VMs:
MySQL/Galera's InnoDB buffer pool holds hot pages under constant access.
If the PVE balloon driver reclaims memory, the guest kernel swaps those hot
pages to swap → massive I/O latency spikes → Galera flow control kicks in
and throttles writes across the ENTIRE cluster. One ballooned Galera node
can slow down the whole cluster. balloon: 0 (disabled) ensures the VM
always holds its full allocated RAM.
Fix sequence (user preference: RCA → restart → mitigation, in that order):
- RCA first — diagnose root cause BEFORE restarting. Check
dmesg -T | grep oomon the PVE host, correlate with Galera journal timestamps on surviving nodes. Understand WHY the VM died before bringing it back. - Restart the killed VM —
qm stop <vmid>; qm start <vmid>(or via HA manager). Wait for SST to complete (Galera rejoins via State Transfer from a surviving node). Expect 30-60 minutes SST time depending on DB size. During SST, the node showsactivatingin systemctl andJoinerin wsrep state. - Mitigation — address the root cause to prevent recurrence:
- Migrate the VM to a node with RAM headroom
- Set strict HA node-affinity rules to prevent failover to small nodes
- Deploy RAM-based rebalancer (see PVE skill
scripts/pve-ram-rebalancer.sh)
- Verify — confirm Galera cluster size = 3, all nodes Synced, MaxScale routing restored
5.3c Galera/MaxScale VM Placement on PVE (Updated 2026-07-13)
⚠️ CRITICAL: Quorum-Aware Placement Rule
Each Galera node MUST be on a different physical PVE host. With 3 Galera nodes, losing any single host must leave ≥2 nodes alive (= quorum). If 2 nodes share a host and that host fails, only 1 survives → no quorum, entire cluster goes down (non-primary state, all writes blocked).
Placement checklist:
- Count Galera nodes per PVE host — max 1 per host
- Verify: for EACH host, if it fails, ≥2 Galera nodes remain on other hosts
- Include MaxScale VMs in the analysis — they should also be distributed
- Document the 3 allowed hosts in the HA
db-nodesrule (strict=1)
Previous placement (problematic — caused OOM + quorum risk):
| VM | Name | Node | RAM | Issue |
|---|---|---|---|---|
| 300 | mariadb-01 | proxmox2 (15GB) | 8 GB | OOM killed — co-located with 12GB K8s CP |
| 301 | mariadb-02 | proxmox6 (15GB) | 8 GB | Overcommit risk |
| 302 | mariadb-03 | proxmox4 (15GB) | 8 GB | 200% overcommit with K8s worker |
| 310 | maxscale-01 | proxmox4 (15GB) | 2 GB | Same node as mariadb-03 |
| 311 | maxscale-02 | proxmox2 (15GB) | 2 GB | Same node as mariadb-01 |
Intermediate placement (quorum risk — 2 nodes on proxmox3):
| VM | Name | Node | Issue |
|---|---|---|---|
| 300 | mariadb-01 | proxmox3 | 2 of 3 Galera on same host — quorum risk |
| 301 | mariadb-02 | proxmox3 | If proxmox3 fails, only 1 node survives |
| 302 | mariadb-03 | n5pro | OK |
Corrected placement (3 different hosts, quorum-safe):
| VM | Name | Node | RAM | Safe? |
|---|---|---|---|---|
| 300 | mariadb-01 | n5pro (91GB) | 8 GB | ✅ Massive headroom |
| 301 | mariadb-02 | proxmox3 (31GB) | 8 GB | ✅ Good headroom |
| 302 | mariadb-03 | proxmox6 (15GB) | 8 GB | ⚠️ 113% overcommit but effective use is only 5.5 GB |
| 310 | maxscale-01 | n5pro (91GB) | 2 GB | ✅ |
| 311 | maxscale-02 | proxmox3 (31GB) | 2 GB | ✅ |
HA rule db-nodes (strict=1) restricts all 5 VMs to n5pro + proxmox3 +
proxmox6 (3 hardware-diverse hosts). Anti-affinity rules keep MaxScale
instances apart (310≠311) and Galera nodes apart (301≠302).
⚠️ PITFALL: Live Migration Can Kill Recently-Recovering Galera Nodes
Migrating a Galera VM that is still in activating state (SST in progress)
or that was recently OOM-killed and restarted can cause it to drop from the
cluster and become unreachable (guest agent down, SSH refused). Sequence:
- OOM-kill destroys KVM process
- QEMU restarts VM (onboot=1) but guest OS boots incompletely
- Live migration triggers → VM freezes during migration
- After migration: guest agent down, SSH "No route to host", MariaDB stuck
Safe migration sequence for recovering Galera VMs:
- Wait until
systemctl is-active mariadb=activeANDwsrep_local_state_comment=Synced - THEN migrate — never migrate during SST/activating state
- After migration, verify guest agent responds:
qm agent <vmid> ping - If guest agent is down post-migration:
qm stop <vmid>; qm start <vmid>(full restart, not live migrate)
⚠️ PITFALL: Anti-Affinity Rules Block Migrations
Negative resource-affinity rules (e.g., vm:310 ↔ vm:311 must not
co-locate) will cause qm migrate to fail with:
cannot migrate resource 'vm:311' to node 'n5pro':
- resource 'vm:310' on target node 'n5pro' in negative affinity with resource 'vm:311'
Always check /etc/pve/ha/rules.cfg for conflicting rules before migrating.
The target node must not contain any VM that has negative affinity with
the VM being migrated.
6.1 The maxscale MySQL User Trick
The maxscale MySQL user (stored in 1Password item mariadb-galera-vm, field maxscale-password) has SUPER privilege. This means you can run SET GLOBAL commands through the MaxScale VIP without SSH access to the Galera nodes:
import pymysql
conn = pymysql.connect(
host="10.0.30.70", port=3306,
user="maxscale", password="<maxscale-password>",
charset="utf8mb4"
)
cur = conn.cursor()
cur.execute("SET GLOBAL wsrep_slave_threads=16")
This is the fastest way to tune Galera — no SSH, no config file edits, instant effect. Changes persist until the next MariaDB restart. To make them permanent, the same parameters must be added to the server config (/etc/mysql/mariadb.conf.d/50-server.cnf or equivalent) via SSH.
6.2 Key Tunable Parameters (Ordered by Impact)
| Parameter | Default | Recommended | Why |
|---|---|---|---|
wsrep_slave_threads |
4 | 16 | Applier threads — if wsrep_local_recv_queue_avg > 1, nodes can't keep up |
innodb_io_capacity |
200 | 2000 | 200 is HDD-tier; SSDs need 2000+ |
innodb_io_capacity_max |
2000 | 4000 | Burst capacity for flush storms |
innodb_read_io_threads |
4 | 8 | Parallel read threads |
innodb_write_io_threads |
4 | 8 | Parallel write threads |
innodb_flush_neighbors |
1 | 0 | 1 coalesces neighboring pages (HDD optimization); unnecessary on SSD |
innodb_purge_threads |
4 | 8 | Cleanup threads for massive deletes (e.g., HA purges) |
innodb_adaptive_hash_index |
OFF | ON | Speeds up point lookups on large tables (states, recipes) |
long_query_time |
10 | 2 | Catch slow queries in the slow query log |
Parameters that cannot be changed at runtime (require restart + config file):
innodb_buffer_pool_size(4 GB → 8-16 GB recommended for HA workloads)innodb_log_file_size(96 MB → 512 MB)gcache.size(128 MB → 1 GB, wsrep_provider_options, prevents expensive SST on rejoin)
6.3 Diagnosing HA Recorder Slowness
When HA history/state charts load slowly, the bottleneck is almost always the states table in the homeassistant database, not Galera itself.
Diagnosis procedure:
- Connect as
ha_recorder(password in 1Passwordmariadb-galera-vm, fieldha-recorder-password):
conn = pymysql.connect(host="10.0.30.70", port=3306, user="ha_recorder",
password="<ha-recorder-password>", database="homeassistant", charset="utf8mb4")
- Check
statestable size:
SELECT table_name, ROUND(SUM(data_length+index_length)/1024/1024,1) AS size_mb, table_rows
FROM information_schema.tables WHERE table_schema='homeassistant'
GROUP BY table_name ORDER BY size_mb DESC LIMIT 10;
- Find the entity's
metadata_id:
SELECT metadata_id, entity_id FROM states_meta
WHERE entity_id LIKE '%stromverbrauch%';
- Time queries at different ranges (24h, 7d, 30d):
SELECT COUNT(*) FROM states
WHERE metadata_id=<ID> AND last_updated_ts >= UNIX_TIMESTAMP('<datetime>');
- Check the index is being used:
EXPLAIN SELECT * FROM states
WHERE metadata_id=<ID> AND last_updated_ts >= UNIX_TIMESTAMP('<datetime>')
ORDER BY last_updated_ts;
Red flags:
statestable > 10 GB or > 50M rows → needs purging or recorder excludes- A single entity with > 1M state changes in 30 days → high-frequency sensor flooding the recorder
- Query time for 7-day range > 5s → Galera applier backlog or insufficient buffer pool
wsrep_local_recv_queue_avg> 1 → applier threads can't keep up (increasewsrep_slave_threads)
6.4 Making Changes Permanent
SET GLOBAL changes are lost on restart. To persist, SSH to each Galera node and edit the server config:
ssh debian@10.0.30.71
sudo tee -a /etc/mysql/mariadb.conf.d/50-server.cnf << 'EOF'
[mariadb]
wsrep_slave_threads=16
innodb_io_capacity=2000
innodb_io_capacity_max=4000
innodb_read_io_threads=8
innodb_write_io_threads=8
innodb_flush_neighbors=0
innodb_purge_threads=8
innodb_adaptive_hash_index=ON
long_query_time=2
EOF
sudo systemctl restart mariadb
Repeat on all 3 nodes. Restart sequentially (one at a time, verify Synced before next).
references/galera-cluster-topology.md— Homelab Galera/MaxScale topology, IPs, VM mapping, credentials locationsreferences/galera-recovery-runbook.md— Step-by-step recovery transcript from June 2026 outagereferences/galera-performance-tuning.md— Performance tuning session: before/after metrics, HA states table diagnosis