La maggior parte dell'affidabilità di un servizio si decide nel suo repository, in come ogni funzione si comporta quando una dipendenza è lenta, non nel cluster su cui gira. Un SRE deve quindi leggere il codice applicativo abbastanza bene da rivederlo e modificarlo: trovare la chiamata di rete senza timeout, il ciclo di retry che triplicherà il carico durante un'interruzione, l'health check che toglierà dal servizio tutte le istanze in una volta sola. Questo post è la lista di review, con le correzioni, e si chiude con l'unico pezzo di codice che l'SRE possiede per intero: alert che chiamano sul burn rate dell'error budget, scritti in Terraform.

Questa è la Parte 3 di Budget for Failure, e la prima di livello 201. La Parte 1 ha definito il ruolo e la Parte 2 gli ha dato dei numeri: uno SLO di disponibilità del 99,9 percento su 30 giorni, misurato a un Application Load Balancer. Quell'esempio continua qui. Gli esempi di codice sono in Python; i pattern sono gli stessi in qualsiasi linguaggio.

Quanto codice serve a un SRE

Non devi essere il miglior feature developer del team. Ti servono tre capacità. Leggere il percorso della richiesta: partendo dall'handler, seguire una richiesta utente attraverso ogni chiamata di rete che fa e dire cosa succede se ciascuna chiamata impiega trenta secondi. Rivedere una pull request per il comportamento operativo, che è una lettura diversa dal rivederla per la correttezza. E scrivere tu stesso modifiche di qualità da produzione: il timeout, la metrica, l'hook di rollback. Un SRE che apre un ticket chiedendo agli sviluppatori di aggiungere un timeout ha descritto il problema. Uno che manda la pull request l'ha risolto.

Ogni chiamata di rete ha bisogno di una scadenza

Il difetto di affidabilità più comune nel codice applicativo è una chiamata che può aspettare per sempre. La libreria requests di Python non ha alcun timeout di default. L'AWS SDK per Python ha come default 60 secondi per il connect e 60 secondi per il read, con retry automatici sopra. Un handler che chiama DynamoDB con quei default, dietro un load balancer il cui idle timeout è 60 secondi, può tenere occupato un worker per minuti dopo che l'utente ha già ricevuto un 504.

Quando una dipendenza rallenta, le chiamate senza scadenza trasformano una dipendenza lenta in un servizio morto: ogni worker è parcheggiato in attesa, il pool è esaurito, e falliscono anche le richieste che della dipendenza lenta non avevano bisogno. Imposta i limiti di proposito:

import boto3
from botocore.config import Config

sdk = Config(
    connect_timeout=1,
    read_timeout=2,
    retries={"mode": "standard", "max_attempts": 3},
)
ddb = boto3.client("dynamodb", config=sdk)

Scegli il read timeout dalla latenza misurata della dipendenza, poco sopra il suo 99,9° percentile, non da un numero tondo. Poi verifica che i limiti si annidino. Se il budget verso l'utente è di 3 secondi, l'handler non può fare tre chiamate sequenziali che possono impiegare 2 secondi ciascuna con 3 tentativi. La scadenza esterna va propagata verso il basso, in modo che le chiamate interne si arrendano quando non c'è più nessuno ad aspettare la risposta.

Retry: la correzione che causa interruzioni

Un retry trasforma un fallimento transitorio in un successo, e un fallimento persistente in uno più grande. Tre regole lo tengono dal lato giusto.

Fai retry a un solo livello. Se il client riprova tre volte, l'API riprova tre volte la chiamata al database e l'SDK riprova tre volte sotto, una singola azione utente diventa fino a 27 richieste contro una dipendenza che è già in difficoltà. Scegli il livello più vicino al guasto e fai fallire in fretta gli altri.

