
Lo sviluppo di progetti ad alta intensità su qualsiasi linguaggio richiede un approccio particolare e l'uso di strumenti specializzati, ma quando si parla di applicazioni PHP, la situazione può diventare così critica che è necessario sviluppare, ad esempio, . In questo articolo parleremo di un problema ben noto riguardante la memorizzazione distribuita delle sessioni e la memorizzazione nella cache dei dati in memcached, e di come abbiamo affrontato queste sfide in un progetto 'sotto osservazione'.
Il protagonista è un'applicazione PHP basata sul framework symfony 2.3, il cui aggiornamento non è affatto nelle intenzioni del business. Oltre alla gestione standard delle sessioni, in questo progetto si stava applicando una politica di 'caching totale' in memcached: risposte a richieste ai database e server API, vari flag, lock per sincronizzare l'esecuzione del codice e molto altro. In una tale situazione, il malfunzionamento di memcached diventa fatale per il funzionamento dell'applicazione. Inoltre, la perdita della cache porta a conseguenze gravi: il DBMS inizia a dare segni di cedimento, i servizi API — a bloccare le richieste, ecc. Stabilizzare la situazione può richiedere decine di minuti, durante i quali il servizio rallenterà drasticamente o diventerà completamente non disponibile.
Abbiamo dovuto garantire la possibilità di scalare orizzontalmente l'applicazione senza grandi sforzi, cioè con modifiche minime al codice sorgente e mantenendo completamente la funzionalità. Rendere la cache non solo resistente ai guasti, ma anche cercare di minimizzare la perdita di dati da essa.
Cosa c'è che non va in memcached?
In realtà, l'estensione memcached per PHP supporta 'nativamente' lo storage distribuito di dati e sessioni. Il meccanismo di hashing consistente delle chiavi consente una distribuzione uniforme dei dati su più server, indirizzando in modo univoco ogni chiave specifica a un determinato server del gruppo. I sistemi di failover integrati garantiscono un'elevata disponibilità del servizio di caching (ma, sfortunatamente, non dei dati).
La gestione delle sessioni è un po' migliore: è possibile configurare memcached.sess_number_of_replicas, il che significa che i dati verranno memorizzati su più server contemporaneamente e, in caso di guasto di un'istanza di memcached, i dati saranno forniti da altri. Tuttavia, se il server torna online senza dati (come di solito accade dopo un riavvio), parte delle chiavi verrà redistribuita a suo favore. In effetti, questo significherà perdita dei dati di sessione, poiché non c'è modo di 'recuperare' un'altra replica in caso di errore.
Gli strumenti standard della libreria sono principalmente orientati a livello orizzontale scalabilità: consentono di aumentare la cache a dimensioni gigantesche e di accedervi da codice ospitato su diversi server. Tuttavia, nella nostra situazione, la quantità di dati memorizzati non supera alcuni gigabyte, e le prestazioni di uno o due nodi sono più che sufficienti. Pertanto, tra le opzioni utili, gli strumenti standard potrebbero garantire la disponibilità di memcached mantenendo almeno un'istanza della cache in stato operativo. Tuttavia, non siamo riusciti nemmeno a sfruttare questa possibilità… È importante ricordare l'età del framework utilizzato nel progetto, che ha reso difficile far funzionare l'applicazione con un pool di server. Non dimentichiamo neanche le perdite di dati delle sessioni: il cliente ha vissuto un aumento dei disconnessioni degli utenti.
Ideale era necessario replicazione dei dati in memcached e bypass delle repliche in caso di errore o di mancanza. Questa strategia è stata supportata da .
mcrouter
Questo è un router per memcached, sviluppato da Facebook per risolvere i propri problemi. Supporta il protocollo testuale di memcached, che consente di scalare le installazioni di memcached a dimensioni incredibili. Una descrizione dettagliata di mcrouter può essere trovata in . Oltre ad altre può fare ciò di cui abbiamo bisogno:
- replicare la registrazione;
- effettuare il fallback su altri server del gruppo in caso di errore.
Iniziamo!
Configurazione di mcrouter
Passo subito alla configurazione:
{
"pools": {
"pool00": {
"servers": [
"mc-0.mc:11211",
"mc-1.mc:11211",
"mc-2.mc:11211"
},
"pool01": {
"servers": [
"mc-1.mc:11211",
"mc-2.mc:11211",
"mc-0.mc:11211"
},
"pool02": {
"servers": [
"mc-2.mc:11211",
"mc-0.mc:11211",
"mc-1.mc:11211"
},
"route": {
"type": "OperationSelectorRoute",
"default_policy": "AllMajorityRoute|Pool|pool00",
"operation_policies": {
"get": {
"type": "RandomRoute",
"children": [
"MissFailoverRoute|Pool|pool02",
"MissFailoverRoute|Pool|pool00",
"MissFailoverRoute|Pool|pool01"
]
}
}
}
}Perché tre pool? Perché i server si ripetono? Vediamo come funziona.
- In questa configurazione, mcrouter sceglie il percorso attraverso il quale inviare la richiesta in base al comando della richiesta. Questo è indicato dal tipo
OperationSelectorRoute. - Le richieste GET vengono elaborate da
RandomRoute, che seleziona casualmente un pool o un percorso tra gli oggetti dell'arraychildren. Ogni elemento di questo array è a sua volta un gestoreMissFailoverRoute, che controllerà ogni server nel pool fino a ricevere una risposta con i dati, che sarà restituita al cliente. - Se utilizzassimo esclusivamente
MissFailoverRouteun pool di tre server, tutte le richieste andrebbero prima al primo esemplare di memcached, mentre gli altri riceverebbero le richieste in base alla disponibilità dei dati. Questo approccio comporterebbe un carico eccessivo sul primo server della lista, quindi è stata presa la decisione di generare tre pool con indirizzi in sequenze diverse e selezionarli in modo casuale. - Tutte le altre richieste (che sono scritture) vengono gestite tramite
AllMajorityRoute. Questo gestore invia richieste a tutti i server del pool e attende risposte da almeno N/2 + 1 di essi. L'uso diAllSyncRouteper le operazioni di scrittura è stato abbandonato, poiché questo metodo richiede una risposta positiva da parte tutti dei server del gruppo — altrimenti restituiràSERVER_ERROR. Anche se mcrouter memorizza i dati nelle cache disponibili, la funzione PHP chiamante restituirà un errore e genererà un avviso.AllMajorityRouteè meno rigoroso e consente di escludere fino alla metà dei nodi senza i problemi descritti sopra.
Il principale svantaggio Il problema di questo schema è che, se non ci sono dati nella cache, per ogni richiesta del cliente verranno effettivamente eseguite N richieste a memcached — ai di tutto server nel pool. Possiamo ridurre il numero di server nei pool, ad esempio, a due: sacrificando l'affidabilità della memorizzazione, otterremo unaunmaggiore velocità e minore carico dalle richieste su chiavi assenti.
NB: Collegamenti utili per studiare mcrouter potrebbero essere e (incluse quelle chiuse), che rappresentano un vero tesoro di varie configurazioni.
Compilazione e avvio di mcrouter
L'applicazione (e lo stesso memcached) funzionano su Kubernetes — di conseguenza, anche mcrouter è lì. Per compilare il contenitore utilizziamo , la configurazione per il quale sarà simile a questa:
NB: Gli elenchi presentati nell'articolo sono pubblicati nel repository .
configVersion: 1
project: mcrouter
deploy:
namespace: '[[ env ]]'
helmRelease: '[[ project ]]-[[ env ]]'
---
image: mcrouter
from: ubuntu:16.04
mount:
- from: tmp_dir
to: /var/lib/apt/lists
- from: build_dir
to: /var/cache/apt
ansible:
beforeInstall:
- name: Installa i prerequisiti
apt:
name: [ 'apt-transport-https', 'tzdata', 'locales' ]
update_cache: yes
- name: Aggiungi la chiave APT di mcrouter
apt_key:
url: https://facebook.github.io/mcrouter/debrepo/xenial/PUBLIC.KEY
- name: Aggiungi il repository di mcrouter
apt_repository:
repo: deb https://facebook.github.io/mcrouter/debrepo/xenial xenial contrib
filename: mcrouter
update_cache: yes
- name: Imposta il fuso orario
timezone:
name: "Europe/Moscow"
- name: Assicurati che esista una locale
locale_gen:
name: en_US.UTF-8
state: present
install:
- name: Installa mcrouter
apt:
name: [ 'mcrouter' ]()
… e annotiamo Chart Helm. Di interessante — qui c'è solo il generatore di configurazione basato sul numero di repliche (se qualcuno ha una versione più concisa ed elegante, condividetela nei commenti):
{{- $count := (pluck .Values.global.env .Values.memcached.replicas | first | default .Values.memcached.replicas._default | int) -}}
{{- $pools := dict -}}
{{- $servers := list -}}
{{- /* Заполняем массив двумя копиями серверов: "0 1 2 0 1 2" */ -}}
{{- range until 2 -}}
{{- range $i, $_ := until $count -}}
{{- $servers = append $servers (printf "mc-%d.mc:11211" $i) -}}
{{- end -}}
{{- end -}}
{{- /* Смещаясь по массиву, получаем N срезов: "[0 1 2] [1 2 0] [2 0 1]" */ -}}
{{- range $i, $_ := until $count -}}
{{- $pool := dict "servers" (slice $servers $i (add $i $count)) -}}
{{- $_ := set $pools (printf "MissFailoverRoute|Pool|pool%02d" $i) $pool -}}
{{- end -}}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: mcrouter
data:
config.json: |
{
"pools": {{- $pools | toJson | replace "MissFailoverRoute|Pool|" "" -}},
"route": {
"type": "OperationSelectorRoute",
"default_policy": "AllMajorityRoute|Pool|pool00",
"operation_policies": {
"get": {
"type": "RandomRoute",
"children": {{- keys $pools | toJson }}
}
}
}
}()
Distribuiamo nell'ambiente di test e controlliamo:
# php -a
Interactive mode enabled
php > # Проверяем запись и чтение
php > $m = new Memcached();
php > $m->addServer('mcrouter', 11211);
php > var_dump($m->set('test', 'value'));
bool(true)
php > var_dump($m->get('test'));
string(5) "value"
php > # Работает! Тестируем работу сессий:
php > ini_set('session.save_handler', 'memcached');
php > ini_set('session.save_path', 'mcrouter:11211');
php > var_dump(session_start());
PHP Warning: Uncaught Error: Failed to create session ID: memcached (path: mcrouter:11211) in php shell code:1
Stack trace:
#0 php shell code(1): session_start()
#1 {main}
thrown in php shell code on line 1
php > # Не заводится… Попробуем задать session_id:
php > session_id("zzz");
php > var_dump(session_start());
PHP Warning: session_start(): Cannot send session cookie - headers already sent by (output started at php shell code:1) in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Unable to clear session lock record in php shell code on line 1
PHP Warning: session_start(): Failed to read session data: memcached (path: mcrouter:11211) in php shell code on line 1
bool(false)
php >La ricerca del testo dell'errore non ha dato risultati, ma la richiesta «» nei primi risultati ha mostrato il problema più antico non risolto del progetto — del protocollo binario memcached.
NB: il protocollo ASCII in memcached è più lento di quello binario, e anche gli strumenti standard per l'hashing dei key funzionano solo con il protocollo binario. Ma questo non crea problemi per il caso specifico.
Tutto è a posto: resta solo da passare al protocollo ASCII e tutto funzionerà... Tuttavia, in questo caso, l'abitudine di cercare risposte nella ha giocato un brutto scherzo. La risposta corretta non la troverete lì... a meno che, ovviamente, non scendiate in fondo, dove nella sezione «Note contribuite dagli utenti» troverete la risposta giusta e .
Sì, il nome corretto dell'opzione è memcached.sess_binary_protocol. Deve essere disattivata, dopodiché le sessioni inizieranno a funzionare. Rimane solo da posizionare il contenitore con mcrouter nel pod con PHP!
Conclusione
In questo modo, con solo modifiche all'infrastruttura, siamo riusciti a risolvere la questione: il problema con la tolleranza ai guasti di memcached è risolto, l'affidabilità dello storage della cache è aumentata. Oltre ai vantaggi evidenti per l'applicazione, questo ha fornito spazio di manovra durante i lavori sulla piattaforma: quando tutti i componenti hanno un backup, la vita dell'amministratore diventa molto più semplice. Sì, questo metodo ha anche i suoi svantaggi, potrebbe sembrare un "palliativo", ma se fa risparmiare soldi, risolve il problema e non genera nuovi problemi, perché no?
P.S.
Leggete anche nel nostro blog:
- «Pratica con dapp» (con l'esempio di symfony-demo): e ;
- «».
Fonte: habr.com
