Nel momento in cui abbiamo aggiunto il nostro secondo server, i sistemi distribuiti sono diventati il modo di vivere in Amazon. Quando ho iniziato ad Amazon nel 1999, avevamo così pochi server che potevamo assegnare ad alcuni dei nomi riconoscibili come "fishy" o "online-01". Tuttavia, anche nel 1999, il calcolo distribuito non è stato facile. Allora, come adesso, le sfide con i sistemi distribuiti riguardavano latenza, ridimensionamento, comprensione delle API di rete, dati di marshalling e non-marshalling e complessità di algoritmi come Paxos. Mano a mano che i sistemi diventavano rapidamente più grandi e più distribuiti, quelli che erano stati casi teorici limite si sono trasformati in eventi regolari.

Lo sviluppo di servizi di calcolo distribuito delle utility, come reti telefoniche interurbane affidabili o servizi Amazon Web Services (AWS), è difficile. Il calcolo distribuito è anche più strano e meno intuitivo rispetto ad altre forme di calcolo a causa di due problemi correlati. Errori indipendenti e non determinismo causano i problemi più rilevanti nei sistemi distribuiti. Oltre ai tipici errori informatici a cui la maggior parte degli ingegneri sono abituati, gli errori nei sistemi distribuiti possono verificarsi in molti altri modi. Quel che è peggio è che è sempre impossibile sapere se qualcosa non ha funzionato.

Nella Builders' Library di Amazon, ci occupiamo di come AWS gestisce i più complicate problemi di sviluppo e operazioni derivanti dai sistemi distribuiti. Prima di approfondire queste tecniche nel dettaglio in altri articoli, vale la pena rivedere i concetti che contribuiscono al motivo per cui il calcolo distribuito sia così piuttosto strano. Innanzitutto, esaminiamo i tipi di sistemi distribuiti.

Tipi di sistemi distribuiti

I sistemi distribuiti variano effettivamente in termini di difficoltà dell'implementazione. A un'estremità dello spettro, abbiamo i sistemi distribuiti offline. Questi includono sistemi di elaborazione in batch, cluster di analisi di big data, aziende di rendering di scene di film, cluster di ripiegamento delle proteine e simili. Sebbene siano ben lungi dall'avere un'implementazione banale, i sistemi distribuiti offline vantano quasi tutti i vantaggi del calcolo distribuito (scalabilità e tolleranza agli errori) e quasi nessuno degli aspetti negativi (modalità di errore complesse e non determinismo).
 
Nel mezzo dello spettro, troviamo i sistemi distribuiti soft real-time. Si tratta di sistemi critici che devono continuamente produrre o aggiornare i risultati, ma che dispongono di una finestra temporale relativamente generosa entro cui farlo. Esempi di questi sistemi includono alcuni costruttori di indici di ricerca, sistemi che cercano server con problemi, ruoli per Amazon Elastic Compute Cloud (Amazon EC2) e così via. Un'indicizzatore di ricerca potrebbe essere offline (a seconda dell'applicazione) da 10 minuti a molte ore senza indebito impatto sul cliente. I ruoli per Amazon EC2 devono trasferire le credenziali aggiornate (essenzialmente) ad ogni istanza EC2, ma devono disporre di ore per farlo poiché le vecchie credenziali non scadono per un certo lasso di tempo.
 
All'estrema e più difficile estremità dello spettro, abbiamo sistemi distribuiti hard real-time. Spesso sono chiamati servizi di richiesta/risposta. In Amazon, quando pensiamo alla costruzione di un sistema distribuito, il sistema hard real-time è il primo tipo a cui pensiamo. Purtroppo, i sistemi distribuiti hard real-time sono i più difficili da gestire bene. Ne determinano la difficoltà l'arrivo imprevedibile delle richieste e la necessità di dover fornire le risposte rapidamente (ad esempio, il cliente attende attivamente la risposta). Gli esempi includono server Web front-end, la pipeline degli ordini, le transazioni con carta di credito, le singole API AWS, la telefonia e così via. I sistemi distribuiti hard real-time sono il principale focus di questo articolo.

I sistemi hard real-time sono strani

