GKE Cluster¶
The single Kubernetes cluster behind everything Tomoda runs. Provisioned by infrastructure/gcp/gke.tf and infrastructure/gcp/gke_iam.tf.
Cluster shape¶
| Field | Value |
|---|---|
| Name | gke-tomoda |
| Control plane | us-central1-a (single zonal, free) |
| Node zones | us-central1-a / -b / -f (multi-zonal, node_locations) |
| Node arch | ARM (t2a-standard-4 general, t2a-standard-2 burst); x86 (e2-standard-2) on data |
| Network | gke-tomoda-vpc (custom) |
| Subnetwork | gke-tomoda-subnet |
| Pod range | pods secondary range (10.1.0.0/16) |
| Services range | services secondary range (10.2.0.0/20) |
| Workload Identity | Enabled (${project_id}.svc.id.goog) |
| Dataplane | V2 (datapath_provider = "ADVANCED_DATAPATH") |
| Default node pool | Removed (remove_default_node_pool = true) |
| Deletion protection | On |
Dataplane V2 is create-time only
datapath_provider = "ADVANCED_DATAPATH" (GKE Dataplane V2) uses eBPF networking and enforces Kubernetes NetworkPolicy. It cannot be changed on a live cluster; flipping it forces a cluster replacement. See Network policies.
Deletion protection
deletion_protection = true. terraform destroy fails until you flip the bit in gke.tf and apply. Losing the cluster means losing every workload, every PVC, and the ability to restore CNPG without a manual DR flow.
The cluster name gke-tomoda is load-bearing (CI substitutions, kubeconfig contexts, runbooks). A Terraform rename force-replaces the cluster; don't rename without a coordinated rollout.
Topology: multi-zonal ARM nodes¶
- Control plane — single-zonal in
us-central1-a(free). - Nodes — multi-zonal: every pool sets
node_locations = var.node_zones(us-central1-a/-b/-f), so the autoscaler draws from three zones. A single-zone spot stockout doesn't block scheduling; multi-replica workloads survive a zone loss. Zonesa/b/fare the ones offering ARM (T2A). - Arch — ARM (
t2a-standard-2, 2 vCPU / 8 GB) on all pools exceptdata, which is x86 (e2-standard-2). ARM is a separate Compute Engine inventory pool from x86, adding capacity diversity against an x86 spot stockout.datastays x86 because the custom Postgres image builds oncloudnative-pg/postgis(amd64-only upstream). All app/platform images are multi-arch; setvar.node_machine_typeto ane2-*type to move ARM pools to x86. - Autoscaling counts — multi-zonal pools use
total_min_node_count/total_max_node_count(cluster-wide totals spread across zones), not per-zonemin/max.
Node pools¶
Three pools, all under the dedicated node service account gke-node-sa (google_service_account.gke_nodes, wired via node_config.service_account on each pool in gke.tf), not the default Compute Engine SA. It is least-privilege, holding only roles/logging.logWriter, roles/monitoring.metricWriter, and roles/artifactregistry.reader. See IAM Overview.
| Pool label | Mode | Machine | Total autoscale | Carries |
|---|---|---|---|---|
pool=general |
on-demand | t2a-standard-4 |
1–3 | Control plane, Traefik, Argo CD, operators, monitoring + prod app (api + async), Photon, embeddings, xavia-prod |
pool=data |
on-demand, tainted workload=stateful:NoSchedule |
e2-standard-2 (x86) |
1–3 | CNPG + Redis (prod + dev) |
pool=burst |
spot, location_policy = ANY |
t2a-standard-2 |
0–5 | Dev app, CI runners, photon-indexer, xavia-dev |
pool=general— the single on-demand pool for everything except stateful data: control-plane and observability (Traefik, Argo CD, ARC controller + listener, cloudflared, monitoring, cert-manager / external-secrets / external-dns / oauth2-proxy) alongside the user-facing tier (prod api + async, Photon's planet index at 6 Gi / 250 Gi PVC / ~1.8h to rebuild, embeddings, xavia-prod). Never spot. Lean start is one larger node (floor 1);BALANCEDspreads the pool across zones as it grows.pool=data— on-demand, x86, tainted so only CNPG + Redis (toleratingworkload=stateful) land here. A preempted database is an outage, so never spot. Lean floor is 1; the HA flavour bumps the ceiling for a synchronous CNPG standby across zones.pool=burst— the only spot pool.location_policy = ANYgrabs whichever zone has spot capacity, surviving a single-zone stockout. Scales to zero when idle.
Cost posture¶
| Posture | Monthly | Survives |
|---|---|---|
| Lean (runs today) | ~$300/mo | Node preemption, single-zone spot stockout |
| Resilient HA | ~$470/mo | Zone loss (multi-replica DB + app across zones) |
The resilient upgrade raises the data pool ceiling and turns on the CNPG synchronous standby + Redis Sentinel (see Postgres, Redis). A regional control plane (survives a control-plane zone outage) adds ~$73/mo — a further tier, not built.
Scaling the cluster¶
Every pool has a GKE cluster autoscaler (autoscaling {} block in gke.tf): grows on unschedulable pods, shrinks on underutilized nodes, replaces preempted spot nodes.
The cluster runs the OPTIMIZE_UTILIZATION autoscaling profile (cluster_autoscaling in gke.tf). The default BALANCED profile refuses to drain any node hosting a local-storage (emptyDir) pod — which every node does (kube-dns, metrics-server, GMP collectors) — so an underutilized node never becomes a scale-down candidate and pools stay stuck at their high-water mark. OPTIMIZE_UTILIZATION raises the scale-down utilization threshold and evicts those pods, so pools consolidate back down. Trade-off: more aggressive scale-down means more frequent pod reschedules, so multi-replica workloads carry PDBs. (Node auto-provisioning stays off — enabled = false; pools are managed explicitly.)
- Higher ceiling — bump
total_max_node_countingke.tfandterraform apply. Don't hand-edit node counts. - Pod-level — add an HPA against the Deployment. HPA scales replicas; the autoscaler provisions nodes when capacity is exhausted.
- One-off bursts (e.g. Photon indexer via
scripts/photon-index-local.sh) — spin up a separate Compute Engine VM rather than scaling the cluster, to keep Argo CD's drift detection happy.
Workload Identity¶
Workload Identity is enabled with the pool ${project_id}.svc.id.goog. Every Workload Identity binding in this project follows the pattern:
serviceAccount:${project_id}.svc.id.goog[<namespace>/<ksa-name>]
The actual bindings used by workloads in the cluster are listed in IAM Overview. The relevant ones for this page:
- The node service account
gke-node-sagetsroles/artifactregistry.readerso nodes can pull images from both repos without per-pool config (gke.tf). - Per-workload GCP SAs (CNPG backup, photon-indexer, image-updater, cloud-build) are documented on their own pages.
What is not in here¶
- No private cluster — control plane endpoint is public; access relies on GCP auth, not network ACLs.
- No release channel —
release_channelunset, so the cluster follows the default channel. DeclareRAPID/REGULAR/STABLEfor predictable version upgrades. - No maintenance window — upgrades happen whenever GCP decides. Watch the GKE console for advance notice.
- No GPU pools. The only taint is
workload=stateful:NoScheduleonpool=data. Every other pool is untainted; placement is bynodeSelectoronpool.
Recovery¶
If the cluster is somehow lost (deletion-protection bypassed, project deleted, etc.):
- Restore the cluster with
terraform applyfrom clean state. - Re-bootstrap Argo CD from the repo's
argocd/manifests. - Restore Postgres from the most recent CNPG base backup in the backup bucket; step-by-step in the Postgres operations runbook.
No automated DR — recovery is a manual replay of Terraform + Argo CD + the Postgres restore.