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:
- Control plane — EKS manages this across multiple AZs automatically; you don’t configure this directly.
- VPC with subnets in at least 3 AZs — both public (for load balancers/NAT) and private (for worker nodes).
- Worker nodes distributed across AZs — via managed node groups or self-managed Auto Scaling Groups per AZ.
- Pod Topology Spread Constraints — to ensure pod replicas actually land across zones, not just that nodes exist in each zone.
- 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
WaitForFirstConsumeron StorageClasses — causes PVC/pod AZ mismatches and stuckPendingpods. - 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.