We moved 200+ microservices from AWS to multi-region GCP over 6 months. Not a single request dropped. Here's what that actually looked like, including the parts that nearly broke us.
MPL (Mobile Premier League) was running at 5 million concurrent users during peak tournaments. The infrastructure was on AWS, and it worked. But "it works" is a low bar when you're adding 200-400ms of unnecessary latency because users in India and Brazil are hitting US-East endpoints. A GCP partnership brought the opportunity to rethink the whole setup.
The fear was simple: one bad config, one race condition in the database migration, one missed dependency — and millions of live game sessions crash simultaneously. That's not a pager alert. That's a press release.
The Strategy: Traffic Mirroring, Not Big Bang
We ruled out the big-bang approach immediately. "Pick a weekend and flip DNS" is how you get an incident on Monday morning and a retrospective nobody wants to write.
Instead: deploy everything to GCP in parallel, mirror traffic, then shift gradually using weighted DNS. The AWS clusters kept running. The GCP clusters got real traffic at increasing percentages — 5%, then 20%, then 50%, then 100%. At any point we could roll back by editing two numbers in a Terraform file.
# Week 1: 5% GCP, 95% AWS
resource "google_dns_record_set" "api" {
routing_policy {
wrr {
weight = 95
rrdatas = [aws_alb_endpoint]
}
wrr {
weight = 5
rrdatas = [gcp_lb_endpoint]
}
}
}
# Week 3: flip to 50/50
# Week 6: 100% GCP, AWS decommissioned
Every service had a cloud_provider label in Prometheus. Grafana dashboards showed
error rate, latency P99, and saturation split by AWS vs GCP in real time. Any degradation on GCP
was visible at 5% traffic exposure, not after the full cutover.
The Terraform Journey: 32 Modules and State Management Nightmares
Before touching any traffic, we spent six weeks Terraforming the entire GCP environment. Every GKE cluster, VPC, subnet, IAM binding, Cloud DNS zone, Cloud Armor policy, NAT gateway: all code. We built 32+ reusable modules with regional targeting so the same module could deploy to India, US, and Brazil with different variable files.
module "gke_cluster_india" {
source = "./modules/gke-cluster"
project_id = var.project_id
region = "asia-south1"
cluster_name = "prod-india"
node_pools = var.india_node_pools
network = google_compute_network.platform_vpc.name
subnetwork = google_compute_subnetwork.india.name
}
module "gke_cluster_brazil" {
source = "./modules/gke-cluster"
region = "southamerica-east1"
cluster_name = "prod-brazil"
node_pools = var.brazil_node_pools
# same module, different vars
}
The state management was the ugly part. We started with a single state file per environment. Sounds fine until you have 32 modules and a plan that takes 8 minutes to run and touches things it has no business touching. We eventually split into per-module state with remote backends on GCS. Design your state boundaries before you have 600 resources in one file.
ArgoCD App-of-Apps: Managing 200 Services Across 3 Clusters
Deploying 200+ microservices to three GKE clusters by hand was never an option. We used ArgoCD's app-of-apps pattern with ApplicationSets: one parent ArgoCD app per cluster region, generating child apps from a directory structure in our GitOps repo.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: platform-services
spec:
generators:
- git:
repoURL: https://github.com/org/gitops-repo
revision: HEAD
directories:
- path: apps/platform/*
template:
metadata:
name: '{{path.basename}}'
spec:
project: platform
source:
repoURL: https://github.com/org/gitops-repo
targetRevision: HEAD
path: '{{path}}'
destination:
server: '{{cluster}}'
namespace: platform
syncPolicy:
automated:
prune: true
selfHeal: true
New service? Add a directory, push to main, ArgoCD deploys it to all three clusters within minutes. No manual kubectl, no per-cluster ceremony. This was the unlock that made 200+ services manageable.
KEDA and the Python Session Controller
MPL's traffic is violently spiky. 8pm India time is tournament prime time, and concurrent users can double in under a minute. AWS's managed autoscaling handled this adequately. GCP's default HPA did not: it scaled on CPU, which lagged real demand by 3-5 minutes.
We set up KEDA with a custom Python session controller that exposed active session counts as
custom metrics. KEDA's ScaledObject pointed at these metrics and kept one pod per
250 active sessions. Scale-up happened in under 30 seconds. Scale-down waited for sessions to
drain before evicting. The autoscaler finally understood what it was actually scaling.
The Night We Flipped DNS
Six months in. All 200 services deployed on GCP. Stateful migrations complete: PostgreSQL replicas promoted, Kafka Mirror Maker 2 offset sync verified, Redis AOF dumps restored. The weighted DNS had been at 50/50 for two weeks without a single incident.
We scheduled the final flip for a Tuesday at 2am IST, lowest traffic in the week. Change DNS weights to 100% GCP, verify for 30 minutes, decommission AWS load balancers. Four people on a call, dashboards open, fingers on the rollback Terraform.
The weight change propagated in 47 seconds (we'd set TTL to 60). Latency dropped. Error rate didn't move. The GCP clusters absorbed 100% of production traffic like they'd been doing it for years. In a sense, they had. Thirty minutes later, we decommissioned the last AWS load balancer. Total production impact: zero.
The Numbers
What We'd Do Differently
- Get observability working before migrating anything. We spent too long moving services before our GCP Prometheus/Grafana stack was solid. You can't trust a migration you can't see.
- Automate data consistency checks. We manually spot-checked database replication lag. A continuous automated consistency check would have saved 20+ hours of verification across the team.
- Design Terraform state boundaries on day one. Splitting state after you have 600 resources is genuinely awful. Structure it around blast radius from the start.
The migration wasn't glamorous. Six months of dual environments, incremental traffic shifts, and dashboards left open 24/7. When the final DNS change went out and nothing broke, it felt exactly right. Not dramatic. Just done.