Desideri essere informato quando sono disponibili nuovi contenuti?
Gli errori si verificano
Ogni volta che un servizio o un sistema chiama un altro, possono verificarsi degli errori. Questi errori possono provenire da una serie di fattori. Essi includono server, reti, sistemi di bilanciamento del carico, software, sistemi operativi o anche errori da operatori di sistema. Progettiamo i nostri sistemi per ridurre la probabilità di errore ma è impossibile creare sistemi che non falliscono mai. Quindi da Amazon, progettiamo i nostri sistemi in modo da tollerare e ridurre la probabilità di errore ed evitare di ampliare una piccola percentuale di errori in un blackout completo. Per costruire sistemi resilienti, impieghiamo tre strumenti fondamentali: il timeout, i nuovi tentativi ed il backoff.
Molti tipi di errori diventano evidenti come richieste che richiedono più tempo del solito e che potrebbero non essere completate mai. Quando un client aspetta più a lungo del solito perché una richiesta sia completata, trattiene per più tempo anche le risorse che stava usando per quella richiesta. Quando un numero di richieste trattiene le risorse per un lungo tempo, il server può esaurire quelle risorse. Queste risorse possono includere memoria, thread, connessioni, porte effimere o qualunque altra cosa sia limitata. Per evitare questa situazione, i client impostano dei timeout. I timeout sono la quantità massima di tempo che un client aspetta per il completamento di una richiesta.
Spesso, ritentare la stessa richiesta di nuovo porta al successo della richiesta. Ciò succede perché i tipi di sistemi che costruiamo non falliscono spesso come singola unità. Subiscono, invece, dei fallimenti parziali o transitori. Un fallimento parziale accade quando una percentuale delle richieste ha successo. Un fallimento transitorio accade quando una richiesta fallisce per un breve periodo di tempo. I tentativi permettono ai client di sopravvivere a questi fallimenti parziali casuali e fallimenti transitori di breve durata inviando la stessa richiesta di nuovo.
Non è sempre sicuro effettuare un nuovo tentativo. Se il sistema sta già fallendo, perché si sta avvicinando ad un sovraccarico, un tentativo può aumentare il carico sul sistema chiamato. Per evitare questo problema, applichiamo ai nostri client l'utilizzo di backoff. Questo aumenta il tempo tra tentativi successivi, il che mantiene costante il carico sul backend. L'altro problema con i tentativi è che alcune chiamate remote hanno effetti collaterali. Un timeout o un fallimento non significa necessariamente che non ci siano stati effetti collaterali. Se fare gli effetti collaterali più volte non è desiderabile, una best practice è progettare le API in modo che siano idempotenti, ossia che possano essere ritentate in modo sicuro.
Infine, il traffico non arriva ad un ritmo costante nei servizi Amazon. Il tasso di arrivo delle richieste, invece, presenta spesso delle grandi esplosioni. Queste esplosioni possono essere causate dal comportamento del client, dal recupero del fallimento e persino da qualcosa di semplice come un cron job periodico. Se gli errori sono causati dal carico, i tentativi possono essere inefficaci se tutti i client effettuano nuovi tentativi allo stesso tempo. Per evitare questo problema, impieghiamo il jitter. Si tratta di un periodo di tempo casuale prima di fare o ritentare una richiesta per aiutare a prevenire grandi esplosioni espandendo il tasso di arrivo.
Ognuna di queste soluzioni è discussa nelle sezioni che seguono.
Timeout
• Maggiore latenza di backend piccola che porta a un blackout completo perché tutte le richieste iniziano ad essere ritentate.
• Questo approccio non funziona inoltre con servizi che hanno limiti di latenza stretti, dove p99.9 è vicino a p50. In questi casi, aggiungere del riempimento ci aiuta ad evitare piccoli aumenti di latenza che causano un alto numero di timeout.
• Abbiamo incontrato un errore comune nell'applicazione dei timeout. SO_RCVTIMEO di Linux è potente, ma ha alcuni svantaggi che lo rendono inadeguato come una presa di timeout end-to-end. Alcuni linguaggi, come Java, espongono questo comando direttamente. Altri linguaggi, come Go, forniscono meccanismi di timeout più robusti.
• Ci sono anche applicazioni dove il timeout non copre tutte le chiamate remote, come le handshake DNS o TLS. In generale, preferiamo utilizzare i timeout integrati nei client ben testati. Se applichiamo i nostri propri timeout, facciamo molta attenzione all'esatto significato delle opzioni della presa di timeout e al lavoro che viene svolto.
Nuovi tentativi e backoff
I nuovi tentativi sono "egoisti." In altre parole, quando un client fa un nuovo tentativo, spende più tempo del server per avere una possibilità di successo più alta. Dove i fallimenti sono rari e temporanei, non rappresenta un problema. Questo perché il numero complessivo di nuove richieste è piccolo e l'alternanza di aumentare la disponibilità apparente funziona bene. Quando i fallimenti sono causati dal sovraccarico, i nuovi tentativi che aumentano il carico possono peggiorare le cose. Possono persino ritardare il ripristino mantenendo il carico alto tempo dopo che il problema originale è stato risolto. I nuovi tentativi sono simili ad una medicina potente - utile in giusta dose ma che può causare danni significativi quando viene usata troppo. Sfortunatamente, nei sistemi distribuiti non c'è quasi nessun modo di coordinare tutti i client per ottenere il giusto numero di nuovi tentativi.
La soluzione preferita che usiamo in Amazon è un backoff. Invece di effettuare un nuovo tentativo immediatamente ed aggressivamente, il client aspetta un certo periodo di tempo tra i tentativi. Il modello più comune è un backoff esponenziale, dove il tempo di attesa viene aumentato esponenzialmente dopo ogni tentativo. Il backoff esponenziale può portare a tempi di backoff molto lunghi, perché le funzioni esponenziali crescono rapidamente. Per evitare di ritentare per troppo tempo, le implementazioni limitano tipicamente il backoff ad un valore massimo. Viene chiamato, com'è prevedibile, backoff esponenziale massimale. Tuttavia, questo introduce un altro problema. Ora tutti i client effettuano nuovi tentativi costantemente al tasso massimale. In quasi tutti i casi, la nostra soluzione è limitare il numero di volte in cui un client effettua nuovi tentativi e gestire prima il conseguente fallimento nell'architettura orientata ai servizi. Nella maggioranza dei casi, il client rinuncerà alla chiamata in ogni caso perché ha i suoi propri timeout.
Ci sono altri problemi con i nuovi tentativi, descritti nel modo seguente:
• I sistemi distribuiti hanno spesso più livelli. Considera un sistema dove la chiamata del cliente causa un five-deep stack di chiamate di servizio. Si conclude con una query ad un database e tre nuovi tentativi ad ogni livello. Cosa succede quando un database inizia a fallire le query sotto carico? Se ogni livello effettua indipendentemente un nuovo tentativo, il carico sul database aumenta di 243 volte, rendendo improbabile una possibile ripresa. Questo perché i nuovi tentativi si moltiplicano a ogni livello - prima tre tentativi, poi nove tentativi e così via. Al contrario, effettuare tentativi al più alto livello dello stack potrebbe sprecare lavoro da chiamate precedenti, il che riduce l'efficienza. In generale, per operazioni a basso costo di piano dati e piano di controllo, la nostra best practice è di ritentare ad un punto singolo nello stack.
• Carico. Anche con un singolo livello di nuovi tentativi, il traffico aumenta ancora notevolmente quando gli errori iniziano. Gli interruttori, dove le chiamate ad un servizio a valle sono interrotte del tutto quando una soglia di errore viene superata, sono ampiamente promosse per risolvere questo problema. Sfortunatamente, gli interruttori introducono il comportamento modale nei sistemi che possono essere difficili da testare e possono introdurre un significativo tempo aggiunto per la ripresa. Abbiamo scoperto di poter mitigare questo rischio limitando i nuovi tentativi localmente utilizzando un token bucket. Questo permette a tutte le chiamate di effettuare tentativi fin quando ci sono token e quindi ritentare ad un tasso fisso quando i token sono esauriti. AWS ha aggiunto questo comportamento ad AWS SDK nel 2016. Quindi i clienti che usano SDK hanno questo comportamento di throttling integrato.
• Decidere quando effettuare nuovi tentativi. In generale, la nostra visione è che le API con effetti collaterali non sono sicure da ritentare a meno che forniscano idempotenza. Questo garantisce che gli effetti collaterali succedano solo una volta indipendentemente da quanto spesso ritenti. Le API di sola lettura sono tipicamente idempotenti, mentre le API di creazione delle risorse potrebbero non esserlo. Alcune API, come l'API RunInstances di Amazon Elastic Compute Cloud (Amazon EC2), forniscono meccanismi espliciti basati sul token per fornire idempotenza e renderle sicure per ritentare. Una buona cura e progettazione API, quando si applicano i client, è necessaria per prevenire effetti collaterali duplicati.
• Sapere quali fallimenti vale la pena ritentare. HTTP fornisce una chiara distinzione tra gli errori di client e server. Indica che gli errori del client non dovrebbero essere ritentati con la stessa richiesta perché non avranno successo in seguito, mentre gli errori del server potrebbero avere successo durante tentativi successivi. Sfortunatamente, la consistenza finale nei sistemi sfuma il confine notevolmente. Un errore client di un momento potrebbe cambiare in un successo il momento successivo al propagare dello stato.
Nonostante questi rischi e queste sfide, i nuovi tentativi sono un meccanismo potente per fornire alta disponibilità a dispetto di errori transitori e casuali. E' richiesto buon senso nel trovare il giusto compromesso per ogni servizio. Nella nostra esperienza, un buon punto di partenza è ricordare che i nuovi tentativi sono egoisti. I nuovi tentativi sono un modo per i client di sostenere l'importanza della propria richiesta e di richiedere che il servizio spenda più risorse per gestirla. Se un client è troppo egoista può creare un vasto numero di problemi.
Jitter
Quando gli errori sono causati da sovraccarico o conflitto, fare marcia indietro spesso non aiuta tanto quanto sembra doverlo fare. Questo a causa della correlazione. Se tutte le chiamate fallite fanno marcia indietro nello stesso momento, causano conflitto o sovraccarico di nuovo quando sono ritentate. La nostra soluzione è il jitter. Il jitter aggiunge una certa quantità di casualità alla marcia indietro per distribuire i tentativi nel tempo. Per ulteriori informazioni su quanto jitter aggiungere e sul migliore modo per farlo, consulta Backoff Esponenziale e Jitter.
Il jitter non è solo per nuovi tentativi. L'esperienza operativa ci ha insegnato che il traffico ai nostri servizi, inclusi sia piani di controllo che piani di dati, tendono ad avere dei picchi. Questi picchi di traffico possono essere molto corti e sono spesso nascosti da parametri aggregati. Quando costruiamo sistemi, consideriamo l'aggiunta di jitter a tutti i timer, attività periodiche e altre attività ritardate. Questo aiuta a distribuire i picchi di lavoro e rendono più facile per i servizi a valle la distribuzione per un carico di lavoro.
Quando aggiungiamo il jitter al lavoro programmato, non selezioniamo a caso il jitter su ogni host. Usiamo, invece, un metodo consistente che produce lo stesso numero ogni volta sullo stesso host. In questo modo, se c'è un servizio che viene sovraccaricato o una condizione di competizione, succede nello stesso modo secondo uno schema. Noi esseri umani siamo bravi a identificare modelli ed è più probabile che riusciamo a determinare la causa principale. Utilizzare un metodo a caso assicura che se una risorsa è sovraccarica, succede solo - a caso, appunto. Questo rende la risoluzione dei problemi molto più difficile.
Su sistemi su cui ho lavorato, come Amazon Elastic Block Store (Amazon EBS) e AWS Lambda, abbiamo scoperto che i client inviano spesso richieste a intervallo regolare, come una volta al minuto. Tuttavia, quando un client ha server multipli che si comportano allo stesso modo, possono allinearsi e innescare la loro richiesta nello stesso momento. Possono essere i primi secondi di un minuto o i primi secondi dopo mezzanotte per attività giornaliere. Facendo attenzione al carico per secondo e lavorando con client per ritardare i loro carichi di lavoro periodici, abbiamo ottenuto la stessa quantità di lavoro con meno capacità del server.
Abbiamo meno controllo sui picchi nel traffico del cliente. Tuttavia, anche per i compiti innescati dal cliente, è una buona idea aggiungere il jitter dove non ha impatto sull'esperienza del cliente.
Conclusione
In sistemi distribuiti, latenza o fallimenti transitori in interazioni remote sono inevitabili. I timeout mantengono i sistemi dal restare sospesi troppo a lungo, i nuovi tentativi possono mascherare quei fallimenti e il backoff ed il jitter possono migliorare l'utilizzo e ridurre la congestione sui sistemi.
Ad Amazon abbiamo imparato che è importante essere cauti riguardo ai nuovi tentativi. I nuovi tentativi possono ampliare il carico su un sistema dipendente. Se le chiamate a un sistema non rispondono e quel sistema è sovraccarico, i nuovi tentativi possono peggiorare il sovraccarico invece di migliorarlo. Evitiamo questa amplificazione ritentando soltanto quando osserviamo che la dipendenza è sana. Smettiamo di ritentare quando i nuovi tentativi non sono d'aiuto a migliorare la disponibilità.
Informazioni sull'autore
Marc Brooker è Senior Principal Engineer presso Amazon Web Services. Ha lavorato presso AWS dal 2008 su diversi servizi tra cui EC2, EBS e IoT. Oggi, si occupa di AWS Lambda e del lavoro su dimensionamento e virtualizzazione. Marc apprezza particolarmente leggere COE e post-mortem. Ha conseguito un dottorato di ricerca in ingegneria elettrica.