GCP Architecture

GCP Multi-Region Active-Active Architecture: Global Load Balancing, Spanner and Cross-Region Data

Every cloud region eventually has a bad day. Not a zone — a region: a networking control-plane fault, a capacity event, a bad rollout that takes your stack down no matter how many zones you spread across. When that day comes, single-region architecture has one answer — wait — and every minute is revenue, SLA credits and trust. Multi-region active-active removes the waiting: two or more regions serve production traffic simultaneously, so losing one is not a disaster to recover from but a capacity event to absorb.

Active-active is really two hard problems wearing one name: traffic (how users reach the closest healthy region, and how traffic moves in seconds when one dies — without trusting every DNS resolver on the internet) and state (containers run anywhere; data lives somewhere, and physics charges 100+ ms between continents). Google Cloud hands you a distinctive tool for each. For traffic, the global external Application Load Balancer is one worldwide load balancer with one anycast IP — failover is a routing decision inside Google’s network, not a DNS change you hope clients notice. For state, Cloud Spanner multi-region configurations deliver external consistency and a 99.999% SLA — synchronous-quorum writes across regions, RPO zero, no promote step.

This article is the full design: DR tiers and the RTO/RPO contract; the anycast front door and why it beats DNS failover (with an honest AWS/Azure contrast); Cloud Run and MIGs serving from two regions behind one backend service; the data-layer decision between Spanner, Cloud SQL cross-region replicas, AlloyDB and Firestore; GCS dual-region with turbo replication; session and cache design; failover drills; replication-lag realities; cross-region observability; and cost — because active-active is a spectrum, and the priciest mistake is buying five nines for data that needed three. Everything ships with real gcloud and Terraform.

What problem this solves

A well-built single-region deployment — multi-zone MIGs or Cloud Run, regional Cloud SQL HA — already survives zone loss. It cannot survive the failure domain above the zone: the region itself and its control planes and quotas. Active-active caps the blast radius of exactly five failure classes:

# Failure class What it looks like from your pager Single-region outcome Active-active outcome
1 Full regional outage All zones unreachable; regional APIs erroring; Google status page confirms Hard down until Google recovers (hours) Surviving region absorbs traffic in seconds–minutes
2 Gray regional degradation Elevated 5xx/latency in one region; health checks flap; no status-page entry yet Partial outage, hard to prove, hard to escape Drain the sick region with one command; users barely notice
3 Regional capacity/quota exhaustion Autoscaler wants 40 VMs, region allocates 12; ZONE_RESOURCE_POOL_EXHAUSTED Throttled at peak; you cannot buy your way out mid-incident Spillover: the global LB waterfalls excess traffic to the next region
4 Latency for a global audience EU users pay 140 ms RTT to us-central1 on every request Permanent latency tax; CDN only hides the static part Users served from the nearest region; p50 drops by the ocean crossing
5 Risky change blast radius A bad deploy or config change takes out the whole stack 100% of users exposed to every change Deploy region-by-region; a bad rollout burns ≤ 50% and is drained instantly

Only class 1 is “disaster recovery” in the classic sense; classes 2–5 are everyday value — spillover, latency, safer deploys. That is the honest financial argument: you are not paying double to insure a once-in-three-years event, you are buying a permanently better serving platform that also survives region loss. Who needs it: payments and trading platforms with contractual RPO/RTO, global SaaS, retail that cannot be down during a sale, and any workload whose 99.99%+ target mathematically exceeds a single region’s dependency chain.

What breaks without it: DNS-failover plans that were never tested and quietly depend on a 60-second TTL mobile carriers ignore; “restore from backups” plans with a real RTO of a working day; and read-replica DR where the promote runbook lives in one engineer’s head and replication lag at the moment of disaster decides — at random — how many transactions you lose.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with GCP’s resource model, have deployed something real on Cloud Run or Compute Engine, and know basic Cloud SQL administration. The failure-domain mental model is upstream reading: GCP Regions and Zones: resiliency and what regions and zones really mean. Because GCP’s VPC is global by design, skim why the GCP network spans the planet — two regions sharing one VPC, one firewall policy and one address space quietly removes half the plumbing AWS multi-region designs need.

This article sits at the top of the GCP architecture track, composing services covered elsewhere (Cloud Run, compute choices, Cloud Monitoring) into a cross-region system. Running Azure too? The companion design is Azure multi-region active-active & disaster recovery — reading both shows what is cloud philosophy and what is physics.

Every layer crosses regions differently; knowing which layer you are debugging is half the incident:

Layer Scope on GCP Cross-region mechanism Failover actor Typical failover time
DNS / domain Global (Cloud DNS 100% SLA) None needed — points at one anycast IP forever Nobody (that is the point) 0 (no change)
Entry / L7 Global — one external ALB, one anycast VIP GFE edge → nearest healthy region with capacity Google’s LB control plane Seconds (health-check driven)
Serving / compute Regional (Cloud Run, MIG, GKE) Deploy per region; LB stitches them Autoscaler + LB Seconds–minutes (scale-up)
Cache / session Regional (Memorystore) None (classic Redis) — design around it You, by design Rebuild/re-auth
Database Regional or multi-region Spanner quorum · SQL async replica · AlloyDB secondary Spanner: automatic · SQL/AlloyDB: you 0 (Spanner) · minutes (promote)
Objects Regional / dual / multi-region (GCS) Async replication (default or turbo) GCS, automatic 0 for reads (same namespace)
Messaging Global service (Pub/Sub) Global topic endpoints, regional storage policy Pub/Sub, automatic ~0

The table whispers the core truth: everything above the database line fails over in seconds, by Google; everything at and below it fails over as fast as your data architecture allows — which is why the data-layer section below is the longest.

Core concepts

Active-active vs active-passive. In active-passive, region B holds capacity and replicas but serves nothing until a failover event changes state — DNS, a promote, a scale-up — and every untested state change fails at 2 a.m. In active-active, both regions serve all day; “failover” is just removing one region from a set the load balancer already balances across. No cliff-edge state change on the serving path is precisely why active-active RTOs are seconds. The price moves to the data layer, where “both regions accept writes” forces a choice between synchronous quorum (latency), async replication (RPO > 0), or partitioning writes by geography.

RTO and RPO are the contract. RTO (Recovery Time Objective): how long until service is restored. RPO (Recovery Point Objective): how much recently-written data you may lose, in time. They are business numbers: “checkout back in 5 minutes, zero lost payments” prices the ledger onto Spanner or an equivalent quorum store, because no asynchronous replica can promise RPO 0. Write the numbers per dataset, not per company; a catalog tolerating 15 minutes of loss should not pay the ledger’s bill.

The consistency-latency trade is physics. A synchronous cross-region commit cannot return faster than the round trip to a quorum of replicas — ~10–20 ms between nearby regions in a Spanner config, far more across continents. Async replication returns instantly and moves the cost to failover time, where lag becomes loss. Every data product below is one point on that line; the skill is matching each dataset to the right point.

GCP’s shape is unusually kind to multi-region. The VPC is global (one network, regional subnets, backbone transit — no peering mesh between your regions). The external Application Load Balancer is a global control plane — one URL map, one certificate set, one IP, backends in many regions. And several data services are natively multi-region (Spanner, Firestore, GCS dual/multi-region, Pub/Sub), so replication is a checkbox with an SLA, not a pipeline you operate. Know which services are regional and which are global before drawing a single box:

Service Scope Multi-region story Consistency across regions Your failover work
External Application LB Global One LB, backends in N regions, one anycast VIP n/a (stateless) None — automatic
Cloud DNS, IAM, Artifact Registry (multi-region repos) Global Control-plane global Strong enough for purpose None
Cloud Run, GKE, MIGs Regional Deploy N copies, stitch via LB n/a (keep stateless) Deploy + capacity per region
Cloud SQL / AlloyDB primary Regional Async cross-region replica / secondary cluster Eventual (lag) Promote/switchover + repoint
Cloud Spanner (multi-region config) Multi-region Native synchronous quorum External consistency (strongest) None — leader moves itself
Firestore (multi-region location) Multi-region Native replication, two serving regions + witness Strong None
Cloud Storage (dual/multi-region) Dual/multi-region Native async replication (turbo option) Strong metadata/read-after-write; replication async None for reads; mind RPO for writes
Memorystore (Redis) Regional Classic: none · Redis Cluster: cross-region replicas Async where it exists Rebuild or switch clusters
Pub/Sub Global service Global topic, regional message storage policy Per-message ordering scope None (constrain storage regions if needed)
BigQuery Regional / multi-region datasets Cross-region dataset replication (analytics) Eventual Mostly none (analytics path)

