Come Battlehouse Games raggiunge il 99,99% di uptime su AWS

Nel mio precedente articolo ho spiegato come Battlehouse Games ha risparmiato $ 60.000 all’anno ottimizzando l’utilizzo di AWS. La storia di oggi si concentra sul modo in cui raggiungiamo una disponibilità quasi perfetta 24/7 dei nostri giochi basati sul web.

Battlehouse gestisce un portafoglio di titoli di strategia multigiocatore su Facebook, con oltre 5 milioni di account con azioni di battaglia senza sosta in giochi come Thunder Run: War of Clans.

Tradizionalmente, i giochi multiplayer di massa subiscono frequenti interruzioni del servizio. Ad esempio, EVE Online di CCP viene disattivato per un’ora di manutenzione programmata ogni singolo giorno. World of Warcraft di Blizzard ha una finestra di manutenzione settimanale che spesso impone diverse ore di inattività, soprattutto in merito ai principali aggiornamenti dei contenuti.

In Battlehouse crediamo che i giochi online debbano soddisfare lo stesso livello di disponibilità 24/7 che gli utenti si aspettano da siti popolari come Facebook e YouTube.

Usando la moderna architettura di applicazioni Web, abbiamo dimostrato come portare i giochi allo stesso livello di robustezza e affidabilità di questi principali siti di consumo.

Gestisco l’infrastruttura principale di Battlehouse dal lancio del nostro primo gioco nel 2012. Sono orgoglioso di annunciare che da allora abbiamo realizzato oltre il 99,9% di uptime e durante l’anno appena concluso del 2018, abbiamo aggiunto un altro ” nove “a quella cifra!

In questo articolo spiegherò come abbiamo progettato la nostra architettura cloud back-end per ottenere un uptime del servizio del 99,99% su centinaia di importanti implementazioni e aggiornamenti di funzionalità, con solide difese contro l’instabilità della piattaforma e gli attacchi esterni.

Giochi come app Web

Battlehouse offre giochi gratuiti sul Web aperto utilizzando gli standard HTML5. I giocatori si connettono a titoli come Thunder Run utilizzando un’app del browser Javascript che comunica con i server back-end in esecuzione su Amazon AWS.

Sebbene questa architettura sia simile a molte app Web moderne, i nostri giochi multiplayer comportano insolite sfide operative oltre a ciò che deve affrontare una tipica app consumer:

  • Sessioni di gioco lunghe : i nostri giocatori più dedicati rimangono connessi al gioco per diverse ore alla volta. Anche una breve interruzione nella reattività del server può avere un impatto negativo sul gameplay. Ad esempio, durante intense battaglie, interrompere la connessione al server di gioco per più di un paio di secondi può causare una sconfitta frustrante e inaspettata.
  • Aggiornamenti frequenti : implementiamo nuove funzionalità e contenuti di gioco almeno una volta alla settimana, alcuni dei quali richiedono migrazioni unidirezionali, come le modifiche dello schema del database.
  • Modifiche alle regole di gioco: alcuni aggiornamenti influiscono sulle regole di gioco per il combattimento giocatore contro giocatore. Questi aggiornamenti devono essere distribuiti contemporaneamente a tutti i giocatori. Ciò creerebbe vantaggi ingiusti se alcuni giocatori avessero accesso all’aggiornamento prima di altri.

Nelle sezioni seguenti, descriverò i metodi che abbiamo sviluppato per affrontare queste sfide speciali.

Ricarica a caldo dei dati di gioco

Il nostro motore di gioco incapsula i dati di gioco specifici del titolo, come le statistiche di unità, edifici e forze nemiche, in una serie di file JSON che vengono caricati all’avvio di un’istanza del server. Abbiamo aggiunto un’API lato server che ci consente di distribuire una nuova versione di questi dati di gioco e di “caricarli a caldo” in un server in esecuzione senza disconnettere alcun giocatore. (per evitare problemi con dati incoerenti, prestiamo particolare attenzione a conservare sia i “vecchi” che i “nuovi” set di dati in memoria e passare atomicamente i lettori appena collegati ai “nuovi” dati).

Questo sistema di ricarica rapida ci consente di distribuire la maggior parte degli aggiornamenti minori senza alcun churn tra le istanze del server back-end. Aggiornamenti più grandi, come quelli che aggiungono un nuovo codice motore, richiedono di arrestare e riavviare i server, ma la maggior parte degli aggiornamenti giornalieri può essere gestita con questa semplice funzione di hot-ricaricamento.

Funzionalità di rilascio a tempo

