Health Checks in Production Systems
A complete guide to health checks in production engineering — covering liveness, readiness, and startup probes, deep health check design, load balancer integration, Kubernetes configuration, and operational best practices.
Introduction
A service that is running is not necessarily a service that is healthy.
A pod can be alive — consuming CPU, responding to the OS — while being completely unable to serve requests. Its database connection pool may be exhausted, its downstream dependency timed out, or its message queue consumer stalled.
Health checks are the mechanism by which a production system continuously verifies that each component is not just running but actually capable of doing useful work.
Without health checks:
- Load balancers route traffic to broken instances
- Kubernetes keeps pods running that cannot serve any requests
- Deployments complete successfully while silently serving errors
- Engineers discover failures from customer support tickets
With properly configured health checks:
- Traffic is automatically removed from unhealthy instances
- Broken pods are restarted before customers notice
- Deployments are gated — new instances only receive traffic when they are genuinely ready
- Cascading failures are detected and contained early
The Three Types of Health Checks
Production systems use three distinct health check types, each serving a different purpose.
flowchart TB
Kubernetes["Kubernetes / Orchestrator"]
Kubernetes --> Liveness["Liveness Probe\nIs the process alive?\nRestart if failing"]
Kubernetes --> Readiness["Readiness Probe\nCan this instance serve traffic?\nRemove from load balancer if failing"]
Kubernetes --> Startup["Startup Probe\nHas the app finished initializing?\nBlock liveness/readiness until passing"]
| Probe | Question It Answers | Action on Failure |
|---|---|---|
| Liveness | Is the process in a broken state? | Restart the container |
| Readiness | Is the instance ready to accept traffic? | Remove from load balancer pool |
| Startup | Has the app finished starting up? | Delay other probes |
These three probes work together. Each is configured independently and checked on its own schedule.
Liveness Probe
Purpose
The liveness probe answers: "Is this process alive and functional, or is it deadlocked/corrupted and needs a restart?"
A liveness failure triggers a container restart. Use it only for conditions that are unrecoverable without a restart.
When to Fail Liveness
- The application has entered a deadlock
- The event loop is blocked and will never recover
- A critical goroutine or thread has panicked
- The application is out of memory and cannot recover
When NOT to Fail Liveness
- A downstream dependency (database, cache) is temporarily unavailable
- A queue is temporarily unresponsive
- The service is under high load
Failing liveness for dependency issues causes a restart loop — the restarted pod will immediately fail again because the dependency is still down.
sequenceDiagram
participant Kubernetes
participant Pod
participant LivenessEndpoint
Kubernetes->>LivenessEndpoint: GET /actuator/health/liveness
LivenessEndpoint-->>Kubernetes: 200 UP
Note over Pod: Deadlock occurs
Kubernetes->>LivenessEndpoint: GET /actuator/health/liveness
LivenessEndpoint-->>Kubernetes: 503 DOWN
Kubernetes->>Pod: Restart container
Note over Pod: Fresh start — deadlock cleared
Liveness Endpoint Response
GET /actuator/health/liveness
{
"status": "UP"
}
Readiness Probe
Purpose
The readiness probe answers: "Is this instance currently able to serve user traffic?"
A readiness failure removes the pod from the load balancer's active pool without restarting it. Traffic stops flowing to the instance until it recovers.
When to Fail Readiness
- The database connection pool is exhausted
- A required cache connection is unavailable
- The service is still warming up (e.g., loading large in-memory data)
- The service is intentionally draining before a graceful shutdown
- A downstream dependency is degraded enough to affect responses
Readiness Flow
sequenceDiagram
participant LoadBalancer
participant Kubernetes
participant Pod
participant Database
LoadBalancer->>Pod: User Request
Pod->>Database: Query
Database-->>Pod: Connection refused (pool exhausted)
Kubernetes->>Pod: GET /actuator/health/readiness
Pod-->>Kubernetes: 503 OUT_OF_SERVICE
Kubernetes->>LoadBalancer: Remove Pod from pool
Note over LoadBalancer: No more traffic sent to this pod
Note over Database: Pool recovers
Kubernetes->>Pod: GET /actuator/health/readiness
Pod-->>Kubernetes: 200 UP
Kubernetes->>LoadBalancer: Re-add Pod to pool
Readiness Endpoint Response
GET /actuator/health/readiness
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"redis": { "status": "UP" }
}
}
When any component returns DOWN, the overall status becomes OUT_OF_SERVICE and the pod stops receiving traffic.
Startup Probe
Purpose
The startup probe answers: "Has the application finished its initialization sequence?"
Until the startup probe passes, Kubernetes suppresses both liveness and readiness checks. This prevents premature restarts for slow-starting applications (e.g., those loading large datasets, running database migrations, or JVM applications with slow warm-up).
flowchart LR
Start["Container Starts"]
Start --> StartupCheck{"Startup Probe\nPassing?"}
StartupCheck -- No --> Wait["Wait & Retry\n(up to failureThreshold)"]
Wait --> StartupCheck
StartupCheck -- Yes --> Normal["Enable Liveness\n& Readiness Probes"]
StartupCheck -- Timeout --> Kill["Kill & Restart Container"]
When to Use a Startup Probe
- Applications that run database migrations on startup
- JVM services with long classloading or Spring context initialization
- Services that pre-load large caches or datasets into memory
- Any service where startup takes more than 10–15 seconds
Without a startup probe, you must set very generous initialDelaySeconds on liveness — which means slow detection of actual deadlocks in production. The startup probe is a cleaner solution.
Shallow vs Deep Health Checks
Health check depth is a critical design decision. Not all health check endpoints should check the same things.
flowchart TB
Types["Health Check Depth"]
Types --> Shallow["Shallow Check\n(Ping)\nIs the HTTP server alive?"]
Types --> Medium["Medium Check\nAre critical dependencies reachable?"]
Types --> Deep["Deep Check\nAre all components functional?"]
Shallow Health Check
Checks only that the HTTP server is accepting connections.
GET /health → 200 OK
| Use case | Detail |
|---|---|
| Purpose | Confirm the process is not crashed |
| Suitable for | Liveness probe |
| Checks | HTTP server only |
| Latency | < 5ms |
Medium Health Check (Dependency Check)
Checks that critical dependencies are reachable and responding.
GET /health/ready
{
"status": "UP",
"components": {
"database": { "status": "UP", "responseTimeMs": 3 },
"redis": { "status": "UP", "responseTimeMs": 1 }
}
}
| Use case | Detail |
|---|---|
| Purpose | Confirm the instance can handle real requests |
| Suitable for | Readiness probe |
| Checks | DB connectivity, cache reachability |
| Latency | 5–50ms |
Deep Health Check (Functional Check)
Verifies actual business functionality end-to-end — a read query, a cache set/get cycle, a message queue ping.
GET /health/deep
{
"status": "UP",
"components": {
"database": { "status": "UP", "query": "SELECT 1", "responseTimeMs": 4 },
"redis": { "status": "UP", "operation": "PING", "responseTimeMs": 1 },
"kafka": { "status": "UP", "lag": 0, "responseTimeMs": 12 },
"paymentApi": { "status": "UP", "responseTimeMs": 45 }
},
"checkedAt": "2026-07-05T14:22:10Z"
}
| Use case | Detail |
|---|---|
| Purpose | Full operational verification |
| Suitable for | Internal monitoring dashboards, alerting only |
| Checks | All dependencies with functional operations |
| Latency | 50–500ms |
| Warning | Do NOT use as a liveness or readiness probe — too slow and too risky |
Health Check Endpoints — Standard Layout
Every production service should expose a consistent set of endpoints:
| Endpoint | Type | Used By |
|---|---|---|
/actuator/health/liveness |
Shallow | Kubernetes liveness probe |
/actuator/health/readiness |
Medium | Kubernetes readiness probe |
/actuator/health |
Medium | Load balancer health check |
/actuator/health/deep |
Deep | Internal monitoring only |
The deep check endpoint must never be exposed publicly or used by automated probes — only consumed by internal monitoring systems.
Health Checks in Load Balancers
Before Kubernetes, and still used alongside it, load balancers perform their own health checking independent of container orchestration.
flowchart TB
Internet --> LB["Load Balancer\n(AWS ALB / Nginx / HAProxy)"]
LB --> App1["Instance 1\n✅ Healthy"]
LB --> App2["Instance 2\n✅ Healthy"]
LB --> App3["Instance 3\n❌ Failing /health"]
LB -. "Stops routing to Instance 3" .-> App3
LB -. "All traffic to Instances 1 & 2" .-> App1
LB -. "All traffic to Instances 1 & 2" .-> App2
How load balancer health checking works:
- The load balancer polls
/healthon each instance every N seconds - If an instance returns a non-2xx response for M consecutive checks, it is marked unhealthy
- No new requests are routed to the unhealthy instance
- The load balancer continues polling — when the instance recovers, it is re-added to the pool
AWS ALB example configuration:
| Setting | Recommended Value | Reason |
|---|---|---|
| Health check path | /actuator/health |
Checks DB + cache connectivity |
| Healthy threshold | 2 successes | Avoid flapping — confirm recovery is stable |
| Unhealthy threshold | 3 failures | Balance speed vs false positives |
| Interval | 15 seconds | Frequent enough without excessive overhead |
| Timeout | 5 seconds | Fail fast on hung instances |
Health Checks in Kubernetes
Kubernetes health probes are configured per container in the pod spec.
flowchart TB
Pod["Pod Lifecycle"]
Pod --> S["1. Container starts"]
S --> SP["2. Startup probe runs\n(liveness & readiness suppressed)"]
SP --> LP["3. Liveness probe enabled\n(checks every N seconds)"]
SP --> RP["4. Readiness probe enabled\n(gates traffic routing)"]
LP --> Restart["Restart container\non failure"]
RP --> Remove["Remove from\nService endpoints on failure"]
Kubernetes Probe Configuration
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 5
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
failureThreshold: 30
periodSeconds: 5
Configuration explained:
| Field | Liveness | Readiness | Startup |
|---|---|---|---|
initialDelaySeconds |
0 (startup probe handles delay) | 5s | N/A |
periodSeconds |
10s | 10s | 5s |
failureThreshold |
3 | 3 | 30 (allows 150s) |
timeoutSeconds |
3s | 5s | — |
| On failure | Restart pod | Remove from LB | Kill & restart |
Graceful Shutdown and Health Checks
Health checks play a critical role in zero-downtime deployments and graceful shutdown.
sequenceDiagram
participant Kubernetes
participant Pod
participant LoadBalancer
Note over Kubernetes: Rolling update begins
Kubernetes->>Pod: SIGTERM signal
Pod->>Pod: Set readiness to DOWN
Pod-->>LoadBalancer: /readiness → 503
Note over LoadBalancer: Stop routing new requests to this pod
Pod->>Pod: Drain in-flight requests
Note over Pod: Wait preStop hook (e.g., 15 seconds)
Pod->>Pod: Finish remaining work
Pod->>Kubernetes: Process exits cleanly
Note over Kubernetes: Start new pod with updated version
Steps for correct graceful shutdown:
- Kubernetes sends
SIGTERMto the container - Application immediately fails its readiness probe → removed from load balancer pool
- Application finishes processing all in-flight requests
- Application closes connections to databases and queues
- Application exits with code 0
- Kubernetes registers the pod as terminated
A preStop hook with a short sleep (5–15s) ensures the load balancer has time to propagate the readiness failure before the process stops accepting connections.
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
terminationGracePeriodSeconds: 60
Health Check Anti-Patterns
Anti-Pattern 1: Failing Liveness on Dependency Issues
flowchart LR
DB["Database\n(Temporarily Down)"]
Pod["Pod\nFails /liveness\nbecause DB is down"]
Kubernetes["Kubernetes\nRestarts Pod"]
Pod2["New Pod\nFails immediately —\nDB still down"]
DB --> Pod
Pod --> Kubernetes
Kubernetes --> Pod2
Pod2 --> DB
Result: Restart storm. Every pod keeps restarting while the database is down, making recovery harder.
Fix: Dependency failures belong in the readiness probe, not liveness.
Anti-Pattern 2: Deep Check as Liveness Probe
If a deep health check calls a slow external API and that API is slow (200ms+), every liveness probe call takes 200ms. Under load, liveness probes time out, pods get restarted unnecessarily.
Fix: Liveness = shallow check only. Deep checks = internal monitoring only.
Anti-Pattern 3: No Health Check Timeout
A health endpoint that waits indefinitely for a database response will block the probe thread. If the database is hung, the health check hangs too, and Kubernetes never gets a response — eventually timing out and restarting the pod unnecessarily.
Fix: Always set a short timeout on every dependency call inside a health check (< 2 seconds).
Anti-Pattern 4: Skipping the Startup Probe for Slow Services
Without a startup probe, a JVM service that takes 45 seconds to initialize will have its liveness probe fail and be restarted before it finishes starting. The service never successfully starts.
Fix: Always configure a startup probe for services with non-trivial startup times.
Anti-Pattern 5: Exposing Deep Health Checks Publicly
A deep health check may reveal internal topology — database hosts, service names, response times. This is valuable operational data that should not be exposed to the public internet.
Fix: Protect deep health check endpoints with network policy or authentication.
Health Check Architecture — Full Picture
flowchart TB
Internet --> LB["Load Balancer\n(Polls /health every 15s)"]
LB --> GW["API Gateway"]
GW --> S1["Service A"]
GW --> S2["Service B"]
GW --> S3["Service C"]
K8s["Kubernetes\nControl Plane"] -. "Liveness probe\n(restart on fail)" .-> S1
K8s -. "Readiness probe\n(remove from LB)" .-> S1
K8s -. "Startup probe\n(suppress others)" .-> S1
S1 --> DB["PostgreSQL"]
S1 --> Cache["Redis"]
S1 --> MQ["Kafka"]
S1 -- "/readiness checks" --> DB
S1 -- "/readiness checks" --> Cache
Monitor["Internal Monitoring\n(Prometheus)"] -. "Polls /health/deep\nevery 60s" .-> S1
Every layer has its own health verification:
- Load balancer — routes traffic only to healthy instances
- Kubernetes — restarts broken pods, gates traffic via readiness
- Internal monitoring — deep checks feed dashboards and alerts
Health Check Readiness Checklist
Before deploying a service to production:
Probe Configuration
- Liveness probe configured — shallow check only (
/liveness) - Readiness probe configured — checks DB and cache connectivity (
/readiness) - Startup probe configured for services with startup time > 15 seconds
- Probe timeouts set (
timeoutSeconds< 5s for liveness) -
failureThresholdtuned to avoid false restarts
Endpoint Design
- Liveness endpoint returns quickly (< 10ms) — no dependency calls
- Readiness endpoint checks all required dependencies with a short timeout
- Deep health endpoint exists for internal monitoring
- Deep health endpoint is not publicly accessible
Graceful Shutdown
- Application fails readiness on
SIGTERMbefore draining -
preStophook adds a short delay for LB propagation -
terminationGracePeriodSecondsis long enough for in-flight requests to complete - Application exits with code 0 on clean shutdown
Load Balancer
- Load balancer health check path is configured
- Unhealthy threshold is tuned (3 failures before removal)
- Healthy threshold requires 2+ successes before re-admission
Summary
Health checks are not optional in production. They are the mechanism that makes high availability, zero-downtime deployments, and automatic recovery possible.
| Probe | Checks | Action on Failure | Depth |
|---|---|---|---|
| Liveness | Process state | Restart container | Shallow |
| Readiness | Dependencies | Remove from load balancer | Medium |
| Startup | Init complete | Delay other probes | Shallow |
Key principles:
- Liveness must be shallow — never fail it for dependency issues
- Readiness reflects actual capability — check every dependency the service needs
- Startup probe prevents false restarts — use it for any service with non-trivial startup
- Graceful shutdown requires readiness cooperation — fail readiness on SIGTERM before draining
- Deep checks belong in monitoring — not in Kubernetes probes