How to Scale Deployments in Kubernetes

Scaling Deployments in Kubernetes

Scaling is one of the reasons people adopt Kubernetes in the first place, and yet it’s also one of those things with more nuance than it first appears — manual scaling, autoscaling, scaling up versus scaling out, and the node-level capacity that has to exist for any of it to actually work. In this guide I’ll cover manual scaling, Horizontal Pod Autoscaling at a practical level, Vertical Pod Autoscaling, and how Cluster Autoscaling ties it all together so scaling requests actually have somewhere to land.

The Two Axes of Scaling

Most stateless applications scale horizontally by default because it’s simpler and more resilient — losing one of ten replicas is a non-event, but losing your one giant vertically-scaled Pod is a full outage.

Step 1: Manual Scaling

The simplest possible scaling operation:

kubectl scale deployment myapp --replicas=5
kubectl get deployment myapp
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
myapp   5/5     5            5           10m

Or edit the manifest directly and reapply:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 5
  # ...
kubectl apply -f myapp-deployment.yaml

Manual scaling is fine for predictable, known load changes (e.g., scaling up ahead of a planned event) but obviously doesn’t react to real-time traffic on its own.

Step 2: Horizontal Pod Autoscaling

For automatic reaction to load, use HorizontalPodAutoscaler. This requires the Metrics Server and resource requests defined on your containers (see the dedicated HPA guide for full depth):

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
kubectl apply -f myapp-hpa.yaml
kubectl get hpa myapp-hpa
NAME        REFERENCE          TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
myapp-hpa   Deployment/myapp   40%/65%   3         15        3          20s

Important nuance: once an HPA targets a Deployment, you should stop manually running kubectl scale against it — the HPA controller will simply overwrite your manual change on its next reconciliation loop, which can be confusing if you don’t expect it.

Step 3: Vertical Pod Autoscaling

Sometimes the right answer isn’t more replicas but bigger ones — a memory-hungry batch job, for instance, that can’t be usefully parallelized. VerticalPodAutoscaler (VPA) automatically adjusts resource requests based on observed usage:

kubectl apply -f https://github.com/kubernetes/autoscaler/releases/latest/download/vpa-v1-crd-gen.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: myapp-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  updatePolicy:
    updateMode: "Auto"
  resourcePolicy:
    containerPolicies:
      - containerName: myapp
        minAllowed:
          cpu: 100m
          memory: 128Mi
        maxAllowed:
          cpu: "2"
          memory: 4Gi
kubectl apply -f myapp-vpa.yaml
kubectl describe vpa myapp-vpa
Recommendation:
  Container Recommendations:
    Container Name: myapp
    Lower Bound:
      Cpu: 150m
      Memory: 256Mi
    Target:
      Cpu: 220m
      Memory: 384Mi
    Upper Bound:
      Cpu: 400m
      Memory: 512Mi

Important: updateMode: "Auto" evicts and recreates Pods to apply new resource values, meaning brief disruption. For production, updateMode: "Off" (recommendation-only, letting you review and apply manually) or "Initial" (only sets values on Pod creation, never evicts) are often safer choices. Also, VPA and HPA should generally not target the same metric (CPU) on the same Deployment simultaneously — they can fight each other.

Step 4: Cluster Autoscaling — Making Sure There’s Room to Scale Into

HPA and manual scaling only work if there’s actual node capacity for new Pods to land on. If your cluster is already at capacity, new replicas will sit Pending forever:

kubectl get pods -l app=myapp
NAME                     READY   STATUS    RESTARTS   AGE
myapp-7f9d8c6b5-4k2pl    0/1     Pending   0          2m
kubectl describe pod myapp-7f9d8c6b5-4k2pl
Warning  FailedScheduling  2m  default-scheduler  0/5 nodes are available: 5 Insufficient cpu.

This is where Cluster Autoscaler (or Karpenter on AWS) comes in — it watches for unschedulable Pods and automatically provisions new nodes to fit them, then removes underutilized nodes later:

# Example: enabling on GKE at cluster creation
gcloud container clusters create mycluster \
  --enable-autoscaling \
  --min-nodes=3 \
  --max-nodes=20

Karpenter (AWS) config for the same idea, defined as a Kubernetes resource:

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  limits:
    cpu: 1000
  template:
    spec:
      requirements:
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]

Without a working node autoscaler, HPA is only ever as effective as your static node capacity — worth checking early if scale-out attempts keep leaving Pods Pending.

Step 5: Scaling StatefulSets

The same kubectl scale command works for StatefulSet, though remember it scales in ordinal order (0, 1, 2…) and doesn’t delete PVCs on scale-down by default:

kubectl scale statefulset postgres --replicas=5

Step 6: Combining Manual, HPA, and PodDisruptionBudget

For production workloads, I always pair autoscaling with a PodDisruptionBudget so voluntary disruptions (node drains, cluster-autoscaler scale-down) don’t reduce availability below a safe threshold, even as replica counts fluctuate:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: myapp-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: myapp
kubectl apply -f myapp-pdb.yaml

This guarantees at least 2 Pods stay available even while the HPA scales replicas up and down, or while nodes get drained for maintenance.

Debugging Scaling Issues

kubectl get hpa
kubectl describe hpa myapp-hpa
kubectl top pods
kubectl get events --sort-by=.lastTimestamp

A common one: HPA shows <unknown>/65% instead of a real percentage — this almost always means the Metrics Server isn’t reachable or resource requests aren’t defined on the container.

Best Practices

Common Mistakes

Scaling Considerations for Multi-Tenant Clusters

When multiple teams share a cluster, uncoordinated scaling from one team can starve capacity from another. ResourceQuota caps how much a namespace can consume in total, which interacts directly with how far HPA can actually scale a Deployment within that namespace:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-alpha-quota
  namespace: team-alpha
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    pods: "50"

If an HPA tries to scale beyond what the namespace’s quota allows, new Pods fail to schedule with a quota-exceeded error rather than a capacity error — worth knowing so you don’t misdiagnose it as a node capacity or Cluster Autoscaler problem:

kubectl describe pod <pending-pod>
Warning  FailedCreate  10s  replicaset-controller  
Error creating: pods "myapp-xyz" is forbidden: exceeded quota: team-alpha-quota, 
requested: requests.cpu=200m, used: requests.cpu=19.9, limited: requests.cpu=20

LimitRange for Sane Defaults

Beyond quotas at the namespace level, LimitRange sets sensible per-container defaults and bounds, which matters a lot for scaling accuracy — Pods without explicit resource requests get one assigned automatically, and without a LimitRange, that default might be wildly inappropriate for your actual workload:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: team-alpha
spec:
  limits:
    - default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      type: Container

Scaling Batch and Job Workloads Differently

Everything covered so far assumes long-running Deployments. Batch workloads (Job, CronJob) scale along a different axis entirely — parallelism rather than steady-state replica count:

apiVersion: batch/v1
kind: Job
metadata:
  name: data-processing
spec:
  parallelism: 10
  completions: 100
  template:
    spec:
      containers:
        - name: worker
          image: myrepo/batch-worker:1.0.0
      restartPolicy: OnFailure

This runs up to 10 Pods concurrently until 100 total completions are reached — a fundamentally different scaling model than HPA’s continuous reconciliation toward a target metric, and one where Cluster Autoscaler’s speed at provisioning burst capacity often matters more than fine-grained metric-based tuning.

Predictive and Scheduled Scaling

HPA reacts to current metrics, which means there’s inherent lag between a traffic spike starting and replicas actually catching up — usually tolerable, but not always, especially for very sudden, predictable spikes (a scheduled sale event, a known daily traffic pattern). For genuinely predictable load, scheduled scaling ahead of the event beats reactive autoscaling:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: pre-scale-for-peak
spec:
  schedule: "55 8 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: scaler
          containers:
            - name: scaler
              image: bitnami/kubectl
              command:
                - kubectl
                - scale
                - deployment/myapp
                - --replicas=20
          restartPolicy: OnFailure

This gives capacity a five-minute head start before a known 9 a.m. traffic ramp, with the HPA (set with a minReplicas that respects this floor temporarily, or simply left to take back over once real metrics reflect the new load) handling any additional organic variation from there. KEDA also supports cron-based scaling triggers natively if you’d rather not manage a separate CronJob for this.

Summary

Scaling in Kubernetes spans manual replica changes, automated horizontal scaling via HPA, automated vertical scaling via VPA, and cluster-level node autoscaling that makes room for all of it. Getting predictable, resilient scaling in production means combining these deliberately — HPA reacting to real load, Cluster Autoscaler ensuring capacity exists, and PodDisruptionBudgets protecting availability throughout.

References

Exit mobile version