All’inizio, abbiamo rilasciato nuovi contenuti di gioco, come nuove unità e livelli, spingendo un singolo grande aggiornamento al set completo di server back-end. Questo ci ha costretto a fare una distribuzione coordinata di “stop the world” in cui tutti i giocatori dovevano essere disconnessi durante il tempo impiegato per ricaricare tutte le istanze del server.

Per ridurre i tempi di inattività, è preferibile utilizzare la strategia di aggiornamento “rolling” utilizzata da molti servizi Web comuni. In un aggiornamento continuativo, le istanze di back-end vengono chiuse, aggiornate e riavviate una ad una, quindi non c’è mai un momento in cui tutti gli utenti devono essere disconnessi contemporaneamente. Inoltre, le istanze che eseguono il vecchio codice possono essere mantenute attive in uno stato di “svuotamento” fino alla scomparsa dell’ultimo client connesso, il che significa che non è necessario disconnettere forzatamente nessuno durante l’intero processo di implementazione.

Tuttavia, non potremmo utilizzare una strategia di aggiornamento continuo per i nuovi contenuti di gioco, perché in qualsiasi momento ci sarebbero alcuni giocatori in grado di utilizzare il contenuto aggiornato e alcuni giocatori senza di esso, creando vantaggi ingiusti.

Abbiamo risolto questo problema aggiungendo flag di funzionalità “time-release” in tutti i dati di gioco. Il nostro motore di gioco include un linguaggio di programmazione specifico del dominio chiamato “predicati” che consente ai progettisti di abilitare o disabilitare i contenuti di gioco in base a criteri come i livelli dei giocatori o obiettivi sbloccati. Aggiungendo un predicato del “tempo assoluto”, i progettisti possono organizzare che i contenuti del gioco vengano distribuiti in uno stato nascosto e quindi automaticamente visibili ai giocatori in un momento specifico in futuro.

Questa strategia di “time-release” ci libera liberando l’accoppiamento della distribuzione di un aggiornamento del server dalla versione visibile del lettore della funzione. Oggi applichiamo predicati a rilascio temporaneo alla stragrande maggioranza degli aggiornamenti dei contenuti, consentendoci di eseguire un aggiornamento del server di rotazione giorni o addirittura settimane prima che una funzionalità diventi effettivamente visibile ai giocatori.

Gestione degli aggiornamenti a rotazione

Gli aggiornamenti a rotazione sono controllati tramite il nostro backplane “PCHECK” interno, che gestisce le fasi di avvio di nuove istanze del server, instradamento dei giocatori verso di esse, quindi chiusura delle rotte verso istanze precedenti e disattivazione quando le ultime connessioni si esauriscono.

Le radici di PCHECK risalgono al 2012, quando non c’erano molti sistemi standard per raggiungere questo obiettivo. Se oggi progettassimo questo sistema da zero, prenderemmo in considerazione la costruzione di un sistema di gestione di ingresso / distribuzione esistente come Envoy, Spinnaker o HAProxy.

Migrazioni compatibili con versioni precedenti e precedenti

Durante gli aggiornamenti continui, diverse istanze del server possono eseguire versioni diverse del nostro codice motore, tutte connesse allo stesso database back-end. Ciò aggiunge vincoli speciali quando si tratta di sviluppare nuove funzionalità che cambiano le strutture di dati interne o i formati di database. Dedichiamo sforzi ingegneristici supplementari per limitare le interruzioni dovute all’esecuzione simultanea di versioni precedenti e più recenti del gioco.

Il requisito principale è che tutti gli aggiornamenti devono essere compatibili con le versioni precedenti . Ciò significa che il codice appena scritto deve essere in grado di caricare e operare su oggetti di gioco scritti da una versione precedente del software server. Lo realizziamo disabilitando qualsiasi eliminazione o modifica del significato delle proprietà esistenti sugli oggetti di gioco. Le nuove funzionalità vengono aggiunte inventando nuove proprietà e trattando i vecchi oggetti come aventi un valore predefinito ragionevole ogni volta che un oggetto manca la nuova proprietà. Per i casi in cui ciò è imbarazzante o impossibile, abbiamo aggiunto la possibilità di eseguire una funzione “hook” una volta ogni volta che viene caricato un account giocatore. Questo hook può eseguire eventuali aggiornamenti o riformattazioni una tantum di strutture di dati obsolete.

Richiediamo inoltre che gli aggiornamenti siano compatibili in avanti , per quanto possibile. Durante le distribuzioni a rotazione, è possibile che un’istanza del server precedente legga alcuni dati del giocatore che sono stati scritti da un’altra istanza che esegue il codice più recente. Questo può accadere, ad esempio, quando un giocatore che è ancora connesso a una vecchia istanza attacca una base appena scritta da una nuova istanza. Per evitare la perdita di dati, abbiamo scritto il nostro codice di serializzazione degli oggetti per preservare tutte le proprietà che non riconosce, scrivendole con gli stessi valori con cui sono state lette.

