Active Nerds
Module #30 · advanced Article

Cluster Resource Exhaustion: Monitoring and Scaling Strategy

In-depth architectural breakdown and operational implementation for Cluster Resource Exhaustion: Monitoring and Scaling Strategy.

22 min read+20 XPPublished 2026-06-27

Prerequisite knowledge: Resource requests/limits, HPA basics, Prometheus fundamentals


Question (Scenario-Based)

Your cluster is running out of resources. Pods are stuck in Pending state, nodes are at 90% CPU, and the on-call engineer is getting paged at 2 AM. What monitoring and scaling strategies do you implement to prevent this and respond automatically?


Quick Answer (30 sec revision)

Implement a three-layer autoscaling stack: HPA (scales pods based on metrics), VPA (right-sizes pod resource requests), and Cluster Autoscaler (adds/removes nodes). Add Prometheus alerts for early warning, and enforce LimitRanges and ResourceQuotas to prevent resource starvation.


Detailed Answer

️ Three-Layer Autoscaling Architecture

Layer 3: Infrastructure
┌────────────────────────────────────────┐
│         Cluster Autoscaler             │
│  Adds nodes when pods can't schedule   │
│  Removes underutilized nodes           │
└────────────────┬───────────────────────┘
                 │ triggers
Layer 2: Pod Scaling
┌────────────────▼───────────────────────┐
│  HPA (Horizontal Pod Autoscaler)       │   ← Scale OUT (more pods)
│  VPA (Vertical Pod Autoscaler)         │   ← Scale UP (bigger pods)
│  KEDA (Event-driven autoscaling)         │   ← Scale on custom metrics
└────────────────┬───────────────────────┘
                 │ informed by
Layer 1: Observability
┌────────────────▼───────────────────────┐
│  Prometheus + Grafana                  │
│  Metrics Server                        │
│  Alert Manager                         │
└────────────────────────────────────────┘

️ Step-by-Step Implementation

Step 1: Metrics Server (prerequisite for HPA)

# Install Metrics Server (required for kubectl top and HPA)
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# Verify it's working
kubectl top nodes
kubectl top pods -A

# Expected output:
# NAME              CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
# worker-node-01    1823m        45%    6241Mi          78%
# worker-node-02    2100m        52%    7100Mi          88%   ← Getting critical

Step 2: Horizontal Pod Autoscaler (HPA v2)

# hpa-ecommerce.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ecommerce-api-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ecommerce-api
  minReplicas: 5
  maxReplicas: 50

  metrics:
    # CPU-based scaling (classic)
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # Scale when avg CPU > 70% of request

    # Memory-based scaling
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

    # Custom metric: requests per second (via Prometheus Adapter)
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "1000"    # Scale when >1000 RPS per pod

    # External metric: SQS queue depth (via KEDA or custom adapter)
    - type: External
      external:
        metric:
          name: sqs_queue_depth
          selector:
            matchLabels:
              queue: order-processing
        target:
          type: AverageValue
          averageValue: "100"     # 1 pod per 100 messages in queue

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60    # Wait 60s before scaling up again
      policies:
        - type: Pods
          value: 5                      # Add at most 5 pods at once
          periodSeconds: 60
        - type: Percent
          value: 100                    # Or double pod count
          periodSeconds: 60
      selectPolicy: Max                 # Use whichever adds MORE pods (aggressive scale-up)

    scaleDown:
      stabilizationWindowSeconds: 300   # Wait 5 min before scaling down (avoid flapping)
      policies:
        - type: Pods
          value: 2                      # Remove at most 2 pods at once
          periodSeconds: 120
      selectPolicy: Min                 # Use whichever removes FEWER pods (conservative scale-down)

Step 3: Vertical Pod Autoscaler (VPA) for Right-Sizing

# Install VPA
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-install.sh
# vpa-ecommerce.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: ecommerce-api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ecommerce-api

  updatePolicy:
    updateMode: "Off"   # Options: Off | Initial | Recreate | Auto
    # Off      → Only generates recommendations, no changes (use for analysis)
    # Initial  → Apply recommendations only to NEW pods
    # Recreate → Evict and recreate pods with new resources (causes brief disruption)
    # Auto     → Same as Recreate currently (future: in-place resize)

  resourcePolicy:
    containerPolicies:
      - containerName: ecommerce-api
        minAllowed:
          cpu: "100m"        # Never go below 100m CPU
          memory: "256Mi"    # Never go below 256Mi RAM
        maxAllowed:
          cpu: "4"           # Never exceed 4 CPU cores
          memory: "4Gi"      # Never exceed 4Gi RAM
        controlledResources:
          - cpu
          - memory
# Check VPA recommendations (your right-sizing advisor)
kubectl describe vpa ecommerce-api-vpa -n production

# Output shows:
# Recommendation:
#   Container Recommendations:
#     Container Name: ecommerce-api
#     Lower Bound:
#       Cpu:     200m
#       Memory:  400Mi
#     Target:              ← What VPA recommends you set
#       Cpu:     450m
#       Memory:  600Mi
#     Upper Bound:
#       Cpu:     1200m
#       Memory:  1500Mi

⚠️ Important: Do NOT run VPA in Auto mode alongside HPA CPU/memory metrics simultaneously — they conflict. Use VPA in Off mode for recommendations, apply them manually, then use HPA for scaling.

Step 4: KEDA for Event-Driven Scaling

# Install KEDA
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace
# keda-scaledobject.yaml — Scale based on Kafka lag
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor-scaledobject
  namespace: production
spec:
  scaleTargetRef:
    name: order-processor
  minReplicaCount: 1
  maxReplicaCount: 30
  cooldownPeriod: 120      # Seconds to wait before scaling down
  pollingInterval: 15      # Check trigger every 15 seconds

  triggers:
    # Scale based on Kafka consumer group lag
    - type: kafka
      metadata:
        bootstrapServers: kafka.production.svc.cluster.local:9092
        consumerGroup: order-processor-group
        topic: orders
        lagThreshold: "50"        # 1 pod per 50 unprocessed messages
        offsetResetPolicy: latest

    # Also scale based on Prometheus metric
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc.cluster.local:9090
        metricName: pending_orders_count
        query: sum(pending_orders_total{namespace="production"})
        threshold: "100"

Step 5: Cluster Autoscaler

# cluster-autoscaler-deployment.yaml (AWS EKS example)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cluster-autoscaler
  template:
    metadata:
      labels:
        app: cluster-autoscaler
      annotations:
        cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
    spec:
      serviceAccountName: cluster-autoscaler
      containers:
        - image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.29.0
          name: cluster-autoscaler
          command:
            - ./cluster-autoscaler
            - --v=4
            - --stderrthreshold=info
            - --cloud-provider=aws
            - --skip-nodes-with-local-storage=false
            - --expander=least-waste       # Choose node group that wastes least resources
            - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-cluster
            - --balance-similar-node-groups # Keep node groups balanced
            - --scale-down-delay-after-add=5m      # Wait 5m after scale-up before scaling down
            - --scale-down-unneeded-time=10m       # Node must be unneeded for 10m to scale down
            - --scale-down-utilization-threshold=0.5  # Scale down if node < 50% utilized
            - --max-graceful-termination-sec=600
# Annotate nodes to prevent Cluster Autoscaler from removing them
kubectl annotate node worker-node-01 \
  cluster-autoscaler.kubernetes.io/scale-down-disabled=true

# Check Cluster Autoscaler status and decisions
kubectl -n kube-system logs -f deployment/cluster-autoscaler | grep -E "scale|node"

Step 6: ResourceQuotas and LimitRanges (Prevent Resource Starvation)

# resource-quota-production.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    # Compute limits
    requests.cpu: "100"          # Total CPU requests across all pods
    requests.memory: "200Gi"     # Total memory requests
    limits.cpu: "200"
    limits.memory: "400Gi"

    # Object count limits
    pods: "500"
    services: "50"
    persistentvolumeclaims: "100"
    secrets: "200"
    configmaps: "200"

    # Storage limits
    requests.storage: "5Ti"
---
# limit-range.yaml — Default limits for pods that don't specify
apiVersion: v1
kind: LimitRange
metadata:
  name: production-limit-range
  namespace: production
spec:
  limits:
    - type: Container
      default:           # Applied if container doesn't set limits
        cpu: "500m"
        memory: "512Mi"
      defaultRequest:    # Applied if container doesn't set requests
        cpu: "100m"
        memory: "128Mi"
      max:               # Hard ceiling per container
        cpu: "4"
        memory: "8Gi"
      min:               # Hard floor per container
        cpu: "50m"
        memory: "64Mi"

    - type: Pod
      max:               # Hard ceiling per pod (sum of all containers)
        cpu: "8"
        memory: "16Gi"

    - type: PersistentVolumeClaim
      max:
        storage: "100Gi"
      min:
        storage: "1Gi"

Step 7: Prometheus Alerting Rules (Early Warning System)

# prometheus-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: cluster-resource-alerts
  namespace: monitoring
spec:
  groups:
    - name: cluster.resources
      interval: 30s
      rules:
        # Alert: Node CPU critical
        - alert: NodeCPUCritical
          expr: |
            (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) by (node)) * 100 > 85
          for: 5m
          labels:
            severity: critical
            team: platform
          annotations:
            summary: "Node {{ $labels.node }} CPU above 85%"
            description: "Node CPU has been above 85% for 5 minutes. Current: {{ $value }}%"
            runbook: "https://wiki.mycompany.com/runbooks/node-cpu-high"

        # Alert: Node Memory critical
        - alert: NodeMemoryCritical
          expr: |
            (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 85
          for: 5m
          labels:
            severity: critical
          annotations:
            summary: "Node {{ $labels.node }} memory above 85%"

        # Alert: Pods stuck in Pending (early indicator of resource exhaustion)
        - alert: PodsPendingTooLong
          expr: |
            kube_pod_status_phase{phase="Pending"} == 1
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} stuck Pending"
            description: "Pod has been Pending for >10 minutes. Check node resources and events."

        # Alert: HPA at max replicas (scaling ceiling reached)
        - alert: HPAAtMaxReplicas
          expr: |
            kube_horizontalpodautoscaler_status_current_replicas
            ==
            kube_horizontalpodautoscaler_spec_max_replicas
          for: 15m
          labels:
            severity: warning
          annotations:
            summary: "HPA {{ $labels.namespace }}/{{ $labels.horizontalpodautoscaler }} at max replicas"
            description: "HPA has been at maximum replicas for 15 minutes. Consider increasing maxReplicas or node capacity."

        # Alert: Cluster CPU capacity warning (across all nodes)
        - alert: ClusterCPUCapacityWarning
          expr: |
            sum(kube_node_status_allocatable{resource="cpu"}) -
            sum(kube_pod_container_resource_requests{resource="cpu"}) < 10
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Cluster has less than 10 CPU cores of unallocated capacity"

Step 8: Grafana Dashboard Essentials

# Import community dashboards for cluster monitoring
# Kubernetes Cluster Overview: Dashboard ID 7249
# Node Exporter Full:          Dashboard ID 1860
# HPA Overview:                Dashboard ID 10257
# Kubernetes Capacity Planning: Dashboard ID 5309

# Key panels to build custom dashboards:
# 1. CPU/Memory allocation vs actual usage per namespace
# 2. Pending pods over time
# 3. HPA current vs max replicas
# 4. Node capacity remaining
# 5. Cluster Autoscaler activity (scale-up/down events)

️ Trade-offs & Alternatives

| Tool | Best For | Limitation | |---|---|---| | HPA (CPU/Memory) | Stateless apps with predictable load | Reactive, not proactive; CPU lag | | HPA (custom metrics) | Business-metric-driven scaling | Requires Prometheus Adapter setup | | VPA | Right-sizing long-running services | Requires pod restart to apply | | KEDA | Queue/event-driven workloads | Additional CRD complexity | | Cluster Autoscaler | Dynamic node provisioning | 2–5 min lag to add nodes | | Karpenter (AWS) | Fast, flexible node provisioning | AWS-specific (has Azure port) |

Karpenter vs Cluster Autoscaler: Karpenter (developed by AWS) provisions nodes directly without Auto Scaling Groups, giving sub-minute node provisioning and more flexible instance selection. If you're on AWS, Karpenter is generally preferred over Cluster Autoscaler for its speed and flexibility.


️ Common Mistakes & Misconceptions

  • "HPA will save me even without resource requests set." — HPA calculates utilization as a percentage of resource requests. Without requests set, HPA cannot function. LimitRanges ensure every pod has requests.
  • "Cluster Autoscaler adds nodes instantly." — Node provisioning typically takes 2–5 minutes. Design your HPA to scale out pods proactively before nodes are actually needed (scale pods → Pending → CA triggers).
  • "I'll set high limits so pods never OOMKill." — Over-provisioning limits wastes cluster capacity. Use VPA recommendations to right-size, and treat OOMKills as signals to increase memory requests, not just limits.
  • "VPA and HPA can't work together." — They can, but only if HPA is using custom or external metrics, not CPU/memory. Running both on CPU is a conflict.

Key Takeaway

Cluster resource management is a feedback loop, not a one-time configuration. Observability (Prometheus alerts) gives you early warning, HPA/KEDA scale your workloads reactively, VPA keeps your requests accurate, and Cluster Autoscaler ensures the underlying infrastructure grows with you. The goal is to eliminate the 2 AM page by making scaling decisions automated, data-driven, and proactive.


Self-Assessment Checklist

  • [ ] Can you explain why HPA requires resource requests to function?
  • [ ] Can you describe why VPA and HPA conflict when both target CPU?
  • [ ] Can you write an HPA manifest that scales on both CPU and a custom Prometheus metric?
  • [ ] Can you explain the difference between Cluster Autoscaler and Karpenter?
  • [ ] Can you write a Prometheus alert rule for pods stuck in Pending state?

Finished reading?

Mark as complete to claim your +20 XP and track progress.