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:

There are three probe mechanisms — the same mechanism can be used for any of the three probe types:

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:

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:

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