Guide · EKS Operations
Readiness and Liveness Probes for MCP Servers on EKS
The single most common MCP server incident on EKS is a restart loop caused by one mistake: using the same /health endpoint for both the readiness and liveness probes, so a slow database query — which should quietly remove the pod from rotation — instead triggers a container restart that makes everything worse.
TL;DR
Expose two separate health endpoints: /healthz/ready (readiness — checks DB connection, secret availability, warm-up state) and /healthz/live (liveness — returns 200 unless the process is in an unrecoverable state like deadlock or memory exhaustion). Configure a startupProbe pointing at /healthz/ready with a generous failureThreshold to cover slow initializations. Set initialDelaySeconds on the readiness probe to exceed your SDK init + connection pool creation + secret loading time. Never share endpoints between readiness and liveness — a transient dependency failure should remove the pod from load balancing, not restart it.
The three probe types and what each one does
Kubernetes supports three probe types, each with a distinct action on failure:
- readinessProbe — gates traffic routing. When the probe fails, kubelet removes the pod from the Service's endpoint list. The container is not restarted. Existing connections continue until they close. The pod becomes ready again automatically once the probe passes again.
- livenessProbe — restart trigger. When the probe fails
failureThresholdconsecutive times, kubelet kills and restarts the container. Use only for unrecoverable states — not for transient dependency failures. - startupProbe — delays liveness and readiness checking during slow container starts. Both liveness and readiness probes are disabled until the startup probe succeeds. If the startup probe itself fails
failureThresholdtimes, the container is killed.
There are three probe mechanisms — the same mechanism can be used for any of the three probe types:
httpGet— kubelet makes an HTTP GET to the specified path and port; success is any 2xx or 3xx response code.tcpSocket— kubelet attempts a TCP connection to the port; success means the connection was accepted.exec— kubelet runs a command inside the container; success is exit code 0.
For MCP servers, httpGet is almost always the right choice: it exercises the actual HTTP server stack, and you can return structured JSON with dependency statuses that are easy to debug.
Why readiness and liveness must use different endpoints
The failure semantics are completely different and must be encoded in different endpoints:
- Readiness endpoint (
/healthz/ready) should return a non-2xx code whenever the server cannot safely handle new requests: the database connection pool is exhausted, a required secret has expired, a required downstream service is unreachable, or the server is still warming up its in-memory index. These are transient conditions — the server is not broken, it just should not receive new traffic right now. - Liveness endpoint (
/healthz/live) should return a non-2xx code only when the process is in a state it cannot recover from on its own: the event loop is deadlocked, memory is exhausted and the garbage collector is not keeping up, or a critical background thread has panicked and left the server unable to process any request regardless of how much time passes. These are the cases where a restart is the right answer.
If you use the same endpoint for both and a database goes offline for 30 seconds, the liveness probe fails, the container restarts, and now you have compounded the incident: the pod missed the traffic it would otherwise have shed gracefully, its in-flight requests were killed, and it is now in the back-off restart loop — all because of a 30-second dependency blip that did not require a restart to resolve.
A slow or unavailable dependency should fail readiness but not liveness. A deadlocked event loop should fail liveness. These two facts require two separate endpoints.
Complete Deployment YAML with all three probes
The following Deployment configures a startup probe for slow initialization, separate readiness and liveness probes, and named container ports for clean probe references:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: prod
spec:
replicas: 3
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
terminationGracePeriodSeconds: 60
containers:
- name: mcp-server
image: your-registry/mcp-server:latest
ports:
- name: http # name the port; probes reference it by name
containerPort: 3000
protocol: TCP
# startupProbe: disables readiness+liveness until this passes.
# failureThreshold * periodSeconds = max startup time budget.
# Here: 30 * 5s = 150s = 2.5 minutes to finish initialization.
startupProbe:
httpGet:
path: /healthz/ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
successThreshold: 1
# readinessProbe: remove from Service endpoints on failure.
# Does NOT restart the container.
# Checks DB, secrets, warm-up. Returns 503 if any dependency is unhealthy.
readinessProbe:
httpGet:
path: /healthz/ready
port: http
initialDelaySeconds: 0 # startupProbe gates this; no additional delay needed
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3 # 30s of failures before removing from endpoints
successThreshold: 1
# livenessProbe: restart the container ONLY on unrecoverable failure.
# Points to a different endpoint that does minimal checking.
# Give it a generous timeout so high CPU doesn't trigger false restarts.
livenessProbe:
httpGet:
path: /healthz/live
port: http
initialDelaySeconds: 0 # startupProbe gates this too
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 3 # 45s of failures before restart
successThreshold: 1
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
Key decisions in this configuration:
- Both liveness and readiness set
initialDelaySeconds: 0because thestartupProbealready handles the delay. SettinginitialDelaySecondson both startup and readiness probes doubles the wait time unnecessarily. - The liveness probe uses a longer
timeoutSeconds(10s vs 5s) and longerperiodSeconds(15s vs 10s) than readiness. Under CPU pressure, probe responses slow down; an aggressive liveness probe turns high load into a restart loop. - Both probes reference the port by name (
port: http) rather than by number. If the container port changes, you update one place.
Node.js health endpoint implementation
Here is a production-grade implementation of both endpoints in Node.js. The readiness check exercises real dependency connections; the liveness check only inspects process-level state:
// health.js — mount this as a router in your Express/Fastify/Hono MCP server
// Shared state that other parts of the server update
let isReady = false; // set to true after full initialization
let isLive = true; // set to false only on unrecoverable error
let dbPool = null; // set by the DB init code
let secretsLoaded = false; // set by the secrets init code
// Called during server startup after all async init is complete
export function markReady(pool) {
dbPool = pool;
secretsLoaded = true;
isReady = true;
}
// Called if the event loop watchdog detects a deadlock or OOM
export function markDead(reason) {
console.error('Server marked dead:', reason);
isLive = false;
}
// GET /healthz/ready
// Returns 200 with JSON status when all dependencies are healthy.
// Returns 503 when any dependency is unhealthy (transient — no restart).
export async function readinessHandler(req, res) {
const checks = {};
let healthy = true;
// 1. Basic readiness flag (covers slow startup)
checks.initialized = isReady;
if (!isReady) healthy = false;
// 2. Database connectivity — lightweight query, not a full round-trip
if (dbPool) {
try {
const client = await Promise.race([
dbPool.connect(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 2000)
)
]);
await client.query('SELECT 1');
client.release();
checks.database = 'ok';
} catch (err) {
checks.database = err.message;
healthy = false;
}
} else {
checks.database = 'not_initialized';
healthy = false;
}
// 3. Secrets availability
checks.secrets = secretsLoaded ? 'ok' : 'not_loaded';
if (!secretsLoaded) healthy = false;
const status = healthy ? 200 : 503;
res.status(status).json({ status: healthy ? 'ready' : 'not_ready', checks });
}
// GET /healthz/live
// Returns 200 unless the process is in an unrecoverable state.
// Keep this extremely cheap — it runs every 15 seconds under load.
// Do NOT check external dependencies here.
export function livenessHandler(req, res) {
if (!isLive) {
// Process is wedged — restart will help
res.status(500).json({ status: 'dead' });
return;
}
// Optional: check event loop lag as a proxy for deadlock
const start = Date.now();
setImmediate(() => {
const lag = Date.now() - start;
if (lag > 5000) {
// Event loop is blocked — this is an unrecoverable state
res.status(500).json({ status: 'event_loop_blocked', lag_ms: lag });
} else {
res.status(200).json({ status: 'live', lag_ms: lag });
}
});
}
Mount these handlers before any authentication middleware — kubelet does not send auth headers when it probes. If your MCP server requires auth on all routes, exempt /healthz/* explicitly.
The readiness handler uses a 2-second timeout on the DB connection attempt. This is intentionally shorter than the probe's timeoutSeconds: 5 — it ensures the endpoint itself returns within the probe's window even if the DB is completely unreachable, rather than hanging and timing out at the kubelet level (which counts as a failure anyway, but produces less useful logs).
Startup probe tuning for slow-starting MCP servers
Some MCP servers load large in-memory indexes, pre-warm embedding caches, or perform migrations on start. These can take 30–120 seconds. Without a startupProbe, you have two bad options: set initialDelaySeconds high enough to cover the worst case (delaying detection of real startup failures), or keep it low and accept restart loops during normal slow starts.
The startupProbe solves this cleanly. Configure failureThreshold * periodSeconds to equal your maximum acceptable startup time:
| Startup time budget | periodSeconds | failureThreshold | Notes |
|---|---|---|---|
| 30 seconds | 5 | 6 | Typical MCP server with DB pool + secrets loading |
| 60 seconds | 5 | 12 | MCP server with schema migration on startup |
| 2 minutes | 10 | 12 | MCP server loading a large in-memory model or vector index |
| 5 minutes | 15 | 20 | Maximum reasonable startup budget; consider whether init containers would be better |
Once the startupProbe succeeds once, it is disabled and the liveness and readiness probes take over. The startup probe does not run again unless the container restarts.
For MCP servers that fetch configuration from AWS Secrets Manager or Parameter Store at startup, account for the AWS API call latency (typically 100–500ms but can spike to 2–3 seconds on cold Lambda-adjacent environments). Budget at least 5 seconds of startup time per Secrets Manager call, plus your connection pool creation time.
Probe parameter tuning reference
| Parameter | What it controls | Readiness recommendation | Liveness recommendation |
|---|---|---|---|
initialDelaySeconds |
Wait before first probe attempt after container start | 0 if using startupProbe; else SDK init time + pool creation time | 0 if using startupProbe; else same as readiness |
periodSeconds |
Interval between probe attempts | 10s (fast traffic shedding) | 15–20s (avoid false restarts under load) |
timeoutSeconds |
Max time for probe to respond before counting as failure | 3–5s (readiness check should be fast) | 10s (liveness check must survive CPU spikes) |
failureThreshold |
Consecutive failures before action is taken | 3 (30s before traffic shed) | 3–5 (45–100s before restart — absorb transient load spikes) |
successThreshold |
Consecutive successes to mark as healthy | 1 (restore traffic quickly) | 1 (must be 1 for liveness; not configurable in effect) |
The product failureThreshold * periodSeconds is the most important thing to reason about. For readiness, it is how long a dependency outage must persist before traffic is shed. For liveness, it is how long the pod must appear unrecoverable before a restart is triggered. Err on the side of longer liveness windows — an unnecessary restart during a traffic spike is almost always worse than waiting an extra 30 seconds.
Failure modes reference
| Failure mode | Symptom | Cause | Fix |
|---|---|---|---|
| Same endpoint for readiness and liveness | Restart loop during DB blips; pod count oscillates; high error rate | A transient dependency failure fails the shared endpoint; liveness probe triggers restart instead of traffic shedding | Create separate /healthz/ready (checks dependencies) and /healthz/live (checks only process state) |
initialDelaySeconds too short |
Pod enters CrashLoopBackOff immediately after deploy; logs show "probe failed" during first 10 seconds | Probe fires before the server finishes connecting to the database or loading secrets | Add a startupProbe with failureThreshold * periodSeconds equal to the worst-case startup time; or increase initialDelaySeconds on liveness and readiness probes |
| Liveness probe too aggressive under load | Pods restart during traffic spikes; restarts make the remaining pods handle more traffic, causing more restarts (cascade) | High CPU slows the health endpoint response past timeoutSeconds; kubelet counts it as a failure |
Increase timeoutSeconds to 10s and failureThreshold to 5 on the liveness probe; keep the liveness endpoint itself trivially cheap (no I/O) |
| Missing readiness probe | New pods receive traffic immediately on startup; first requests fail with connection errors or 500s during initialization | Without a readiness probe, kubelet marks the pod Ready as soon as the container starts — before the MCP server has finished connecting to the database | Add a readiness probe that checks real initialization state; the pod only enters the Service endpoints after the probe passes |
| Probe endpoint has expensive logic | Probe calls add measurable latency to the server; health checks themselves consume memory or trigger GC pauses; in extreme cases, OOM during probe | Health endpoint runs full tool list traversal, large query, or allocates significant memory to construct the response | Keep health endpoints O(1): read cached state set by the initialization code; do not run queries inside the probe handler; use a 2s max internal timeout on any dependency check |
| Probe hits authenticated route | All probes return 401 or 403; pod never becomes Ready; stays in 0/1 Ready state indefinitely | Authentication middleware runs before the health handler; kubelet does not send auth credentials | Exempt /healthz/* from auth middleware; mount health routes before the auth middleware in the middleware chain |
| startupProbe not configured for slow init | Pod restarts once or twice after each deploy of a slow-starting image; eventually stabilizes | Liveness probe fires before initialDelaySeconds expires (or the delay is set too short); kills the container during normal initialization |
Add a startupProbe with a generous failure budget; liveness and readiness probes are disabled until it passes |