
Nota: i nomi james e jason usati in questo post sono i nomi-codice che usiamo internamente per i due nodi del cluster: non corrispondono a hostname pubblici della rete.
Febbraio 2024, ore 05:14 di un sabato. Alert Prometheus: mariadb_up == 0 su uno dei due nodi MariaDB di un cliente editoriale. Mi sono svegliato, ho controllato dal telefono, ho riavviato il servizio in trenta secondi via SSH. Ho cercato di riaddormentarmi senza riuscirci. Quel mattino ho deciso che avremmo cambiato l’architettura del cluster: niente più master-slave standard, ma una replica circolare master-master tra due nodi DB con un MaxScale per app server davanti. Il piano l’avevamo abbozzato da mesi, quel sabato è stato la goccia. Vedi anche gli altri post di Production.
Il punto di partenza
Il cluster preesistente serviva due database editoriali grandi, circa 5 e 4 GB ciascuno, su una piattaforma WordPress multi-sito di un partner storico (gruppo da decine di milioni di pageview/mese complessivi sul portfolio). L’architettura era il pattern classico:
- 1 nodo MariaDB master
- 1 nodo MariaDB slave (replica async standard, GTID-based)
- 2 application server WordPress con
DB_HOSTpuntato direttamente al master via IP privato - Failover manuale (cioè: io, in pigiama, con tre tab di terminale aperti)
Funzionava. Ma il single point of failure era esattamente sul master DB: ogni systemctl stop mariadb sul master per maintenance, ogni picco di scrittura imprevisto che bloccava le query, ogni crash del processo mysqld significava downtime sul lato applicativo. Lo slave c’era, ma era in async pure: mancava un layer di routing automatico, e il fallback richiedeva intervento manuale (cambio DB_HOST su entrambi gli app server, restart php-fpm, validazione, recovery del master, riallineamento). Mediamente cinque-quindici minuti. Su un cliente editoriale che fa traffico a tre cifre sui social durante l’incidente, sono pageview perse e advertising buchi.
La domanda era: come ottengo high availability senza single point of failure, mantenendo la latenza di rete sub-millisecondo che WordPress richiede per non degradare la TTFB?
L’architettura target
Quattro nodi, ognuno con un ruolo specifico:
┌──────────────────────┐
│ WordPress Apps │
└──────┬────┬───────────┘
│ │
┌────────────┘ └────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ james (app-A) │ │ jason (app-B) │
│ App Server │ │ App Server │
│ MaxScale :4006 │ │ MaxScale :4006 │
└────────┬─────────────┘ └─────────┬───────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ james-db (DB-A) │◄─────►│ jason-db (DB-B) │
│ DB Server │ circ │ DB Server │
│ MariaDB 10.11.6 │ repl │ MariaDB 10.11.6 │
│ server-id=1 │ │ server-id=2 │
└─────────────────────┘ └─────────────────────┘
Due app server (nomi-codice james e jason, app-A e app-B) e due DB server (nomi-codice james-db e jason-db, DB-A e DB-B). Tutti su una subnet privata /24 dedicata, quattro indirizzi consecutivi assegnati a app-A, DB-A, app-B, DB-B; latenza inter-nodo sotto i 0.3ms misurata con mtr.
Sui DB: MariaDB 10.11.6, replica circolare master-master. Sugli app server: MaxScale come proxy locale che parla al DB locale come master e tiene quello remoto come failover. Sopra: lsyncd bidirezionale dei file WordPress, esclusi cache/, tmp/, wflogs/.
La configurazione MariaDB
Il cuore della replica circolare master-master è la combinazione di tre cose: GTID, auto-increment offset diversi, e logging binario sempre attivo. La config in /etc/mysql/mariadb.conf.d/50-server.cnf su entrambi i DB (variando server-id e auto_increment_offset):
[mysqld]
server-id = 1 # 2 su jason-db
log-bin = mysql-bin
binlog_format = ROW
expire_logs_days = 1
max_binlog_size = 104857600
auto_increment_increment = 2
auto_increment_offset = 1 # 2 su jason-db
log_slave_updates = 0
gtid_strict_mode = 1
auto_increment_increment=2 con offset 1 e 2 fa sì che james-db generi sempre ID dispari (1, 3, 5, 7) e jason-db sempre pari (2, 4, 6, 8). Questo elimina il rischio di conflitti su PRIMARY KEY auto-increment durante scritture concorrenti su entrambi i master. È uno schema classico (lo trovi descritto in chiaro su MariaDB docs) ma ha un costo: gli ID non sono più sequenziali densi. Per un sito editoriale è irrilevante (i post si ordinano per post_date, non per ID).
expire_logs_days=1 con max_binlog_size=104857600 (100 MB) tiene sotto controllo lo spazio occupato dai binlog. Lezione imparata su un altro cluster: senza expire_logs_days, in due settimane di traffico editoriale i binlog occupano centinaia di GB e ti riempiono il disco senza preavviso. La gestione dei binlog la racconto a fine post.
log_slave_updates=0 è una scelta deliberata: in un cluster master-master a due nodi senza replica a cascata, abilitare lo slave-log creerebbe loop di replica infiniti. Lo lasciamo a 0 perché non abbiamo terzi nodi che leggano da uno slave.
gtid_strict_mode=1 è la chiave per il rejoin automatico via MaxScale: forza la replica a usare GTID puri, niente fallback a coordinate binlog filename + position. Quando MaxScale fa auto_rejoin, parte dal GTID dell’ultimo evento applicato e riallinea senza richiedere intervento.
Dopo la prima sincronizzazione iniziale (mysqldump da james-db, restore su jason-db, CHANGE MASTER TO MASTER_USE_GTID=slave_pos), entrambi i nodi sono master di entrambi i database editoriali e ognuno è anche slave dell’altro. La replica circolare è attiva.
La configurazione MaxScale
Su ogni app server gira un MaxScale locale, in /etc/maxscale.cnf:
[maxscale]
threads=auto
[james-db]
type=server
address=<DB-A_IP>
port=3306
priority=1 # 2 su jason
[jason-db]
type=server
address=<DB-B_IP>
port=3306
priority=2 # 1 su jason
[MariaDB-Monitor]
type=monitor
module=mariadbmon
servers=james-db,jason-db
user=<maxscale_user>
password=<encrypted>
auto_failover=true
auto_rejoin=true
failcount=3
monitor_interval=2000ms
enforce_simple_topology=true
[Splitter-Service]
type=service
router=readwritesplit
servers=james-db,jason-db
user=<maxscale_user>
password=<encrypted>
master_accept_reads=true
slave_selection_criteria=ADAPTIVE_ROUTING
[Splitter-Listener]
type=listener
service=Splitter-Service
protocol=MariaDBProtocol
port=4006
Il punto sottile è priority. Su james (app server 1) il DB locale james-db ha priority=1, il remoto jason-db ha priority=2. Su jason (app server 2) è invertito. Questo significa che ogni MaxScale, in condizioni normali, vede il DB locale come suo master e ci scrive. La rete tra app server e DB server, essendo sulla stessa subnet privata, ha latenza sotto 0.3ms: ogni round-trip MySQL costa quasi nulla.
auto_failover=true con failcount=3 significa che se il DB locale non risponde a tre check consecutivi (a monitor_interval=2000ms, ovvero 6 secondi totali), MaxScale promuove automaticamente il remoto a master per quell’app server. Niente intervento manuale, niente cambio DB_HOST, niente restart php-fpm. L’app continua a scrivere, solo che adesso scrive sul DB remoto.
auto_rejoin=true chiude il cerchio: quando il DB locale torna su, MaxScale lo reinserisce nella topology e lo rimette come master locale (perché ha priority=1 sul nodo locale).
enforce_simple_topology=true è importante per la replica circolare: dice a MaxScale di accettare topologie a due nodi master-master invece di provare a inferire un albero classico master-many-slaves.
La configurazione WordPress
Il wp-config.php su entrambi gli app server punta a 127.0.0.1:4006. Cioè a MaxScale locale. Niente IP del DB server hardcoded:
define('DB_HOST', '127.0.0.1:4006');
define('DB_USER', '<maxscale_user>');
define('DB_PASSWORD', '<password>');
WordPress non sa che dietro c’è un cluster. Vede MaxScale come il suo MySQL. MaxScale fa il routing: scritture al master locale, letture distribuite (con master_accept_reads=true accetta letture sia sul master che sullo slave per bilanciare). Questo è importante per WordPress: il pattern read-after-write (es. wp_insert_post seguito immediatamente da get_post) ha bisogno di consistency, e mantenendo le letture sul master locale evitiamo split-brain di replica lag su query critiche.
lsyncd per i file
Il DB è risolto dal cluster, ma WordPress ha anche file: uploads, plugins, themes. Se un editor carica un’immagine sull’app server james, il file deve apparire anche su jason entro pochi secondi, sennò un utente che ricarica la pagina dietro il load balancer Cloudflare può finire sul server senza il file.
Soluzione: lsyncd in modalità bidirezionale tra /home/<user>/web/<domain>/public_html/ su entrambi gli app server. Configurazione in /etc/lsyncd/lsyncd.conf.lua:
sync {
default.rsyncssh,
source = "/home/<user>/web/<domain>/public_html/",
host = "root@<APP-B_IP>",
targetdir = "/home/<user>/web/<domain>/public_html/",
delay = 5,
excludeFrom = "/etc/lsyncd/exclude.lst",
}
exclude.lst esclude cache/, tmp/, wflogs/ (Wordfence). Quei path generano scrittura altissima e replicarli sarebbe spreco di IO. Il delay di 5 secondi è il compromesso giusto: un editor che carica un’immagine la vede sul server opposto entro 6-7 secondi nel peggiore dei casi, e WordPress nel frattempo non genera URL diretti al filesystem (gli uploads vanno via wp-content/uploads/, l’attachment è nel DB che è già replicato).
In sostanza: il DB è cluster master-master, i file sono cluster eventually-consistent via lsyncd. Per un sito editoriale con 30-60 articoli al giorno e media uploads sotto i 200/giorno, la convergenza è invisibile all’utente.
Le procedure di emergenza che mi sono scritto
Non basta architettare il cluster, bisogna avere i runbook per quando qualcosa va in dis-sync. Tre scenari documentati nel runbook interno del cluster.
Replicazione rotta. Verifica con SHOW SLAVE STATUS\G su entrambi i nodi. Se Slave_IO_Running o Slave_SQL_Running è No, leggo Last_Error per capire la causa. Spesso è un evento DDL eseguito a mano su un nodo (lezione: niente DDL a mano, mai). Se la divergenza è minima, STOP SLAVE; SET GLOBAL gtid_slave_pos='<gtid>'; START SLAVE; resetta. Se è significativa, dump dal nodo con i dati più recenti con mysqldump --single-transaction --master-data=2 --gtid --routines --triggers, restore sull’altro, riallineamento GTID, restart MaxScale per forzare il re-discovery della topology.
Disco pieno su un DB. I binlog occupano spazio anche con expire_logs_days=1 quando il traffico è elevato (50-100 MB di binlog all’ora non è strano in un picco). Procedura: PURGE BINARY LOGS BEFORE NOW() - INTERVAL 1 HOUR; per pulire fino a un’ora indietro mantenendo finestra di sicurezza. Se MariaDB non accetta connessioni (caso too many connections da connection leak su un’applicazione lato dev): systemctl stop mariadb, rimozione manuale dei binlog vecchi tenendo gli ultimi 3-4, aggiornamento di mysql-bin.index con i soli file rimasti, systemctl start mariadb, ripartenza.
Switchover programmato. Per maintenance pianificata di un nodo DB: maxctrl call command mariadbmon switchover MariaDB-Monitor <new-master> da uno degli app server. MaxScale promuove l’altro a master, redirige le scritture, e quando torno a manutenere il primo lo reinserisce come slave. Tempo totale di operazione: 30-90 secondi, di cui downtime effettivo zero.
Cosa ho guadagnato e cosa ho pagato
Guadagnato. High availability vera: quando uno qualunque dei due DB cade, l’app continua a scrivere senza che io faccia nulla. Manutenzione zero-downtime: posso aggiornare MariaDB su un nodo, riavviarlo, aspettare il rejoin, e fare la stessa cosa sull’altro, senza che il sito noti. Riduzione drastica degli alert notturni (ne ricevo ancora, ma sono informativi, non azionabili: il cluster si è già auto-recuperato prima che io tocchi il telefono).
Pagato. Complessità operativa. Devo capire la replica circolare master-master e i suoi failure mode. Devo gestire offset auto-increment, gtid_strict_mode, binlog rotation. Devo avere lsyncd configurato e monitorato. MaxScale aggiunge un layer in più che può rompersi (raramente, ma succede: una volta in due anni MaxScale ha smesso di rispondere su 4006 per un memory leak su una versione che poi è stata patchata). E soprattutto: scrivere su due master in parallelo apre la porta a conflitti applicativi. WordPress raramente li genera (il pattern di scrittura è quasi sempre serializzato per post), ma se mai dovessi mettere un sistema di voting o counter molto contesi, dovrei pensare al lock distribuito.
In due anni di produzione, conflitti master-master segnalati: due. Entrambi su una pagina di dashboard interna che faceva UPDATE wp_options SET option_value = JSON_SET(...) senza locking. Soluzione: spostare quella scrittura via job in queue Redis con processore singolo. Risolto in mezz’ora, mai più visto.
Quello che vale la pena tenere a mente
Il pattern replica circolare master-master con MaxScale non è il default per chiunque. È la scelta giusta quando:
- hai due nodi DB (non tre o più: con tre nodi pensa a Galera o a un setup a quorum tipo etcd/Consul invece)
- hai due app server che possono lavorare entrambi come writer
- hai latenza inter-nodo sub-millisecondo (subnet privata o stessa rack)
- la tua app non genera scritture ad alta contesa sulla stessa riga
WordPress su nodo editoriale tipico soddisfa tutti e quattro. Per altri casi ci sono altre architetture: read replicas, sharding, leader election esplicita, repliche cross-region async, etc. Ognuna ha il suo posto. Per il caso editoriale di Romiltec, james-db/jason-db con MaxScale è da febbraio 2024 il riferimento. Il giorno della migrazione l’ho fatto io, da solo, in una finestra di sei ore di sabato pomeriggio. Da quel giorno, di sabati alle cinque del mattino non ne ho più. Per chi gestisce database in produzione su MariaDB, quel cambio di scenario vale ogni ora di setup.
Il cluster, da quel febbraio in poi, ha smesso di tremare. E io ho smesso di rispondere agli alert in pigiama.
