Joao Brietzke Blog

← voltar

Route 53 na prática: projetando o DNS de uma API multi-região

Os dois posts anteriores entregaram as peças. Route 53 e DNS explicou como um nome vira um endereço, e health checks e políticas de roteamento explicou cada política isolada. Falta a parte que nenhum dos dois faz: montar.

Este post é um desenho completo, construído em rodadas. Começa com a coisa mais simples que funciona e adiciona um requisito de cada vez, porque é assim que uma árvore de registros nasce de verdade, nunca pronta. E cada rodada termina com a mesma pergunta, que é a única ideia que este post realmente defende: qual é a camada mais baixa que enxerga esse problema?


O cenário

exemplo.com, um SaaS B2B. API pública em api.exemplo.com, hoje um ALB só, em sa-east-1. Nos meses seguintes o negócio entrega quatro requisitos, nesta ordem:

  1. Uma queda de região não pode devolver erro de conexão.
  2. Clientes nos EUA reclamam de latência.
  3. Deploy tem que sair como canário, não de uma vez.
  4. Clientes na União Europeia precisam ser atendidos por infraestrutura na UE, por contrato.

Nenhum deles menciona DNS. Três vão parar na árvore do Route 53 mesmo assim, e um não, e o que não vai é o mais instrutivo.

Rodada 0: a linha de base

Um ALB, um registro.

api.exemplo.com   A   ALIAS ──▶ alb-sa-east-1   (simple, sem health check)

Duas decisões já foram tomadas aqui, e as duas importam lá na frente.

Alias, não CNAME. CNAME não existe no ápice da zona (exemplo.com), e consulta respondida por alias apontando para recurso AWS não é cobrada. Mais importante: alias é o mecanismo com que a árvore inteira vai ser construída, então estabelecer o hábito agora não custa nada.

Sem health check. A política simple não aceita um, e ele seria inútil de qualquer forma: existe uma resposta só, não há escolha a fazer. Se o ALB morrer, o Route 53 continua entregando o endereço dele. Isso é exatamente o requisito 1.

TTL de 300s está de bom tamanho. Nada reage a nada ainda, então TTL baixo só compraria consulta.

Rodada 1: sobreviver à região

Requisito: queda tem que mostrar página de manutenção, não erro de conexão.

A política é failover, e a parte interessante é decidir o que é o secundário. Não pode ser outro ALB na mesma região, porque o modo de falha que você está cobrindo é a região. E ainda não existe uma segunda região. Sobra um bucket S3 com uma página estática.

api.exemplo.com   A   (failover)
├── PRIMARY   ──alias──▶ alb-sa-east-1     ETH: ligado
└── SECONDARY ──alias──▶ s3-manutencao     ETH: desligado

Por que ETH ligado no primário e desligado no secundário. No primário, o Evaluate Target Health faz o Route 53 herdar a saúde do próprio ALB, saudável se houver ao menos um target saudável em ao menos uma AZ, de graça e sem sonda externa. No secundário, um endpoint de site S3 não tem saúde a avaliar, o Route 53 trata o bucket como saudável enquanto ele existir. É precisamente o que se quer de uma página de manutenção: ela tem que estar sempre disponível para aparar o tráfego.

Agora a decisão de TTL vira real. Recuperação é detecção mais TTL:

ALB perde o último target saudável
   │
   ├── ~0s        ETH já sabe (o ELB conhece os próprios targets)
   ├── até o TTL  resolvers seguem servindo o endereço do ALB do cache
   ▼
tráfego chega na página de manutenção

Com ETH não existe a janela de 90 segundos de detecção, o sinal é imediato. O TTL passa a ser a totalidade do tempo de recuperação. Baixe para 60s.

A armadilha desta rodada. ETH só sabe o que o ALB sabe. Se o health check do target group é um GET / devolvendo 200, e a aplicação está de pé mas o banco caiu, todos os targets estão "saudáveis", o ALB está "saudável", o ETH diz saudável, e o Route 53 segue mandando tráfego para uma API quebrada. A página de manutenção nunca dispara.

