Como Chegámos a Zero Erros 502 nos Deploys
Sempre que fazíamos deploy do backend do Mythex, um punhado de pedidos falhava com 502 Bad Gateway. Não muitos — mas um 502 durante um deploy significa que o clique de alguém não fez nada, ou que uma compilação que alguém estava a ver parou por um momento. Seguimos isto até dois problemas separados: um watchdog de autorrecuperação que lutava contra os nossos deploys, e um intervalo entre o momento em que um servidor deixava de escutar e o momento em que o balanceador de carga deixava de lhe enviar tráfego. Em três deploys consecutivos de produção depois das correções, a contagem passou de 10 para 6 e depois para 0.
A configuração
A nossa API e o orquestrador correm como contentores num pequeno grupo de servidores atrás de um Application Load Balancer (ALB) da AWS. Um deploy atualiza um servidor de cada vez: drena os contentores antigos, arranca os novos e passa ao seguinte. Um watchdog em cada servidor verifica se os contentores estão no ar e reinicia qualquer um que não esteja — existe para que um processo que falhe às 3 da manhã volte sem que ninguém tenha de acordar.
Ambos são razoáveis por si. Juntos, não funcionavam bem.
Problema 1: o watchdog competia com o deploy
O watchdog corria uma vez por minuto e "curava" ao primeiro único check de saúde falhado. Um deploy põe deliberadamente os serviços em drenagem durante até 150 segundos — durante os quais um check de saúde deve falhar. Por isso, o watchdog disparava em cada deploy.
Na maioria das vezes perdia a corrida sem consequências. Uma vez, ganhou: reescreveu o ficheiro compose (a descrição dos contentores que devem correr) a meio de um deploy, e deixou um servidor sem contentor de gateway nenhum — enquanto o job de deploy reportava sucesso.
As correções:
- Um único lock. O deploy passa agora a usar o mesmo lock de ficheiro do script de bootstrap do servidor, para que o watchdog e um deploy nunca corram ao mesmo tempo.
- Quatro falhas, não uma. O watchdog precisa de quatro checks de saúde falhados consecutivos antes de agir. Um serviço em drenagem já não parece morto.
- Uma só definição. O ficheiro compose tem agora uma única fonte, publicada para os servidores, em vez de cópias que podiam divergir.
- Verificar, não confiar. O job de deploy já não passa só porque um comando devolveu sucesso. Verifica a API, o orquestrador, o gateway e o worker de publicação de cada servidor antes de poder ser bem-sucedido.
Problema 2: o servidor saiu antes de o tráfego sair
O segundo problema era mais discreto. Quando um deploy drena um servidor, este passa a responder ao seu endpoint de saúde com 503 para que o balanceador de carga o retire de rotação. O nosso fazia isso durante 5 segundos e depois desligava-se.
Mas o ALB só verificava a saúde de tempos a tempos, e precisava de várias falhas seguidas antes de deixar de encaminhar para um alvo — até cerca de 60 segundos no total. Por isso, durante uns 55 segundos, o balanceador continuava a enviar pedidos reais para um servidor que já tinha deixado de escutar. Esses eram os nossos 502.
A correção foi fazer as duas linhas de tempo concordarem:
- O target group verifica agora a cada 5 segundos e marca um alvo como não saudável após 2 falhas — cerca de 10 segundos para notar.
- A drenagem continua a servir tráfego real durante 15 segundos depois de começar a falhar os checks de saúde, para haver sempre sobreposição.
- Estas definições vivem no script que provisiona a infraestrutura, e não numa consola que alguém clicou uma vez.
Fazer os números sustentarem-se uns aos outros
Timeouts como estes dependem uns dos outros. A drenagem tem de durar mais do que o tempo que o balanceador precisa para notar; a drenagem mais o encerramento têm de terminar antes de o contentor ser morto à força. É fácil alguém mudar um número daqui a seis meses e reintroduzir o bug sem dar por isso.
Por isso, escrevemos essas relações como testes: a drenagem tem de ser mais longa do que o intervalo de check de saúde vezes o limiar de falhas; o tempo para voltar a ser marcado como saudável tem de continuar curto; a drenagem inteira tem de caber no período de graça de paragem do contentor. Muda um número sem mudar os outros e o CI falha. Também fizemos mutation testing aos próprios testes — mudámos cada número para um valor errado e verificámos que um teste o apanhava.
Medir um deploy é fácil de fazer mal
A nossa primeira medição dizia que um deploy limpo estava cerca de 3% partido. Não estava. Estávamos a contar todas as respostas que não eram 200, e um 503 de um endpoint de saúde durante uma drenagem não é uma falha — é a drenagem a fazer o seu trabalho, dizendo ao balanceador para não escolher esse servidor enquanto continua a servir pedidos reais.
O que os utilizadores sentem é um 502: o pedido não chegou a lado nenhum. Contar só esses deu a imagem real — 10, depois 6, depois 0 em três deploys de produção.
O que tirámos disto
- A autorrecuperação tem de conhecer o downtime planeado. Um watchdog que não consegue distinguir um deploy de um crash acaba por lutar contra o deploy.
- Drenar é um aperto de mão. O servidor tem de continuar a servir até o balanceador de carga ter provado que deixou de enviar tráfego, e esse timing é definido no balanceador, não no servidor.
- Codifica os invariantes como testes. Os números operacionais derivam. Testes que os relacionam entre si não derivam.
- Decide o que estás a medir antes de medir. "Erros" era a métrica errada; "pedidos que não chegaram a lado nenhum" era a certa.
Se publicas uma app no Mythex, nada disto é visível para ti — que é precisamente o objetivo. Relacionado: como a tua app publicada continua no ar enquanto a republicas.