Fai backoff con jitter. I client che riprovano a intervalli fissi arrivano tutti insieme e buttano giù di nuovo il servizio che si sta riprendendo. L'analisi dell'AWS Architecture Blog su questo è il riferimento standard; la sua variante "full jitter" dorme un tempo casuale tra zero e un tetto che cresce esponenzialmente:

import random, time

def call_with_retry(fn, attempts=3, base=0.1, cap=2.0, deadline=None):
    for attempt in range(attempts):
        try:
            return fn()
        except RetryableError:
            if attempt == attempts - 1:
                raise
            pause = random.uniform(0, min(cap, base * 2 ** attempt))
            if deadline and time.monotonic() + pause > deadline:
                raise
            time.sleep(pause)

Riprova solo ciò che è sicuro ripetere. Una lettura andata in timeout è sicura. Un "addebita la carta" andato in timeout no, a meno che l'operazione non sia idempotente: il client manda una chiave univoca con la richiesta e il server memorizza il risultato sotto quella chiave, così una ripetizione restituisce il primo esito invece di addebitare due volte. In review, qualsiasi retry attorno a una scrittura è un invito a chiedere dov'è la chiave di idempotenza.

Fallire in piccolo: breaker, shedding, degradazione

Timeout e retry gestiscono una dipendenza che sta male per poco. Per una che è giù, il codice ha bisogno di un modo per smettere di provare e di una decisione su cosa vede l'utente nel frattempo.

  • Circuit breaker. Dopo una serie di fallimenti, smetti di chiamare la dipendenza per un breve intervallo e fallisci immediatamente. Protegge il tuo pool di worker e dà alla dipendenza spazio per riprendersi.
  • Load shedding. Quando il servizio è saturo, rifiuta presto l'eccesso con un 429 o un 503 invece di accettare tutto e servire tutto lentamente. Un rifiuto rapido ed economico del 20 percento delle richieste batte un timeout per il 100 percento.
  • Degradazione controllata. Decidi per ogni dipendenza se è hard o soft. Se il servizio di raccomandazioni è giù, la pagina prodotto dovrebbe renderizzare senza raccomandazioni, non restituire un 500. Ogni dipendenza convertita da hard a soft esce dalla moltiplicazione delle disponibilità, che la Parte 4 fa per intero.

Health check che dicono la verità

Un health check è codice, ed è spesso sbagliato in una di due direzioni. Un check superficiale che restituisce 200 ogni volta che il processo è vivo tiene in rotazione un'istanza rotta. Un check profondo che testa il database da ogni istanza ha un guasto peggiore: quando il database ha un blip, tutte le istanze falliscono il check nello stesso istante e il load balancer non ha più alcun target in salute. Un Application Load Balancer in quello stato va in fail open e instrada comunque verso tutti i target, il che ti salva, ma un orchestratore configurato per sostituire le istanze non in salute inizierà a uccidere una flotta sana durante un incidente del database.

La risposta consueta sono due endpoint. Liveness dice che il processo è in esecuzione e non va riavviato. Readiness dice che questa istanza può fare lavoro utile, e controlla solo ciò che le è locale: configurazione caricata, connection pool creato, cache calde. Le dipendenze condivise si monitorano attraverso lo SLI, non con health check per istanza.

Emettere lo SLI dal codice

Lo SLI al load balancer della Parte 2 vede gli status code. Non sa distinguere un checkout riuscito da un 200 che porta un messaggio di errore nel body, e non può separare per tier di cliente o per operazione. L'applicazione sì. Per ogni richiesta, scrivi una riga di log strutturata con l'operazione, l'esito come lo definisce il business, la durata e il trace ID. Su AWS, il CloudWatch embedded metric format trasforma quella stessa riga di log in metriche senza nessuna chiamata API in più:

