Guide · EKS Autoscaling

Horizontal Pod Autoscaler for MCP servers on EKS

CPU-based HPA works fine for compute-bound workloads — but most MCP servers are I/O-bound, which means CPU stays low even when the tool-call queue is full and users are waiting.

TL;DR

Use autoscaling/v2 HPA with CPU as a baseline metric, but wire up a custom Prometheus metric (mcp_tool_call_queue_depth or mcp_concurrent_requests) for accurate scaling on I/O-bound MCP workloads. Always set resource.requests.cpu on the container or the HPA controller reports "unknown" and stops scaling entirely. Set a scaleDown.stabilizationWindowSeconds of at least 120 s and pair with a generous terminationGracePeriodSeconds to avoid in-flight tool calls being killed mid-stream.

Why CPU alone misleads the HPA for MCP servers

A typical MCP tool call spends most of its time waiting for an upstream LLM API response, a database query, or an external HTTP service. The pod's CPU utilization during that wait is near zero. If you scale on CPU averageUtilization: 70, the HPA sees healthy-looking numbers while fifty tool calls are queued and your users are staring at a spinner.

The right mental model: CPU is a proxy for compute demand. For I/O-heavy MCP servers, the real demand signal is one of:

CPU still belongs in the HPA as a secondary guard for genuinely CPU-heavy tools (code execution, image processing). Use a multi-metric HPA and let Kubernetes scale when any metric crosses its threshold.

Basic CPU-based HPA

Before adding custom metrics, get the baseline working. The only non-obvious requirement: every container in your pod spec must have resources.requests.cpu set. Without it the metrics-server cannot compute utilization and the HPA reports <unknown>/70% forever.

# hpa-mcp-cpu.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
  namespace: mcp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mcp-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: AverageUtilization
          averageUtilization: 65   # scale up when avg CPU > 65% of request
    - type: Resource
      resource:
        name: memory
        target:
          type: AverageUtilization
          averageUtilization: 80   # secondary guard only — see memory note below

And the matching Deployment fragment with explicit resource requests:

# deployment fragment — resources section is mandatory for HPA
spec:
  template:
    spec:
      containers:
        - name: mcp-server
          image: your-ecr-repo/mcp-server:latest
          resources:
            requests:
              cpu: "250m"      # HPA divides current usage by this
              memory: "512Mi"
            limits:
              cpu: "1000m"
              memory: "1Gi"

Memory-based HPA caveat: JVM-based MCP servers (and Node.js servers with large V8 heap pools) inflate memory constantly regardless of request load. The HPA sees high memory utilization and scales out, but adding replicas doesn't reduce memory on the existing pods. Use memory as an upper-bound alert, not a primary scale driver.

Custom metric HPA via Prometheus adapter

To scale on application-level metrics, you need three components: metrics-server (already present in EKS), kube-prometheus-stack (Prometheus + Grafana), and prometheus-adapter (bridges Prometheus metrics into the Kubernetes custom metrics API).

First, instrument your MCP server to expose a Prometheus metric. A gauge counting in-flight tool calls is the most reliable signal:

# Python example — instrument with prometheus_client
from prometheus_client import Gauge, start_http_server

mcp_tool_call_queue_depth = Gauge(
    'mcp_tool_call_queue_depth',
    'Number of tool calls currently queued or in-flight'
)

# Expose on :9090/metrics alongside your MCP endpoint
start_http_server(9090)

Next, create a ServiceMonitor so Prometheus scrapes it:

# servicemonitor-mcp.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: mcp-server-metrics
  namespace: mcp
  labels:
    release: kube-prometheus-stack   # must match Prometheus operator selector
spec:
  selector:
    matchLabels:
      app: mcp-server
  endpoints:
    - port: metrics          # must match a named port on the Service
      path: /metrics
      interval: 15s
  namespaceSelector:
    matchNames:
      - mcp

Configure the prometheus-adapter to expose that metric to the custom metrics API:

# prometheus-adapter values.yaml (Helm)
rules:
  custom:
    - seriesQuery: 'mcp_tool_call_queue_depth{namespace!="",pod!=""}'
      resources:
        overrides:
          namespace: { resource: namespace }
          pod:       { resource: pod }
      name:
        matches:     "mcp_tool_call_queue_depth"
        as:          "mcp_tool_call_queue_depth"
      metricsQuery: 'avg(<<.Series>>{<<.LabelMatchers>>})'

Verify the metric is visible before wiring the HPA:

kubectl get --raw \
  "/apis/custom.metrics.k8s.io/v1beta1/namespaces/mcp/pods/*/mcp_tool_call_queue_depth" \
  | jq .

Now the HPA referencing that custom metric:

# hpa-mcp-custom.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
  namespace: mcp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mcp-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: AverageUtilization
          averageUtilization: 65
    - type: Pods
      pods:
        metric:
          name: mcp_tool_call_queue_depth
        target:
          type: AverageValue
          averageValue: "5"   # scale out when avg queue depth per pod exceeds 5