Corrija na camada mais profunda que enxerga a verdade, que aqui é o health check do target group:

GET /health  →  200 {"status":"ok","db":"ok","cache":"ok"}
                503 quando qualquer dependência está fora

É a lição que vai se repetir: saúde só é tão profunda quanto a coisa mais profunda que você checa.

Rodada 2: duas regiões

Requisito: clientes nos EUA reclamam de latência. Sobe us-east-1.

O movimento ingênuo é jogar os dois ALBs num conjunto latency. Não faça, porque isso joga fora a rodada 1: um conjunto latency de dois ALBs não tem página de manutenção, e se as duas regiões estiverem doentes o fail-open manda o tráfego para um ALB morto sem nenhum plano B estático.

Em vez disso, aninhe. Latency escolhe a região, e cada região guarda o próprio par de failover.

api.exemplo.com                              (latency)
├── região sa-east-1 ──alias──▶ api-sae1.exemplo.com    ETH: ligado
└── região us-east-1 ──alias──▶ api-use1.exemplo.com    ETH: ligado

api-sae1 e api-use1 são registros comuns da mesma zona. Ninguém deveria digitá-los, são nós internos de uma árvore.

E agora repare no que você não construiu. Não existe failover entre regiões nesse desenho, mas ele apareceu sozinho, porque a saúde cascateia de baixo para cima via ETH. Só que, do jeito que está, se alb-sa-east-1 cair, o subconjunto de sa-east-1 ainda tem um secundário saudável (o S3), então api-sae1 se reporta saudável e o brasileiro recebe página de manutenção enquanto os EUA estão no ar.

Quase certamente não é o que você quer. Uma região viva é melhor que uma página estática. Então o secundário de cada região passa a ser a outra região:

api-sae1.exemplo.com                         (failover)
├── PRIMARY   ──alias──▶ alb-sa-east-1
└── SECONDARY ──alias──▶ alb-us-east-1

Só que aí a manutenção sumiu do desenho. Se as duas regiões caírem, todo registro do conjunto está doente, o fail-open devolve todos como saudáveis e o usuário recebe o endereço de um ALB morto.

A página de manutenção precisa viver numa camada onde ela esteja sempre saudável. Colocar como terceiro braço no topo é impossível, o topo é latency e todo registro de um conjunto compartilha a mesma política. A solução é mais uma camada:

api.exemplo.com                              (failover)
├── PRIMARY   ──alias──▶ api-live.exemplo.com     ETH: ligado
└── SECONDARY ──alias──▶ s3-manutencao            ETH: desligado

api-live.exemplo.com                         (latency)
├── sa-east-1 ──alias──▶ api-sae1.exemplo.com     ETH: ligado
└── us-east-1 ──alias──▶ api-use1.exemplo.com     ETH: ligado

Leia a árvore como uma frase: se qualquer região está viva, use latency para escolher a melhor; se nenhuma está, mostre a manutenção. Nada no Route 53 se chama assim. Isso emergiu de empilhar três registros de failover e um de latency.

Rodada 3: o canário

Requisito: deploy sai gradualmente.

A resposta óbvia é weighted dentro de cada região:

api-sae1.exemplo.com                         (failover)
├── PRIMARY   ──alias──▶ api-sae1-app.exemplo.com   (weighted)
│                        ├── peso 95 ──▶ alb-estável
│                        └── peso  5 ──▶ alb-canário
└── SECONDARY ──alias──▶ alb-us-east-1

Funciona, e é a ferramenta errada. A análise honesta:

  • Os 5% são 5% das consultas de resolver, não dos usuários. Um resolver corporativo grande que sortear o canário manda a base inteira dele para lá pelo TTL todo. Em percentuais pequenos a variância é brutal: o seu "canário de 5%" pode ser 0% do tráfego real ou 40% dele.
  • Você não consegue fazer rollback mais rápido que o TTL. Zerar o peso do canário deixa respostas em cache apontando para ele por mais 60 segundos.
  • E peso 0 em todos os registros devolve todos como iguais, então "zera tudo" não é kill switch.

