A maior parte da confiabilidade de um serviço se decide no repositório, em como cada função se comporta quando uma dependência está lenta, não no cluster em que ele roda. Um SRE, portanto, precisa ler código de aplicação bem o suficiente para revisá-lo e alterá-lo: encontrar a chamada de rede sem timeout, o loop de retry que vai triplicar a carga durante uma queda, o health check que vai tirar todas as instâncias de serviço ao mesmo tempo. Este post é a lista de revisão, com as correções, e termina com o único pedaço de código que o SRE possui por inteiro: alertas que acionam alguém pela taxa de queima (burn rate) do error budget, escritos em Terraform.

Esta é a Parte 3 de Budget for Failure, e a primeira parte de nível 201. A Parte 1 definiu o papel e a Parte 2 deu números a ele: um SLO de disponibilidade de 99,9 por cento em 30 dias, medido em um Application Load Balancer. Esse exemplo continua aqui. Os exemplos de código são em Python; os padrões são os mesmos em qualquer linguagem.

Quanto código um SRE precisa

Você não precisa ser o desenvolvedor de feature mais forte do time. Precisa de três habilidades. Ler o caminho da requisição: começando pelo handler, seguir uma requisição de usuário por toda chamada de rede que ela faz e dizer o que acontece se cada chamada levar trinta segundos. Revisar um pull request pelo comportamento operacional, que é uma leitura diferente de revisá-lo por correção. E escrever você mesmo mudanças com qualidade de produção: o timeout, a métrica, o hook de rollback. Um SRE que abre um ticket pedindo aos desenvolvedores que adicionem um timeout descreveu o problema. Um que envia o pull request o resolveu.

Toda chamada de rede precisa de um prazo

O defeito de confiabilidade mais comum em código de aplicação é uma chamada que pode esperar para sempre. A biblioteca requests do Python não tem timeout padrão nenhum. O AWS SDK para Python usa por padrão 60 segundos para conectar e 60 segundos para ler, com retries automáticos por cima. Um handler que chama o DynamoDB com esses padrões, atrás de um load balancer cujo idle timeout é 60 segundos, pode segurar um worker por minutos depois que o usuário já recebeu um 504.

Quando uma dependência fica lenta, chamadas sem prazo transformam uma dependência lenta em um serviço morto: todo worker fica estacionado esperando, o pool se esgota, e requisições que nunca precisaram da dependência lenta falham também. Defina os limites de propósito:

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)

Escolha o read timeout a partir da latência medida da dependência, um pouco acima do percentil 99,9 dela, não de um número redondo. Depois confira se os limites se encaixam. Se o orçamento voltado ao usuário é de 3 segundos, o handler não pode fazer três chamadas sequenciais que podem levar 2 segundos cada com 3 tentativas. O prazo externo precisa ser passado para baixo, para que as chamadas internas desistam quando não há mais ninguém esperando a resposta.

Retries: a correção que causa quedas

Um retry transforma uma falha transitória em sucesso, e uma falha sustentada em uma falha maior. Três regras o mantêm do lado certo.

Faça retry em uma única camada. Se o cliente tenta três vezes, a API tenta a chamada ao banco três vezes e o SDK tenta três vezes por baixo, uma ação de usuário vira até 27 requisições contra uma dependência que já está em dificuldade. Escolha a camada mais próxima da falha e faça as outras falharem rápido.

Recue com jitter. Clientes que tentam de novo em intervalo fixo chegam juntos e derrubam de novo o serviço em recuperação. A análise do AWS Architecture Blog sobre isso é a referência padrão; a variante "full jitter" dorme um tempo aleatório entre zero e um teto que cresce exponencialmente:

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)

Faça retry só do que é seguro repetir. Uma leitura que estourou o tempo é segura. Um "cobrar o cartão" que estourou o tempo não é, a menos que a operação seja idempotente: o cliente envia uma chave única com a requisição e o servidor guarda o resultado sob ela, de modo que uma repetição devolve o primeiro resultado em vez de cobrar duas vezes. Em revisão, qualquer retry em torno de uma escrita é um convite a perguntar onde está a chave de idempotência.

Falhe menor: breakers, shedding, degradação

Timeouts e retries lidam com uma dependência que está brevemente indisposta. Para uma que está fora, o código precisa de um jeito de parar de tentar e de uma decisão sobre o que o usuário vê enquanto isso.

  • Circuit breaker. Depois de uma sequência de falhas, pare de chamar a dependência por um intervalo curto e falhe imediatamente. Protege o seu pool de workers e dá espaço para a dependência se recuperar.
  • Load shedding. Quando o serviço está saturado, rejeite o excesso cedo com um 429 ou 503 em vez de aceitar tudo e servir tudo devagar. Uma rejeição rápida e barata de 20 por cento das requisições vale mais que um timeout para 100 por cento.
  • Degradação graciosa. Decida por dependência se ela é hard ou soft. Se o serviço de recomendações está fora, a página de produto deveria renderizar sem recomendações, não retornar um 500. Cada dependência convertida de hard para soft sai da multiplicação de disponibilidade, que a Parte 4 faz por completo.

