Health checks e readiness probes

1. Fundamentos de Health Checks e Readiness Probes

Em sistemas distribuídos, especialmente em ambientes orquestrados como Kubernetes, a distinção entre liveness probe (health check) e readiness probe é fundamental para a resiliência do serviço.

  • Liveness probe (health check): Indica se o serviço está vivo e funcionando. Se falhar, o orquestrador reinicia o container.
  • Readiness probe: Indica se o serviço está pronto para receber tráfego. Se falhar, o pod é removido dos balanceadores de carga.

O ciclo de vida de um serviço passa por quatro estados principais:
- Startup: Dependências estão sendo carregadas, o serviço ainda não está pronto.
- Running: Serviço saudável e pronto para tráfego.
- Degraded: Serviço funciona, mas com dependências parciais (ex: cache falhou, mas banco funciona).
- Shutting down: Serviço recebe sinal de término, deve parar de aceitar novas requisições.

Para orquestradores como Kubernetes e Docker Swarm, probes bem implementadas evitam downtime e permitem atualizações rolling sem interrupção.

2. Implementando Health Checks com net/http

O pacote net/http do Go permite criar endpoints de health check de forma simples. Vamos construir um exemplo com estado global gerenciado por sync.RWMutex:

package main

import (
    "encoding/json"
    "net/http"
    "sync"
)

type HealthStatus struct {
    Status  string `json:"status"`
    Message string `json:"message,omitempty"`
}

type HealthChecker struct {
    mu     sync.RWMutex
    health bool
}

func NewHealthChecker() *HealthChecker {
    return &HealthChecker{health: true}
}

func (hc *HealthChecker) SetHealth(status bool) {
    hc.mu.Lock()
    defer hc.mu.Unlock()
    hc.health = status
}

func (hc *HealthChecker) IsHealthy() bool {
    hc.mu.RLock()
    defer hc.mu.RUnlock()
    return hc.health
}

func (hc *HealthChecker) HealthHandler(w http.ResponseWriter, r *http.Request) {
    if !hc.IsHealthy() {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(HealthStatus{
            Status:  "unhealthy",
            Message: "service is not healthy",
        })
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(HealthStatus{Status: "healthy"})
}

func main() {
    hc := NewHealthChecker()
    http.HandleFunc("/health", hc.HealthHandler)
    http.ListenAndServe(":8080", nil)
}

Este exemplo retorna 200 OK quando saudável e 503 Service Unavailable quando não. O sync.RWMutex garante acesso concorrente seguro ao estado.

3. Implementando Readiness Probes com Indicadores de Dependência

Readiness probes devem verificar dependências externas. Vamos criar um endpoint /ready que testa conexão com PostgreSQL:

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "net/http"
    "time"
)

type ReadinessChecker struct {
    db *sql.DB
}

func NewReadinessChecker(db *sql.DB) *ReadinessChecker {
    return &ReadinessChecker{db: db}
}

func (rc *ReadinessChecker) ReadyHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    err := rc.db.PingContext(ctx)
    if err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "not ready",
            "error":  err.Error(),
        })
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
}

func main() {
    db, _ := sql.Open("postgres", "postgres://user:pass@localhost/dbname")

    rc := NewReadinessChecker(db)
    http.HandleFunc("/ready", rc.ReadyHandler)
    http.ListenAndServe(":8080", nil)
}

O uso de context.WithTimeout evita que o probe bloqueie indefinidamente caso o banco esteja lento.

4. Estrutura Modular com Interfaces e Handlers

Para sistemas complexos, uma abordagem modular com interfaces facilita a manutenção:

package main

import (
    "context"
    "encoding/json"
    "net/http"
)

type HealthChecker interface {
    Check(ctx context.Context) error
    Name() string
}

type DatabaseHealthChecker struct {
    dsn string
}

func (d *DatabaseHealthChecker) Check(ctx context.Context) error {
    // Implementação de verificação de banco
    return nil
}

func (d *DatabaseHealthChecker) Name() string {
    return "database"
}

type CacheHealthChecker struct {
    addr string
}

func (c *CacheHealthChecker) Check(ctx context.Context) error {
    // Implementação de verificação de cache
    return nil
}

func (c *CacheHealthChecker) Name() string {
    return "cache"
}

type CompositeHealthChecker struct {
    checkers []HealthChecker
}

func (cc *CompositeHealthChecker) Check(ctx context.Context) error {
    for _, checker := range cc.checkers {
        if err := checker.Check(ctx); err != nil {
            return err
        }
    }
    return nil
}

func (cc *CompositeHealthChecker) HealthHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := cc.Check(ctx); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "unhealthy",
            "error":  err.Error(),
        })
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}