A resposta melhor nessa camada é o ALB, não o DNS. Target group com peso numa regra de listener divide tráfego por requisição, dá percentual exato e faz rollback instantâneo, porque não existe cache entre a decisão e o tráfego.

Guarde o weighted para o que ele é bom: divisões grosseiras, lentas e de vida longa. Migrar um datacenter antigo para a AWS a 10% por semana é um encaixe perfeito. Canário por deploy não é.

Ou seja, a rodada 3 não muda nada na árvore. Ela se resolve uma camada abaixo. Reconhecer isso é o resultado de projeto, não uma evasiva.

Rodada 4: residência de dados na UE

Requisito: clientes na UE em infraestrutura na UE, por contrato. Sobe eu-central-1.

O erro a evitar é acrescentar eu-central-1 como terceiro braço do conjunto latency. Latency otimiza velocidade, e um cliente em Lisboa com um caminho rápido até us-east-1 seria roteado para fora da UE. Velocidade não é o requisito aqui, jurisdição é. Quando a regra é de negócio, a política é geolocation.

Só que o topo já é failover e o segundo nível é latency. O geolocation tem que entrar acima do latency, porque ele é uma restrição rígida e latency é uma otimização. Restrição manda em otimização, sempre, na árvore e fora dela.

api.exemplo.com                                  (failover)
├── PRIMARY   ──alias──▶ api-geo.exemplo.com          ETH: ligado
└── SECONDARY ──alias──▶ s3-manutencao                ETH: desligado

api-geo.exemplo.com                              (geolocation)
├── continente EU ──alias──▶ api-euc1.exemplo.com     ETH: ligado
└── Default       ──alias──▶ api-live.exemplo.com     ETH: ligado

api-live.exemplo.com                             (latency)
├── sa-east-1 ──alias──▶ api-sae1.exemplo.com         ETH: ligado
└── us-east-1 ──alias──▶ api-use1.exemplo.com         ETH: ligado

api-sae1  (failover)  PRIMARY alb-sa-east-1    / SECONDARY alb-us-east-1
api-use1  (failover)  PRIMARY alb-us-east-1    / SECONDARY alb-sa-east-1
api-euc1  (failover)  PRIMARY alb-eu-central-1 / SECONDARY s3-manutencao-eu

Três detalhes escondidos aí.

O registro Default não é opcional. Sem ele, consulta vinda de um lugar que você não mapeou recebe resposta vazia e o nome simplesmente não resolve. Silencioso, regional e invisível de dentro do escritório. Se você for criar um único registro geolocation na vida, crie o Default.

O secundário da UE é decisão de negócio, não técnica. Cair para us-east-1 quebraria o contrato que você acabou de implementar. Então o secundário do subconjunto europeu é a página de manutenção. O cliente europeu troca disponibilidade por jurisdição garantida, que é exatamente o que o contrato pede. Escreva isso em algum lugar que um humano leia, porque o próximo engenheiro vai olhar a assimetria e "consertar".

Continente EU não é a União Europeia. Inclui Suíça, Reino Unido, Noruega, Sérvia. Se o contrato fala da UE de verdade, você precisa de registros por país para os 27, e a regra de correspondência mais específica cuida do resto.

Os health checks, concretamente

Agora os sinais de saúde de verdade. Quatro mecanismos, um por tipo de problema:

O que você checa Mecanismo Configuração
O ALB tem target vivo Evaluate Target Health flag em todo alias para ALB
A aplicação funciona mesmo Health check do target group GET /health, 503 quando uma dependência cai
A aplicação funciona de fora Endpoint health check HTTPS, /health, string matching "status":"ok", fast interval
O worker assíncrono acompanha CloudWatch alarm health check ApproximateAgeOfOldestMessage do SQS > 300s
"A região está utilizável" como um sinal só Calculated health check check da app AND check do worker
Tirar uma região de propósito Calculated health check um check que sempre falha, AND-ado na expressão