In una trama dei fumetti di Superman, Superman incontra un alter ego di nome Bizarro che vive su un pianeta (Mondo Bizarro) dove tutto è arretrato. Bizarro assomiglia un po' a Superman, ma in realtà è cattivo. I sistemi distribuiti hard real-time sono uguali. Assomigliano un po' a un normale computer, ma in realtà sono diversi e, francamente, parteggiano un po' dalla parte dei cattivi.

Lo sviluppo di sistemi distribuiti hard real-time è bizzarro per un motivo: la rete di richiesta/risposta. Non intendiamo i dettagli nitidi di TCP/IP, DNS, socket o altri protocolli simili. Tali argomenti sono potenzialmente difficili da comprendere, ma assomigliano ad altri problemi difficili in ambito informatico.

Ciò che rende difficili i sistemi distribuiti hard real-time è che la rete consente l'invio di messaggi da un dominio di errore all'altro. L'invio di un messaggio potrebbe sembrare innocuo. In effetti, l'invio di messaggi è dove tutto inizia a diventare più complicato del normale.

Per fare un esempio semplice, basta guardare il seguente frammento di codice da un'implementazione di Pac-Man. Destinato a funzionare su un singolo computer, non invia alcun messaggio su nessuna rete.

board.move(pacman, user.joystickDirection())
ghosts = board.findAll(":ghost")
for (ghost in ghosts)
  if board.overlaps(pacman, ghost)
    user.slayBy(":ghost")
    board.remove(pacman)
    return

Ora, immaginiamo di sviluppare una versione in rete di questo codice, in cui lo stato dell'oggetto board è mantenuto su un server separato. Ogni chiamata all'oggetto board, come findAll(), comporta l'invio e la ricezione di messaggi tra due server.

Ogni volta che viene inviato un messaggio di richiesta/risposta tra due server, deve sempre verificarsi la stessa serie di otto passaggi. Per comprendere il codice Pac-Man in rete, esaminiamo le basi della messaggistica di richiesta/risposta. 

Messaggistica di richiesta/risposta attraverso una rete

Un'azione di andata/ritorno di richiesta/risposta comporta sempre gli stessi passaggi. Come mostrato nel diagramma seguente, il CLIENT del computer client invia una richiesta MESSAGGIO sulla rete RETE al server SERVER del computer, che risponde con il messaggio RISPOSTA, anche sulla rete RETE.

Nel caso felice in cui tutto funziona, si verificano i seguenti passaggi:

1. INVIA RICHIESTA: IL CLIENT inserisce il MESSAGGIO della richiesta su RETE.
2. RICHIESTA DI CONSEGNA: la RETE consegna il EMSSAGGIO al SERVER.
3. CONVALIDA RICHIESTA: il SERVER convalida il MESSAGGIO.
4. AGGIORNA STATO SERVER: il SERVER aggiorna il proprio stato, se necessario in base al MESSAGGIO.
5. INVIA RISPOSTA: il SERVER invia la risposta RISPOSTA alla RETE.
6. CONSEGNA RISPOSTA: la RETE consegna la RISPOSTA al CLIENT.
7. CONVALIDA RIPOSTA: il CLIENT convalida la RISPOSTA.
8. AGGIORNA STATO DEL CLIENT: il CLIENT aggiorna il proprio stato, se necessario, in base alla RISPOSTA.

Questi sono molti passaggi per un misero viaggio di andata e ritorno! Inoltre, questi passaggi sono la definizione della comunicazione di richiesta/risposta attraverso una rete; non c'è modo di saltarne nessuno. Ad esempio, è impossibile saltare il passaggio 1. Il client deve inviare il MESSAGGIO sulla rete RETE in qualche modo. Fisicamente, questo significa inviare pacchetti tramite un adattatore di rete, che fa sì che i segnali elettrici passino attraverso i fili attraverso una serie di router che comprendono la rete tra CLIENT e SERVER. Tutto ciò è separato dal passaggio 2 perché il passaggio 2 potrebbe non riuscire per motivi indipendenti, tra cui una perdita improvvisa di energia da parte del SERVER che non consente di accettare i pacchetti in arrivo. La stessa logica può essere applicata ai passaggi rimanenti.

