MastertheMesh
Solo · Istio · ambient · multicluster peering · EKS · Global Accelerator · kgateway · Route53
Live · Runs on AWS EKS (two regions)

Multi-region load balancing and failover on Solo ambient

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Two EKS clusters, two AWS regions (eu-central-1 and eu-west-1), peered into one Solo ambient mesh — istiod to istiod, east-west gateways on NLBs, a shared root CA, and no central management plane in the data path. On top of that, regional failover proven at both layers where it actually lives: the mesh (a global service that stays local and fails over cross-region only when local endpoints go unhealthy) and the edge (kgateway per region behind AWS Global Accelerator, static anycast IPs, no DNS TTLs). Every output below is captured from the running clusters.

two EKS regions ambient multicluster peering global service locality-preferred Global Accelerator kgateway ingress no management plane

The question this answers. "Show that traffic to a service in one region is automatically re-routed to another when the local pods go unhealthy or the region becomes unreachable — and confirm the mesh prefers the closest healthy region." Two different failures, two different layers, and it matters which one you are demonstrating. This lab builds both on the same two clusters.

The shape. One workload, region-echo, runs in both regions and answers with the region and pod that served the request — so every response is self-labelling. It is published as a single global service (istio.io/global: "true") with locality preference (trafficDistribution: PreferClose). A client loop in each cluster calls the same hostname throughout; the region in the reply is the whole story.

What this answers

  • Automated regional failover, both layers
  • Prefers the closest healthy region
  • Scale with no management plane

What you need

  • AWS: EKS, NLB, Global Accelerator, Route 53
  • A Solo Enterprise for Istio licence
  • eksctl, helm, istioctl, aws

Footprint

2 EKS clusters · 4 NLBs · 1 accelerator. Billed while running; full teardown included, LB services first.

The architecture

Two clusters, each self-sufficient. There is a management/telemetry cluster in the customer's target picture (Solo UI, telemetry gateway) but it is not in the request path and not in the discovery path — peering is direct istiod-to-istiod. That is the single most important fact for a bank running tens of clusters with no central coordination: nothing you deploy here can take traffic down by failing.

Client Edge front door Global Accelerator · or Route 53 eu-central-1 eu-west-1 kgateway ingress · NLB :80 region-echo ×2 served by ztunnel · PreferClose east-west gateway HBONE · NLB :15008 kgateway ingress · NLB :80 region-echo ×2 served by ztunnel · PreferClose east-west gateway HBONE · NLB :15008 peering One global service (region-echo) resolvable and servable from either region — istiod↔istiod, no management plane.
Two regions, one peered mesh — the client, the edge front door, and the two clusters
LayerComponentJob
Edge (client failover)AWS Global AcceleratorStatic anycast IPs over both regional NLBs; health-checks each region; cuts over in ~20–40s, no DNS TTLs
Regional ingresskgateway per cluster (NLB)North-south entry; an HTTPRoute targets the global service, so either region can serve from the other
Cross-region fabricIstio east-west gateway (NLB, HBONE)mTLS tunnel between clusters; how a request served cross-region actually travels
Mesh data planeztunnel (per node)L4 + locality-weighted load balancing; keeps traffic local, fails over on unhealthy endpoints
Discoveryistiod ↔ istiod peeringEach control plane learns the other's endpoints directly — no management plane involved

Stand it up

Five scripts. Everything creates paid AWS infrastructure, so the profile is forced explicit (LAB_AWS_PROFILE) and there is a teardown that removes it all in the right order.

SETUP

Two clusters, the mesh, the peering, the app

both clusters peered (istioctl remote-clusters shows synced); each region's client served by its own region.

basheksctl + scripts 01–03
# two EKS clusters, one per region (m5.large x3, headroom to 12 for the scale demo)
eksctl create cluster -f eksctl/mesh-eu-central.yaml   # eu-central-1
eksctl create cluster -f eksctl/mesh-eu-west.yaml      # eu-west-1

export LAB_AWS_PROFILE=<your-aws-profile>     # forced explicit — never inherits a sourced AWS_PROFILE
export SECRETS_FILE=~/code/solo/secrets/secrets-envs.sh   # SOLO_ISTIO_LICENSE_KEY

./scripts/01-istio.sh      # Solo ambient on both, plain Helm, shared root CA
./scripts/02-peering.sh    # east-west gateways on NLBs + istiod peering + remote secrets
./scripts/03-app.sh        # region-echo as a global service in both regions
Two plain-Helm gotchas worth knowing, both fixed in the scripts. A Helm install (no Gloo Operator) does not create the istio-eastwest GatewayClass — istiod's east-west controller only reconciles it once it exists, and it stays gated behind AMBIENT_ENABLE_MULTI_NETWORK=true on istiod. And on the peering side, an AWS NLB gives you a DNS name, not an IP, so the remote peer reference needs addressType: Hostname or the Gateway is rejected for a non-IP address.