The availability math. Availability compounds down through serial dependencies and up through parallel paths. Single-region is serial: LB × Cloud Run × Cloud SQL ≈ 99.95%³ ≈ 99.85% by SLA composition — ~13 hours/year of allowed pain before your own bugs. Two independent 99.9% regions in parallel approach 1 − (0.001)² = 99.9999% for serving. Reality is less generous — regional failures share control planes, deploys and a data layer, which usually caps the result — but the direction is right:

Availability Allowed downtime / year / month Achievable with
99.9% (“three nines”) 8 h 46 m 43.8 m Single zone, honest effort
99.95% 4 h 23 m 21.9 m Single region multi-zone (Cloud Run SLA, Cloud SQL Enterprise)
99.99% 52.6 m 4.4 m Multi-zone MIGs (Compute SLA), Cloud SQL Enterprise Plus, Spanner regional
99.999% 5.3 m 26 s Spanner multi-region, Firestore multi-region — data layer only
Serving plane, 2 regions active-active seconds-level events Global LB + 2× regional stacks (no formal composite SLA — engineered, not promised)

Read the last row carefully: Google sells no composite “your app is now 99.999%” SLA. Active-active is how you engineer toward five nines; the SLAs floor each part, and lining the parts up is the rest of this article.

Vocabulary you need fluent before the deep dive:

Term One-line definition Why it matters here
Anycast One IP advertised from many places; the internet routes each client to the nearest Failover without DNS: the IP never changes, the route does
GFE (Google Front End) Google’s edge proxy fleet terminating TLS at 100+ PoPs Your LB is this fleet; requests enter Google near the user
Premium Network Tier Traffic rides Google’s backbone edge-to-region (“cold potato”) Global LB with one VIP requires Premium tier
Serverless NEG Network endpoint group pointing at a Cloud Run/Functions service in one region The glue between a regional serverless service and the global LB
Outlier detection LB ejects backends that keep erroring, without health checks The failover mechanism for serverless NEGs (which cannot be health-checked)
Balancing mode / capacity RATE or UTILIZATION target per backend + capacity-scaler multiplier How the LB knows a region is “full” and spills to the next
Waterfall by region GFE fills the nearest region to capacity, then overflows to next-closest Latency-first, capacity-aware global routing
TrueTime Spanner’s GPS+atomic-clock time API with bounded uncertainty Lets Spanner order transactions globally — external consistency
External consistency Transactions appear to commit in real-time order, globally Strongest consistency; reads never see a stale committed state
Witness replica Spanner voting replica that stores no readable data Breaks quorum ties across two serving regions from a third
Replica promotion Turning an async read replica into a standalone primary The Cloud SQL DR moment: one-way, lag becomes data loss
Switchover Planned, coordinated role swap (Cloud SQL Enterprise Plus / AlloyDB) Drill-able, zero-data-loss role reversal for maintenance and tests
Turbo replication GCS dual-region SLA: 100% of new objects replicated ≤ 15 min Puts a contract on object-storage RPO
RTO / RPO Time to restore / data you may lose The two numbers every design decision must answer to

DR tiers and the RTO/RPO contract

Active-active is the top rung of a ladder, and honest architecture starts by admitting most workloads belong on a lower rung. The four canonical tiers, priced and mapped to GCP:

Tier RTO RPO Steady-state cost over single-region GCP building blocks Right for
1 · Backup & restore 4–24+ h 1–24 h (last backup) +2–5% (storage only) GCS multi-region backups, cross-region backup copies, snapshots, Terraform to rebuild Internal tools, batch, dev/test — allowed to be down a day
2 · Pilot light 30 min–4 h Minutes (async replicas) +10–25% Data replicated (SQL cross-region replica, GCS dual-region); compute in IaC at zero scale; multi-region Artifact Registry Important-not-critical apps; strict budgets with real DR duty
3 · Warm standby 5–30 min Seconds–minutes +30–60% Region B runs a small live copy (Cloud Run min-instances, MIG at 10–20%) on the global LB at low capacity; replicas ready to promote Tier-1 apps tolerating minutes; the classic compliance answer
4 · Active-active ~0 (seconds) 0 (quorum) or seconds (async) +60–120% serving · data varies Global ALB + full stacks in ≥2 regions + Spanner/Firestore multi-region or partitioned writes Payments, ledgers, global SaaS, 99.99%+ targets

Three rules make the table useful. Tier per dataset, not per company: the ledger runs tier 4 on Spanner while the recommendation cache sits at tier 1. RPO 0 is a quorum property: if “asynchronous replica” appears anywhere in a dataset’s path, its true RPO is the lag at the worst moment, whatever the slide says. An untested tier is the tier below it: a warm standby never promoted is a pilot light; a pilot light never lit is backup-and-restore with extra billing.

And because “what RTO/RPO does each service actually give me” is what every review board asks — the reference, SLA-backed figures marked:

Component & mechanism RTO it delivers RPO it delivers Automatic? Notes / the catch
Global external ALB, region loss (MIG backends) Seconds–~1 min (health checks: 5 s interval × 2 failures + propagation) n/a Yes Requires healthy capacity elsewhere; pre-provision headroom
Global external ALB, region loss (serverless NEGs) Seconds–minutes via outlier detection n/a Yes (best-effort) No health checks on serverless NEGs — tune outlier detection
Cloud Run second region ~0 (already serving) n/a Yes Cold if min-instances=0; first requests pay cold start
Regional MIG + autoscaler absorbing 2× load Minutes (VM boot + warm-up) n/a Yes Quota in region B must already allow 2×; request it now
Spanner multi-region config ~0 — leader re-elects in seconds 0 (synchronous Paxos quorum) Yes 99.999% SLA; write latency = quorum RTT
Firestore multi-region (nam5/eur3) ~0 0 Yes 99.999% SLA; strong consistency
Cloud SQL cross-region read replica + promote 5–30 min (detect + decide + promote + repoint) Replication lag: seconds normally, minutes under write bursts No — you promote Promotion is one-way; old primary must be rebuilt as replica
Cloud SQL Enterprise Plus DR switchover Planned: ~minute-level 0 (planned, coordinated) Operator-initiated The drill-able path; unplanned failover still inherits lag
AlloyDB secondary cluster promote/switchover Minutes Seconds (async WAL streaming) Operator-initiated Faster storage-layer replication; still async
GCS dual-region, default replication 0 for reads (single namespace) Typical minutes; design target 99.9% of new objects < 1 h Yes Tail can be hours — not a contract
GCS dual-region + turbo replication 0 for reads ≤ 15 min for 100% of new objects (SLA) Yes Dual-region only; per-GB replication fee
Memorystore for Redis (classic) Rebuild/warm in region B Total (cache lost) No Design caches as disposable; never a system of record
Pub/Sub ~0 (global service) ~0 Yes Use message storage policies if data residency constrains regions

The rows design reviews fight about are the Cloud SQL ones — dissected with commands below. First, the part that makes GCP active-active feel unfairly easy: the front door.

The global front door: one anycast IP, no DNS failover

The most under-appreciated fact in GCP networking: the global external Application Load Balancer is not a regional appliance with global aspirations — it is Google’s edge. Creating one gives you a single anycast IP address advertised via BGP from 100+ edge points of presence. A user in Mumbai and a user in Frankfurt hit the same IP and each lands on the nearest GFE (Google Front End), where TLS terminates milliseconds from the client; the request then rides Google’s private backbone (cold-potato routing, the defining behavior of Premium Network Tier) to whichever of your backend regions the LB chooses. One IP. One certificate set. One URL map. Backends in as many regions as you like.

Why anycast failover beats DNS failover

Contrast this with the DNS-failover pattern you inherit on clouds whose L7 load balancers are regional. There, “global” means: two regional ALBs, two IPs, and a DNS layer (Route 53 on AWS, Traffic Manager on Azure) handing out one of them per lookup, with health checks deciding which. Every link in that chain is a place failover goes to die:

Property GCP global external ALB (anycast) AWS ALB + Route 53 failover (DNS) AWS Global Accelerator + regional ALBs Azure Front Door
Client-facing address One anycast VIP, never changes Different A records per region; changes at failover 2 static anycast IPs Anycast VIP
Failover mechanism Internal routing + health checks; per-request DNS answer change; per-resolution Anycast reroute; per-flow Internal routing; per-request
Failover latency Seconds (health-check driven) Health check (~30–90 s) + TTL (60 s+) + resolver disobedience (minutes→hours tail) Seconds–~1 min Seconds
Client cooperation needed None Yes — must re-resolve DNS, honor TTL None None
Where L7 logic lives (routing rules, WAF) Globally, once — one URL map, one Cloud Armor policy Per-region ALB ×2 (config drift risk) Still per-region ALB ×2 (GA is L4) Globally
Capacity-aware cross-region spill Yes — waterfall by region No (weighted/latency records only) Endpoint weights + traffic dials (manual) Yes (priority/weights)
Extra moving part to operate No Health checks + record sets One more paid service in front No
Cost of the global layer LB pricing only (no per-region duplication) Hosted zone + health checks ×N ~US$0.025/h/accelerator + premium per-GB Front Door pricing

