Blog

Health check que mente: o serviço responde 200 enquanto não consegue atender ninguém

Um cenário que se repete em muitos times: um serviço expõe um endpoint /health que devolve 200 há meses. Numa sexta à tarde o banco fica lento, o pool de conexões perde todas as conexões livres e as requisições passam a esperar quarenta segundos antes de falhar. O balanceador continua mandando tráfego para todas as instâncias, porque o health check segue respondendo 200. E ele não mente sobre estar vivo: o processo está de pé, o event loop gira e a porta aceita conexões. Só que o endpoint nunca toca o banco, então não tem como saber que ninguém está sendo atendido. Este artigo trata da diferença entre estar vivo e conseguir atender, de como liveness, readiness e alerta fazem perguntas distintas, do que um health check honesto precisa medir sem tirar a frota inteira de circulação quando uma dependência compartilhada tropeça, e de como testar o endpoint para que ele falhe quando deve.

2026-10-11 / Arquitetura / 14 min

01

Estar vivo não é o mesmo que conseguir atender

Um health check mistura três perguntas diferentes, e a maioria dos endpoints responde só à primeira. A liveness pergunta se o processo está preso e precisa ser reiniciado: um deadlock, um event loop parado, um estado do qual ele não se recupera. A readiness pergunta se esta instância deve receber tráfego agora. O alerta pergunta se o serviço, como conjunto, entrega o que promete a quem o usa. Cada uma tem consumidor, ação e custo de falso positivo diferentes, e confundi-las é a origem da maioria dos problemas.

VerificaçãoPerguntaQuem consomeAção quando falha
LivenessO processo está preso e precisa ser reiniciado?Kubelet ou supervisorReinicia o container
ReadinessEsta instância consegue atender uma requisição agora?Balanceador e endpoints do ServiceTira a instância da rotação, sem reiniciá-la
Alerta de SLIOs usuários recebem respostas corretas no tempo prometido?Time de plantão e painéisAvisa uma pessoa ou abre incidente

O erro mais caro é colocar a verificação de dependências na liveness. Se o banco fica lento por dois minutos, todas as instâncias falham na liveness ao mesmo tempo, o kubelet reinicia todas, e cada reinício derruba as conexões quentes, esvazia caches locais e obriga o pool a reabrir sessões justamente quando o banco mais precisa de folga. A liveness só deve responder se o processo pode continuar; ela não deve depender de nada fora dele.

02

Por que o endpoint responde 200 com o serviço parado

O endpoint típico cabe numa linha, e essa linha prova muito pouco. Ela mostra que o servidor HTTP aceitou a conexão e que o event loop executou o callback. Não mostra que o pool tem conexão livre, que o Redis responde, que a fila está sendo consumida nem que a resposta sai num tempo que o usuário tolera.

app.get('/health', (req, res) => res.status(200).send('ok'));

Num processo Node.js com o pool do banco esgotado, esse handler continua respondendo em milissegundos, porque a espera pela conexão acontece dentro das rotas de negócio, e não no handler de saúde. O balanceador vê 200, mantém a instância na rotação e manda mais requisições para uma fila que não anda.

A causa aparece de dois jeitos. O primeiro é o health check medir só o processo, por escolha ou por pressa. O segundo é a checagem existir, mas sem prazo. Um SELECT 1 esperando uma conexão do pool fica pendurado, e no node-postgres, sem connectionTimeoutMillis, essa espera não tem fim. O orquestrador acaba marcando a instância como falha pelo timeout da sondagem, o que está certo pelo motivo errado: a instância não está morta, está saturada, e o motivo se perde no caminho.

03

O que medir: a armadilha da dependência compartilhada

Verificar o banco no readiness parece a correção óbvia, e é exatamente o caminho que muitos times seguem depois do incidente. O risco é a correlação. Se todas as instâncias dependem do mesmo banco, uma lentidão nele tira todas da rotação ao mesmo tempo. Sem nenhuma instância saudável, o balanceador responde 503 para todo o tráfego, quando reduzir a concorrência para o banco teria preservado parte do atendimento. A verificação que deveria proteger o sistema vira a causa da indisponibilidade total.

Banco lento (p99 de 4 s), 3 instancias, checagem do banco marcada como critica:

t=0    instancia A: checagem do banco falha -> 503 -> removida
       instancia B: checagem do banco falha -> 503 -> removida
       instancia C: checagem do banco falha -> 503 -> removida
t=10s  balanceador sem backend saudavel: 503 para 100% do trafego

Sem a checagem critica, as tres continuam atendendo: requisicoes lentas e algumas
falhas, mas o trafego que ainda passa segue entregando valor.

A regra prática é separar o que a instância controla do que ela apenas consome. Pool local sem slot livre, worker de fila sem registro, drenagem em curso e event loop com atraso acima do limite operacional são estados da própria instância e podem entrar como critério de rotação. Banco compartilhado, Redis e APIs de terceiros costumam entrar como degradação: o endpoint informa o estado, o alerta dispara e a instância segue atendendo o que consegue.