{
  "_aws": {
    "Timestamp": 1787130000000,
    "CloudWatchMetrics": [{
      "Namespace": "Checkout",
      "Dimensions": [["Operation"]],
      "Metrics": [
        { "Name": "Requests", "Unit": "Count" },
        { "Name": "Failures", "Unit": "Count" },
        { "Name": "Duration", "Unit": "Milliseconds" }
      ]
    }]
  },
  "Operation": "PlaceOrder",
  "Requests": 1,
  "Failures": 0,
  "Duration": 184,
  "trace_id": "1-66c2f1d0-3f1b2c4d5e6f7a8b9c0d1e2f",
  "customer_tier": "enterprise"
}

Tieni i campi ad alta cardinalità come il trace ID e il cliente come proprietà del log, non come dimensioni della metrica; ogni valore di dimensione univoco è una metrica fatturata a parte. Questa è la strumentazione che appartiene al template di servizio della Parte 1, in modo che gli sviluppatori la ottengano senza chiederla.

Rendere il cambiamento la strada sicura

Il registro della Parte 2 mostrava un singolo deploy che consumava il 31 percento del budget di un mese. Le difese lato codice sono il lavoro a più alto rendimento di questa lista.

  • Rollout progressivo con rollback automatico. Manda alla nuova versione il 10 percento del traffico, confronta il suo tasso di errore e la sua latenza con la vecchia versione, e fai rollback senza un umano se è peggiore. CodeDeploy, i deployment ECS e gli alias Lambda possono tutti fermarsi e fare rollback su un allarme CloudWatch. Una release cattiva costa allora minuti del 10 percento del traffico.
  • Separare il deploy dal rilascio. Spedisci il codice spento dietro un feature flag e accendilo gradualmente. Spegnere un flag è più veloce di qualsiasi rollback.
  • Modifiche di schema retrocompatibili. Expand, migrate, contract: aggiungi la nuova colonna, scrivi su entrambe, sposta i lettori, poi rimuovi la vecchia in una release successiva. Una migrazione che richiede che codice vecchio e nuovo non girino mai fianco a fianco rende ogni rollout un'interruzione e ogni rollback impossibile.

Alert as code: chiamare sul burn rate, non sulla CPU

L'ultimo pezzo di codice che un SRE possiede è l'alerting stesso. L'insieme di allarmi di un tipico account AWS chiama sulle cause: CPU sopra l'80 percento, memoria sopra il 90, qualsiasi conteggio di 5xx sopra 10. È rumoroso dove non c'è nulla che non va (un autoscaling group al 95 percento di CPU che rispetta il suo obiettivo di latenza) e silenzioso dove qualcosa non va (un deploy che fa fallire il 2 percento dei checkout con ogni metrica host piatta). Lo SLO della Parte 2 offre una condizione migliore su cui chiamare: stiamo spendendo l'error budget abbastanza in fretta che, lasciato a sé, manchiamo l'obiettivo? Quella velocità è il burn rate.

burn rate = observed error ratio / (1 - SLO)

SLO 99.9%  ->  budget ratio 0.001
error ratio 0.1%   ->  burn rate 1      ->  budget lasts 30 days
error ratio 0.6%   ->  burn rate 6      ->  budget lasts 5 days
error ratio 1.44%  ->  burn rate 14.4   ->  budget lasts 50 hours
error ratio 100%   ->  burn rate 1000   ->  budget lasts 43 minutes

Un alert ha bisogno di una soglia e di una finestra, e le due insieme decidono quanto budget è già andato quando scatta:

budget consumed at alert = burn rate x alert window / SLO window

14.4 x 1 hour  / 720 hours =  2%
6    x 6 hours / 720 hours =  5%
1    x 3 days  / 30 days   = 10%

Quelle tre righe sono la raccomandazione del capitolo sull'alerting del Google SRE Workbook, e sono un default sensato per uno SLO a 30 giorni: chiama quando il 2 percento del budget se n'è andato in un'ora o il 5 percento in sei ore, e apri un ticket quando il 10 percento se n'è andato in tre giorni. I problemi rapidi e gravi ricevono una chiamata rapida. Le perdite lente, quelle che un allarme a soglia non cattura mai, ricevono un ticket per il giorno lavorativo successivo.