The DNS column deserves the scar tissue: TTL is a request, not a command. Enterprise resolvers, mobile-carrier DNS, JVMs with networkaddress.cache.ttl=-1 and long-lived connection pools happily pin the dead region’s IP for minutes to hours after your failover “completed”. Anycast removes the client from the failover path: the IP stays correct; the route underneath it changed. (Azure Front Door plays the same anycast trick — see the Azure companion article; AWS Global Accelerator buys anycast entry, but the L7 brain — listeners, rules, WAF — stays duplicated per region behind it.)

Two prerequisites make or break this on GCP: the one-VIP global ALB exists only in Premium Network Tier (Standard forces regional forwarding rules — back to DNS games), and you want the EXTERNAL_MANAGED scheme (the Envoy-based global external ALB) — outlier detection and advanced traffic management live there, not in the classic ALB.

Setting Values Default For active-active Trade-off / gotcha
Network tier PREMIUM / STANDARD Premium PREMIUM (mandatory) Standard = regional VIPs, cheaper egress, no global LB
LB scheme EXTERNAL_MANAGED / EXTERNAL (classic) EXTERNAL_MANAGED Classic lacks outlier detection & advanced routing
Anycast VIP Reserved global static IP (IPv4 + optional IPv6) Ephemeral Reserve both stacks Reserve before DNS records; add a 2nd forwarding rule for IPv6
TLS Google-managed or self-managed certs Google-managed (auto-renew) Managed certs need DNS pointing at the VIP to provision
SSL policy Min TLS version + profile TLS 1.0, COMPATIBLE TLS 1.2, MODERN/RESTRICTED Attach explicitly; default is permissive
Cloud Armor Security policy on backend service None Attach WAF + rate limiting Enforced at the edge — attacks never reach your regions
Cloud CDN --enable-cdn on backend service Off On for static/cacheable routes Cache hits never cross the ocean at all

How the LB picks a region: waterfall by region