Failover at the mesh layer

This is the layer the customer's Bookinfo demo showed, done cleanly. The client calls region-echo.shop.svc.cluster.local and never changes. With trafficDistribution: PreferClose istiod translates the service to ztunnel's Failover load-balancing policy — routing preference Network → Region → Zone, healthy endpoints only. Traffic stays local; it crosses regions only when the local endpoints are gone.

Client in-mesh · one hostname ztunnel global service VIP Failover LB · healthy only prefers local eu-central-1 local endpoints · healthy served here normally eu-west-1 taken when local dies via east-west gateway Same hostname throughout — ztunnel serves local while healthy, spills cross-region only during the outage.
Mesh-layer failover — same hostname, ztunnel spills cross-region only during the outage
DEMO 1

Local pods die, the global service serves from the other region

local → cross-region on replicas=0 → local again on restore, no client change.

bashscripts/04-demo-pod-failover.sh
# the service that makes it work — a k8s-native global service, locality-preferred
kind: Service
metadata:
  name: region-echo
  namespace: shop
  labels:
    app: region-echo
    istio.io/global: "true"          # published across the peered mesh
spec:
  trafficDistribution: PreferClose   # -> ztunnel Failover LB (Network,Region,Zone)
  ports: [{ name: http, port: 8080, targetPort: 8080, appProtocol: http }]

# the demo: scale the local endpoints to 0, watch the client, scale back
kubectl -n shop scale deploy/region-echo --replicas=0    # eu-central only
textcaptured live — the eu-central client loop (same hostname throughout)
Phase 1 — steady state
  -> {"region": "eu-central-1", "pod": "region-echo-7c6dc74977-8bgsd"}   <- local

Phase 2 — eu-central endpoints scaled to 0
  -> {"region": "eu-west-1", "pod": "region-echo-597c98cb47-vfvgf"}      <- cross-region, over the east-west GW

Phase 3 — restored
  -> {"region": "eu-central-1", "pod": "region-echo-7c6dc74977-6dvfp"}   <- local again
This is faster than DNS and needs no health-check config. Locality preference is deterministic, not probed — every endpoint carries its region/zone and network from the node, so ztunnel knows "closest" without measuring latency. Cross-region bytes flow only during the outage, which is exactly the data-transfer-cost property you want. It also covers "region unreachable" at the mesh layer: a client that still reaches its local ingress keeps getting served, from the far region, with no code change.

Failover at the edge layer — pick one

The mesh layer (above) handles callers already inside the mesh. External clients hitting a regional endpoint need a front door that fails a whole region out, and AWS gives you two independent ways to build it. They are alternatives, not layers — you pick one. The choice comes down to a single trade-off: what the client holds onto, and therefore how fast the cutover is.

 Approach A — Route 53 (DNS)Approach B — Global Accelerator
The client holdsa resolved IP (must re-resolve on failure)2 fixed anycast IPs (never change)
Cutover bounded byhealth check + record TTL + resolver cacheshealth check only (~20–40s)
Needs a domain?yes — a hosted zoneno
What it costshealth checks (cheap)accelerator hourly + data premium
Reach for it whenyou already steer clients with DNSrecovery time is the priority

Both are built and demonstrated live below, each end to end.

Edge approach A · Route 53 (DNS failover)

A Route 53 failover record set over the two regional ingress NLBs. The client resolves the hostname and gets the primary region's address; when the primary's health check fails, Route 53 hands out the secondary region's address on the next resolution. Simple, no extra service to run — the cutover is bounded by the record TTL and whatever the client and its resolvers cache.

Client resolves hostname Route 53 latency / failover record + health checks returns an IP eu-central-1 ingress NLB · healthy returned to the client eu-west-1 ingress NLB · standby served after re-resolve On failure the client must re-resolve DNS to get the new IP — cutover bounded by the record's TTL.
Route 53 — failover happens at DNS resolution time

What you create

How — the exact commands

bashscripts/08-dns-route53.sh
# 1. one health check per region, TCP:80 against that region's ingress NLB
HC1=$(aws route53 create-health-check --caller-reference eu-central-$(date +%s) \
  --health-check-config Type=TCP,FullyQualifiedDomainName=$ING1_NLB,Port=80,RequestInterval=10,FailureThreshold=2 \
  --query 'HealthCheck.Id' --output text)
HC2=$(aws route53 create-health-check --caller-reference eu-west-$(date +%s) \
  --health-check-config Type=TCP,FullyQualifiedDomainName=$ING2_NLB,Port=80,RequestInterval=10,FailureThreshold=2 \
  --query 'HealthCheck.Id' --output text)

