Health, Readiness & Liveness Probes
Separate liveness (is the process alive?) from readiness (can this instance serve traffic?) so orchestrators and load balancers behave correctly during startup, dependency outages, and deploys.
Summary
Liveness probes restart stuck containers.
Readiness probes remove instances from service endpoints until they can handle requests.
Startup probes (Kubernetes 1.16+) protect slow-starting apps before liveness kicks in.
Probe handlers must be fast, idempotent, and free of auth middleware that blocks kubelet traffic.
Recipe
Quick-reference recipe card - copy-paste ready.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if depsHealthy(r.Context()) {
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, "not ready", http.StatusServiceUnavailable)
})When to reach for this:
- Kubernetes Deployments with HTTP probes
- Cloud load balancers that health-check backend pools
- Rolling deploys that must wait for migrations before receiving traffic
- Autoscaling groups replacing unhealthy instances
- Service mesh outlier detection that respects readiness state
Working Example
package main
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"sync"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
type App struct {
db *sql.DB
ready sync.RWMutex
isReady bool
}
func (a *App) setReady(v bool) {
a.ready.Lock()
a.isReady = v
a.ready.Unlock()
}
func (a *App) checkReady() bool {
a.ready.RLock()
defer a.ready.RUnlock()
return a.isReady
}
func (a *App) liveness(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func (a *App) readiness(w http.ResponseWriter, r *http.Request) {
if !a.checkReady() {
http.Error(w, "starting", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if err := a.db.PingContext(ctx); err != nil {
http.Error(w, "db unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
func (a *App) business(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"orders":[]}`))
}
func main() {
db, err := sql.Open("pgx", "postgres://localhost:5432/app?sslmode=disable")
if err != nil {
slog.Error("db open failed", "err", err)
return
}
app := &App{db: db}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", app.liveness)
mux.HandleFunc("GET /readyz", app.readiness)
mux.HandleFunc("GET /orders", app.business)
go func() {
time.Sleep(2 * time.Second)
app.setReady(true)
slog.Info("readiness enabled")
}()
slog.Info("listening", "addr", ":8080")
if err := http.ListenAndServe(":8080", mux); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen failed", "err", err)
}
}What this demonstrates:
- Liveness returns 200 without touching PostgreSQL
- Readiness gates on startup flag plus bounded
PingContext - Business routes stay separate from probe paths
- 503 responses signal load balancers to stop sending traffic
Deep Dive
How It Works
- Kubelet calls liveness on an interval; repeated failures restart the container.
- Endpoints controller removes pods failing readiness from Service endpoints.
- Load balancers mirror readiness: only route to instances returning success.
- Startup probes disable liveness until the app finishes slow initialization.
Probe Comparison
| Probe | Fails when | Orchestrator action |
|---|---|---|
| Liveness | Process deadlocked, cannot serve HTTP at all | Restart pod |
| Readiness | Dependencies down, migrations running | Remove from LB |
| Startup | Still booting past initialDelaySeconds | Wait before liveness |
Kubernetes HTTP Probe Fields
| Field | Guidance |
|---|---|
periodSeconds | 10s typical for readiness; liveness slightly longer |
timeoutSeconds | Must exceed handler worst case; keep handler under 1s |
failureThreshold | 3 failures common before action |
successThreshold | 1 for readiness after recovery |
Go Notes
// Exempt probes from expensive auth middleware
func skipProbePaths(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz", "/readyz", "/startupz":
next.ServeHTTP(w, r)
return
}
authMiddleware(next).ServeHTTP(w, r)
})
}Gotchas
- Database check on liveness - DB blip restarts all pods; use readiness only for dependency checks.
- Auth on
/healthz- Kubelet does not send your service tokens; probes get 401 and pods restart. - Slow readiness handlers - Long checks block kubelet threads and flap readiness; use short timeouts.
- Returning 200 during migrations - Traffic hits schema mismatch; keep readiness false until migrations complete.
- Same path for liveness and readiness - You cannot distinguish stuck vs dependency failure.
- Probe traffic in metrics - Exclude
/healthzfrom RED dashboards or labelprobe=trueseparately. - TLS-only admin port - Ensure probes hit the port and scheme the kubelet or LB is configured for.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
HTTP /healthz | Standard Kubernetes apps | Process has no HTTP server (use exec/tcp) |
| gRPC health protocol | gRPC-only services | Simple REST microservices |
| Exec probe | CLI check scripts | You can expose cheap HTTP instead |
| TCP socket | Listener up is enough | You need dependency validation |
FAQs
What status code should probes return?
200 for healthy, 503 for not ready.
Avoid 401/403 on probe paths.
Should readiness check every dependency?
Check only hard requirements for serving traffic.
Optional features can degrade without failing readiness if documented.
How fast should liveness be?
Sub-millisecond ideally - no I/O, only in-process checks.
What is startupProbe for?
It prevents liveness kills while the app loads large caches or runs migrations on first boot.
Can readiness flap during deploys?
Yes if you enable readiness before warm-up finishes.
Delay readiness until caches and connection pools are warm.
Do I need separate ports?
Optional.
Many teams serve probes on the main HTTP port with dedicated paths.
How do ALB health checks differ?
ALB uses HTTP codes and paths you configure, similar to readiness.
They do not restart instances - they stop routing.
Should probes hit the database every 10s?
Lightweight Ping with timeout is common.
Heavy queries belong in monitoring, not probes.
What about gRPC health?
Implement grpc.health.v1.Health Check RPC for gRPC-native services.
How do I test probes locally?
curl -i localhost:8080/readyz while stopping PostgreSQL and assert 503.
Can readiness return JSON?
Yes, but orchestrators only care about status codes.
Keep bodies small for human debugging.
What headers should probes send?
None required.
Avoid requiring custom headers kubelet cannot configure easily.
Related
- Observability Basics - minimal health handlers
- Graceful Shutdown & Signal Handling - drain during deploys
- net/http Best Practices - allowlist probe paths in auth
- Production Go: Logs, Metrics, Traces, and Signals - operational signals
- Configuration Management: Env, Flags & Viper - readiness for missing config
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).