Implementando healthcheck endpoints ricos com dependências externas

1. Fundamentos do Healthcheck em Sistemas Distribuídos

Um healthcheck endpoint é um ponto de verificação que vai muito além de um simples 200 OK. Em sistemas distribuídos, ele é a principal ferramenta para orquestradores como Kubernetes e Docker Swarm tomarem decisões sobre o estado dos serviços. Um healthcheck pobre pode causar falsos positivos, onde um serviço aparentemente saudável está na verdade degradado, ou falsos negativos, onde um serviço funcional é derrubado desnecessariamente.

A diferença entre os tipos de probes é crucial:
- Liveness probe: Indica se o container deve ser reiniciado. Se falha, o pod é morto e recriado.
- Readiness probe: Indica se o serviço está pronto para receber tráfego. Se falha, o pod é removido dos endpoints do Service.
- Startup probe: Usado para serviços que demoram a inicializar, desativando as outras probes até que esteja pronto.

Orquestradores que recebem healthchecks pobres podem causar efeitos cascata, derrubando serviços inteiros por falhas temporárias em dependências não críticas.

2. Projetando a Estrutura do Endpoint de Healthcheck

A resposta JSON deve ser rica e padronizada. Um modelo eficiente inclui:

{
  "status": "healthy",
  "version": "1.0.0",
  "uptime": 123456,
  "dependencies": {
    "database": {
      "status": "healthy",
      "latency_ms": 2,
      "last_check": "2024-01-15T10:30:00Z"
    },
    "redis": {
      "status": "degraded",
      "latency_ms": 150,
      "error": "High latency detected"
    },
    "external_api": {
      "status": "unhealthy",
      "error": "Connection refused"
    }
  },
  "metadata": {
    "hostname": "web-01",
    "environment": "production"
  }
}

Versionamento do endpoint é prática recomendada:
- /healthz para liveness probe
- /ready para readiness probe
- /health para healthcheck completo

A estratégia de agregação deve considerar degradação parcial. Um status degraded indica que o serviço funciona, mas com performance reduzida, enquanto unhealthy significa falha total.

3. Verificando Dependências Externas de Forma Robusta

Cada tipo de dependência requer uma abordagem específica:

Banco de dados: Além do ping, execute uma consulta leve como SELECT 1 para validar a capacidade de resposta real.

async function checkDatabase() {
  try {
    const start = Date.now();
    await db.raw('SELECT 1');
    return {
      status: 'healthy',
      latency_ms: Date.now() - start
    };
  } catch (error) {
    return {
      status: 'unhealthy',
      error: error.message
    };
  }
}

Filas e caches: Redis, RabbitMQ e Kafka devem ser verificados com comandos específicos. Para Redis, use PING; para RabbitMQ, verifique a conexão com o canal.

APIs externas: Implemente timeouts rigorosos e fallbacks. Uma API externa falhando não deve derrubar todo o serviço.

async function checkExternalAPI() {
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 2000);

    const response = await fetch('https://api.exemplo.com/health', {
      signal: controller.signal
    });

    clearTimeout(timeout);
    return {
      status: response.ok ? 'healthy' : 'unhealthy',
      status_code: response.status
    };
  } catch (error) {
    return {
      status: 'degraded',
      error: 'API externa indisponível'
    };
  }
}

4. Tratamento de Timeouts, Erros e Degradação Parcial

Cada dependência deve ter seu próprio timeout para evitar bloqueio do healthcheck geral. Use Promise.race ou AbortController para garantir que verificações lentas não travem todo o processo.

Classificação de erros:
- Conexão recusada: Serviço não está rodando ou porta errada
- Timeout: Serviço lento ou sobrecarregado
- Resposta inesperada: Dados corrompidos ou protocolo incompatível

A lógica de degradação deve ser inteligente. Se um componente não crítico falha, o serviço pode continuar operando com funcionalidade reduzida, mas o healthcheck deve reportar degraded em vez de unhealthy.

5. Cache e Otimização de Performance no Healthcheck

Healthchecks não devem sobrecarregar as dependências. Implemente cache local com TTL configurável:

const healthCache = {
  data: null,
  lastUpdate: 0,
  ttl: 5000 // 5 segundos
};

async function getHealthStatus() {
  const now = Date.now();
  if (healthCache.data && (now - healthCache.lastUpdate) < healthCache.ttl) {
    return healthCache.data;
  }

  healthCache.data = await runHealthChecks();
  healthCache.lastUpdate = now;
  return healthCache.data;
}

Verificações paralelas reduzem drasticamente a latência total. Use Promise.all para executar verificações simultâneas, mas com cuidado para não sobrecarregar recursos.

Rate limiting é essencial para evitar que healthchecks frequentes derrubem dependências já sobrecarregadas.

6. Segurança e Observabilidade do Endpoint

Restrinja o acesso ao healthcheck:
- Rede interna apenas (firewall rules)
- Autenticação básica ou token
- Rate limiting por IP

Logging estruturado deve incluir:
- Trace ID para correlação
- Timestamp preciso
- Latência por dependência
- Status code retornado

Métricas Prometheus essenciais:

healthcheck_latency_seconds{dependency="database"} 0.002
healthcheck_failures_total{dependency="redis"} 5
healthcheck_status{dependency="external_api"} 0

7. Exemplo Prático: Implementação em Node.js/Express

const express = require('express');
const app = express();

async function checkDependency(name, checkFn) {
  const start = Date.now();
  try {
    const result = await checkFn();
    return {
      name,
      status: result.status || 'healthy',
      latency_ms: Date.now() - start,
      ...(result.error && { error: result.error })
    };
  } catch (error) {
    return {
      name,
      status: 'unhealthy',
      latency_ms: Date.now() - start,
      error: error.message
    };
  }
}

async function runHealthChecks() {
  const checks = await Promise.all([
    checkDependency('database', () => db.raw('SELECT 1')),
    checkDependency('redis', () => redis.ping()),
    checkDependency('external_api', () => 
      fetch('https://api.exemplo.com/health', { signal: AbortSignal.timeout(2000) })
    )
  ]);

  const dependencies = {};
  let overallStatus = 'healthy';

  for (const check of checks) {
    dependencies[check.name] = {
      status: check.status,
      latency_ms: check.latency_ms,
      ...(check.error && { error: check.error })
    };

    if (check.status === 'unhealthy') {
      overallStatus = 'unhealthy';
    } else if (check.status === 'degraded' && overallStatus !== 'unhealthy') {
      overallStatus = 'degraded';
    }
  }

  return {
    status: overallStatus,
    version: '1.0.0',
    uptime: process.uptime(),
    dependencies
  };
}

app.get('/health', async (req, res) => {
  const health = await runHealthChecks();
  const statusCode = health.status === 'healthy' ? 200 : 
                     health.status === 'degraded' ? 200 : 503;
  res.status(statusCode).json(health);
});

app.listen(3000);

8. Boas Práticas e Integração com Orquestradores

Para Kubernetes, configure probes adequadamente:

livenessProbe:
  httpGet:
    path: /healthz
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 15

readinessProbe:
  httpGet:
    path: /ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 10

Evite efeito cascata: healthcheck não deve depender de si mesmo. Não faça chamadas recursivas ou loops infinitos.

Documente o formato da resposta e o SLA esperado para cada dependência. Isso facilita troubleshooting e mantém expectativas claras entre equipes.

Referências