# 2. the failover record set: PRIMARY -> eu-central, SECONDARY -> eu-west,
#    each bound to its health check, TTL 15s
aws route53 change-resource-record-sets --hosted-zone-id $HOSTED_ZONE_ID --change-batch '{
  "Changes": [
    {"Action":"UPSERT","ResourceRecordSet":{
      "Name":"region-echo.example.com","Type":"CNAME","TTL":15,
      "SetIdentifier":"primary","Failover":"PRIMARY",
      "HealthCheckId":"'$HC1'","ResourceRecords":[{"Value":"'$ING1_NLB'"}]}},
    {"Action":"UPSERT","ResourceRecordSet":{
      "Name":"region-echo.example.com","Type":"CNAME","TTL":15,
      "SetIdentifier":"secondary","Failover":"SECONDARY",
      "HealthCheckId":"'$HC2'","ResourceRecords":[{"Value":"'$ING2_NLB'"}]}}
  ]}'
DEMO 2A

Route 53 fails the region out at resolution time

resolves to primary; take the primary ingress down → resolves to and serves the secondary; restore → primary returns.

bashscripts/09-demo-dns-failover.sh
kubectl --context $CTX1 -n kgateway-system scale deploy/ingress --replicas=0   # primary down
dig +short region-echo.example.com     # watch it switch to the eu-west NLB
curl -s http://region-echo.example.com/
textcaptured live
Phase 1 — steady state (primary = eu-central-1)
  serves:      "region": "eu-central-1"

Phase 2 — eu-central ingress down (primary health check fails)
  flipped:     "region": "eu-west-1"
  resolves to: a3ddd3a7336194d77af4f662963f25f5-...elb.eu-west-1.amazonaws.com   <- the secondary NLB

Phase 3 — restore
  primary returns once its health check passes again and the TTL expires
The cutover is health-check detection plus record TTL plus client caching. With a 10s health-check interval and a 15s TTL it settles in well under a minute here, but a client or resolver that caches beyond the TTL keeps hitting the dead region until its own cache expires. That residual caching is the reason Global Accelerator exists.

Edge approach B · Global Accelerator (anycast)

Two static anycast IPs sit in front of both regional NLBs. The client always targets the same IPs; Global Accelerator health-checks each region and routes to the nearest healthy one, rerouting internally on failure with no DNS involved. Nothing waits on a resolver cache — this is the answer when recovery time is the priority.

Client 2 fixed IPs Global Accelerator static anycast IPs health-checks each region routes internally eu-central-1 ingress NLB · healthy active endpoint eu-west-1 ingress NLB · standby taken on health-fail The client keeps the same anycast IPs — GA reroutes behind them. No re-resolve, cutover ~20–40s.
Global Accelerator — failover happens inside AWS, IPs never change

What you create

How — the exact commands

bashscripts/05-ingress-ga.sh (the GA control-plane API lives in us-west-2)
# 1. the accelerator (returns 2 static anycast IPs + a DNS name)
GA_ARN=$(aws globalaccelerator create-accelerator --name mesh-multiregion \
  --region us-west-2 --query 'Accelerator.AcceleratorArn' --output text)

# 2. a TCP:80 listener
LARN=$(aws globalaccelerator create-listener --accelerator-arn $GA_ARN \
  --protocol TCP --port-ranges FromPort=80,ToPort=80 \
  --region us-west-2 --query 'Listener.ListenerArn' --output text)

# 3. one endpoint group per region, pointing at that region's ingress NLB ARN
aws globalaccelerator create-endpoint-group --listener-arn $LARN \
  --endpoint-group-region eu-central-1 \
  --endpoint-configurations EndpointId=$ING1_NLB_ARN,Weight=100 \
  --health-check-port 80 --health-check-protocol TCP \
  --health-check-interval-seconds 10 --threshold-count 2 --region us-west-2
# (repeat for eu-west-1 -> $ING2_NLB_ARN)
DEMO 2B

Global Accelerator cuts to the healthy region, IPs unchanged

anycast serves the near region; kill its ingress → served only by the far region in ~40s; restore → back.

bashscripts/07-demo-region-failover.sh
# take down the region GA is actually serving THIS client, watch the same anycast address
kubectl --context $CTX -n kgateway-system scale deploy/ingress --replicas=0
for i in $(seq 20); do curl -s http://$GA_DNS/; done   # count the regions
textcaptured live — 20 requests to the GA anycast address per phase
Phase 1 — steady state
  15  "region": "eu-central-1"
   5  "region": "eu-west-1"

Phase 2 — eu-west ingress down (the region GA was serving us)
  flipped to eu-central-1 after 40s
  19  "region": "eu-central-1"
   1  "region": "(timeout)"        <- single request mid-cutover