func main() {
    composite := &CompositeHealthChecker{
        checkers: []HealthChecker{
            &DatabaseHealthChecker{dsn: "postgres://..."},
            &CacheHealthChecker{addr: "localhost:6379"},
        },
    }

    http.HandleFunc("/health", composite.HealthHandler)
    http.ListenAndServe(":8080", nil)
}

5. Integração com Frameworks e Roteadores Populares

Exemplo com Gin

package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    healthGroup := r.Group("/health")
    {
        healthGroup.GET("/live", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{"status": "alive"})
        })
        healthGroup.GET("/ready", func(c *gin.Context) {
            // Verificar dependências
            c.JSON(http.StatusOK, gin.H{"status": "ready"})
        })
    }

    r.Run(":8080")
}

Exemplo com gorilla/mux

package main

import (
    "net/http"
    "github.com/gorilla/mux"
)

func healthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Lógica de health check
        next.ServeHTTP(w, r)
    })
}

func main() {
    r := mux.NewRouter()
    r.Use(healthMiddleware)

    r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }).Methods("GET")

    http.ListenAndServe(":8080", r)
}

6. Health Checks Avançados com Métricas e Degradação Gradual

Para sistemas em produção, incluir métricas e permitir degradação gradual é essencial:

package main

import (
    "encoding/json"
    "expvar"
    "net/http"
    "sync/atomic"
)

var (
    requestCount  int64
    errorCount    int64
    latencySum    int64
)

type DegradedHealthResponse struct {
    Status      string            `json:"status"`
    Degraded    []string          `json:"degraded,omitempty"`
    Metrics     map[string]int64  `json:"metrics"`
}

func advancedHealthHandler(w http.ResponseWriter, r *http.Request) {
    degraded := []string{}

    // Simular verificação de dependências
    dbHealthy := checkDatabase()
    cacheHealthy := checkCache()

    if !dbHealthy {
        degraded = append(degraded, "database")
    }
    if !cacheHealthy {
        degraded = append(degraded, "cache")
    }

    status := "healthy"
    httpStatus := http.StatusOK

    if len(degraded) > 0 {
        if dbHealthy { // Ainda funcional, mas degradado
            status = "degraded"
        } else {
            status = "unhealthy"
            httpStatus = http.StatusServiceUnavailable
        }
    }

    response := DegradedHealthResponse{
        Status:   status,
        Degraded: degraded,
        Metrics: map[string]int64{
            "requests": atomic.LoadInt64(&requestCount),
            "errors":   atomic.LoadInt64(&errorCount),
            "avg_latency_ms": atomic.LoadInt64(&latencySum) / 
                max(1, atomic.LoadInt64(&requestCount)),
        },
    }

    w.WriteHeader(httpStatus)
    json.NewEncoder(w).Encode(response)
}

7. Testes Automatizados de Health e Readiness

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealthHandler(t *testing.T) {
    hc := NewHealthChecker()

    // Teste quando saudável
    req := httptest.NewRequest("GET", "/health", nil)
    w := httptest.NewRecorder()

    hc.HealthHandler(w, req)

    if w.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", w.Code)
    }

    // Teste quando não saudável
    hc.SetHealth(false)

    w = httptest.NewRecorder()
    hc.HealthHandler(w, req)

    if w.Code != http.StatusServiceUnavailable {
        t.Errorf("expected 503, got %d", w.Code)
    }
}

func TestReadinessWithTimeout(t *testing.T) {
    // Mock de banco de dados que demora
    mockDB := &MockDB{delay: 5 * time.Second}
    rc := NewReadinessChecker(mockDB)

    req := httptest.NewRequest("GET", "/ready", nil)
    w := httptest.NewRecorder()

    rc.ReadyHandler(w, req)

    if w.Code != http.StatusServiceUnavailable {
        t.Errorf("expected timeout to return 503, got %d", w.Code)
    }
}

8. Boas Práticas e Armadilhas Comuns

  1. Evite loops infinitos: Probes devem ter timeout e não depender de recursos que podem travar.
  2. Cuidado com cascata de falhas: Não faça seu health check depender de serviços que dependem do seu.
  3. Cache de resultados: Para dependências lentas, use cache com TTL para evitar sobrecarga:
type CachedHealthChecker struct {
    checker    HealthChecker
    mu         sync.RWMutex
    lastResult error
    lastCheck  time.Time
    ttl        time.Duration
}

func (c *CachedHealthChecker) Check(ctx context.Context) error {
    c.mu.RLock()
    if time.Since(c.lastCheck) < c.ttl {
        err := c.lastResult
        c.mu.RUnlock()
        return err
    }
    c.mu.RUnlock()

    c.mu.Lock()
    defer c.mu.Unlock()

    c.lastResult = c.checker.Check(ctx)
    c.lastCheck = time.Now()
    return c.lastResult
}
  1. Documentação e logging estruturado: Sempre registre falhas de probe com contexto suficiente para diagnóstico.

Referências