Existe a exceção. Se a instância não tem como fazer nada sem uma dependência, porque a razão de existir dela é aquele recurso, a verificação pode ser crítica. Mesmo assim, avalie o efeito em massa: se a queda da dependência vai tirar todas as instâncias de uma vez, a decisão precisa de um limite de quantas podem sair simultaneamente, ou deve ficar com quem enxerga a frota inteira.

04

Um avaliador que não mente em Node.js

O avaliador abaixo tem quatro propriedades. Cada checagem tem prazo, então uma conexão que nunca chega vira falha em vez de espera infinita. As checagens rodam em paralelo, então o tempo total é o da mais lenta, e não a soma. O resultado vale por dois segundos e é compartilhado entre requisições concorrentes, para que uma rajada de sondagens não vire uma rajada de SELECT 1 contra o banco. E a resposta separa o que bloqueia a instância, com 503, do que só degrada, com 200 e o detalhe no corpo.

import http from 'node:http';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

const CACHE_MS = 2000; // o resultado vale por 2 s: sondagens concorrentes reaproveitam a mesma checagem
const PRAZO_PADRAO_MS = 1000; // abaixo do timeoutSeconds da sondagem, com folga

function comPrazo(promessa, ms, nome) {
  let timer;
  const prazo = new Promise((_, rejeitar) => {
    timer = setTimeout(() => rejeitar(new Error(nome + ': sem resposta em ' + ms + ' ms')), ms);
  });
  return Promise.race([promessa, prazo]).finally(() => clearTimeout(timer));
}

export function criarAvaliador({ checagens, criticas = [], prazoMs = PRAZO_PADRAO_MS }) {
  let cache = null;
  let emCurso = null;

  async function executar() {
    const resultados = {};
    await Promise.all(
      Object.entries(checagens).map(async ([nome, checar]) => {
        const inicio = performance.now();
        try {
          await comPrazo(checar(), prazoMs, nome);
          resultados[nome] = { ok: true, ms: Math.round(performance.now() - inicio) };
        } catch (erro) {
          resultados[nome] = { ok: false, erro: erro.message };
        }
      }),
    );

    const bloqueada = criticas.some((nome) => !resultados[nome].ok);
    const degradada = Object.values(resultados).some((r) => !r.ok);
    const resposta = {
      status: bloqueada ? 503 : 200,
      corpo: {
        estado: bloqueada ? 'indisponivel' : degradada ? 'degradado' : 'ok',
        checagens: resultados,
      },
    };
    cache = { em: Date.now(), resposta };
    return resposta;
  }

  return function avaliar() {
    if (cache && Date.now() - cache.em < CACHE_MS) return Promise.resolve(cache.resposta);
    emCurso ??= executar().finally(() => {
      emCurso = null;
    });
    return emCurso;
  };
}

export function criarServidorSaude(avaliar) {
  return http.createServer(async (req, res) => {
    if (req.url === '/health/live') {
      res.writeHead(200).end('ok'); // o processo responde; nenhuma dependencia entra aqui
      return;
    }
    if (req.url === '/health/ready') {
      const { status, corpo } = await avaliar();
      res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(corpo));
      return;
    }
    res.writeHead(404).end();
  });
}
  • O prazo interno precisa ficar abaixo do timeoutSeconds da sondagem, com folga. Se o avaliador leva 1,5 s e o kubelet desiste em 1 s, a instância é marcada como falha mesmo saudável, e o corpo da resposta nunca chega a quem precisava dele.
  • O corpo expõe o nome das checagens e as mensagens de erro. Num endpoint público isso revela topologia e detalhes internos. Restrinja o endpoint à rede interna ou remova o campo erro da resposta e mantenha a mensagem no log.
  • O event loop entra como degradação, não como bloqueio. Uma instância lenta por carga ainda produz; tirá-la da rotação concentra o tráfego nas outras, que ficam mais lentas, e o efeito se espalha.

Na inicialização, o avaliador é ligado ao pool e ao monitor de event loop:

import { Pool } from 'pg';
import { criarAvaliador, criarServidorSaude } from './saude.js';

const pool = new Pool({ max: 10 });
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();

const avaliar = criarAvaliador({
  checagens: {
    banco: () => pool.query('SELECT 1'), // se nao ha conexao livre, a espera e limitada pelo prazo
    event_loop: async () => {
      const p99Ms = loop.percentile(99) / 1e6; // o histograma guarda nanossegundos
      loop.reset();
      if (p99Ms > 200) throw new Error('p99 de ' + Math.round(p99Ms) + ' ms');
    },
  },
  criticas: ['banco'], // so o banco local bloqueia a instancia; o event loop apenas degrada
});

criarServidorSaude(avaliar).listen(3000);

05

Quem lê o health check e o que cada um faz com ele