Phase 3 — restored
  20  "region": "eu-central-1"
The gotcha that decides whether GA fails over at all: externalTrafficPolicy: Local on the ingress Service. Global Accelerator inherits an NLB's target-group health rather than probing itself. In the default Cluster mode every node answers the health check even with zero ingress pods on it, so GA never sees the region as unhealthy. Set externalTrafficPolicy: Local and the check passes only on nodes actually running an ingress pod — now scaling to zero deterministically fails the region out (and preserves the client source IP).
Both approaches: health-check the ingress, never the east-west gateway. The east-west gateway is mesh-internal HBONE (:15008/:15012), not a path any external client traverses — probing it tells you nothing about whether the region can serve requests.

Does it prefer the closest region? (yes, and there is no latency check)

A direct answer to the second requirement. Locality-weighted routing in the mesh is deterministic, not measured. trafficDistribution: PreferClose becomes a ztunnel Failover policy whose routing preference is Network → Region → Zone over healthy endpoints only. "Closest" is read from topology labels the node already carries — there is no latency probe, and there does not need to be. Latency-based steering, if you want it, is the client-side job (Route53 latency records or Global Accelerator), not the mesh's.

textcaptured live — the ztunnel LB policy istiod compiled for region-echo
$ istioctl ztunnel-config service <ztunnel> -o json | jq '.[] | select(.name=="region-echo").loadBalancer'
{
  "mode": "Failover",
  "routingPreferences": ["Network", "Region", "Zone"],
  "healthPolicy": "OnlyHealthy"
}
One build note. On this line the networking.istio.io/traffic-distribution annotation is ignored — use the Service spec.trafficDistribution field. istiod only compiles the Failover policy from the field.

Scale to 1000 tenants — test the thing that actually scales

The third requirement asked whether "the Gloo Management Plane can sync discovery for 1k tenants without hitting rate limits." The honest reframing: in ambient peering there is no management plane in the discovery path. Peering is direct istiod-to-istiod; the management/telemetry cluster carries the UI and metrics only and cannot take traffic down. So the component to load-test is istiod (per cluster) and the peering fan-out — which is what the scale script measures.

DEMO 3

Ramp N tenant namespaces on both clusters, measure what scales

istiod push latency and ztunnel memory as N grows; time for a new global service to become resolvable from the peer.

bashscripts/06-scale.sh
./scripts/06-scale.sh 100      # ramp to 100 tenants on both clusters (default)
./scripts/06-scale.sh 1000     # the full number — scale the nodegroups first:
#   eksctl scale nodegroup --cluster mesh-eu-central -r eu-central-1 --name workers -N 10

# each tenant = one namespace + one global service (http-echo). The script captures:
#   - pilot_xds_pushes / convergence time from istiod at 100/500/1000
#   - istiod + ztunnel CPU/memory (kubectl top)
#   - time for a freshly-created global service to be served from the PEER cluster
Design lever for the real deployment: discovery and config size scale with the global set, not the total estate. Only mark services global that genuinely need cross-region serving, and where tenants must not see each other, segments bound both the discovery scope and the blast radius per tenant.

Which layer for which failure

FailureLayer that handles itMechanismCutover
Local pods unhealthyMesh (ztunnel)Global service, Failover LB, healthy endpoints onlyseconds, no config
Region's edge unreachableEdge (Global Accelerator)Anycast IPs, per-region health check~20–40s, no DNS
Prefer closest regionMesh (ztunnel)Topology-deterministic, Network→Region→Zoneno latency probe
Latency-based client steeringClient (Route53 / GA)Route53 latency records or GA proximityDNS TTL (Route53)
1000 tenants discoveryistiod peeringDirect istiod↔istiod, no management planeper-cluster istiod

Tear it down

This is billed infrastructure — two EKS clusters, four NLBs, a Global Accelerator, Route53 health checks. The teardown removes them in the order that avoids orphaned load balancers.

bashscripts/teardown.sh
export LAB_AWS_PROFILE=<your-aws-profile>
./scripts/teardown.sh    # GA -> health checks -> LB services -> both clusters
Delete the LoadBalancer services before the clusters. If eksctl delete cluster runs first, the NLBs it did not create (the mesh and ingress ones) are orphaned and keep billing. The teardown deletes the Kubernetes Service objects first so their NLBs are released, then the accelerator, then the clusters.

See also

Versions

Built and verified on:

Enterprise
AWSGlobal Accelerator + Route 53 + NLB
Gateway APIv1.5.1
Istio Helm charts1.29.3-solo
Kubernetes (EKS)1.33
Solo Istio (distribution)1.29.3-solo
kgateway (ingress)v2.2.0