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
- Evite loops infinitos: Probes devem ter timeout e não depender de recursos que podem travar.
- Cuidado com cascata de falhas: Não faça seu health check depender de serviços que dependem do seu.
- 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
}
- Documentação e logging estruturado: Sempre registre falhas de probe com contexto suficiente para diagnóstico.
Referências
- Kubernetes Liveness and Readiness Probes Documentation — Documentação oficial do Kubernetes sobre configuração de probes de liveness, readiness e startup.
- Go net/http Package Documentation — Documentação oficial do pacote net/http do Go, fundamental para criar handlers HTTP.
- Gin Web Framework Documentation — Documentação oficial do Gin, framework web popular para Go com exemplos de rotas e middlewares.
- Gorilla Mux Package Documentation — Documentação do roteador gorilla/mux, amplamente utilizado para criar rotas HTTP em Go.
- expvar Package Documentation — Documentação oficial do pacote expvar do Go, usado para expor métricas e variáveis de diagnóstico.
- Database/sql Package Documentation — Documentação oficial do pacote database/sql, essencial para verificar conexões com bancos de dados em readiness probes.
- Context Package Documentation — Documentação oficial do pacote context do Go, usado para controlar timeouts em probes.