Frontend Labs
⋯

Day 106 — Health Checks & Readiness Probes

Learning objective Build a composable health check framework with per-subsystem status
Key packages net/http, context, encoding/json, sync, time
Security context Kubernetes routes traffic based on readiness probes — misconfigured probes cause user-visible failures

Key Concepts

Kubernetes probe types:

Probe Question Failure action
Liveness Is the process alive and not deadlocked? Kill + restart container
Readiness Is the service ready for traffic? Remove from load balancer pool
Startup Has the service finished startup? Don't kill during slow init

Rule: Liveness checks only detect catastrophic failure (deadlock, panic). Don't check upstream dependencies in liveness — a slow database should not cause your pod to restart.

Exercise — Health Check Framework

package health

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "sync"
    "time"
)

// CheckFunc is a function that checks if a subsystem is healthy.
// It must respect context cancellation and return within the deadline.
type CheckFunc func(ctx context.Context) error

type checkEntry struct {
    name    string
    fn      CheckFunc
    timeout time.Duration
    // For readiness: is this required for traffic?
    required bool
}

// Registry holds all registered health checks
type Registry struct {
    mu     sync.RWMutex
    checks []checkEntry
}

func NewRegistry() *Registry {
    return &Registry{}
}

// Register adds a check. Use required=true for readiness-gating checks.
func (r *Registry) Register(name string, fn CheckFunc, timeout time.Duration, required bool) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.checks = append(r.checks, checkEntry{name: name, fn: fn, timeout: timeout, required: required})
}

type CheckResult struct {
    Status  string `json:"status"` // "ok" or "fail"
    Error   string `json:"error,omitempty"`
    Latency string `json:"latency_ms"`
}

type HealthResponse struct {
    Status string                 `json:"status"` // "ok" or "degraded" or "unhealthy"
    Checks map[string]CheckResult `json:"checks"`
    Took   string                 `json:"took_ms"`
}

func (r *Registry) run(ctx context.Context) HealthResponse {
    r.mu.RLock()
    checks := make([]checkEntry, len(r.checks))
    copy(checks, r.checks)
    r.mu.RUnlock()

    start := time.Now()
    results := make(map[string]CheckResult, len(checks))
    var mu sync.Mutex
    var wg sync.WaitGroup

    for _, c := range checks {
        wg.Add(1)
        go func(c checkEntry) {
            defer wg.Done()
            checkCtx, cancel := context.WithTimeout(ctx, c.timeout)
            defer cancel()

            checkStart := time.Now()
            err := c.fn(checkCtx)
            latency := time.Since(checkStart)

            result := CheckResult{
                Latency: fmt.Sprintf("%.1f", float64(latency.Microseconds())/1000.0),
            }
            if err != nil {
                result.Status = "fail"
                result.Error = err.Error()
            } else {
                result.Status = "ok"
            }

            mu.Lock()
            results[c.name] = result
            mu.Unlock()
        }(c)
    }
    wg.Wait()

    // Aggregate status
    status := "ok"
    for _, c := range checks {
        res := results[c.name]
        if res.Status == "fail" && c.required {
            status = "unhealthy"
            break
        } else if res.Status == "fail" {
            if status == "ok" {
                status = "degraded"
            }
        }
    }

    return HealthResponse{
        Status: status,
        Checks: results,
        Took:   fmt.Sprintf("%.1f", float64(time.Since(start).Microseconds())/1000.0),
    }
}

// LivezHandler handles GET /healthz (liveness — is process alive and not deadlocked?)
// Only runs lightweight checks — never checks upstream dependencies.
func (r *Registry) LivezHandler(w http.ResponseWriter, req *http.Request) {
    // Liveness is simple: if this handler runs, the process is alive.
    // Run only non-required checks with very short timeouts.
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

// ReadyzHandler handles GET /readyz (readiness — ready for traffic?)
// Runs all required checks. Fails if any required check fails.
func (r *Registry) ReadyzHandler(w http.ResponseWriter, req *http.Request) {
    ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
    defer cancel()

    resp := r.run(ctx)
    w.Header().Set("Content-Type", "application/json")
    if resp.Status == "unhealthy" {
        w.WriteHeader(http.StatusServiceUnavailable)
    }
    json.NewEncoder(w).Encode(resp)
}

// StartupHandler handles GET /startup — allows slow initialization.
// After startup completes, this always returns 200.
type StartupHandler struct {
    ready chan struct{}
    once  sync.Once
}

func NewStartupHandler() *StartupHandler {
    return &StartupHandler{ready: make(chan struct{})}
}

func (sh *StartupHandler) MarkReady() {
    sh.once.Do(func() { close(sh.ready) })
}

func (sh *StartupHandler) Handler(w http.ResponseWriter, r *http.Request) {
    select {
    case <-sh.ready:
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ready"}`))
    default:
        w.WriteHeader(http.StatusServiceUnavailable)
        w.Write([]byte(`{"status":"starting"}`))
    }
}

// Example checks for common subsystems
func NewTCPCheck(addr string) CheckFunc {
    return func(ctx context.Context) error {
        d := net.Dialer{}
        conn, err := d.DialContext(ctx, "tcp", addr)
        if err != nil {
            return fmt.Errorf("tcp connect to %s: %w", addr, err)
        }
        conn.Close()
        return nil
    }
}

func NewHTTPCheck(url string) CheckFunc {
    client := &http.Client{Timeout: 2 * time.Second}
    return func(ctx context.Context) error {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return err
        }
        resp, err := client.Do(req)
        if err != nil {
            return fmt.Errorf("http get %s: %w", url, err)
        }
        resp.Body.Close()
        if resp.StatusCode >= 500 {
            return fmt.Errorf("upstream returned %d", resp.StatusCode)
        }
        return nil
    }
}

func NewGoroutineLeakCheck(baseline int, maxMultiplier float64) CheckFunc {
    return func(ctx context.Context) error {
        current := runtime.NumGoroutine()
        limit := int(float64(baseline) * maxMultiplier)
        if current > limit {
            return fmt.Errorf("goroutine count %d exceeds %dx baseline (%d)", current, int(maxMultiplier), baseline)
        }
        return nil
    }
}

Generated from week15.md · 8 Oct 2026 16:36