Knowing the routing algorithm lets you predict failover instead of hoping. Per request at an edge PoP, the LB (1) applies the URL map to select a backend service, (2) sends the request to the closest region with healthy capacity among that service’s backends, and (3) at capacity or unhealthy, spills to the next-closest region — the “waterfall”. Health state comes from centralized probes originating from 35.191.0.0/16 and 130.211.0.0/22 — firewall rules must allow them (the #1 rookie blocker). Consequences you design around:

Session affinity across regions

Affinity is where active-active quietly breaks stateful assumptions — every mode is best-effort, legally broken by capacity changes, backend churn and failover:

Affinity mode Keyed on Survives region failover? Use when Gotcha
NONE (default) n/a Stateless services (the goal)
CLIENT_IP Client IP hash No (different region = different backend set) Legacy quick fix Mobile/CGNAT users share IPs; uneven load
GENERATED_COOKIE LB-issued cookie No Sticky canaries, A/B Cookie pins to a backend that may vanish
HTTP_COOKIE / stateful cookie affinity Your named cookie No Blue/green by cookie Same — affinity ≠ session storage
HEADER_FIELD Named header hash No API keys → consistent shard Breaks on backend set change

The rule: affinity may optimize (cache locality), never guarantee. State that matters lives where both regions can reach it — the session-design section below.

Build it: gcloud

The full two-region front door, Cloud Run edition (MIG variant in the next section):

PROJECT=kv-pay-prod
REGIONS=(us-central1 europe-west1)

# 1. One global anycast IP — this is THE address, forever
gcloud compute addresses create pay-vip --global --ip-version=IPV4

# 2. A serverless NEG per region, each pointing at that region's Cloud Run service
for R in "${REGIONS[@]}"; do
  gcloud compute network-endpoint-groups create neg-checkout-$R \
    --region=$R --network-endpoint-type=serverless \
    --cloud-run-service=checkout
done

# 3. ONE global backend service holding BOTH regional NEGs
gcloud compute backend-services create bes-checkout \
  --global --load-balancing-scheme=EXTERNAL_MANAGED --protocol=HTTPS
for R in "${REGIONS[@]}"; do
  gcloud compute backend-services add-backend bes-checkout --global \
    --network-endpoint-group=neg-checkout-$R \
    --network-endpoint-group-region=$R
done

# 4. URL map, managed cert, HTTPS proxy, forwarding rule on the VIP
gcloud compute url-maps create um-pay --default-service=bes-checkout
gcloud compute ssl-certificates create cert-pay \
  --domains=pay.example.com --global
gcloud compute ssl-policies create tls-modern \
  --profile=MODERN --min-tls-version=1.2
gcloud compute target-https-proxies create thp-pay \
  --url-map=um-pay --ssl-certificates=cert-pay --ssl-policy=tls-modern
gcloud compute forwarding-rules create fr-pay --global \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --address=pay-vip --target-https-proxy=thp-pay --ports=443

Point pay.example.com at the VIP once, and DNS never participates in an incident again.

Build it: Terraform

resource "google_compute_global_address" "vip" {
  name = "pay-vip"
}

resource "google_compute_region_network_endpoint_group" "run" {
  for_each              = toset(["us-central1", "europe-west1"])
  name                  = "neg-checkout-${each.key}"
  region                = each.key
  network_endpoint_type = "SERVERLESS"
  cloud_run { service = "checkout" }
}

resource "google_compute_backend_service" "checkout" {
  name                  = "bes-checkout"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  protocol              = "HTTPS"
  security_policy       = google_compute_security_policy.edge.id

  dynamic "backend" {
    for_each = google_compute_region_network_endpoint_group.run
    content { group = backend.value.id }
  }

  outlier_detection {                # the serverless failover mechanism
    consecutive_errors    = 5
    interval            { seconds = 10 }
    base_ejection_time  { seconds = 30 }
    max_ejection_percent  = 50
  }

  log_config {
    enable      = true
    sample_rate = 1.0
  }
}

resource "google_compute_url_map" "pay" {
  name            = "um-pay"
  default_service = google_compute_backend_service.checkout.id
}

resource "google_compute_managed_ssl_certificate" "pay" {
  name    = "cert-pay"
  managed { domains = ["pay.example.com"] }
}

resource "google_compute_target_https_proxy" "pay" {
  name             = "thp-pay"
  url_map          = google_compute_url_map.pay.id
  ssl_certificates = [google_compute_managed_ssl_certificate.pay.id]
}

resource "google_compute_global_forwarding_rule" "pay" {
  name                  = "fr-pay"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  ip_address            = google_compute_global_address.vip.id
  target                = google_compute_target_https_proxy.pay.id
  port_range            = "443"
}

The serving layer: Cloud Run and MIGs in two regions

With the front door global, the serving layer’s job is: identical, stateless capacity in each region, sized so survivors absorb a dead region’s load. Two mainstream shapes behind the same backend-service abstraction:

Property Cloud Run + serverless NEG Regional MIG + instance-group backend Hybrid/zonal NEG (GKE, on-prem)
Health checking by LB Not supported — outlier detection instead Full health checks (interval/thresholds you tune) Full health checks
Failover trigger Outlier ejection on real errors, per backend Health-check failure → region drops from waterfall Health-check failure
Capacity signal to LB Automatic (Cloud Run scales itself) Balancing mode: RATE or UTILIZATION + max-* targets RATE per endpoint
Idle-region cost ~0 with min-instances=0; small with 1–2 warm Full VM cost at whatever floor you keep Cluster cost
Cold capacity risk on failover Cold starts on surge (mitigate: min-instances, startup CPU boost) VM boot minutes (mitigate: headroom floor) Pod scheduling delay
Scale-out speed Seconds Minutes (image boot) Seconds–minutes (pods) if nodes exist
Best for APIs, web, event handlers — most active-active serving Stateful-ish daemons, licensed software, custom kernels, GPUs Existing GKE estates

Cloud Run multi-region: the serverless NEG contract

A Cloud Run service is regional; multi-region means deploying the same service into each region and stitching them with one serverless NEG per region on a single global backend service — exactly what the front-door code builds. Deployment is a loop, and should be a pipeline stage, not a human:

IMAGE=us-docker.pkg.dev/$PROJECT/apps/checkout:v2026.07.07
for R in us-central1 europe-west1; do
  gcloud run deploy checkout --region=$R --image=$IMAGE \
    --min-instances=2 --max-instances=200 --concurrency=80 \
    --cpu=2 --memory=1Gi --port=8080 \
    --ingress=internal-and-cloud-load-balancing \
    --set-env-vars=REGION=$R \
    --no-allow-unauthenticated
done

The settings that specifically matter for active-active:

Setting Value for active-active Why Gotcha
--ingress internal-and-cloud-load-balancing All public traffic via the global LB + Cloud Armor Default all leaves per-region run.app reachable — a WAF bypass
--min-instances 1–3 per region (tier-1 paths) Failover surge hits a warm floor, not a cold-start wall Always-on instance-hours; still far cheaper than idle VMs
--concurrency Load-tested value (often 50–100) Sets instances needed when region B suddenly serves 2× Too high → latency collapse; too low → instance storm
--max-instances ≥ 2× normal regional peak Region B must be allowed to double Default cap can silently limit failover capacity
--set-env-vars=REGION=$R Always Region-tagged logs/metrics/traces
Startup CPU boost / startup probe Enable for JVM-ish stacks Cuts cold-start latency during surge Misconfigured probes = instances never ready

The catch unique to serverless: serverless NEGs cannot be health-checked — an erroring (but infrastructurally alive) region would keep its waterfall share forever. The fix is outlier detection: the LB watches real responses per backend NEG and temporarily ejects one that keeps failing, re-admitting it later to probe recovery. It is failure-driven — some real requests must fail to trigger it — so treat it as damage limitation and tune consciously:

Outlier detection field Meaning Sane starting value Effect of raising it
consecutiveErrors 5xx streak that marks a backend an outlier 5 Slower, more tolerant ejection
interval Analysis sweep period 10 s Slower reaction overall
baseEjectionTime Ejection duration (× times ejected) 30 s Longer cool-off, fewer flaps
maxEjectionPercent Max % of backends ejectable at once 50 (with 2 regions: allows ejecting 1) 100 risks ejecting everything during a global blip
enforcingConsecutiveErrors % of eligible ejections actually enforced 100 Lower = dry-run mode
successRateMinimumHosts / RequestVolume Stats floor for success-rate ejection defaults Avoids ejecting on thin data

Two operational notes: users see errors in the window before ejection kicks in — measure it in game days (typically seconds at production request rates) — and ejection is per backend service, so /checkout and /catalog eject independently, which is what you want.

Regional MIGs: capacity-aware failover, the explicit way

Where Cloud Run infers capacity, MIG backends declare it. A regional MIG spreads identical VMs across ≥3 zones (zone loss handled inside the region); the backend service then needs each group’s traffic ceiling:

Balancing mode Capacity target LB behavior at target Use when Notes
RATE --max-rate-per-instance=N RPS Region treated as full → waterfall spills Request-shaped workloads, predictable RPS/VM The most predictable for active-active math
UTILIZATION --max-utilization=0.8 (CPU) Same, on CPU CPU-bound heterogeneous requests Lags under spiky load; pair with autoscaler headroom
CONNECTION max connections Same, on concurrent conns TCP/SSL proxy LBs (not HTTP ALB w/ MIGs) Not for this LB type

The multiplier that makes drills trivial: capacity-scaler (0.0–1.0) scales declared capacity without touching VMs. --capacity-scaler=0 is the region-evacuation switch — connections drain gracefully, no new traffic admitted, 1.0 un-drains. The single most useful flag in this architecture:

# Regional MIG per region + health check + backend wiring
gcloud compute health-checks create http hc-web --port=8080 \
  --request-path=/healthz --check-interval=5s --timeout=5s \
  --healthy-threshold=2 --unhealthy-threshold=2

for R in us-central1 europe-west1; do
  gcloud compute instance-groups managed create mig-web-$R \
    --region=$R --size=6 --template=tpl-web-v42
  gcloud compute instance-groups managed set-autoscaling mig-web-$R \
    --region=$R --min-num-replicas=6 --max-num-replicas=40 \
    --target-load-balancing-utilization=0.7
  gcloud compute backend-services add-backend bes-web --global \
    --instance-group=mig-web-$R --instance-group-region=$R \
    --balancing-mode=RATE --max-rate-per-instance=800 --capacity-scaler=1.0
done

# THE regional drain / undrain (game-day favorite):
gcloud compute backend-services update-backend bes-web --global \
  --instance-group=mig-web-us-central1 --instance-group-region=us-central1 \
  --capacity-scaler=0.0

Health-check math sets your serving RTO: at check-interval=5s, unhealthy-threshold=2, a hard regional failure stops receiving traffic in ~10–15 seconds plus propagation. The autoscaler handles the second half — growing the survivor — in VM-boot minutes; your capacity floor decides whether users feel the gap.

The N+1 capacity rule

Active-active sizing is an availability decision dressed as a cost decision. Two regions at 50% of global peak each means the survivor must instantly run at 100% — so each region must be provisioned, or provisionable within your RTO (quota granted, max-instances raised, autoscaler caps lifted), for the whole load. Three regions at ~34% cut the idle overhead (150% total vs 200%) at the price of a third data-locality story. Do the arithmetic explicitly in autoscaler configs and quota requests — “we’ll scale when it happens” is how spillover becomes a second outage. And the quiet killer: regional quotas (CPUs, IPs, Cloud Run instances) are default-sized for single-region thinking; file region-B increases the week you adopt the architecture.

The data layer decision: Spanner vs Cloud SQL vs AlloyDB vs Firestore

Everything so far fails over in seconds because it is stateless. The data layer is where active-active is won or lost, and the question is not “which database is best” but “which consistency-latency-cost point does this dataset need”:

Attribute Cloud Spanner (multi-region) Cloud SQL (+ cross-region replica) AlloyDB (+ secondary cluster) Firestore (multi-region)
Model Relational (GoogleSQL/PostgreSQL dialect) MySQL / PostgreSQL / SQL Server PostgreSQL-compatible Document (NoSQL)
Cross-region writes Yes — synchronous Paxos quorum No — single-writer primary No — single-writer primary Yes — managed replication
Cross-region consistency External consistency (strongest) Async replica (eventual) Async secondary (eventual) Strong
RPO on region loss 0 Replication lag (sec–min) Seconds (typically) 0
RTO on region loss ~0 (auto leader election, seconds) 5–30 min (you promote + repoint) Minutes (promote/switchover) ~0 (automatic)
SLA (multi-region shape) 99.999% 99.99% (Enterprise Plus, per instance) 99.99% 99.999%
Write latency (multi-region) +quorum RTT (~10–50 ms by config) Local-region latency (replication is async) Local-region latency Regional quorum latency
Schema/app migration effort Real: interleaving, no sequences-as-you-know-them, hotspot-safe keys ~None (it’s stock MySQL/PG) Low from PostgreSQL Rewrite for document model
Scale ceiling Horizontal, near-unbounded (splits) Vertical + read replicas Vertical + read pools Horizontal, massive
Entry cost (multi-region) ~US$0.30/h at 100 processing units on nam3 (~US$219/mo) 2× instance cost + egress 2× cluster cost + egress Pay-per-op (cheap at low volume)
Failover automation Fully managed Manual/scripted (EP switchover helps) Operator-initiated Fully managed
Best for Ledgers, orders, inventory, anything RPO-0 + relational Existing apps, catalogs, tolerant-of-minutes datasets PG estates needing faster engine + faster DR User profiles, sessions, mobile/web state

Senior teams converge on polyglot by RPO: money tables to Spanner (or Firestore if document-shaped), the long tail on Cloud SQL with an honest minutes-grade RPO, caches regional and disposable. Forcing the whole estate onto Spanner buys consistency nobody asked for at a price everybody notices; leaving the ledger on async replicas writes the “we lost 3 minutes of payments” post-mortem in advance.

Cloud Spanner multi-region: TrueTime and external consistency

Spanner multi-region is the only relational option where both regions accept writes with zero loss on region failure — because there is no “failover” in the replication sense at all. A config places replicas of every database split in a fixed, named topology:

Replica type Votes in Paxos? Serves reads? Can be leader? Purpose
Read-write Yes Yes Yes The serving core — two regions get 2 each
Witness Yes No No Tie-breaker in a third region; survives a serving-region loss without storing data
Read-only No Yes (stale or strong) No Low-latency local reads on distant continents

A typical two-serving-region config runs five voters: 2 read-write in region A, 2 in region B, 1 witness in region C. A write commits when a majority (3 of 5) durably logs it — which by construction spans two regions, hence RPO 0. Lose the leader region and the survivors elect a new leader in seconds; clients retry and continue. Nobody promotes anything.

TrueTime makes this globally consistent rather than merely durable: GPS receivers and atomic clocks in Google’s datacenters expose time as a bounded interval, and Spanner timestamps every transaction inside that bound, waiting out the uncertainty (commit-wait, single-digit ms) before acknowledging. If T2 starts after T1 commits, T2’s timestamp is provably greater — globally. That is external consistency: reads at a timestamp never see a partially-applied or out-of-order world from any region, with zero application effort.

The named configurations you will actually choose between (verify layouts with gcloud spanner instance-configs describe <config> — they are fixed per config):

Config Serving (read-write) regions Extra replicas Character Indicative compute price
Regional (e.g. us-central1) 1 region, 3 zonal replicas 99.99%, zone-proof only ~US$0.90/node-h
nam3 us-east4 (default leader) + us-east1 Witness in us-central1 US East pair, low quorum RTT ~US$3.00/node-h
nam6 us-central1 (leader) + us-east1 Read-only in us-west1/us-west2 + witness Continental US serve-everywhere Higher
eur3 europe-west1 + europe-west4 Witness in a third EU region EU data residency + five nines Similar band to nam3
asia1 asia-northeast1 + asia-northeast2 Witness Japan pair Similar band
nam-eur-asia1 US voting core (us-central1 leader) Read-only in europe-west1 + asia-east1 Three-continent reads; writes still quorum in US ~3× nam3 band

Read the last row twice: the intercontinental config does not give low-latency writes on three continents — voting stays close (physics); distant continents get fast reads via read-only replicas. Choosing a config is choosing where the leader sits (nearest your write-heavy services — leader-aware routing in the client libraries sends writes there automatically) and where reads serve locally.

Reads are where you claw back latency, because Spanner lets you choose staleness per read:

Read type Guarantee Latency profile Use for
Strong read Sees every commit up to now May require leader round-trip from distant regions Read-your-write flows, balances before debit
Bounded staleness (maxStaleness=10s) At most N seconds stale, monotonic Served by nearest replica almost always Catalogs, dashboards, timelines
Exact staleness (readTimestamp=T) Frozen at T Local replica Reports, consistent exports
Directed reads Route to named replica type/region You control placement Pinning analytics to read-only replicas

Provisioning is in processing units (100 PU = 0.1 node; 1000 PU = 1 node), with managed autoscaling available — floor it for quorum comfort, cap it for budget:

gcloud spanner instances create pay-ledger \
  --config=nam3 --description="payments ledger" \
  --autoscaling-min-processing-units=1000 \
  --autoscaling-max-processing-units=5000 \
  --autoscaling-high-priority-cpu-target=65 \
  --autoscaling-storage-target=90

gcloud spanner databases create ledger --instance=pay-ledger
resource "google_spanner_instance" "ledger" {
  name         = "pay-ledger"
  config       = "nam3"
  display_name = "payments ledger"
  autoscaling_config {
    autoscaling_limits {
      min_processing_units = 1000
      max_processing_units = 5000
    }
    autoscaling_targets {
      high_priority_cpu_utilization_percent = 65
      storage_utilization_percent           = 90
    }
  }
}

Two production warnings. Hotspots: sequential primary keys (timestamps, auto-increment) funnel writes to one split and cap the five-nines database at one machine’s throughput — use UUIDv4, hashed prefixes or interleaved child tables. CPU headroom: keep multi-region high-priority CPU under ~45–65% so failover surges and split rebalancing have room; a hot instance fails over slower.

Cloud SQL cross-region replicas: honest asynchrony

Most estates arrive with MySQL/PostgreSQL schemas that would cost quarters to port. Cloud SQL cross-region read replicas are the pragmatic tier: the primary in region A streams async replication to a replica in B, which serves reads today and becomes the DR primary tomorrow — if you operate it with clear eyes:

# Primary (regional HA within us-central1) — Enterprise Plus for the DR features
gcloud sql instances create catalog-db \
  --database-version=POSTGRES_16 --edition=enterprise-plus \
  --tier=db-perf-optimized-N-4 --region=us-central1 \
  --availability-type=REGIONAL

# Cross-region replica in europe-west1
gcloud sql instances create catalog-db-euw1 \
  --master-instance-name=catalog-db --region=europe-west1 \
  --tier=db-perf-optimized-N-4

# The DR moment (unplanned): one-way promotion
gcloud sql instances promote-replica catalog-db-euw1

# The drill / planned move (Enterprise Plus): coordinated, zero-loss role swap
gcloud sql instances switchover catalog-db-euw1

The three role-change operations get confused in every incident channel; keep the distinction on the wall:

Operation Trigger Data loss Old primary afterwards Reversible Use for
HA failover (gcloud sql instances failover) Zonal failure (within region) 0 (synchronous regional standby) Becomes standby Automatic anyway Zone loss — not a region strategy
Replica promotion (promote-replica) Region loss / emergency Replication lag at that instant Orphaned; must be deleted & re-created as replica No — one-way Real disasters only
Switchover (switchover, Enterprise Plus) Planned drill, region move 0 (waits for sync) Automatically becomes the new replica Yes (switch back) Quarterly DR drills, region evacuation with notice

After promotion the writer endpoint changes — applications must repoint. Bake that in day one: connect via DNS you control (private Cloud DNS record per role) or the Enterprise Plus write endpoint (a DNS name that follows the primary across switchovers), never hardcoded IPs. And monitor the number that is your RPO — database/replication/replica_lag: alert at 30 s, page at 5 min, knowing bulk imports, ALTER TABLEs and vacuum storms spike it exactly when you least want.

AlloyDB secondary clusters

AlloyDB for PostgreSQL upgrades the same single-writer pattern: a primary cluster in region A, a secondary cluster in B fed by storage-layer replication (seconds of lag), read pools in both, with promote (emergency, one-way) and switchover (planned, reversible):

gcloud alloydb clusters create-secondary catalog-sec \
  --primary-cluster=projects/$PROJECT/locations/us-central1/clusters/catalog \
  --region=europe-west1

# drill:
gcloud alloydb clusters switchover catalog-sec --region=europe-west1

Choose AlloyDB over Cloud SQL for the faster engine (columnar cache, vectorized reads) and cleaner DR ergonomics without leaving PostgreSQL. It remains async cross-region — RPO small but not zero.

Firestore multi-region

For document-shaped state — profiles, carts, presence, mobile sync — Firestore in a multi-region location (nam5 United States, eur3 Europe) gives the Spanner treatment for free operationally: two serving regions plus a witness, strong consistency, automatic failover, 99.999% SLA, no capacity planning. Its limits are structural: document model, ~1 sustained write/second per document (fan out counters), query semantics that punish relational thinking. When data is naturally documents it is the cheapest RPO-0 you will ever buy; when it is not, forcing it costs more than Spanner would have.

Storage, cache and session state across regions

Cloud Storage: multi-region, dual-region and turbo replication

GCS is strongly consistent for read-after-write globally — the multi-region questions are where copies live and how fast they get there:

Location type Example Copies live in Availability SLA (Standard class) Write RPO story Price band (Standard)
Region us-central1 1 region (multi-zone) 99.9% Region loss can strand recent + old data alike Cheapest (~US$0.020/GB-mo)
Dual-region nam4, or custom pair (us-central1+us-east1) 2 named regions 99.95% Async replication; turbo option ~US$0.022–0.036/GB-mo band
Multi-region US, EU, ASIA ≥2 regions, Google-chosen 99.95% Async, best-effort ~US$0.026/GB-mo

Dual-region is the active-active workhorse: you pick the pair to match your compute regions — one bucket, one namespace, served in both, no failover step. Respect the replication fine print:

Replication mode Contract for newly written objects SLA-backed? Extra cost Turn on with
Default replication Design target: 99.9% within 1 hour, 100% within 12 hours (typically minutes) No (default)
Turbo replication 100% within 15 minutes Yes Per-GB replication fee (~US$0.04/GB) --rpo=ASYNC_TURBO (dual-region only)
gcloud storage buckets create gs://pay-media-prod \
  --location=us --placement=us-central1,us-east1 \
  --rpo=ASYNC_TURBO --uniform-bucket-level-access
resource "google_storage_bucket" "media" {
  name     = "pay-media-prod"
  location = "US"
  rpo      = "ASYNC_TURBO"
  custom_placement_config {
    data_locations = ["US-CENTRAL1", "US-EAST1"]
  }
  uniform_bucket_level_access = true
}

Notes that bite later: region loss during the replication window can strand objects written in the last ≤15 minutes until the region returns (RPO ≠ 0 — plan idempotent re-upload); co-locate the bucket pair with your serving pair (requests use the nearer copy); and front static assets with Cloud CDN — an edge cache hit makes the bucket’s region irrelevant. Storage classes and lifecycle are orthogonal — see Cloud Storage classes explained.

Memorystore: the cache stays regional — design accordingly

Memorystore for Redis (classic) is regional with zero cross-region replication; Memorystore for Redis Cluster adds async cross-region replica clusters. The stance that never regrets itself: the cache is regional, disposable and rebuildable — cross-region cache replication ships every write across the ocean for data that was, by definition, reconstructible.

Option Cross-region? SLA On region loss Verdict for active-active
Memorystore for Redis, Basic No (single node) None Cache gone Dev only
Memorystore for Redis, Standard No (zonal replica) 99.9% Cache gone Fine as regional cache
Memorystore for Redis Cluster Yes — async secondary cluster 99.99% Switch to secondary (stale by lag) When cold-cache stampede is a real risk
Region-local cache + source-of-truth rebuild n/a Surviving region warms from DB Default recommendation

Engineer, don’t hope, around the thundering herd after failover — a cold cache letting 100% of reads through to the database. Mitigations: request coalescing (singleflight), cache warmers on drain events, TTL jitter, a DB tier sized for a cold-cache minute.

Sessions and user state

Session design decides whether a region evacuation is invisible or a mass logout. The options, from best to workaround:

Pattern How Survives region loss? Latency cost Notes
Stateless tokens (JWT/PASETO) Signed claims in the cookie; no server session Yes — nothing to lose ~0 Revocation needs a (small, replicated) denylist; keep tokens short-lived
Session store in Firestore/Spanner Session row replicated multi-region Yes +ms per read (cacheable) The pattern for carts/wizard state that must survive
Regional Redis session + re-auth fallback Fast path regional; miss → re-login or rebuild from DB Degraded-yes 0 normally Acceptable when re-auth is cheap (SSO)
LB session affinity + in-memory session Cookie pins user to a backend No 0 Affinity is best-effort even without failover — do not ship this

Corollaries: make every write path idempotent (an evacuated user’s retried POST must not double-charge — idempotency keys in the replicated tier), and propagate a request-region tag end-to-end. For async work, prefer Pub/Sub — a global service where both regions share the topic, with a message storage policy to constrain persistence regions if residency demands (see Pub/Sub event-driven architecture).

Failover mechanics, testing and replication-lag realities

What actually happens when a region dies

The timeline for a hard us-central1 failure with the reference architecture (Cloud Run + MIGs behind the global ALB, Spanner nam3, Cloud SQL replica in europe-west1):

T+ Event Actor User impact
0 s Region stops answering; MIG health checks start failing; Cloud Run backends start erroring US users see errors/timeouts
~10–15 s MIG backends marked unhealthy (5 s × 2 failures); region leaves the waterfall LB health checking MIG-served paths recover as requests route to europe-west1
~10–60 s Outlier detection ejects the us-central1 serverless NEG after error streaks LB outlier detection Cloud Run paths recover; a slice of requests failed in the window
~sec Spanner re-elects leaders for splits led in the dead region Spanner (automatic) Writes stall milliseconds–seconds, then continue. RPO 0
1–5 min europe-west1 autoscalers grow (Cloud Run instances, MIG VMs) into the 2× load Autoscalers Latency elevated until warm; min-instances/headroom decide how much
5–20 min Decision: promote catalog-db-euw1? Check replica_lag first — that number is your data loss You (runbook) Catalog writes down until promoted; reads fine from replica
+15 min GCS turbo replication horizon passes; any unreplicated tail objects are the media RPO GCS Rare 404s on very fresh objects until region returns
Recovery Region returns: un-drain gradually (capacity-scaler 0→0.25→1.0), rebuild old SQL primary as replica, review You None if staged carefully

Only two moments are human: the SQL promote decision and the eventual un-drain. Everything else is machinery — if capacity, quotas and min-instances were pre-provisioned.

Drills: if you have not run it, you do not have it

Failover you have never executed is a rumor. The drill catalogue, in escalating order of nerve:

Drill Command / method Validates Cadence
Drain one region’s serving --capacity-scaler=0 (MIG) / remove-backend on the serverless NEG Waterfall spillover, surviving-region capacity, alerting Monthly, business hours
Kill instances brutally Delete MIG VMs / gcloud run services update --min-instances=0 + fault-injecting revision Health checks, outlier detection timing Quarterly
Cloud SQL switchover gcloud sql instances switchover (Enterprise Plus) The whole promote path with RPO 0 — measures real RTO Quarterly
Full region evacuation game day Drain serving + switchover DB + block egress to region A from bastions Runbooks, humans, dashboards, the un-drain Twice a year
Chaos on dependencies Kill Memorystore, stall Pub/Sub subscribers Cold-cache herd handling, backlog drain Twice a year

Record for each drill: detection time, traffic-shift time (LB logs), data role-change time, client-observed error window (synthetic probes per continent), and lag at cutover. Those five numbers are your real RTO/RPO — put them in the DR doc instead of the aspirational ones.

Replication lag: the honest table

Path Normal lag Blows up when Watch Alert at
Cloud SQL cross-region replica < 1–10 s Bulk loads, big transactions, DDL, replica undersized database/replication/replica_lag 30 s warn / 300 s page
AlloyDB secondary cluster Seconds Sustained write storms Cluster replication metrics Similar
Spanner (voting replicas) n/a — synchronous Never lags for durability; hot splits raise commit latency instead Commit latency, CPU by priority p99 commit > 100 ms
Spanner read-only replicas Sub-second typically Long-running reads on cold replicas Read staleness metrics Per read-SLA
GCS default replication Minutes typical Cross-continent pairs, huge objects, regional pressure (design target, not metric-guaranteed) Use turbo if it matters
GCS turbo replication ≤ 15 min for 100% (SLA) Bucket replication metrics Missed-RPO alerts
Memorystore Redis Cluster secondary Seconds Write-heavy caches Cluster replication lag Treat as advisory

The consequence: any promotion decision reads the lag first. A 4-minute lag means promoting now = losing 4 minutes of writes; waiting risks a longer outage. Decide the policy before the incident (“promote if lag < 60 s; else escalate to IC”) and write it in the runbook.

Observability across regions

Active-active turns “is it down?” into “is it down for whom, where?” — telemetry must answer by region or it answers nothing. Three practices carry the weight (deep dive: Cloud Monitoring & operations):

Probe from outside, per continent. Uptime checks run from Google probers in USA, Europe, Asia-Pacific and South America against the anycast VIP; pair them with per-region direct checks so “region A is sick” is distinguishable from “the front door is sick”. Alert on SLO burn rate, not raw errors.

Slice everything by region. LB request logs and metrics carry the serving region (backend_scope) and statusDetails; app logs carry the REGION env var you set at deploy. Every dashboard chart gets a region group-by; every SLO a per-region child. The one Logging filter to bookmark — every request the LB could not serve from a backend:

resource.type="http_load_balancer"
resource.labels.url_map_name="um-pay"
jsonPayload.statusDetails!="response_sent_by_backend"
statusDetails value Meaning Multi-region reading
response_sent_by_backend Normal The baseline you filter out
failed_to_pick_backend No healthy/capacity backend for the request Both regions drained/full — the true global-outage signal
backend_timeout Backend exceeded the backend-service timeout One region degrading — expect outlier ejection next
backend_connection_closed_before_data_sent_to_client Backend died mid-response Crash-looping region; check recent deploys
client_disconnected_before_any_response Client gave up Latency too high — cold failover capacity?
denied_by_security_policy Cloud Armor block Attack or over-tight rule — check per-region spike

Trace across the ocean. Propagate trace context end-to-end (Cloud Trace/OpenTelemetry) so a checkout that entered in Frankfurt, ran in europe-west1 and committed to a Spanner leader in us-east4 reads as one trace — that leader hop is the latency you will be asked to explain. Watch Spanner commit latency by region and the surviving region’s p95 during drills.

Architecture at a glance

Read the diagram left to right — the request’s actual journey. Users everywhere resolve one hostname to one anycast VIP; TLS terminates at the nearest edge PoP, Cloud Armor filters before your regions see a packet, and the global external ALB applies one URL map to pick the closest healthy region with capacity. Region A (us-central1) and Region B (europe-west1) are deliberate mirrors: Cloud Run behind serverless NEGs (badge 2 — no health checks, outlier detection does the ejecting), MIGs across three zones, and a Cloud SQL primary in A streaming an async replica to B (badge 3: that lag is the catalog RPO; badge 4: promotion is one-way). On the right sits shared state: Spanner multi-region (badge 5: TrueTime quorum, RPO 0, self-electing leader) and a dual-region GCS bucket with turbo replication (badge 6: 15-minute RPO SLA).

Badge 1 is the thesis: failover never touches DNS. Kill Region A and the same IP keeps working — the waterfall reroutes to Region B in seconds, autoscalers absorb the surge, Spanner keeps committing, and the only human decision left is whether the replica’s lag is small enough to promote.

Active-active on GCP read left to right: global users hit one anycast VIP where Cloud Armor and the global external Application Load Balancer terminate TLS at the edge and route to the nearest healthy region; Region A (us-central1) and Region B (europe-west1) each run Cloud Run behind serverless NEGs and three-zone MIGs, with a Cloud SQL primary in A asynchronously replicating to a promotable DR replica in B; both regions share a Spanner multi-region instance (TrueTime quorum, RPO zero, automatic leader election) and a dual-region Cloud Storage bucket with turbo replication's 15-minute RPO SLA; numbered badges mark anycast failover with no DNS change, the serverless-NEG health-check gap covered by outlier detection, replica lag as the real RPO, one-way promotion, Spanner external consistency, and GCS turbo replication

Real-world scenario

NovaPay, a Bengaluru-based payments processor, ran everything in us-central1: Cloud Run APIs, Cloud SQL PostgreSQL (regional HA), Memorystore sessions. Zonal HA had worked twice. Then a regional networking degradation put them hard down 47 minutes on a Friday — ₹2.1 crore in failed transactions, one unpleasant board call, and a mandate: RTO 5 minutes, RPO 0 for the ledger, budget scrutiny on everything.

The redesign followed this article’s shape. Serving: Cloud Run in us-central1 + europe-west1 (EU merchants got faster — an unplanned win), one global external ALB on one anycast IP, Cloud Armor in front, min-instances=3 per region after load tests exposed cold-start pain at surge. Data: split by RPO instead of migrating wholesale. The ledger and idempotency keys went to Spanner nam3 at 2,000 PU — a real migration (UUID keys to kill a week-one hotspot, interleaved transaction-line tables). The catalog and back office — 340 tables of stock PostgreSQL — stayed on Cloud SQL Enterprise Plus with a europe-west1 DR replica and the write endpoint. Sessions became JWTs; Memorystore shrank to a per-region read cache.

The first game day found the bodies: draining us-central1 worked in 40 seconds, but europe-west1 Cloud Run slammed into its default max-instances cap (quota nobody had filed), and the catalog replica showed 6 minutes of lag — the drill coincided with the nightly reconciliation batch. Fixes: quotas doubled, reconciliation chunked and moved off-peak, lag alert at 30 s, a written promote policy (“promote if lag < 60 s, else IC decides”). Second drill, eight weeks later: 3 m 40 s evacuation end-to-end, zero ledger loss, 11 s of client-visible errors.

Metric Before (single region) After (active-active)
Region-loss RTO 47 min (observed) 3 m 40 s (drilled)
Ledger RPO Minutes (async replica) 0 (Spanner nam3)
Catalog RPO Untested ≤ 60 s enforced by policy + alerting
EU merchant p50 API latency 148 ms 31 ms
Monthly infra cost ~₹6.2 L ~₹11.8 L (+90%)
Failed-transaction exposure per region event ₹2.1 cr ~₹0 (11 s error window)

The board stopped asking about the +90% after seeing the last row.

Advantages and disadvantages

Advantages Disadvantages
Region loss becomes a capacity event: RTO seconds–minutes, drilled not prayed Serving cost roughly doubles (two full stacks + headroom for N+1)
One anycast IP — failover never depends on DNS TTLs or client behavior Premium tier egress and cross-region replication traffic add per-GB costs
Global users get nearest-region latency every day, not just during disasters Data layer forces hard choices: Spanner migration effort, or accept async RPO
Deploys can roll region-by-region with instant drain of a bad rollout Every stateful dependency (cache, sessions, cron, queues) must be redesigned region-aware
Spanner/Firestore give RPO 0 with automatic failover — no promote runbook at all Spanner multi-region compute is ~3× regional price; five nines are bought, not wished
Spillover absorbs regional capacity/quota shortfalls automatically Two regions double the operational surface: quotas, monitoring, drills, config drift
Cloud Armor + TLS enforced once, at the edge, for all regions Outlier detection (serverless) is failure-driven — a brief error window is inherent

Honest summary: active-active pays for itself when downtime is expensive, users are global, or an SLA says 99.99%+. A regional business tolerant of an hour’s outage gets 80% of the protection at 40% of the cost from warm standby (tier 3) — and this same architecture scales down to that tier by dropping min-instances while keeping the LB wiring.

Hands-on lab

The smallest honest active-active: Cloud Run in two regions behind one anycast IP — prove nearest-region routing, drain a region, watch traffic move. Cost: pennies for an hour (Cloud Run scales to zero; the forwarding rule bills ~US$0.025/h — tear down at the end).

1. Setup.

gcloud config set project YOUR_PROJECT_ID
gcloud services enable run.googleapis.com compute.googleapis.com
REGIONS=(us-central1 europe-west1)

2. Deploy the same service to both regions (revision suffix marks the region so responses are distinguishable):

for R in "${REGIONS[@]}"; do
  gcloud run deploy hello-aa --region=$R \
    --image=us-docker.pkg.dev/cloudrun/container/hello \
    --revision-suffix=$R --allow-unauthenticated
done

3. Wire the global front door (HTTP :80 to skip cert wait in a lab):

gcloud compute addresses create aa-vip --global
for R in "${REGIONS[@]}"; do
  gcloud compute network-endpoint-groups create neg-hello-$R \
    --region=$R --network-endpoint-type=serverless --cloud-run-service=hello-aa
done
gcloud compute backend-services create bes-hello --global \
  --load-balancing-scheme=EXTERNAL_MANAGED --protocol=HTTP
for R in "${REGIONS[@]}"; do
  gcloud compute backend-services add-backend bes-hello --global \
    --network-endpoint-group=neg-hello-$R --network-endpoint-group-region=$R
done
gcloud compute url-maps create um-hello --default-service=bes-hello
gcloud compute target-http-proxies create thp-hello --url-map=um-hello
gcloud compute forwarding-rules create fr-hello --global \
  --load-balancing-scheme=EXTERNAL_MANAGED --address=aa-vip \
  --target-http-proxy=thp-hello --ports=80
VIP=$(gcloud compute addresses describe aa-vip --global --format='value(address)')
echo "http://$VIP"

4. Prove nearest-region routing (wait ~2–3 min for the LB to program):

curl -s http://$VIP | grep -o 'hello-aa-[a-z0-9-]*'
# From India/Europe expect: hello-aa-europe-west1 ; from the Americas: hello-aa-us-central1

Run the same curl from Cloud Shell (US) vs your laptop — same IP, different serving region: anycast + waterfall in one line of output.

5. Evacuate a region and watch the failover — remove the nearest region’s backend while curling in a loop:

while true; do curl -s http://$VIP | grep -o 'hello-aa-[a-z0-9-]*'; sleep 1; done &
gcloud compute backend-services remove-backend bes-hello --global \
  --network-endpoint-group=neg-hello-europe-west1 \
  --network-endpoint-group-region=europe-west1

Within seconds the loop flips from ...europe-west1 to ...us-central1 — no DNS change, the IP never moved. Re-add the backend (add-backend, same flags) and watch it flip back.

6. See it in the logs:

gcloud logging read 'resource.type="http_load_balancer"' \
  --limit=5 --format='value(jsonPayload.statusDetails, httpRequest.status)'

7. Teardown (order matters):

kill %1
gcloud compute forwarding-rules delete fr-hello --global -q
gcloud compute target-http-proxies delete thp-hello -q
gcloud compute url-maps delete um-hello -q
gcloud compute backend-services delete bes-hello --global -q
for R in "${REGIONS[@]}"; do
  gcloud compute network-endpoint-groups delete neg-hello-$R --region=$R -q
  gcloud run services delete hello-aa --region=$R -q
done
gcloud compute addresses delete aa-vip --global -q

Common mistakes & troubleshooting

The playbook — symptom first, because that is how incidents arrive:

# Symptom Root cause Confirm Fix
1 Failover “done” but users still hit dead region Per-region run.app URLs/regional IPs leaked into clients Grep client configs for regional URLs; LB logs show no traffic for those users Everything through the one VIP; --ingress=internal-and-cloud-load-balancing
2 “Global” LB but each region has its own IP Standard network tier (regional forwarding rules) gcloud compute forwarding-rules list shows region column filled Recreate in Premium tier with --global
3 MIG region never receives traffic Health-check probes blocked by VPC firewall gcloud compute backend-services get-health bes-web --globalUNHEALTHY Allow 35.191.0.0/16,130.211.0.0/22 to the health port
4 Sick Cloud Run region keeps traffic for minutes No outlier detection (serverless NEGs have no health checks) backend-services describeoutlierDetection absent Add outlier detection (5 errors / 10 s / 30 s ejection)
5 Failover works; surviving region melts Capacity not N+1: max-instances caps, quotas, autoscaler ceilings at 50% Instance count flatlines at cap; Quota exceeded in autoscaler logs Raise --max-instances, file region quotas for 2×, keep min floor
6 Promote executed, app still writes to old primary Hardcoded writer endpoint Connection errors referencing old IP/host DNS-based endpoints or Enterprise Plus write endpoint; repoint step in runbook
7 Lost 4 minutes of orders after promotion Promoted during lag spike without checking database/replication/replica_lag graph at incident time Lag-gated promote policy; alert at 30 s; move bulk jobs off-peak
8 Spanner multi-region but write p99 tripled vs regional Quorum RTT + leader far from writers; or hot split from sequential keys Commit latency by region; key-visualizer heatmap Pick config with leader near writers; UUID/hashed keys; interleave children
9 Users logged out en masse during drain Sessions in region-local Memorystore / relying on LB affinity Session-miss rate spike in surviving region JWTs or Firestore/Spanner session store; affinity only as optimization
10 404s on fresh uploads after region loss Object written < replication window, region died before copy GCS request logs: 404s on objects < 15 min old Turbo replication; idempotent re-upload path; tolerate-tail UX
11 failed_to_pick_backend spikes globally All backends drained/ejected/at capacity — often a drill gone wide (maxEjectionPercent=100) LB logs filter on statusDetails Cap maxEjectionPercent=50; stagger drains; never drain both regions
12 Cross-region latency fine in test, terrible in prod Chatty service→DB patterns amplified by ocean RTT (N+1 queries × 100 ms) Trace shows dozens of sequential cross-region calls per request Batch queries; stale reads locally; keep request-path chatter intra-region

Best practices

Security notes

Multi-region widens the attack surface only if entry points multiply — so don’t let them. Enforce all ingress through the global LB: Cloud Armor (WAF, rate limiting, adaptive protection) and the TLS 1.2+ SSL policy then apply identically to every region, with per-region Cloud Run URLs unreachable (LB-only ingress, --no-allow-unauthenticated) and MIGs holding no external IPs. Keep cross-region service-to-service traffic on the global VPC’s private backbone — internal IPs, Private Service Connect/private services access for Cloud SQL and AlloyDB, never public hops. If you use CMEK, plan key residency deliberately: a Spanner nam3 instance or dual-region bucket needs keys available to all its locations (multi-region key rings), and losing key access is a self-inflicted regional outage. IAM is global — one least-privilege service account set per service, not per region, so drift cannot open a weaker door in region B. And set residency constraints (resource-location org policies, Pub/Sub message storage policies) plus audit logging for both regions before data flows, not after the first compliance review.

Cost & sizing

The bill, in driver order: the second serving stack, the data layer’s consistency tier, cross-region/Premium-tier traffic, the always-on floors. A reference month for a mid-size platform (~500 req/s, ~2 TB objects, US–EU pair; list-price ballpark at ₹84/US$):

Component Sizing ~US$/mo ~₹/mo Notes
Global ext. ALB 1 fwd rule + data processing 25–60 2.1–5 k Same LB either way — not a multi-region tax
Cloud Armor 1 policy, 10 rules, standard 20–40 1.7–3.4 k Edge-enforced once
Cloud Run ×2 regions 2 vCPU/1 GiB, min 2/region + traffic 250–500 21–42 k Idle region ≈ just the min-instances floor
MIG tier ×2 (if used) 6× e2-standard-4 per region ~1,400 ~1.2 L The expensive way to be stateless
Spanner nam3 1,000–2,000 PU autoscaled + 500 GB 2,300–4,600 1.9–3.9 L The five-nines line item
Cloud SQL EP + DR replica 4 vCPU/16 GB ×2 + storage 800–1,200 67 k–1 L ~2.1× single instance
GCS dual-region + turbo 2 TB + 200 GB/day new writes 90–150 7.5–12.6 k Turbo fee ≈ US$0.04/GB replicated
Memorystore ×2 5 GB standard per region ~180 ~15 k Regional, disposable
Cross-region replication egress SQL WAL + app chatter, ~500 GB 10–40 0.8–3.4 k Intercontinental costs more than intra-US
Monitoring/logging Full LB log sampling, SLOs 50–150 4.2–12.6 k Drop sample_rate if noisy

Sizing rules of thumb: the serving layer roughly doubles; Spanner multi-region compute runs ~3.3× its regional price (US$3.00 vs US$0.90 node-hour on nam3 vs us-central1) plus pricier storage (~US$0.50 vs US$0.30/GB-mo); Cloud SQL DR runs ~2.1× one instance. The comparison everyone asks for, at a 4-node-equivalent workload:

Data option ~US$/mo compute+storage (500 GB) RPO Failover work
Cloud SQL EP primary+DR replica (8 vCPU each) ~1,600–2,000 Lag (sec–min) Runbook + humans
Spanner regional, 4,000 PU ~2,700 0 in-region only None (but region-bound)
Spanner nam3, 4,000 PU ~8,900 0 cross-region None

That 4–5× gap between “SQL with a good runbook” and “Spanner nam3” is the true price of RPO 0 + RTO 0 with no 2 a.m. decisions — pay it precisely for datasets whose loss costs more, and lean on cheap levers elsewhere: Cloud Run scale-to-zero, committed-use discounts on the floors, Standard-tier egress for non-user-facing bulk moves.

Interview & exam questions

Directly relevant to the Professional Cloud Architect exam (case studies love RTO/RPO) and any senior-SRE loop:

  1. Why does GCP multi-region failover not require DNS changes? The global external ALB exposes one anycast IP from Google’s edge; failover is an internal routing decision (health checks/outlier detection remove a region from the waterfall). Clients keep the same IP — TTLs and resolver behavior are irrelevant.
  2. How would you do this on AWS, and what changes? Regional ALBs need Route 53 (DNS failover: health-check delay + TTL + resolver disobedience) or Global Accelerator (anycast entry, L7 config still duplicated per region). GCP’s difference is the single global L7 control plane.
  3. What is “waterfall by region”? Each request goes to the closest region whose backends have capacity per balancing-mode targets; at capacity or unhealthy, traffic spills to the next-closest region automatically.
  4. Serverless NEGs can’t be health-checked — how does Cloud Run failover work? Outlier detection observes real responses and temporarily ejects an erroring regional NEG. Failure-driven, so a brief error window is inherent; tune consecutiveErrors/interval/ejection and keep maxEjectionPercent ≤ 50.
  5. How does Spanner achieve RPO 0 across regions? Writes commit via Paxos to a majority of voters spanning two regions plus a witness; TrueTime assigns globally ordered timestamps (external consistency). Any surviving majority holds every acknowledged write; leaders re-elect in seconds.
  6. What is a witness replica? A voting, non-serving replica in a third region that keeps quorum available when either serving region is lost, without paying to serve data there.
  7. Cloud SQL cross-region DR: real RTO and RPO? RPO = replication lag at the failure instant (seconds normally, minutes under bursts); RTO = detect + decide + promote-replica + repoint, realistically 5–30 minutes. Enterprise Plus switchover is the zero-loss planned path for drills.
  8. Why is promotion one-way? The promoted replica starts a new timeline; the old primary’s diverged history cannot rejoin — it must be deleted and re-created as a replica of the new primary.
  9. When Spanner vs Cloud SQL replicas vs Firestore? Spanner: relational + RPO 0 + auto-failover (ledgers, orders). Cloud SQL: existing SQL estates tolerating minutes. Firestore: document-shaped state wanting RPO 0 with zero ops. Split by dataset, not company.
  10. What breaks affinity-based sessions here? Affinity is best-effort — capacity shifts, churn and failover re-route users. State belongs in replicated stores (JWTs, Firestore/Spanner sessions); affinity is only a cache-locality optimization.
  11. GCS dual-region vs multi-region? Dual-region pins the exact pair to your compute regions and unlocks turbo replication (15-min RPO SLA); multi-region is Google-chosen placement, best-effort replication. Same namespace either way — no read failover step.
  12. How do you prove RTO/RPO? Drills: drain a region (capacity-scaler=0/remove-backend), Enterprise Plus switchover; measure with synthetic probes and LB logs; record detection, traffic-shift, role-change and client-error windows; alert on replica_lag as the live RPO.

Quick check

  1. What single fact makes GCP failover independent of DNS?
  2. A serverless NEG region is throwing 500s — what mechanism removes it, and what can’t remove it?
  3. Your Cloud SQL replica lag is 240 s when the region dies. What is your RPO if you promote now?
  4. Which Spanner replica type votes but never serves reads, and why does it exist?
  5. What does --rpo=ASYNC_TURBO promise, and on which bucket type?

Answers

  1. The global external ALB’s single anycast IP — reroute happens inside Google’s network, per request; the client-facing address never changes.
  2. Outlier detection ejects it based on real error streaks; health checks can’t, because serverless NEGs don’t support them.
  3. ~240 seconds of writes — replication lag at the failure instant is the RPO of an async replica.
  4. The witness replica — a third-region tie-breaker that keeps Paxos quorum available when either serving region is lost, without storing readable data.
  5. 100% of newly written objects replicated to the second region within 15 minutes, SLA-backed — dual-region buckets only.

Glossary

Next steps

GCPMulti-RegionActive-ActiveCloud SpannerGlobal Load BalancerCloud RunDisaster RecoveryCloud SQL
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading