How to Set Up Multi-AZ Clusters with Kubernetes on AWS

How to Set Up Multi-AZ Clusters with Kubernetes on AWS

A single-AZ Kubernetes cluster is a ticking time bomb dressed up as a cost saving. I learned this the hard way during an AWS us-east-1 AZ outage a while back — every node in that cluster was sitting in the same availability zone, and when it went dark, so did the entire application. Nothing about the Kubernetes control plane could save us because the underlying infrastructure was the single point of failure. Multi-AZ isn’t optional for anything that matters.

This guide covers designing and deploying a genuinely resilient multi-AZ Kubernetes cluster on AWS, using EKS as the reference implementation (the same concepts apply to self-managed clusters on EC2).

Why Multi-AZ Matters

Availability Zones are physically isolated data centers within an AWS region, connected by low-latency links but with independent power, cooling, and networking. Spreading across AZs protects against:

  • Full AZ outages (power, network, or hardware failures)
  • Correlated hardware failures within a single data center
  • Maintenance events that AWS performs zone-by-zone

The tradeoff is cross-AZ data transfer costs and slightly higher latency between AZs — both usually worth it for production workloads.

Architecture Overview

A properly designed multi-AZ EKS setup includes:

  1. Control plane — EKS manages this across multiple AZs automatically; you don’t configure this directly.
  2. VPC with subnets in at least 3 AZs — both public (for load balancers/NAT) and private (for worker nodes).
  3. Worker nodes distributed across AZs — via managed node groups or self-managed Auto Scaling Groups per AZ.
  4. Pod Topology Spread Constraints — to ensure pod replicas actually land across zones, not just that nodes exist in each zone.
  5. Multi-AZ storage — EBS volumes are AZ-locked, so stateful workloads need special handling (EFS, or storage classes with topology awareness).

Step 1: VPC and Subnet Design

Using Terraform-style structure (or the AWS CLI/Console equivalent):

aws ec2 create-vpc --cidr-block 10.0.0.0/16 --region us-east-1

Create subnets across three AZs:

aws ec2 create-subnet --vpc-id vpc-xxxx --cidr-block 10.0.1.0/24 --availability-zone us-east-1a
aws ec2 create-subnet --vpc-id vpc-xxxx --cidr-block 10.0.2.0/24 --availability-zone us-east-1b
aws ec2 create-subnet --vpc-id vpc-xxxx --cidr-block 10.0.3.0/24 --availability-zone us-east-1c

Repeat for private subnets (10.0.11.0/24, 10.0.12.0/24, 10.0.13.0/24) — worker nodes should live in private subnets with outbound access via NAT Gateways, ideally one NAT Gateway per AZ to avoid a single NAT becoming a cross-AZ bottleneck and single point of failure.

Tag subnets correctly for EKS and load balancer auto-discovery:

aws ec2 create-tags --resources subnet-xxxx \
  --tags Key=kubernetes.io/cluster/my-cluster,Value=shared \
         Key=kubernetes.io/role/elb,Value=1

Step 2: Create the EKS Cluster

eksctl create cluster \
  --name multi-az-prod \
  --region us-east-1 \
  --version 1.30 \
  --vpc-private-subnets subnet-priv-1a,subnet-priv-1b,subnet-priv-1c \
  --vpc-public-subnets subnet-pub-1a,subnet-pub-1b,subnet-pub-1c \
  --without-nodegroup

eksctl automatically spreads the managed control plane across AZs — this is handled by AWS and isn’t something you configure directly.

Step 3: Create Managed Node Groups Across AZs

You can either let a single managed node group span multiple AZs, or create one node group per AZ for finer control. For most production setups, a single multi-AZ managed node group with the cluster autoscaler is simpler to operate:

eksctl create nodegroup \
  --cluster multi-az-prod \
  --name workers-multi-az \
  --node-type m5.xlarge \
  --nodes 6 \
  --nodes-min 3 \
  --nodes-max 12 \
  --subnet-ids subnet-priv-1a,subnet-priv-1b,subnet-priv-1c \
  --managed

Verify node distribution:

kubectl get nodes -L topology.kubernetes.io/zone
NAME                          STATUS   ROLES    AGE   VERSION   ZONE
ip-10-0-11-23.ec2.internal    Ready    <none>   5m    v1.30.2   us-east-1a
ip-10-0-12-45.ec2.internal    Ready    <none>   5m    v1.30.2   us-east-1b
ip-10-0-13-67.ec2.internal    Ready    <none>   5m    v1.30.2   us-east-1c

Step 4: Enforce Pod Distribution with Topology Spread Constraints

Having nodes in three AZs means nothing if the scheduler happens to pile every replica onto one zone. Use topology spread constraints to enforce even distribution:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-api
spec:
  replicas: 6
  selector:
    matchLabels:
      app: web-api
  template:
    metadata:
      labels:
        app: web-api
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: web-api
      containers:
      - name: web-api
        image: myregistry.io/web-api:2.5.0
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "1"
            memory: "1Gi"

maxSkew: 1 with DoNotSchedule guarantees no zone has more than one extra replica compared to any other — with 6 replicas across 3 zones, that’s a clean 2/2/2 split.

Step 5: Multi-AZ Aware Storage

EBS volumes are zone-locked — a pod using an EBS-backed PVC can only run in the AZ where that volume exists. This matters a lot for StatefulSets.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-multi-az
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
parameters:
  type: gp3

volumeBindingMode: WaitForFirstConsumer is critical here — it delays volume provisioning until a pod is actually scheduled, so the volume gets created in the same AZ as the pod, rather than a random AZ that might not match.

For workloads that genuinely need shared storage across AZs (not just per-replica volumes), use EFS instead:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: efs-shared
provisioner: efs.csi.aws.com
parameters:
  provisioningMode: efs-ap
  fileSystemId: fs-0123456789abcdef0
  directoryPerms: "700"

EFS is inherently multi-AZ, at the cost of higher latency than EBS for most workloads.

Step 6: Load Balancing Across AZs

The AWS Load Balancer Controller automatically provisions an ALB/NLB that spans all AZs where you have subnets tagged appropriately:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-api-ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web-api
            port:
              number: 80
kubectl apply -f web-api-ingress.yaml
kubectl get ingress web-api-ingress

The resulting ALB automatically health-checks targets in every AZ and routes traffic away from any zone that’s failing.

High Availability for the Cluster Autoscaler

Run the cluster autoscaler with zone-aware node groups so it can scale the right pool when a specific AZ is under pressure:

helm install cluster-autoscaler autoscaler/cluster-autoscaler \
  --namespace kube-system \
  --set autoDiscovery.clusterName=multi-az-prod \
  --set awsRegion=us-east-1 \
  --set extraArgs.balance-similar-node-groups=true

balance-similar-node-groups=true is the key setting — it actively tries to keep node counts even across AZ-specific node groups during scale-up.

Testing AZ Failure Resilience

Simulate an AZ failure by cordoning and draining all nodes in one zone:

for node in $(kubectl get nodes -l topology.kubernetes.io/zone=us-east-1a -o name); do
  kubectl cordon $node
  kubectl drain $node --ignore-daemonsets --delete-emptydir-data
done

Watch pods reschedule into the remaining zones:

kubectl get pods -o wide -w

If your topology spread constraints and PodDisruptionBudgets are configured correctly, you should see zero downtime — just a rebalance into us-east-1b and us-east-1c.

PodDisruptionBudgets

Pair topology spread with a PDB so voluntary disruptions (node drains, upgrades) never take out too many replicas at once:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web-api-pdb
spec:
  minAvailable: 4
  selector:
    matchLabels:
      app: web-api

Cost Considerations of Multi-AZ Design

Multi-AZ resilience isn’t free, and it’s worth understanding where the costs actually come from before your first AWS bill arrives:

  • Cross-AZ data transfer — traffic between AZs within the same region incurs a per-GB charge on both sides of the transfer. Chatty microservice architectures with many small cross-AZ calls can accumulate meaningful cost at scale.
  • Per-AZ NAT Gateways — running one NAT Gateway per AZ (recommended for resilience) multiplies the hourly NAT Gateway charge by the number of zones, plus per-GB data processing charges.
  • Idle capacity headroom — to genuinely survive losing an AZ, your remaining zones need enough spare capacity to absorb the failed zone’s load, meaning you’re intentionally running below 100% utilization across your fleet at all times.

A reasonable mitigation for the data transfer cost specifically is being deliberate about which services need strict zone spread (stateful, customer-facing, latency-sensitive) versus which can tolerate looser placement (internal batch jobs, non-critical background workers) and skip the topology spread constraint entirely for the latter.

# For workloads where cross-AZ cost matters more than zone-level resilience
topologySpreadConstraints:
- maxSkew: 1
  topologyKey: kubernetes.io/hostname
  whenUnsatisfiable: ScheduleAnyway
  labelSelector:
    matchLabels:
      app: internal-batch-worker

Spreading at the node level rather than the zone level for such workloads still gets you resilience against single-node failures without the cross-AZ cost profile of enforcing zone-level spread on every single Deployment in the cluster.

Region Selection and AZ Count

Not every AWS region has the same number of availability zones, and this affects your minimum viable multi-AZ design. us-east-1 has six AZs; some newer or smaller regions have only two or three. Before committing to a region for a production workload, verify AZ count matches your resilience requirements:

aws ec2 describe-availability-zones --region us-east-1 --query 'AvailabilityZones[].ZoneName'

If a target region only offers two AZs, your effective resilience story changes significantly — losing one zone means the other absorbs the entire load with zero remaining redundancy, which is a materially different risk profile than a three-AZ deployment where losing one zone still leaves two to share the load.

Common Mistakes

  • Using a single NAT Gateway for all AZs — creates a cross-AZ bottleneck and defeats zone isolation for outbound traffic.
  • Forgetting WaitForFirstConsumer on StorageClasses — causes PVC/pod AZ mismatches and stuck Pending pods.
  • Not setting topology spread constraints — nodes being multi-AZ doesn’t mean pods are; the scheduler needs to be told explicitly.
  • Two AZs instead of three — with only two zones, losing one means the other absorbs 100% of load; three zones is the practical minimum for genuine resilience.
  • Ignoring cross-AZ data transfer costs in chatty microservice architectures — sometimes worth co-locating latency-sensitive services with pod affinity within the constraints of your spread policy.

Disaster Recovery Considerations

Multi-AZ protects against zone failure but not region failure. For full disaster recovery, pair this setup with either a warm-standby cluster in a second region or a backup/restore strategy using tools like Velero, snapshotting both cluster state and persistent volumes to S3 with cross-region replication enabled.

Summary

A resilient multi-AZ Kubernetes cluster on AWS requires more than just spreading EC2 instances across zones — it needs subnet design with per-AZ NAT gateways, topology-aware storage classes, explicit pod topology spread constraints, zone-aware autoscaling, and load balancers that health-check across all zones. Get these pieces right and an entire AZ can disappear without your application even blinking.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Implement Job Objects in Kubernetes

How to Implement Job Objects in Kubernetes

Next Post
How to Use Taints and Tolerations in Kubernetes

How to Use Taints and Tolerations in Kubernetes

Related Posts