Nella stragrande maggioranza dei casi, queste tecniche ci consentono di distribuire aggiornamenti continui senza tempi di inattività. Molto raramente, quando un aggiornamento compatibile con il passato o il passato è impraticabile, programmiamo una breve finestra per “fermare il mondo”, disconnettendo tutti i giocatori in modo da poter garantire che non vi sia alcuna combinazione di codice vecchio e nuovo che tocchi i dati dei giocatori dal vivo.

Disponibilità del database

Come descritto nel mio precedente articolo sull’ottimizzazione dei costi di AWS, utilizziamo una distinzione “caldo / freddo” nella nostra architettura di archiviazione. I dati “caldi” devono essere altamente disponibili con bassa latenza, ma hanno dimensioni di lavoro limitate che crescono solo in modo lineare con il numero di giocatori collegati. I dati “freddi” crescono senza limiti, ma non devono essere altrettanto veloci o affidabili. Questa distinzione è molto utile quando si tratta di progettare la nostra memoria dati per tempi di attività elevati.

I dati caldi , come i contenuti delle basi e degli eserciti dei giocatori, devono essere disponibili ogni volta che il gioco è operativo. Qualsiasi interruzione della durata di alcuni secondi interromperà gravemente il gioco. Soddisfiamo questo requisito di elevata disponibilità utilizzando MongoDB come back-end principale, distribuito come set di repliche su tre istanze EC2 ridondanti in diverse zone di disponibilità AWS. Se una singola istanza viene disattivata, un’altra viene sostituita come master in pochi secondi. I set di repliche MongoDB consentono inoltre aggiornamenti continui per gli aggiornamenti del motore di database, le compattazioni di archiviazione e gli scambi di hardware dell’istanza. Finora, non abbiamo mai dovuto togliere l’intero cluster MongoDB per qualsiasi manutenzione.

Archiviamo anche alcuni dati “caldi” in Amazon S3, che funziona meno bene di MongoDB come back-end di archiviazione ad alta disponibilità. In realtà, è la nostra più grande fonte di downtime non programmati (continua a leggere per i dettagli). Intendiamo infine migrare questa parte del nostro sistema di archiviazione su PostgreSQL in esecuzione su RDS o su un cluster Postgres self-hosted.

I dati a freddo , come i log di battaglia storici e i punteggi di gioco, non sono vitali per il gameplay e i nostri server sono progettati per funzionare normalmente anche se un back-end di archiviazione a freddo non è disponibile. Ad esempio, se il database dei punteggi storici è offline, i giocatori vedono un risultato vuoto se cercano i punteggi delle classifiche precedenti, ma in caso contrario possono giocare normalmente. Questo ci dà la libertà di utilizzare opzioni di archiviazione semplici ed economiche, come PostgreSQL o S3 ospitato su RDS. Siamo inoltre liberi di portare offline la cella frigorifera per eseguire attività di manutenzione di lunga durata, come l’ottimizzazione di ingombranti tabelle SQL o la migrazione di versioni del software RDS, senza interrompere il gameplay live. Non abbiamo bisogno di spendere risorse di ingegneria per il complesso problema della creazione di storage ad alta disponibilità per un set di dati “freddo” in continua crescita.

Cause di downtime

Nonostante tutte le misure di cui sopra, ci sono ancora alcuni brevi periodi di inattività non programmata. Ecco le principali cause:

Interruzioni di Amazon S3

S3 svolge un ruolo chiave nella memorizzazione dei dati dell’account giocatore. Gli oggetti vengono caricati e salvati su S3 ogni volta che un giocatore accede o esce. Qualsiasi errore dell’API S3 può quindi impedire agli utenti di accedere ai nostri giochi.

S3 è normalmente molto affidabile e la semplice logica retry-on-error si occupa della maggior parte delle richieste non riuscite. Tuttavia, ogni paio di mesi, incontriamo periodi di dieci minuti o più in cui S3 non soddisfa tutte le richieste a fronte di un determinato bucket di archiviazione.

L’accordo sul livello di servizio di S3 implica un livello molto elevato di affidabilità. Nella nostra esperienza, tuttavia, i guasti S3 hanno un impatto molto maggiore di quanto suggeriscano i numeri, poiché possono verificarsi in modo correlato, interessando tutti gli oggetti in un intero bucket per un lungo periodo di tempo.