Perché due finestre

Una singola finestra lunga ha un problema di reset. Se gli errori sono andati al 15 percento per dieci minuti e poi si sono fermati, la media a un'ora resta sopra la soglia di 14,4x per gran parte dell'ora successiva. L'allarme continua a scattare verso un ingegnere che sta guardando un sistema sano.

La correzione è richiedere che una seconda finestra, breve, sia sopra la stessa soglia nello stesso momento. La linea guida del workbook è una finestra breve pari a un dodicesimo di quella lunga. La finestra lunga dice che la spesa è significativa; quella breve dice che sta ancora accadendo. Insieme:

AzioneFinestra lungaFinestra breveBurn rateTasso di errore al 99,9%Budget consumato
Chiamata1 ora5 minuti14,41,44%2%
Chiamata6 ore30 minuti60,6%5%
Ticket3 giorni6 ore10,1%10%

Il rilevamento è veloce dove deve esserlo. In un'interruzione totale la media a un'ora supera l'1,44 percento dopo circa 52 secondi, e la finestra a cinque minuti è già oltre. Quando l'interruzione finisce, la finestra a cinque minuti scende sotto la soglia nel giro di minuti e la chiamata si chiude, anche se l'ora è ancora inquinata.

Costruirlo in CloudWatch con Terraform

CloudWatch ha i due pezzi che servono: allarmi metric math, per allarmare su un tasso di errore invece che su un conteggio grezzo, e allarmi compositi, per mettere in AND la finestra lunga e quella breve. Un allarme metrico per finestra, con un singolo periodo di valutazione lungo quanto la finestra, e nessuna azione collegata:

locals {
  slo    = 0.999
  budget = 1 - local.slo
  lb     = "app/checkout/0123456789abcdef"

  windows = {
    fast_long  = { period = 3600,  burn = 14.4 }
    fast_short = { period = 300,   burn = 14.4 }
    slow_long  = { period = 21600, burn = 6 }
    slow_short = { period = 1800,  burn = 6 }
  }

  elb_metrics = {
    req  = "RequestCount"
    t5xx = "HTTPCode_Target_5XX_Count"
    e5xx = "HTTPCode_ELB_5XX_Count"
  }
}

resource "aws_cloudwatch_metric_alarm" "burn" {
  for_each = local.windows

  alarm_name          = "checkout-burn-${each.key}"
  alarm_description   = "Error ratio above ${each.value.burn}x burn over ${each.value.period}s"
  comparison_operator = "GreaterThanThreshold"
  threshold           = each.value.burn * local.budget
  evaluation_periods  = 1
  treat_missing_data  = "notBreaching"

  metric_query {
    id          = "ratio"
    expression  = "(FILL(t5xx, 0) + FILL(e5xx, 0)) / req"
    label       = "error ratio"
    return_data = true
  }

  dynamic "metric_query" {
    for_each = local.elb_metrics
    content {
      id = metric_query.key
      metric {
        namespace   = "AWS/ApplicationELB"
        metric_name = metric_query.value
        stat        = "Sum"
        period      = each.value.period
        dimensions  = { LoadBalancer = local.lb }
      }
    }
  }
}

La soglia è il burn rate moltiplicato per il rapporto di budget: 14,4 x 0,001 = 0,0144 per la coppia veloce, 0,006 per quella lenta. Cambia slo a 0.9995 e ogni soglia segue. Poi l'allarme composito, che è l'unica cosa che chiama:

resource "aws_cloudwatch_composite_alarm" "slo_page" {
  alarm_name        = "checkout-availability-slo-page"
  alarm_description = "Burning 30-day error budget at 14.4x (1h) or 6x (6h). Runbook: https://wiki.example.com/runbooks/checkout-slo"

  alarm_rule = join(" OR ", [
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["fast_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["fast_short"].alarm_name}))",
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["slow_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["slow_short"].alarm_name}))",
  ])

  alarm_actions = [aws_sns_topic.pager.arn]
  ok_actions    = [aws_sns_topic.pager.arn]
}

L'OR dentro un unico allarme composito risolve anche la soppressione. Un'interruzione dura soddisfa sia la coppia veloce sia quella lenta; poiché alimentano un solo allarme, l'ingegnere riceve una chiamata, non due.

Il livello ticket a tre giorni è più facile sulla strada gestita. Se lo SLO vive in CloudWatch Application Signals, gli colleghi delle finestre di look-back per il burn rate (fino a 10.080 minuti, cioè sette giorni) e allarmi sulla metrica di burn rate che pubblica. La formula di soglia di AWS è X% x SLO interval length / look-back window, e nasconde una trappola: 14,4 è corretto solo per un intervallo di 30 giorni. Il due percento di un budget a 28 giorni in un'ora è 13,44. Deriva le soglie dalle percentuali; non copiare mai i moltiplicatori.

La production readiness review

Messa insieme, questa è la review che un SRE fa con gli sviluppatori prima che un servizio prenda traffico reale, e che ripete quando l'architettura cambia:

  • Ogni chiamata in uscita ha un timeout, e i timeout stanno dentro la scadenza del chiamante?
  • C'è esattamente un livello di retry, con backoff e jitter, e le scritture ritentate sono idempotenti?
  • Per ogni dipendenza: hard o soft, e cosa vede l'utente quando è giù?
  • Gli health check evitano le dipendenze condivise?
  • Il servizio emette metriche di richieste, fallimenti e durata per operazione, e propaga un trace ID?
  • C'è uno SLO, un owner per esso, e un alert sul burn rate che chiama una rotazione designata con un link al runbook?
  • Una release può essere riportata indietro in meno di cinque minuti senza una modifica di schema?
  • Quale limite viene raggiunto per primo a dieci volte il traffico, e cosa succede a quel punto?

Modalità di guasto e cosa tenere d'occhio

Il basso traffico rompe la matematica del burn rate. A 100 richieste l'ora, un burn di 14,4x su uno SLO del 99,9 percento sono 1,44 richieste fallite, quindi due timeout sfortunati chiamano qualcuno. Le opzioni nell'SRE Workbook sono traffico sintetico, aggregare servizi piccoli in un unico SLO, un obiettivo più basso o una finestra più lunga. In metric math puoi condizionare sul volume con IF(req > 200, ratio, 0), al prezzo della cecità sotto la soglia di volume.

I dati mancanti non sono buone notizie di default. treat_missing_data = "notBreaching" è giusto per un rapporto che svanisce senza traffico e sbagliato se il traffico è svanito perché il DNS è rotto. Affianca all'allarme SLO un allarme semplice su RequestCount che scende a zero nelle ore in cui non dovrebbe mai.

I bucket non sono finestre scorrevoli. Un datapoint CloudWatch con un periodo di un'ora è un bucket, quindi l'allarme sulla finestra lunga può reagire più tardi di un calcolo scorrevole ideale. La finestra breve mantiene veloce il rilevamento degli incidenti gravi; tratta i tempi del burn lento come approssimativi.

Codice di resilienza mai esercitato. Un circuit breaker che non si è mai aperto in produzione è un'ipotesi. La fault injection, prima in un ambiente di test e poi in produzione dentro l'error budget, è il modo in cui i percorsi di fallback si guadagnano la fiducia.

Tutto quanto sopra presuppone che la piattaforma regga. Timeout e retry sono scritti contro un provider che per lo più funziona. Cosa promette davvero quel provider, dove stanno i suoi domini di guasto e cosa cambia quando il provider è il tuo data center è la Parte 4.

Leggi questo dopo

Per consulenza su AWS, architettura cloud, pratica SRE, pipeline CI/CD, Linux e altro, parti da ercanermis.com.

Riferimenti