Um mesmo health check é lido por consumidores com regras diferentes, e configurar cada um sem pensar nos outros gera o próximo incidente. O kubelet olha a liveness e reinicia o container. O Service do Kubernetes usa a readiness para tirar o pod dos endpoints. Um balanceador externo, como um ALB apontando para IPs ou um nginx com upstream estático, faz a própria checagem e não obedece ao Kubernetes.

ConsumidorEndpointEfeito da falhaAjuste que evita o problema
Kubelet (liveness)/health/liveReinicia o containerSó o processo; initialDelaySeconds cobrindo a inicialização e failureThreshold alto
Service do Kubernetes (readiness)/health/readyTira o pod dos endpointstimeoutSeconds acima do prazo interno do avaliador
Balanceador externo/health/readyTira a instância do pool de destinoIntervalo e limiar iguais aos da readiness, para não divergir
Alerta e painéis/health/ready e métricasDispara notificaçãoAlerta por janela de tempo, não por uma única amostra ruim

O limiar importa mais do que parece. Com periodSeconds de 5 e failureThreshold de 2, uma instância ruim recebe tráfego por cerca de dez segundos antes de sair. A readiness precisa ser rápida para remover e tolerante para não remover por um soluço de meio segundo.

livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  initialDelaySeconds: 15
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2

06

Testar o caminho que deveria falhar

O endpoint de saúde costuma ser o código menos testado do serviço, e é justamente ele que decide se o serviço recebe tráfego. Testar que ele responde 200 quando tudo está bem não custa nada e não prova nada. O que importa é simular a dependência que não responde, o pool sem conexão livre e a checagem que lança erro, e verificar que o status muda e que a resposta sai dentro do prazo.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { criarAvaliador } from './saude.js';

test('pool sem conexao livre vira 503, nao 200', async () => {
  const avaliar = criarAvaliador({
    checagens: { banco: () => new Promise(() => {}) }, // nunca resolve: a conexao nunca chega
    criticas: ['banco'],
    prazoMs: 50,
  });

  const { status, corpo } = await avaliar();

  assert.equal(status, 503);
  assert.equal(corpo.checagens.banco.ok, false);
});

test('falha em dependencia nao critica degrada sem remover a instancia', async () => {
  const avaliar = criarAvaliador({
    checagens: {
      banco: async () => {},
      cache: async () => {
        throw new Error('ECONNREFUSED');
      },
    },
    criticas: ['banco'],
    prazoMs: 50,
  });

  const { status, corpo } = await avaliar();

  assert.equal(status, 200);
  assert.equal(corpo.estado, 'degradado');
});

Teste de unidade prova a lógica, mas não o comportamento com o pool real. Dois exercícios completam a cobertura. O primeiro é um teste de carga com o pool no tamanho de produção e uma rota que segura a conexão por três segundos: a readiness deve passar a 503 enquanto a carga dura e voltar a 200 quando ela cai, sem nenhum reinício. O segundo é um experimento de falha em homologação, cortando a rede até o banco: o alerta precisa disparar e a instância precisa sair da rotação em poucos ciclos de sondagem. Se qualquer um desses resultados não acontecer, o health check é decoração.

FAQ

Perguntas frequentes

Posso usar só o endpoint de liveness para o balanceador?

Pode, mas você perde a única informação que tira a instância da rotação sem reiniciá-la. Liveness reinicia; readiness remove. Quando a instância tem o pool esgotado mas o processo está íntegro, reiniciar só joga fora o cache e as conexões que ainda eram úteis. Use readiness para a rotação e reserve liveness para estado irrecuperável, como um deadlock comprovado ou um event loop parado por tempo demais.

Quantas checagens um health check deve ter?

As que mudam uma decisão. Cada checagem é um ponto de falha novo, um consumo extra de conexões e uma linha de ruído no alerta. Se uma checagem falha e ninguém faz nada diferente por causa dela, ela não pertence ao endpoint de readiness; é uma métrica. Três ou quatro checagens cobrindo pool, event loop e uma dependência crítica costumam bastar para a maioria dos serviços.

Como evitar que o próprio health check sobrecarregue o banco?

Com cache curto, como os dois segundos do avaliador, e com um SELECT 1 em vez de uma consulta de negócio. Se a sondagem roda a cada cinco segundos em dezenas de instâncias, o custo é pequeno; o risco aparece quando alguém troca o SELECT 1 por uma consulta com joins e cada sondagem vira carga real. Revise o health check com o mesmo rigor de uma rota de produção.

O health check responde pelo que o serviço consegue fazer, não pelo que o processo é

Um endpoint que responde 200 enquanto ninguém é atendido não é um sinal de saúde, é um sinal de que o processo está vivo. A liveness deve olhar só para o processo, a readiness deve refletir o estado local que a instância controla, as dependências compartilhadas devem degradar e não derrubar a frota, e cada checagem precisa de prazo, cache e um teste que simule a falha real. Com isso, o balanceador deixa de mandar tráfego para quem não consegue atender, e o time deixa de descobrir o incidente pelo suporte.