The type: Pods metric aggregates across all pods and divides by replica count. Scale when the average queue depth exceeds 5 per pod — tune this number for your expected tool-call latency and acceptable queue length.

Scale-down behavior and MCP server cold start

The default HPA scale-down stabilization window is 300 seconds (5 minutes). That means after load drops, the HPA waits 5 minutes before removing a pod. This is intentionally conservative. For MCP servers with fast cold starts, you can reduce it. For servers that take 60+ seconds to initialize, you may want to increase minReplicas instead of fighting the stabilization window.

The spec.behavior block gives fine-grained control over both directions:

# hpa-mcp-behavior.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
  namespace: mcp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mcp-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: AverageUtilization
          averageUtilization: 65
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0     # scale up immediately — don't wait
      policies:
        - type: Pods
          value: 4                       # add at most 4 pods per period
          periodSeconds: 60
        - type: Percent
          value: 100                     # or double the pod count per period
          periodSeconds: 60
      selectPolicy: Max                  # use whichever policy adds more pods
    scaleDown:
      stabilizationWindowSeconds: 180   # wait 3 min of sustained low load
      policies:
        - type: Pods
          value: 2                       # remove at most 2 pods per period
          periodSeconds: 60
        - type: Percent
          value: 20                      # or remove at most 20% per period
          periodSeconds: 60
      selectPolicy: Min                  # use whichever policy removes fewer pods

Cold start math: if your MCP server takes 60 seconds to pass its readiness probe after boot, a sudden 4x traffic spike will cause degraded service for at least 60 seconds even with stabilizationWindowSeconds: 0 on scale-up. The only remedies are: lower minReplicas floor, faster initialization (lazy-load tools, reduce startup I/O), or predictive scaling via KEDA's cron-based scaler.

KEDA for event-driven MCP workloads

KEDA (Kubernetes Event-Driven Autoscaler) replaces or augments the HPA for workloads that scale on external event sources. Key advantages over HPA for MCP servers:

# keda-scaledobject-mcp.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: mcp-server-scaledobject
  namespace: mcp
spec:
  scaleTargetRef:
    name: mcp-server
  minReplicaCount: 0          # scale to zero when no requests
  maxReplicaCount: 20
  cooldownPeriod: 120         # seconds to wait before scaling to zero
  pollingInterval: 10         # how often to check scalers (seconds)
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://kube-prometheus-stack-prometheus.monitoring:9090
        metricName: mcp_tool_call_queue_depth
        threshold: "5"
        query: avg(mcp_tool_call_queue_depth{namespace="mcp"})
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789012/mcp-tool-jobs
        queueLength: "10"     # scale out when >10 messages visible
        awsRegion: us-east-1
      authenticationRef:
        name: keda-aws-credentials

KEDA and HPA can conflict if both target the same Deployment — pick one. When KEDA is installed, it creates an HPA internally and manages it. Do not create a separate HPA for the same target.

HPA + Cluster Autoscaler interaction: when HPA scales pods out and no node has sufficient capacity, new pods land in Pending. The Cluster Autoscaler (CA) detects Pending pods and provisions new EC2 nodes. The end-to-end delay is: HPA decision time (15–30 s) + CA reaction time (up to 60 s) + EC2 node launch (~90 s) + node join + pod schedule + readiness probe. Plan for 3–5 minutes of latency on a cold scale-out event and set minReplicas accordingly.

Failure modes reference

Symptom Root cause Fix
<unknown>/70% in kubectl get hpa resource.requests.cpu not set on the container Add resources.requests.cpu to every container in the pod spec; redeploy
HPA reports correct CPU but never scales metrics-server not installed, or pod labels don't match scaleTargetRef Run kubectl top pods -n mcp; verify scaleTargetRef.name matches the Deployment name exactly
Requests fail during scale-down Pod terminated before in-flight tool calls complete Add preStop sleep hook and increase terminationGracePeriodSeconds; ensure MCP server handles SIGTERM gracefully
Scale-up too slow — users see errors before new pods are ready MCP server cold start (60+ s readiness probe) longer than demand spike Raise minReplicas; optimize startup path; use KEDA HTTP add-on for zero-to-warm pre-warming
Pods stuck in Pending after HPA scales out Cluster Autoscaler not provisioning nodes fast enough, or node pool exhausted maxSize Check CA logs (kubectl logs -n kube-system -l app=cluster-autoscaler); increase node group maxSize; use Karpenter for faster node provisioning
Memory-based HPA causes constant scale-out that never resolves JVM/Node.js heap grows to fill available memory regardless of load Remove memory from HPA metrics; use it only as a VPA recommendation or Pod alert; set explicit heap limits in JVM flags (-Xmx)
Custom metric shows no metrics returned from custom metrics API prometheus-adapter not configured, ServiceMonitor label mismatch, or metric name mismatch Run the kubectl get --raw custom metrics API query; check prometheus-adapter logs and rules config
KEDA and HPA both defined for same Deployment KEDA creates its own HPA; manual HPA conflicts and causes undefined behavior Delete the manually created HPA; let KEDA manage it exclusively