O calculated é o que torna as duas últimas linhas possíveis. Defina, por região:

regiao-saudavel(sa-east-1) =
      endpoint-check(api-sae1 /health)
  AND cloudwatch-alarm(fila-sa-east-1)
  AND NOT chave-de-dreno-manual(sa-east-1)

Pendure isso no registro PRIMARY da região e você ganha um dreno: liga a chave-de-dreno-manual e, um TTL depois, sa-east-1 esvazia de forma limpa, sem deploy e sem mudança de código. Vale construir antes de precisar, porque quando precisar vai ser num momento ruim.

Dois detalhes de configuração que mordem exatamente uma vez:

  • Os verificadores do Route 53 vêm de faixas de IP publicadas pela AWS. Se o security group ou o WAF do ALB não liberar essas faixas, o check falha para sempre e uma região perfeitamente saudável é drenada. É o outage autoinfligido mais comum deste desenho.
  • No check ancorado em alarme, decida explicitamente o que INSUFFICIENT_DATA significa. A métrica de idade da fila só existe quando há mensagem. Às quatro da manhã a fila está vazia, a métrica some, e se você mapeou INSUFFICIENT_DATA para doente acabou de drenar uma região sem que nada estivesse errado.

Percorrendo as falhas

Um target morre em sa-east-1. O ALB tira ele de rotação. Nada chega no DNS. O Route 53 nunca fica sabendo e nem precisa. Recuperação em segundos, inteiramente no ALB. Essa é a maioria das falhas reais, e a camada de DNS está corretamente fora do assunto.

O banco de sa-east-1 cai. O /health passa a devolver 503, todos os targets reprovam no check do target group, o ALB fica com zero targets saudáveis, o ETH reporta o registro doente e o failover troca para o alias de us-east-1.

banco cai
├── ~30s       target group marca todos os targets como doentes (2 × 15s)
├── ~0s        ETH reflete imediatamente
├── até 60s    respostas em cache expiram
▼  ~90s no pior caso, o tráfego brasileiro é servido por us-east-1

Repare no que compõe esse número: o intervalo do target group, não os 90 segundos padrão do Route 53. Saúde avaliada mais perto do recurso é saúde mais rápida.

Queda total de sa-east-1. O PRIMARY de api-sae1 fica doente, o SECONDARY é alb-us-east-1, que está saudável, então api-sae1 continua resolvendo, e resolve para o ALB americano. O latency nem precisa excluir a região. O usuário brasileiro tem uma API mais lenta, mas funcionando, dentro de um TTL.

sa-east-1 e us-east-1 fora ao mesmo tempo. Os dois nós de região reportam doente, api-live não tem nenhum registro saudável. O fail-open devolveria um ALB morto, mas api-live só é alcançado através do registro Default do api-geo, com ETH ligado, que fica doente, que derruba o api-geo, que reprova o PRIMARY do topo, que promove a página de manutenção. O empilhamento se pagou. E o tráfego europeu segue intacto o tempo todo, porque nunca entra nesse subconjunto.

Tudo fora, inclusive o S3. O fail-open devolve todos os registros como saudáveis e o usuário recebe erro de conexão contra um endereço morto. Não existe sintoma no DNS. A resposta durante um outage total é indistinguível de uma resposta saudável. O seu alarme tem que ser um alarme do CloudWatch em cima do status do health check, nunca "a resolução está falhando", porque a resolução nunca falha.

O inventário de registros

Doze registros para uma API em três regiões. Vale ver como lista plana, porque é isso que existe no console:

