Nell'articolo parlerò di come ci siamo avvicinati al problema della resilienza di PostgreSQL, perché è diventato importante per noi e quale è stato il risultato finale.
Abbiamo un servizio ad alta richiesta: 2,5 milioni di utenti in tutto il mondo, oltre 50K utenti attivi ogni giorno. I server si trovano su Amazon in un unico regione dell'Irlanda: lavoriamo costantemente con oltre 100 server diversi, di cui quasi 50 con basi di dati.
L'intero backend è un grande monolite stateful applicazione in Java, che mantiene una connessione websocket costante con il cliente. Quando più utenti lavorano contemporaneamente su una stessa bacheca, tutti vedono le modifiche in tempo reale, perché ogni modifica viene registrata nel database. Riceviamo circa 10K richieste al secondo dai nostri database. Durante i picchi di carico su Redis scriviamo tra 80-100K richieste al secondo.

Perché siamo passati da Redis a PostgreSQL
Inizialmente, il nostro servizio funzionava con Redis, uno storage key-value che memorizza tutti i dati in memoria. server.
Vantaggi di Redis:
- Alta velocità di risposta, poiché tutto è memorizzato in memoria;
- Facilità di backup e replica.
Svantaggi di Redis per noi:
- Non ci sono transazioni reali. Abbiamo cercato di imitarle a livello della nostra applicazione. Sfortunatamente, ciò non ha sempre funzionato bene e ha richiesto di scrivere codice molto complesso.
- La quantità di dati è limitata dalla quantità di memoria. Aumentando la quantità di dati, la memoria crescerà e, alla fine, ci scontreremo con le caratteristiche dell'istanza scelta, il che in AWS richiede di fermare il nostro servizio per modificare il tipo di istanza.
- È necessario mantenere costantemente un livello di latenza basso, poiché abbiamo un numero molto elevato di richieste. Il livello di latenza ottimale per noi è tra 17-20 ms. Con un livello di 30-40 ms riceviamo risposte lunghe alle richieste della nostra applicazione e degradazione del servizio. Sfortunatamente, questo è accaduto a settembre 2018, quando uno degli istanze con Redis ha ricevuto per qualche motivo una latenza doppia rispetto al normale. Per risolvere il problema, abbiamo fermato il servizio a metà giornata lavorativa per una manutenzione straordinaria e sostituito l'istanza problematica di Redis.
- È facile ottenere incoerenze nei dati anche con piccoli errori nel codice e poi spendere molto tempo a scrivere codice per correggere questi dati.
Abbiamo considerato i lati negativi e capito che era necessario passare a qualcosa di più conveniente, con transazioni normali e minore dipendenza dalla latenza. Abbiamo condotto una ricerca, analizzato numerose opzioni e scelto PostgreSQL.
Stiamo migrando al nuovo DB da 1,5 anni e abbiamo trasferito solo una piccola parte dei dati, quindi attualmente lavoriamo simultaneamente con Redis e PostgreSQL. Maggiori dettagli sulle fasi della migrazione e sullo switch dei dati tra i DB sono scritti in un articolo del mio collega.
Quando abbiamo iniziato la migrazione, la nostra applicazione interagiva direttamente con il DB e si collegava al master di Redis e PostgreSQL. Il cluster PostgreSQL era composto da un master e una replica con replica asincrona. Questa era la configurazione del lavoro con i database:

Implementazione di PgBouncer
Mentre stavamo migrando, il prodotto si stava anche evolvendo: aumentava il numero di utenti e il numero di server che lavoravano con PostgreSQL, e abbiamo iniziato a esaurire le connessioni. PostgreSQL crea un processo separato per ogni connessione e consuma risorse. Il numero di connessioni può essere aumentato fino a un certo punto, altrimenti c'è il rischio di un funzionamento subottimale del DB. La soluzione ideale in questa situazione sarebbe scegliere un gestore di connessioni, che si collocherebbe davanti al database.
Avevamo due opzioni per il gestore di connessioni: Pgpool e PgBouncer. Tuttavia, il primo non supporta la modalità di lavoro transazionale con il database, quindi abbiamo scelto PgBouncer.
Abbiamo impostato la seguente configurazione: la nostra applicazione si connette a un PgBouncer, che gestisce i master di PostgreSQL, e dietro ogni master c'è una replica con replica asincrona.