Health checks que dizem a verdade

Um health check é código, e com frequência está errado em uma de duas direções. Um check raso que retorna 200 sempre que o processo está vivo mantém uma instância quebrada em rotação. Um check profundo que testa o banco de dados a partir de cada instância tem uma falha pior: quando o banco pisca, toda instância falha no check no mesmo instante, e o load balancer fica sem nenhum target saudável. Um Application Load Balancer nesse estado falha aberto e roteia para todos os targets mesmo assim, o que te salva, mas um orquestrador configurado para substituir instâncias não saudáveis começa a matar uma frota saudável durante um incidente de banco.

A resposta usual são dois endpoints. Liveness diz que o processo está rodando e não deve ser reiniciado. Readiness diz que esta instância consegue fazer trabalho útil, e verifica apenas o que é local a ela: configuração carregada, pool de conexões criado, caches aquecidos. Dependências compartilhadas são monitoradas pelo SLI, não por health checks por instância.

Emita o SLI a partir do código

O SLI do load balancer da Parte 2 enxerga códigos de status. Ele não distingue um checkout bem-sucedido de um 200 que carrega uma mensagem de erro no corpo, e não consegue separar por nível de cliente ou por operação. A aplicação consegue. Para cada requisição, escreva uma linha de log estruturada com a operação, o resultado como o negócio o define, a duração e o trace ID. Na AWS, o embedded metric format do CloudWatch transforma essa mesma linha de log em métricas sem nenhuma chamada de API extra:

{
  "_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"
}

Mantenha campos de alta cardinalidade, como o trace ID e o cliente, como propriedades de log, não como dimensões de métrica; cada valor único de dimensão é uma métrica cobrada à parte. Esta é a instrumentação que pertence ao template de serviço da Parte 1, para que os desenvolvedores a recebam sem pedir.

Faça da mudança o caminho seguro

O livro-razão da Parte 2 mostrou um único deploy consumindo 31 por cento do orçamento de um mês. As defesas do lado do código são o trabalho de maior retorno desta lista.

  • Rollout progressivo com rollback automático. Mande 10 por cento do tráfego para a versão nova, compare sua razão de erros e sua latência com a versão antiga e faça rollback sem um humano se ela for pior. CodeDeploy, deployments do ECS e aliases do Lambda conseguem parar e reverter com base em um alarme do CloudWatch. Um release ruim passa a custar minutos de 10 por cento do tráfego.
  • Separe deploy de release. Entregue o código apagado atrás de uma feature flag e ligue-o gradualmente. Desligar uma flag é mais rápido que qualquer rollback.
  • Mudanças de schema retrocompatíveis. Expandir, migrar, contrair: adicione a coluna nova, escreva nas duas, mova os leitores, depois remova a antiga em um release posterior. Uma migração que exige que código antigo e novo nunca rodem lado a lado faz de todo rollout uma queda e de todo rollback uma impossibilidade.

Alertas como código: acione por burn rate, não por CPU

O último pedaço de código que um SRE possui é o próprio alertamento. O conjunto de alarmes de uma conta AWS típica aciona por causas: CPU acima de 80 por cento, memória acima de 90, qualquer contagem de 5xx acima de 10. É ruidoso onde nada está errado (um autoscaling group a 95 por cento de CPU que está cumprindo sua meta de latência) e silencioso onde algo está (um deploy falhando 2 por cento dos checkouts com todas as métricas de host planas). O SLO da Parte 2 dá uma condição melhor para acionar: estamos gastando o error budget rápido o bastante para que, deixado como está, a meta seja perdida? Essa velocidade é a 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

Um alerta precisa de um limiar e de uma janela, e os dois juntos decidem quanto orçamento já se foi quando ele dispara:

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%

Essas três linhas são a recomendação do capítulo de alertas do Google SRE Workbook, e são um padrão sensato para um SLO de 30 dias: acione quando 2 por cento do orçamento se foi em uma hora ou 5 por cento em seis horas, e abra um ticket quando 10 por cento se foi em três dias. Problemas rápidos e severos recebem um acionamento rápido. Vazamentos lentos, do tipo que um alarme de limiar nunca pega, recebem um ticket para o próximo dia útil.

Por que duas janelas

Uma única janela longa tem um problema de reset. Se os erros rodaram a 15 por cento por dez minutos e depois pararam, a média de uma hora fica acima do limiar de 14,4x pela maior parte da hora seguinte. O alarme continua disparando para um engenheiro que está olhando para um sistema saudável.

A correção é exigir que uma segunda janela, curta, esteja acima do mesmo limiar ao mesmo tempo. A orientação do workbook é uma janela curta com um doze avos da longa. A janela longa diz que o gasto é significativo; a curta diz que ele ainda está acontecendo. Juntas:

AçãoJanela longaJanela curtaBurn rateRazão de erros a 99,9%Orçamento consumido
Acionamento1 hora5 minutos14,41,44%2%
Acionamento6 horas30 minutos60,6%5%
Ticket3 dias6 horas10,1%10%

A detecção é rápida onde precisa ser. Em uma queda total, a média de uma hora cruza 1,44 por cento depois de cerca de 52 segundos, e a janela de cinco minutos já passou do limiar. Quando a queda termina, a janela de cinco minutos cai abaixo do limiar em minutos e o acionamento se encerra, mesmo com a hora ainda poluída.

Construindo no CloudWatch com Terraform

O CloudWatch tem as duas peças que isso exige: alarmes de metric math, para alarmar sobre uma razão de erros em vez de uma contagem bruta, e alarmes compostos, para fazer o AND das janelas longa e curta. Um alarme de métrica por janela, com um único período de avaliação do tamanho da janela e nenhuma ação anexada:

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 }
      }
    }
  }
}

O limiar é a burn rate multiplicada pela razão do orçamento: 14,4 x 0,001 = 0,0144 para o par rápido, 0,006 para o par lento. Mude slo para 0.9995 e todos os limiares acompanham. Depois o alarme composto, que é a única coisa que aciona alguém:

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]
}

O OR dentro de um único alarme composto também resolve a supressão. Uma queda dura satisfaz tanto o par rápido quanto o lento; como eles alimentam um único alarme, o engenheiro recebe um acionamento, não dois.

O nível de ticket de três dias é mais fácil pelo caminho gerenciado. Se o SLO vive no CloudWatch Application Signals, você anexa a ele janelas de look-back de burn rate (até 10.080 minutos, ou seja, sete dias) e alarma sobre a métrica de burn rate que ele publica. A fórmula de limiar da AWS é X% x SLO interval length / look-back window, e ela carrega uma armadilha: 14,4 só está correto para um intervalo de 30 dias. Dois por cento de um orçamento de 28 dias em uma hora é 13,44. Derive os limiares das porcentagens; nunca copie os multiplicadores.

A production readiness review

Reunido, este é o review que um SRE conduz com os desenvolvedores antes de um serviço receber tráfego real, e que repete quando a arquitetura muda:

  • Toda chamada de saída tem timeout, e os timeouts cabem dentro do prazo de quem chama?
  • Existe exatamente uma camada de retry, com backoff e jitter, e as escritas repetidas são idempotentes?
  • Para cada dependência: hard ou soft, e o que o usuário vê quando ela está fora?
  • Os health checks evitam dependências compartilhadas?
  • O serviço emite métricas de requisição, falha e duração por operação e propaga um trace ID?
  • Existe um SLO, um dono para ele e um alerta de burn rate que aciona uma escala nomeada com link para um runbook?
  • Um release pode ser revertido em menos de cinco minutos sem mudança de schema?
  • Que limite é atingido primeiro com dez vezes o tráfego, e o que acontece então?

Modos de falha e o que observar

Tráfego baixo quebra a matemática de burn rate. A 100 requisições por hora, uma queima de 14,4x em um SLO de 99,9 por cento são 1,44 requisições falhas, então dois timeouts azarados acionam alguém. As opções no SRE Workbook são tráfego sintético, agregar serviços pequenos em um único SLO, uma meta mais baixa ou uma janela mais longa. Em metric math você pode condicionar ao volume com IF(req > 200, ratio, 0), ao custo de ficar cego abaixo do gate.

Dado ausente não é boa notícia por padrão. treat_missing_data = "notBreaching" está certo para uma razão que some quando não há tráfego e errado se o tráfego sumiu porque o DNS quebrou. Emparelhe o alarme de SLO com um alarme simples sobre RequestCount caindo a zero em horas em que isso nunca deveria acontecer.

Buckets não são janelas deslizantes. Um datapoint do CloudWatch com período de uma hora é um bucket, então o alarme de janela longa pode reagir mais tarde que um cálculo deslizante ideal. A janela curta mantém rápida a detecção de incidentes severos; trate o timing de queima lenta como aproximado.

Código de resiliência que nunca é exercitado. Um circuit breaker que nunca abriu em produção é um palpite. Injeção de falhas, primeiro em um ambiente de teste e depois em produção dentro do error budget, é como os caminhos de fallback conquistam confiança.

Tudo acima presume que a plataforma aguenta. Timeouts e retries são escritos contra um provedor que está majoritariamente funcionando. O que esse provedor de fato promete, onde ficam seus domínios de falha e o que muda quando o provedor é o seu próprio data center é a Parte 4.

Leia isto a seguir

Para consultoria em AWS, arquitetura de nuvem, prática de SRE, pipelines de CI/CD, Linux e mais, comece em ercanermis.com.

Referências