Pertanto, una singola richiesta/risposta sulla rete esplode una singola cosa (richiamando un metodo) in otto cose. Peggio ancora, come menzionato in precedenza, CLIENT, SERVER e RETE possono entrare in errore in maniera indipendente gli uni dagli altri. In caso di errore, il codice degli ingegneri deve gestire ciascuno dei passaggi descritti in precedenza. Questo è raramente vero per l'ingegneria tipica. Per comprendere il perché, esaminiamo la seguente espressione dalla versione del codice a singolo computer.

board.find("pacman")

Tecnicamente, ci sono alcuni strani modi in cui questo codice potrebbe fallire in fase di esecuzione, anche se l'implementazione di board.find è di per sé priva di bug. Ad esempio, la CPU potrebbe surriscaldarsi spontaneamente in fase di esecuzione. L'alimentazione del computer potrebbe interrompersi, anche spontaneamente. Il kernel potrebbe andare nel panico. La memoria potrebbe riempirsi e alcuni oggetti che board.find tenta di creare potrebbero non essere creati. Oppure, il disco sul computer su cui è in esecuzione potrebbe riempirsi e board.find potrebbe non riuscire ad aggiornare alcuni file delle statistiche e quindi restituire un errore, anche se probabilmente non dovrebbe. Un raggio gamma potrebbe colpire il server e stravolgere qualcosa nella RAM. Ma, il più delle volte, gli ingegneri non si preoccupano di queste cose. Ad esempio, i test unitari non prevedono mai lo scenario "Cosa succede se la CPU non funziona" e prevedono solo raramente scenari di memoria insufficiente.

Nell'ingegneria tipica, questi tipi di errore si verificano su un singola computer; vale a dire un singolo dominio di errore. Ad esempio, se il metodo board.find entra in errore perché la CPU si blocca spontaneamente, è lecito ritenere che l'intero computer sia inattivo. Non è nemmeno concettualmente possibile gestire questo errore. È possibile avanzare le stese ipotesi sugli altri tipi di errore elencati in precedenza. Si potrebbe provare a scrivere dei test per alcuni di questi casi, ma non ha molto senso per l'ingegneria tipica. Se si verificano questi errori, è lecito ritenere che anche tutto il resto sia in errore. Tecnicamente, diciamo che condividono tutti lo stesso destino. La condivisione del destino riduce immensamente le diverse modalità di errore che un ingegnere deve gestire.

Gestione delle modalità di errore in sistemi distribuiti hard real-time

Gli ingegneri che lavorano su sistemi distribuiti hard real-time devono verificare tutti gli aspetti dell'errore di rete poiché i server e la rete non condividono lo stesso destino. A differenza del caso del singolo computer, se la rete non funziona, il computer client continuerà a funzionare. Se il computer remoto non funziona, il computer client continuerà a funzionare e così via.

Per testare in modo esauriente i casi di errore delle fasi di richiesta/risposta descritte in precedenza, gli ingegneri devono presumere che ogni fase possa entrare in errore. Inoltre, devono garantire che il codice (sia sul client che sul server) si comporti sempre correttamente alla luce di tali errori.
Esaminiamo un'azione di richiesta/risposta di andata/ritorno in cui le cose non funzionano:

1. INVIA RICHIESTA entra in errore: la RETE non è riuscita a recapitare il messaggio (ad esempio, il router intermedio si è arrestato in modo anomalo nel momento sbagliato) oppure il SERVER lo ha rifiutato esplicitamente.
2. CONSEGNA RICHIESTA entra in errore: la RETE consegna correttamente il MESSAGGIO al SERVER, ma il SERVER si arresta in modo anomalo subito dopo aver ricevuto il MESSAGGIO.
3. CONVALIDA RICHIESTA entra in errore: il SERVER decide che il MESSAGGIO non è valido. La causa potrebbe essere dovuta a qualsiasi cosa. Ad esempio, pacchetti danneggiati, versioni di software incompatibili o bug sul client o sul server.
4. AGGIORNA STATO DEL SERVER entra in errore: il SERVER tenta di effettuare l'aggiornamento senza tuttavia riuscirci.
5. INVIA RISPOSTA entra in errore: indipendentemente dal fatto che stia cercando di rispondere con esito positivo o negativo, il SERVER potrebbe non riuscire a inviare la risposta. Ad esempio, la sua scheda di rete potrebbe arrestarsi in modo anomalo proprio nel momento sbagliato.
6. CONSEGNA RISPOSTA entra in errore: la RETE potrebbe non riuscire a consegnare la RISPOSTA al CLIENT come indicato in precedenza, anche se la RETE funzionava in una fase precedente.
7. CONVALIDA RISPOSTA entra in errore: il CLIENT decide che la RISPOSTA non è valida.
8. AGGIORNA STATO DEL CLIENT entra in errore: Il CLIENT potrebbe ricevere il messaggio RISPOSTA ma non riusce ad aggiornare il proprio stato, non riusce a capire il messaggio (a causa di incompatibilità) o non riesce a completare l'azione per qualche altro motivo.