Tuttavia, non potevamo memorizzare l'intero volume di dati in PostgreSQL e per noi la velocità di lavoro con il database era importante, quindi abbiamo iniziato a shardare PostgreSQL a livello applicativo. Lo schema descritto sopra è abbastanza comodo per questo: aggiungendo un nuovo shard, è sufficiente aggiornare la configurazione di PgBouncer e l'applicazione può immediatamente lavorare con il nuovo shard.
Resilienza di PgBouncer
Questo schema ha funzionato fino a quando l'unico istanza di PgBouncer non è andato in crash. Ci troviamo in AWS, dove tutte le istanze sono eseguite su hardware che muore periodicamente. In tali casi, l'istanza semplicemente si sposta su nuovo hardware e riprende a funzionare. Questo è successo anche con PgBouncer, ma è diventato inaccessibile. Il risultato di questo crash è stata l'indisponibilità del nostro servizio per 25 minuti. AWS consiglia di utilizzare la ridondanza dal lato dell'utente per queste situazioni, cosa che non era stata implementata da noi in quel momento.
Dopo ciò abbiamo seriamente riflettuto sulla resilienza di PgBouncer e dei cluster PostgreSQL, poiché una situazione simile potrebbe ripetersi con qualsiasi istanza nel nostro account AWS.
Abbiamo costruito lo schema di resilienza per PgBouncer nel seguente modo: tutti i server delle applicazioni si collegano a un Network Load Balancer, dietro il quale ci sono due PgBouncer. Ognuno dei PgBouncer guarda gli stessi master PostgreSQL di ciascun shard. In caso di ripetizione della situazione di crash dell'istanza AWS, tutto il traffico viene reindirizzato attraverso l'altro PgBouncer. La resilienza del Network Load Balancer è garantita da AWS.
Questo schema consente di aggiungere nuovi server PgBouncer senza problemi.

Creazione di un cluster PostgreSQL resiliente
Nel risolvere questo problema abbiamo considerato diverse opzioni: failover personalizzato, repmgr, AWS RDS, Patroni.
Script personalizzati
Possono monitorare il funzionamento del master e, in caso di crash, promuovere la replica a master e aggiornare la configurazione di PgBouncer.
I vantaggi di questo approccio risiedono nella massima semplicità, poiché si scrivono da soli gli script e si comprende esattamente come funzionano.
Contro:
- Il master potrebbe non essere andato offline, potrebbe invece essersi verificato un guasto di rete. Il failover, non sapendo di ciò, promuoverà la replica a master, e il vecchio master continuerà a funzionare. Di conseguenza avremo due server nel ruolo di master e non sapremo su quale di essi siano i dati più recenti. Questa situazione viene chiamata anche split-brain;
- Siamo rimasti senza replica. Nella nostra configurazione c'è un master e una replica; dopo il commutamento, la replica viene promossa a master e non abbiamo più repliche, quindi dobbiamo aggiungere manualmente una nuova replica;
- È necessario un monitoraggio aggiuntivo del funzionamento del failover, tenendo presente che abbiamo 12 shard PostgreSQL, il che significa che dobbiamo monitorare 12 cluster. Con l'aumento del numero di shard, non bisogna dimenticare di aggiornare il failover.
Un failover scritto a mano appare molto complesso e richiede un supporto non triviale. Con un unico cluster PostgreSQL, questa sarebbe l'opzione più semplice, ma non è scalabile, quindi non è adatta a noi.
Repmgr
Replication Manager per cluster PostgreSQL, in grado di gestire il funzionamento del cluster PostgreSQL. Tuttavia, non offre un failover automatico “di default”, quindi sarà necessario scrivere un proprio “wrapper” sopra la soluzione predefinita. Pertanto, potrebbe risultare persino più complesso rispetto agli script scritti a mano, quindi non abbiamo nemmeno provato Repmgr.
AWS RDS
Supporta tutto il necessario per noi, è in grado di eseguire backup e supporta un pool di connessioni. Ha uno switch automatico: quando il master muore, la replica diventa il nuovo master, e AWS aggiorna il record DNS sul nuovo master, mentre le repliche possono trovarsi in diverse AZ.
Tra i contro c'è l'assenza di impostazioni dettagliate. Un esempio di impostazioni dettagliate: nei nostri istanti ci sono limitazioni per le connessioni tcp, cosa che, sfortunatamente, non può essere fatta in RDS:
net.ipv4.tcp_keepalive_time=10
net.ipv4.tcp_keepalive_intvl=1
net.ipv4.tcp_keepalive_probes=5
net.ipv4.tcp_retries2=3
Inoltre, il prezzo di AWS RDS è quasi il doppio del prezzo normale di un'istanza, il che è stata la causa principale del rifiuto di questa soluzione.
Patroni
È un template in python per gestire PostgreSQL con una buona documentazione, failover automatico e codice sorgente su github.
Vantaggi di Patroni:
- Ogni parametro di configurazione è dettagliato, è chiaro come funziona tutto;
- Il failover automatico funziona di default;
- Scritto in python, e dato che anche noi scriviamo molto in python, ci sarà più facile affrontare i problemi e, forse, persino contribuire allo sviluppo del progetto;
- Gestisce completamente PostgreSQL, consente di modificare la configurazione su tutti i nodi del cluster e, se per applicare una nuova configurazione è necessario riavviare il cluster, questo può essere fatto nuovamente tramite Patroni.
Contro:
- Dalla documentazione non è chiaro come lavorare correttamente con PgBouncer. Anche se è difficile chiamarlo un difetto, perché il compito di Patroni è gestire PostgreSQL, e come avverranno le connessioni a Patroni è già un nostro problema;
- Ci sono pochi esempi di implementazione di Patroni su grandi volumi, mentre ci sono molti esempi di implementazione da zero.
Alla fine, per creare un cluster ad alta disponibilità, abbiamo scelto proprio Patroni.
Il processo di implementazione di Patroni
Fino a Patroni avevamo 12 shard di PostgreSQL configurati con un master e una replica con replica asincrona. I server delle applicazioni accedevano ai database tramite un Network Load Balancer, dietro il quale si trovavano due instance con PgBouncer, e dietro di essi erano presenti tutti i server PostgreSQL.