Nome Tipo Política Set ID Alvo ETH
api A failover PRIMARY alias api-geo sim
api A failover SECONDARY alias S3 manutenção não
api-geo A geolocation continente EU alias api-euc1 sim
api-geo A geolocation Default alias api-live sim
api-live A latency sa-east-1 alias api-sae1 sim
api-live A latency us-east-1 alias api-use1 sim
api-sae1 A failover PRIMARY alias ALB sa-east-1 sim
api-sae1 A failover SECONDARY alias ALB us-east-1 sim
api-use1 A failover PRIMARY alias ALB us-east-1 sim
api-use1 A failover SECONDARY alias ALB sa-east-1 sim
api-euc1 A failover PRIMARY alias ALB eu-central-1 sim
api-euc1 A failover SECONDARY alias S3 manutenção UE não

E faça o conjunto AAAA também, idêntico, ou você ganha o bug clássico: cliente IPv6 seguindo uma árvore de políticas diferente, ou nenhuma.

O pedaço que costuma sair errado em Terraform:

resource "aws_route53_record" "api_live_sae1" {
  zone_id        = aws_route53_zone.main.zone_id
  name           = "api-live.exemplo.com"
  type           = "A"
  set_identifier = "sa-east-1"

  latency_routing_policy { region = "sa-east-1" }

  alias {
    name                   = "api-sae1.exemplo.com"
    zone_id                = aws_route53_zone.main.zone_id   # a sua zona, não a do ALB
    evaluate_target_health = true                            # é isto que cascateia
  }
}

As duas linhas que as pessoas erram: o zone_id é o da sua hosted zone quando o alias aponta para outro registro, e o evaluate_target_health precisa estar ligado em todo nó interno. Um false em qualquer ponto da corrente e a saúde para de propagar acima dali, silenciosamente.

Custo, por alto

Item Aproximado
Hosted zone US$ 0,50 por mês
Endpoint health checks, 3 regiões, com HTTPS, string matching e fast interval alguns dólares cada por mês
Health checks de alarme do CloudWatch, 3 fração de dólar cada por mês
Calculated health checks, 3 fração de dólar cada por mês
Consultas respondidas por políticas avançadas por milhão, mais caro que o padrão

Dá algo abaixo de vinte dólares por mês de plano de controle, mais consultas. O Traffic Flow acrescentaria algo na casa de cinquenta dólares por policy record, que numa árvore de doze registros mantida em Terraform compra uma interface gráfica que você não precisa. Confira as tarifas atuais na página de preços, principalmente como as consultas de uma árvore aninhada são contadas, que é o número que eu não citaria de memória.

O que este desenho ainda não faz

Reagir em segundos. O piso é o TTL, sempre. Se o requisito é failover regional abaixo de um segundo, a resposta é o Global Accelerator: dois IPs anycast, o desvio acontece dentro da rede da AWS e o DNS não muda.

Rotear por usuário. Toda decisão aqui é por consulta de resolver. Feature flag e roteamento por tenant moram na aplicação ou no ALB, não no DNS.

Vencer um cliente que cacheia para sempre. Uma JVM com networkaddress.cache.ttl em -1 segura a primeira resposta pela vida inteira do processo. Toda a árvore acima é irrelevante para esse cliente até alguém reiniciar o pod.

As cinco coisas para levar

  1. A árvore não se projeta de uma vez. Ela cresce um requisito por vez, e cada requisito novo costuma exigir uma camada acima, não um braço a mais no mesmo conjunto.
  2. Restrição fica acima de otimização. Geolocation por fora, latency por dentro, nunca o contrário.
  3. A página de manutenção precisa morar numa camada onde ela esteja sempre saudável, senão o fail-open devolve um endereço morto no pior momento possível.
  4. Saúde só é tão profunda quanto a coisa mais profunda que você checa, e saúde avaliada perto do recurso é mais rápida que saúde sondada de longe.
  5. Resolva cada requisito na camada mais baixa que enxerga o problema. Target morto é ALB. Dependência quebrada é health check do target group. Região morta é failover de DNS. Jurisdição é geolocation. Canário volta a ser ALB.

A linha que atravessa tudo: em Load balancer a distribuição acontece na conexão, no Gateway Load Balancer ela acontece na tabela de rotas, e aqui ela acontece antes de existir conexão. Projetar bem é saber em qual das três o seu requisito realmente mora.

Referências