Queste modalità di errore sono ciò che rende così difficile il calcolo distribuito. È ciò che chiamo le otto modalità di errore dell'apocalisse. Alla luce di queste modalità di errore, rivediamo nuovamente questa espressione dal codice Pac-Man.

board.find("pacman")

Questa espressione si espande nelle seguenti attività sul lato client:

1. Invia un messaggio, come {action: "find", name: "pacman", userId: "8765309"}, sulla rete, indirizzato al computer Board.
2. Se la rete non è disponibile o la connessione al computer Board viene esplicitamente rifiutata, si genera un errore. Questo caso è in qualche modo speciale perché il client sa, deterministicamente, che la richiesta non avrebbe potuto essere ricevuta dal computer server.
3. Attendere una risposta.
4. Se non si riceve alcuna risposta, abbiamo un timeout. In questo passaggio, il timeout indica che il risultato della richiesta è SCONOSCIUTO. Potrebbe essere successo o meno. Il client deve gestire correttamente l'errore SCONOSCIUTO.
5. Se si riceve una risposta, determinare se si tratta di una risposta andata a buon fine, di una risposta di errore o di una risposta incomprensibile/corrotta.
6. Se non si tratta di un errore, decomprimere la risposta e trasformarla in un oggetto comprensibile dal codice.
7. Se si tratta di un errore o di una risposta incomprensibile, sollevare un'eccezione.
8. Qualunque cosa gestisca l'eccezione deve determinare se deve ritentare la richiesta o rinunciare e interrompere il gioco.

L'espressione avvia anche le seguenti attività sul lato server:

1. Riceve la richiesta (ciò potrebbe anche non accadere).
2. Convalida la richiesta.
3. Cerca l'utente per vedere se l'utente è ancora attivo. (Il server potrebbe aver rinunciato a contattare l'utente perché non ha ricevuto alcun messaggio da troppo tempo).
4. Aggiorna la tabella keep-alive per l'utente in modo che il server sappia che si trova (probabilmente) ancora lì.
5. Cerca la posizione dell'utente.
6. Pubblica una risposta contenente qualcosa come {xPos: 23, yPos: 92, clock: 23481984134}.
7. Qualsiasi ulteriore logica del server deve gestire correttamente gli effetti futuri del client. Ad esempio, non può ricevere il messaggio, riceve il messaggio ma non riesce a capirlo, riceve il messaggio e lo arresta in modo anomalo o lo gestisce correttamente.

In breve, una singola espressione nel codice normale si trasforma in quindici passaggi aggiuntivi nel codice di sistemi distribuiti hard real-time. Questa espansione è dovuta agli otto diversi punti in cui ogni comunicazione di andata/ritorno tra client e server può non riuscire. Qualsiasi espressione che rappresenti un ciclo di andata e ritorno sulla rete, come board.find("pacman"), produce quanto segue.

(error, reply) = network.send(remote, actionData)
switch error
  case POST_FAILED:
    // handle case where you know server didn't get it
  case RETRYABLE:
    // handle case where server got it but reported transient failure
  case FATAL:
    // handle case where server got it and definitely doesn't like it
  case UNKNOWN: // i.e., time out
    // handle case where the *only* thing you know is that the server received
    // the message; it may have been trying to report SUCCESS, FATAL, or RETRYABLE
  case SUCCESS:
    if validate(reply)
      // do something with reply object
    else
      // handle case where reply is corrupt/incompatible

Questa complessità è inevitabile. Se il codice non gestisce correttamente tutti i casi, il servizio alla fine entrerà in errore in modi bizzarri. Immagina di provare a scrivere test per tutte le modalità di errore in cui potrebbe incorrere un sistema client/server come l'esempio Pac-Man!

Testare i sistemi distribuiti hard real-time

Il test della versione per singolo computer dello snippet del codice Pac-Man è relativamente semplice. Crea alcuni oggetti Board diversi, mettili in stati diversi, crea alcuni oggetti User in stati diversi e così via. Gli ingegneri penserebbero in maniera più intensa alle condizioni limite e potrebbero usare test generativi o un fuzzer.

Nel codice Pac-Man, ci sono quattro punti in cui viene utilizzato l'oggetto Board. Nel Pac-Man distribuito, ci sono quattro punti in quel codice che hanno cinque diversi risultati possibili, come illustrato in precedenza (POST_FAILED (invio non riuscito), RETRYABLE (errore non irreversibile), FATAL (errore fatale), UNKNOWN (errore sconosciuto) o SUCCESS (azione completata)). Questi moltiplicano enormemente lo spazio degli stati dei test. Ad esempio, gli ingegneri di sistemi distribuiti hard real-time devono gestire numerose permutazioni. Supponiamo che la chiamata a board.find() non vada a buon fine e produca un errore POST_FAILED. Quindi, è necessario testare cosa succede quando non va a buon fine producendo un errore RETRYABLE, quindi testare cosa succede se non va a buon fine producendo un errore FATAL e così via.

Ma anche quel test è insufficiente. Nel codice tipico, gli ingegneri possono supporre che se board.find() funziona, funzionerà anche la prossima chiamata a board, board.move(). Nell'ingegneria dei sistemi distribuiti hard real-time, non esiste tale garanzia. Il computer server potrebbe non funzionare in modo indipendente in qualsiasi momento. Di conseguenza, gli ingegneri devono scrivere test per tutti e cinque i casi per ogni chiamata rivolta alla scheda. Supponiamo che un ingegnere abbia inventato 10 scenari da testare nella versione per singolo computer di Pac-Man. Ma, nella versione dei sistemi distribuiti, deve testare ciascuno di questi scenari 20 volte. Ciò significa che la matrice di test passa da 10 a 200!

Ma, non è tutto. L'ingegnere può anche possedere il codice del server. Qualunque combinazione di errori del client, di rete e lato server si verifichi, deve eseguire dei test in modo che il client e il server non finiscano in uno stato danneggiato. Il codice del server potrebbe essere simile al seguente.

handleFind(channel, message)
  if !validate(message)
    channel.send(INVALID_MESSAGE)
    return
  if !userThrottle.ok(message.user())
    channel.send(RETRYABLE_ERROR)
    return
  location = database.lookup(message.user())
  if location.error()
    channel.send(USER_NOT_FOUND)
    return
  else
    channel.send(SUCCESS, location)

handleMove(...)
  ...

handleFindAll(...)
  ...

handleRemove(...)
  ...

Ci sono quattro funzioni lato server da testare. Supponiamo che ogni funzione, su un singolo computer, abbia cinque test ciascuno. Ecco 20 test proprio lì. Poiché i client inviano più messaggi allo stesso server, i test dovrebbero simulare sequenze di richieste diverse per assicurarsi che il server rimanga solido. Esempi di richieste includono find (trova), move (sposta), remove (rimuovi) e findAll (trova tutti).

Supponiamo che un costrutto abbia 10 scenari diversi con una media di tre chiamate in ogni scenario. Ecco altri 30 test. Ma uno scenario deve anche testare i casi di errore. Per ciascuno di questi test, è necessario simulare cosa succede se il client ha ricevuto uno dei quattro tipi di errore (POST_FAILED (invio non riuscito), RETRYABLE (errore non irreversibile), FATAL (errore fatale) e UNKNOWN (errore sconosciuto)) e quindi chiama nuovamente il server con una richiesta non valida. Ad esempio, un client potrebbe chiamare con successo find, ma a volte potrebbe tornare indietro UNKNOWN quando chiama move. Potrebbe quindi chiamare di nuovo find per qualche motivo. Il server gestisce correttamente questo caso? Probabilmente, ma non sarà possibile saperlo a meno che non lo si provi. Così, come con il codice lato client, anche la matrice di test sul lato server esplode in complessità.

Gestione di incognite sconosciute

È sbalorditivo considerare tutte le permutazioni di errori che un sistema distribuito può incontrare, specialmente su più richieste. Un modo che abbiamo scoperto per avvicinarci all'ingegneria distribuita è diffidare di tutto. Ogni riga di codice, a meno che non possa dare luogo alla comunicazione di rete, potrebbe non fare ciò che dovrebbe.

Forse la cosa più difficile da gestire è il tipo di errore SCONOSCIUTO delineato nella sezione precedente. Il cliente non sa sempre se la richiesta è andata a buon fine. Forse ha spostato Pac-Man (o, in un servizio bancario, ha prelevato denaro dal conto bancario dell'utente), o forse no. In che modo gli ingegneri dovrebbero gestire queste cose? È difficile perché gli ingegneri sono umani e gli umani tendono a lottare con la vera incertezza. Gli umani sono abituati a guardare al codice come vedremo più avanti.

bool isEven(number)
  switch number % 2
    case 0
      return true
    case 1
      return false

Gli umani comprendono questo codice perché fa quello che sembra fare. Gli umani lottano con la versione distribuita del codice, che distribuisce parte del lavoro a un servizio.

bool distributedIsEven(number)
  switch mathServer.mod(number, 2)
    case 0
      return true
    case 1
      return false
    case UNKNOWN
      return WHAT_THE_FARG?

È quasi impossibile per un essere umano capire come gestire correttamente un errore SCONOSCIUTO. Cosa significa realmente SCONOSCIUTO? Il codice dovrebbe riprovare? Se sì, quante volte? Quanto dovrebbe aspettare tra i tentativi? Peggio ancora quando il codice innesca effetti collaterali. All'interno di un'applicazione di budget in esecuzione su un singolo computer, prelevare denaro da un conto è semplice, come mostrato nell'esempio seguente.

class Teller
  bool doWithdraw(account, amount)
    switch account.withdraw(amount)
      case SUCCESS
        return true
      case INSUFFICIENT_FUNDS
        return false

Tuttavia, la versione distribuita di tale applicazione è strana a causa di un errore SCONOSCIUTO.

class DistributedTeller
  bool doWithdraw(account, amount)
    switch this.accountService.withdraw(account, amount)
      case SUCCESS
        return true
      case INSUFFICIENT_FUNDS
        return false
      case UNKNOWN
        return WHAT_THE_FARG?

Capire come gestire il tipo di errore SCONOSCIUTO è uno dei motivi per cui, nell'ingegneria distribuita, le cose non sono sempre come sembrano.

Branchi di sistemi distribuiti hard real-time

Le otto modalità di errore dell'apocalisse possono verificarsi a qualsiasi livello di astrazione all'interno di un sistema distribuito. L'esempio precedente era limitato a un singolo computer client, una rete e un singolo server. Anche in quello scenario semplicistico, la matrice dello stato di errore è esplosa in complessità. I sistemi distribuiti reali sono dotati di matrici dello stato di errore più complicate rispetto all'esempio del singolo computer client. I sistemi distribuiti reali sono costituiti da più computer che possono essere visualizzati a più livelli di astrazione:

1. Computer individuali
2. Gruppi di computer
3. Gruppi di gruppi di computer
4. E così via (potenzialmente)

Ad esempio, un servizio basato su AWS potrebbe raggruppare computer dedicati alla gestione delle risorse che si trovano all'interno di una particolare zona di disponibilità. Potrebbero esserci anche altri due gruppi di computer che gestiscono altre due zone di disponibilità. Quindi, tali gruppi potrebbero essere raggruppati in un gruppo di regioni AWS. E quel gruppo di regioni potrebbe comunicare (a livello logico) con altri gruppi di regioni. Purtroppo, anche a questo livello più alto e più logico, si applicano esattamente tutti gli stessi problemi.

Supponiamo che un servizio abbia raggruppato alcuni server in un unico gruppo logico, GRUPPO1. Il gruppo GRUPPO1 potrebbe talvolta inviare messaggi a un altro gruppo di server, GRUPPO2. Questo è un esempio di ingegneria distribuita ricorsiva. Qui possono essere applicate tutte le stesse modalità di errore di rete descritte in precedenza. Supponiamo che il GRUPPO1 desideri inviare una richiesta al GRUPPO2. Come mostrato nel diagramma seguente, l'interazione richiesta/risposta a due computer è simile a quella del singolo computer discussa in precedenza.

In un modo o nell'altro, alcuni computer all'interno del GRUPPO1 devono inserire un messaggio sulla rete, RETE, indirizzato (a livello logico) al GRUPPO2. Alcuni computer all'interno del GRUPPO2 devono elaborare la richiesta e così via. Il fatto che GRUPPO1 e GRUPPO2 siano costituiti da gruppi di computer non modifica i principi fondamentali. GRUPPO1, GRUPPO2 e RETE possono continuare ad entrare in errore in maniera indipendente gli uni dagli altri.

Tuttavia, questa è solo una vista a livello di gruppo. Esiste anche un'interazione a livello computer-computer all'interno di ciascun gruppo. Ad esempio, il GRUPPO2 potrebbe essere strutturato come mostrato nel diagramma seguente.

Inizialmente, viene inviato un messaggio al GRUPPO2, tramite il sistema di bilanciamento del carico, a un computer (possibilmente S20) all'interno del gruppo. I progettisti del sistema sanno che S20 potrebbe entrare in errore durante la fase di AGGIORNA STATO. Pertanto, potrebbe essere necessario che S20 passi il messaggio ad almeno un altro computer, che sia uno dei suoi pari o un computer in un gruppo diverso. Come fa effettivamente S20 a farlo? Inviando un messaggio di richiesta/risposta a, diciamo, S25, come mostrato nel diagramma seguente.

Pertanto, S20 sta eseguendo reti in modo ricorsivo. Tutti e otto gli stessi errori possono verificarsi di nuovo, indipendentemente. L'ingegneria distribuita sta avvenendo due volte, anziché una volta. Il messaggio dal GRUPPO1 al GRUPPO2, a livello logico, può entrare in errore in tutti e otto i modi. Quel messaggio si traduce in un altro messaggio, che può entrare in errore esso stesso, indipendentemente, in tutti gli otto modi discussi in precedenza. Testare questo scenario comporterebbe almeno quanto segue:

• Un test per tutti gli otto modi in cui la messaggistica a livello di gruppo dal GRUPPO1 al GRUPPO2 può entrare in errore.
• Un test per tutti gli otto modi in cui la messaggistica a livello di server da S20 a S25 può entrare in errore.

Questo esempio di messaggistica di richiesta/risposta mostra perché testare i sistemi distribuiti rimane un problema particolarmente annoso, anche dopo aver maturato oltre 20 anni di esperienza. Il test è impegnativo data la vastità dei casi limite, ma è particolarmente importante in questi sistemi. Una volta implementati i sistemi, i bug possono impiegare molto tempo prima di esplodere. Inoltre, i bug possono avere un impatto imprevedibilmente ampio su un sistema e sui suoi sistemi adiacenti.

I bug distribuiti sono spesso latenti

Se alla fine si verificherà un errore, la saggezza comune insegna che è meglio che accada prima piuttosto che dopo. Ad esempio, è meglio scoprire un problema di ridimensionamento in un servizio, che richiederà sei mesi per essere risolto, almeno sei mesi prima che quel servizio raggiunga tale scala. Allo stesso modo, è meglio trovare i bug prima che colpiscano la produzione. Se i bug colpiscono la produzione, è meglio trovarli rapidamente, prima che colpiscano molti clienti o abbiano altri effetti collaterali.

I bug distribuiti, ovvero quelli risultanti dalla mancata gestione di tutte le permutazioni delle otto modalità di errore dell'apocalisse, sono spesso gravi. Esempi occorsi nel tempo abbondano nei grandi sistemi distribuiti, dai sistemi di telecomunicazione ai principali sistemi Internet. Queste interruzioni non solo sono diffuse e costose, ma possono essere causate da bug che sono stati distribuiti nella produzione mesi prima. Ci vuole quindi un po' di tempo per innescare la combinazione di scenari che portano effettivamente a questi bug (e nel frattempo si diffondono in tutto il sistema).

I bug distribuiti si diffondono epidemicamente

Vorrei descrivere un altro problema fondamentale per i bug distribuiti:

1. I bug distribuiti implicano necessariamente l'uso della rete.
2. Pertanto, è più probabile che i bug distribuiti si diffondano ad altri computer (o gruppi di computer), perché, per definizione, coinvolgono già l'unica cosa che collega i computer.

Anche Amazon ha riscontrato questi bug distribuiti. Un vecchio esempio, ma pertinente, è un errore a livello di sito di www.amazon.com. L'errore è stato causato dall'errore di un singolo server all'interno del servizio di catalogo remoto durante il riempimento del disco.

A causa della cattiva gestione di tale condizione di errore, il server di catalogo remoto ha iniziato a restituire risposte vuote a ogni richiesta ricevuta. Ha inoltre iniziato a restituirle molto rapidamente, perché è molto più veloce non restituire nulla che qualcosa (almeno così è stato in questo caso). Nel frattempo, il sistema di bilanciamento del carico tra il sito Web e il servizio di catalogo remoto non ha notato che tutte le risposte avevano una lunghezza pari a zero. Tuttavia, ha notato che erano incredibilmente più veloci di tutti gli altri server di catalogo remoti. Quindi, ha inviato un'enorme quantità di traffico da www.amazon.com al server di catalogo remoto il cui disco era pieno. In effetti, l'intero sito Web è andato in crash perché un server remoto non è stato in grado di visualizzare alcuna informazione sul prodotto.

Abbiamo trovato rapidamente il server danneggiato e lo abbiamo rimosso dal servizio per ripristinare il sito Web. Quindi, abbiamo seguito la nostra consueta procedura di determinazione delle cause principali e di identificazione dei problemi per evitare che la situazione si ripetesse. Abbiamo condiviso quelle lezioni su Amazon per aiutare ad impedire che altri sistemi incorressero nello stesso problema. Oltre ad apprendere le lezioni specifiche su questa modalità di errore, questo incidente è stato un ottimo esempio di come le modalità di errore si propagano rapidamente e imprevedibilmente nei sistemi distribuiti.

Riepilogo dei problemi nei sistemi distribuiti

In breve, la progettazione di sistemi distribuiti è difficile perché:

• Gli ingegneri non possono combinare condizioni di errore. Al contrario, devono prendere in considerazione numerose permutazioni di errori. La maggior parte degli errori può verificarsi in qualsiasi momento, indipendentemente da (e quindi, potenzialmente, in combinazione con) qualsiasi altra condizione di errore.
• Il risultato di qualsiasi operazione di rete può essere SCONOSCIUTO, nel qual caso la richiesta potrebbe essere stata completata correttamente, non essere andata a buon fine o essere ricevuta ma non elaborata.
• I problemi distribuiti si verificano a tutti i livelli logici di un sistema distribuito, non soltanto a livello di computer fisici di basso livello.
• I problemi distribuiti peggiorano ai livelli più alti del sistema, a causa della ricorsione.
• I bug distribuiti spesso compaiono molto tempo dopo essere stati distribuiti su un sistema.
• I bug distribuiti possono diffondersi su un intero sistema.
• Molti dei suddetti problemi derivano dalle leggi della fisica delle reti, che non possono essere modificate.

Solo perché il calcolo distribuito è difficile, e strano, non significa che non ci siano modi per affrontare questi problemi. Attraverso la Builders' Library di Amazon, analizziamo come AWS gestisce i sistemi distribuiti. Speriamo che troverai ciò che abbiamo appreso prezioso mentre costruisci per i tuoi clienti.


Informazioni sull'autore

Jacob Gabrielson è Senior Principal Engineer presso Amazon Web Services. Lavora presso Amazon da 17 anni, principalmente sulle piattaforme di microservizi interne. Negli ultimi 8 anni ha lavorato su EC2 ed ECS, compresi i sistemi di distribuzione del software, i servizi di pannello di controllo, il mercato di istanze Spot, Lightsail e, più recentemente, i container. Le passioni di Jacob sono la programmazione di sistemi, i linguaggi di programmazione e il calcolo distribuito. La sua antipatia più grande riguarda il comportamento dei sistemi bimodali, in particolare in condizioni di errore. Ha conseguito una laurea in Scienze informatiche presso L'Università di Washington a Seattle.

Timeout, nuovi tentativi e backoff con jitter Evitare il fallback nei sistemi distribuiti