Per implementare Patroni dovevamo scegliere uno storage distribuito per la configurazione del cluster. Patroni lavora con sistemi di storage distribuiti come etcd, Zookeeper e Consul. Proprio nel nostro ambiente di produzione abbiamo un cluster Consul completamente operativo, che lavora in combinazione con Vault e non lo utilizziamo per altri scopi. Un'ottima occasione per iniziare a utilizzare Consul per il suo scopo.
Come funziona Patroni con Consul
Abbiamo un cluster Consul composto da tre nodi e un cluster Patroni che consiste in un leader e una replica (in Patroni il master è chiamato leader del cluster e gli slave sono le repliche). Ogni istanza del cluster Patroni invia costantemente a Consul informazioni sullo stato del cluster. Pertanto, da Consul è sempre possibile conoscere la configurazione attuale del cluster Patroni e chi è il leader in quel momento.

Per connettere Patroni a Consul è sufficiente consultare la documentazione ufficiale, nella quale è specificato che è necessario indicare l'host nel formato http o https a seconda di come stiamo lavorando con Consul, e lo schema di connessione, opzionalmente:
host: l'host:port per il punto finale Consul, nel formato: http(s)://host:port
schema: (opzionale) http o https, predefinito a httpAppare semplice, ma qui iniziano gli insidie. Con Consul lavoriamo tramite una connessione sicura tramite https e la nostra configurazione di connessione apparirà come segue:
consul:
host: https://server.production.consul:8080
verify: true
cacert: {{ consul_cacert }}
cert: {{ consul_cert }}
key: {{ consul_key }}Ma così non funziona. All'avvio Patroni non riesce a connettersi a Consul perché tenta comunque di andare su http.
Per risolvere il problema è stato utile il codice sorgente di Patroni. È stato fortunato che sia scritto in python. Si scopre che il parametro host non viene elaborato e il protocollo deve essere indicato nello schema. Ecco come appare il blocco di configurazione funzionante per lavorare con Consul nel nostro caso:
consul:
host: server.production.consul:8080
schema: https
verify: true
cacert: {{ consul_cacert }}
cert: {{ consul_cert }}
key: {{ consul_key }}Consul-template
Quindi, abbiamo scelto lo storage per la configurazione. Ora dobbiamo capire come PgBouncer cambierà la sua configurazione quando ci sarà un cambio di leader nel cluster Patroni. Nella documentazione non troviamo risposta a questa domanda, poiché non viene descritta in generale la gestione di PgBouncer.
In cerca di una soluzione, abbiamo trovato un articolo (purtroppo non ricordo il titolo) dove era scritto che Consul-template ha aiutato molto nel collegamento tra PgBouncer e Patroni. Questo ci ha spinto ad approfondire il funzionamento di Consul-template.
Si è scoperto che Consul-template monitora continuamente la configurazione del cluster PostgreSQL in Consul. Quando c'è un cambio di leader, aggiorna la configurazione di PgBouncer e invia un comando per il suo riavvio.