Ad oggi, non abbiamo mitigazione per questi guasti S3 a livello di bucket, quindi sono la ragione numero 1 per i tempi di fermo non programmati. In futuro, potremmo affrontare questo problema migrando l’archiviazione dei dati dei giocatori su un back-end diverso con un’affidabilità più controllata, come un cluster Postgres autogestito.

A proposito, la decisione originale di utilizzare S3 si basava su un’ipotesi iniziale del nostro modello di utilizzo che si è rivelato errato. Abbiamo pianificato un numero molto elevato di account dei giocatori – decine di milioni – che non interagirebbero molto tra loro, mentre in realtà i nostri giochi si sono evoluti per servire un numero minore di account dei giocatori, ma con un numero di giocatori molto maggiore interazione del giocatore. Un semplice database SQL o NoSQL è una scelta migliore per questo modello di utilizzo rispetto a S3.

Riagganci dell’istanza EC2

Le singole istanze EC2 sono generalmente affidabili, ma tendono a soffrire di congelamenti del sistema inspiegabili o eventi di degrado dell’hardware ogni pochi anni di funzionamento della macchina. Con una flotta abbastanza grande di istanze EC2, la possibilità che una sola istanza si blocchi diventa una preoccupazione significativa.

Il nostro attuale codice del server API di gioco non è molto affidabile a fronte di un errore dell’istanza VM EC2 sottostante. Quando un’istanza fallisce, dobbiamo crearne una nuova (un processo per lo più automatizzato ora, grazie a Terraform), regolare il routing del traffico ed eseguire alcuni passaggi di ripristino manuale. Speriamo di risolvere questo problema in futuro ricodificando il codice del server più secondo la filosofia dell’app Twelve-Factor, che ci consentirebbe di containerizzare e gestire i server usa e getta in un pool di istanze ridondanti.

Errore umano

Gli studi sull’affidabilità del sito mostrano che gli errori umani, come gli errori di configurazione, sono uno dei 5 principali motivi di interruzione del servizio.

Battlehouse non è immune all’errore umano! Anche se il nostro processo di distribuzione è quasi completamente automatizzato, i passaggi manuali rimanenti lasciano ancora spazio sufficiente per il clic errato occasionale o il passaggio saltato per interrompere temporaneamente l’accesso a un titolo di gioco. Molto spesso, questo accade quando dimentichiamo di abilitare il routing di nuovi accessi a un gruppo di istanze del server appena lanciato prima di chiudere un gruppo obsoleto. Fortunatamente, riceviamo avvisi su questa situazione entro un paio di minuti, grazie a un sistema di monitoraggio che abbiamo creato utilizzando Amazon Route53 Health Checks e allarmi CloudWatch.

Attacchi DDoS

I giocatori possono essere estremamente appassionati dei loro alleati e nemici virtuali. Abbiamo riscontrato diversi casi in cui i giocatori hanno lanciato attacchi DDoS contro i nostri server nel tentativo di ottenere vantaggi ingiusti o ostacolare altri giocatori. Questo è in cima agli attacchi casuali non diretti che tutti i siti pubblici affrontano in questi giorni.

La maggior parte di questi attacchi non è abbastanza forte da disturbare la nostra pipeline di ingresso core, che consiste in un bilanciamento del carico delle applicazioni AWS che alimenta le connessioni HTTP attraverso server ridondanti HAproxy. Una volta, diversi anni fa, abbiamo subito un attacco sofisticato che ha travolto il bilanciamento del carico AWS e reso le macchine HAproxy non rispondenti a livello di kernel. Abbiamo risolto questo problema posizionando CloudFlare di fronte all’intero stack, il che devia facilmente questi attacchi prima che tocchino AWS. Non abbiamo avuto un singolo caso di downtime a causa di tentativi DDoS da quando abbiamo iniziato a utilizzare CloudFlare.

Lavoro futuro

Sebbene siamo molto soddisfatti dell’alto livello di affidabilità che abbiamo raggiunto, c’è sempre spazio per miglioramenti. Le principali aree di preoccupazione sono l’archiviazione dei dati degli account basata su S3 e l’architettura legacy non a dodici fattori del nostro software server API di gioco. Risolvere questi problemi aprirebbe l’opzione per aggiungere ridondanza ancora più forte, come l’esecuzione di un pool condiviso di server di gioco su più zone di disponibilità per essere robusti contro interruzioni di zona complete. Ad un certo punto, vorremmo pagare questo debito tecnico, ma per ora, la massima priorità è lo sviluppo e il rilascio di contenuti migliori per far divertire i nostri giocatori!