Un grande vantaggio del template è che viene conservato come codice, quindi quando si aggiunge un nuovo shard è sufficiente fare un nuovo commit e aggiornare il template in modo automatico, mantenendo il principio dell'Infrastructure as Code.
Nuova architettura con Patroni
Di conseguenza, abbiamo ottenuto questo schema di lavoro:

Tutti i server dell'applicazione si collegano al bilanciatore → dietro di esso ci sono due istanze di PgBouncer → in ogni istanza è in esecuzione Consul-template, che monitora lo stato di ciascun cluster Patroni e controlla l'aggiornamento della configurazione di PgBouncer, che indirizza le richieste al leader attuale di ciascun cluster.
Test manuali
Prima di mettere in produzione questo schema, lo abbiamo avviato in un piccolo ambiente di test e abbiamo verificato il funzionamento della commutazione automatica. Abbiamo aperto la bacheca, spostato un post-it e in quel momento abbiamo 'abbattuto' il leader del cluster. In AWS per questo è sufficiente spegnere l'istanza tramite la console.

Il post-it tornava indietro dopo 10-20 secondi, e poi iniziava a muoversi normalmente di nuovo. Questo significa che il cluster Patroni ha funzionato correttamente: ha cambiato il leader, ha inviato le informazioni a Consul e Consul-template ha subito raccolto queste informazioni, sostituito la configurazione di PgBouncer e inviato il comando per il reload.
Come sopravvivere a carichi elevati e mantenere un downtime minimo?
Tutto funziona benissimo! Ma sorgono nuove domande: Come funzionerà sotto carichi elevati? Come distribuire tutto in modo rapido e sicuro in produzione?
Rispondere alla prima domanda ci aiuta l'ambiente di test, in cui eseguiamo il test di carico. È completamente identico a production per architettura e dispone di dati di test generati, che sono di volume approssimativamente pari a production. Decidiamo semplicemente di "uccidere" uno dei master PostgreSQL durante il test e vedere cosa succede. Ma prima è importante controllare il rollout automatico, poiché in questo ambiente abbiamo diversi shard PostgreSQL, quindi otterremo un ottimo test degli script di configurazione prima di andare in produzione.
Entrambi i compiti sembrano ambiziosi, ma abbiamo PostgreSQL 9.6. Forse possiamo aggiornare direttamente alla 11.2?
Decidiamo di farlo in 2 fasi: prima aggiornare la versione a 11.2, poi avviare Patroni.
Aggiornamento di PostgreSQL
Per un aggiornamento rapido della versione di PostgreSQL è necessario utilizzare l'opzione -k, in cui vengono creati hard link sul disco e non è necessario copiare i tuoi dati. Sugli archivi da 300-400 GB, l'aggiornamento richiede 1 secondo.
Abbiamo molti shard, quindi l'aggiornamento deve essere fatto in modo automatico. Per questo abbiamo scritto un playbook Ansible, che esegue tutto il processo di aggiornamento per noi:
/usr/lib/postgresql/11/bin/pg_upgrade
<b>--link </b>
--old-datadir='' --new-datadir=''
--old-bindir='' --new-bindir=''
--old-options=' -c config_file='
--new-options=' -c config_file='Qui è importante notare che prima di avviare l'aggiornamento è necessario eseguirlo con il parametro —check, per essere certi della possibilità dell'aggiornamento. Inoltre, il nostro script effettua la sostituzione dei file di configurazione durante l'aggiornamento. Lo script è stato eseguito in 30 secondi, un ottimo risultato.
Avvio di Patroni
Per risolvere il secondo problema, è sufficiente dare un'occhiata alla configurazione di Patroni. Nel repository ufficiale c'è un esempio di configurazione con initdb, che si occupa di inizializzare un nuovo database al primo avvio di Patroni. Ma poiché abbiamo già un database pronto, abbiamo semplicemente rimosso questa sezione dalla configurazione.
Quando abbiamo iniziato a installare Patroni su un cluster PostgreSQL già esistente e a farlo partire, ci siamo imbattuti in un nuovo problema: entrambi i server si avviavano come leader. Patroni non conosce lo stato precedente del cluster e cerca di avviare entrambi i server come due cluster distinti con lo stesso nome. Per risolvere questo problema è necessario eliminare la directory dei dati sullo slave:
rm -rf /var/lib/postgresql/Questo deve essere fatto solo sullo slave!
Collegandosi a una replica pulita, Patroni esegue il basebackup del leader e lo ripristina sulla replica, poi recupera lo stato attuale tramite i log wal.
Un'altra difficoltà con cui ci siamo confrontati è che tutti i cluster PostgreSQL si chiamano main per default. Quando ogni cluster non sa nulla dell'altro, va bene. Ma quando si vuole utilizzare Patroni, tutti i cluster devono avere un nome unico. La soluzione è cambiare il nome del cluster nella configurazione di PostgreSQL.
Test di carico
Abbiamo eseguito un test che simula il lavoro degli utenti sui tabelloni. Quando il carico ha raggiunto la nostra media giornaliera, abbiamo ripetuto esattamente lo stesso test, spegnendo un'istanza con leader PostgreSQL. Il failover automatico ha funzionato come ci aspettavamo: Patroni ha cambiato il leader, Sconsul-template ha aggiornato la configurazione di PgBouncer e ha inviato un comando di ricarica. Dai nostri grafici in Grafana si vedeva che c'erano ritardi di 20-30 secondi e un piccolo numero di errori dai server, relativi alla connessione al database. Questa è una situazione normale, questi valori sono accettabili per il nostro failover e sono decisamente migliori del downtime del servizio.
Output di Patroni in produzione
Alla fine abbiamo ottenuto il seguente piano:
- Deployment di Sconsul-template sui server PgBouncer e avvio;
- Aggiornamento di PostgreSQL alla versione 11.2;
- Cambio del nome del cluster;
- Avvio del cluster Patroni.
In questo modo, il nostro schema consente di eseguire il primo punto praticamente in qualsiasi momento, possiamo togliere ciascun PgBouncer dal lavoro uno alla volta e procedere con il deployment e l'avvio di consul-template. Così abbiamo fatto.
Per un rapido rollout abbiamo utilizzato Ansible, poiché tutti i playbook erano già stati verificati nell'ambiente di test, e il tempo necessario per eseguire l'intero scenario variava da 1,5 a 2 minuti per ogni shard. Potevamo effettuare il rollout uno alla volta su ciascun shard senza fermare il nostro servizio, ma sarebbe stato necessario spegnere ogni PostgreSQL per alcuni minuti. In questo caso, gli utenti i cui dati risiedono su quello shard non avrebbero potuto lavorare a pieno ritmo durante quel tempo, il che per noi è inaccettabile.
L'uscita da questa situazione è stata una manutenzione programmata, che si svolge ogni 3 mesi. Questa è una finestra per lavori pianificati, durante la quale spegniamo completamente il nostro servizio e aggiorniamo le istanze del database. Una settimana prima della prossima finestra, abbiamo deciso di aspettare e prepararci ulteriormente. Durante il tempo di attesa, abbiamo fatto delle precauzioni: per ogni shard di PostgreSQL abbiamo attivato una replica di backup nel caso di un fallimento per mantenere i dati più recenti, e abbiamo aggiunto una nuova istanza per ciascun shard, che dovrebbe diventare una nuova replica nel cluster Patroni, per evitare di eseguire il comando per eliminare i dati. Tutto ciò ha aiutato a ridurre al minimo il rischio di errore.

Abbiamo riavviato il nostro servizio, tutto ha funzionato come previsto, gli utenti hanno continuato a lavorare, ma nei grafici abbiamo notato un carico anomalo sui server Consul.

Perché non lo abbiamo visto nell'ambiente di test? Questo problema illustra molto bene l'importanza di seguire il principio di Infrastructure as Code e di migliorare tutta l'infrastruttura, a partire dagli ambienti di test fino alla produzione. Altrimenti, è molto facile imbattersi in un problema come quello che abbiamo avuto. Cosa è successo? Consul è apparso prima in produzione e poi negli ambienti di test; di conseguenza, negli ambienti di test la versione di Consul era più alta rispetto a quella di produzione. Proprio in uno dei rilasci è stata risolta una perdita di CPU durante l'uso di consul-template. Pertanto, abbiamo semplicemente aggiornato Consul, risolvendo in questo modo il problema.
Riavvia il cluster Patroni
Tuttavia, abbiamo riscontrato un nuovo problema di cui non sospettavamo affatto. Durante l'aggiornamento di Consul, semplicemente rimuoviamo il nodo Consul dal cluster con il comando consul leave → Patroni si connette a un altro server Consul → tutto funziona. Ma quando siamo arrivati all'ultima istanza del cluster Consul e gli abbiamo inviato il comando consul leave, tutti i cluster Patroni si sono semplicemente riavviati, e nei log abbiamo visto il seguente errore:
ERRORE: get_cluster
Traceback (chiamata più recente per ultima):
...
RetryFailedError: 'Superato il termine di riprova'
ERRORE: Errore nella comunicazione con DCS
<b>LOG: il sistema database è stato arrestato</b>Il cluster Patroni non è riuscito a ricevere informazioni sul proprio cluster e si è riavviato.
Per cercare una soluzione ci siamo rivolti agli autori di Patroni tramite un issue su GitHub. Hanno suggerito miglioramenti ai nostri file di configurazione:
consul:
consul.checks: []
bootstrap:
dcs:
retry_timeout: 8Siamo riusciti a riprodurre il problema nell'ambiente di test e abbiamo testato lì questi parametri, ma, sfortunatamente, non hanno funzionato.
Il problema rimane irrisolto. Stiamo pianificando di provare le seguenti opzioni per la soluzione:
- Utilizzare il Sconsul-agent su ciascun'istanza del cluster Patroni;
- Correggere il problema nel codice.
Ci è chiaro il luogo di origine dell'errore: probabilmente, il problema è dovuto all'uso del timeout predefinito, che non viene ridefinito tramite il file di configurazione. Quando l'ultimo server Sconsul viene rimossa dal cluster, l'intero cluster Sconsul va in stallo per più di un secondo, per questo Patroni non riesce a ottenere lo stato del cluster e riavvia completamente l'intero cluster.
Fortunatamente, non abbiamo riscontrato ulteriori errori.
Risultati dell'utilizzo di Patroni
Dopo aver avviato con successo Patroni, abbiamo aggiunto una replica aggiuntiva in ogni cluster. Ora in ogni cluster esiste una sorta di quorum: un leader e due repliche, per coprire eventuali situazioni di split-brain durante il failover.

In produzione, Patroni è attivo da più di tre mesi. Durante questo periodo, è già riuscito ad aiutarci. Recentemente, in AWS, il leader di uno dei cluster è andato offline, il failover automatico è scattato e gli utenti hanno continuato a lavorare. Patroni ha svolto il suo compito principale.
Un breve riepilogo sull'utilizzo di Patroni:
- Facilità di modifica della configurazione. È sufficiente modificare la configurazione su un'istanza affinché venga applicata a tutto il cluster. Se è necessario un riavvio per applicare la nuova configurazione, Patroni lo notificherà. Patroni può riavviare l'intero cluster con un solo comando, il che è molto comodo.
- Il failover automatico funziona ed è già riuscito ad aiutarci.
- Aggiornamento di PostgreSQL senza downtime dell'applicazione. È necessario prima aggiornare le repliche alla nuova versione, quindi cambiare il leader nel cluster Patroni e aggiornare il vecchio leader. Durante questo processo si esegue il necessario test del failover automatico.
Fonte: habr.com
