10 — Production Architecture: Multi-Repo, GKE, Networking & CI/CD¶
Code: 02-gcp-terraform/1-2pc-same-network/ · 2-2VPCs/ · ../../acme-sampleapp-multirepo · Quiz: Multi-Repo & Consul, GKE & WI, Debugging
Architect's Points to Keep in Mind¶
1. Root Boundaries, Blast Radius & File Conventions¶
- A Root is a Blast Radius: One root directory = one state file = one lock = one failure domain.
- When to Split a Root:
- Lifecycle cadence: Stateful, slow-changing infra (VPC, GKE, Cloud SQL) must not share a state file with high-churn application deployments (Helm releases).
- Team ownership & least privilege: App teams shouldn't hold IAM permissions to mutate core networking or databases.
- Plan/refresh latency: Hundreds of resources in one state file slow down every
planand hit API rate limits.
- Standard File Layout: Terraform merges all
.tffiles in a directory alphabetically (versions.tf,providers.tf,backend.tf,variables.tf,locals.tf,data.tf,main.tf,outputs.tf, plus domain splits likeiam.tf,rbac.tf,cloudsql.tf). Always keep alloutputblocks inoutputs.tfand allvariableblocks invariables.tfso the root's schema is scannable withoutgrep.
2. Cross-Config Composition: Shared KV (Consul) vs. terraform_remote_state¶
When Root B needs values created by Root A (e.g., GKE endpoint, VPC subnet, GSA email):
| Dimension | data "terraform_remote_state" |
Shared KV Contract (consul_keys / SSM) |
|---|---|---|
| Extra Infrastructure | None (reads producer's GCS state bucket directly) | Requires highly available KV store with ACLs |
| Security & Isolation | Weak: Consumer requires read access to Producer's entire state file, including all plaintext secrets | Strong: Producer explicitly publishes only non-sensitive contract fields |
| Coupling | Coupled to Producer's backend path and outputs.tf |
Coupled to an explicit JSON key schema |
- Four Cross-Repo Contract Rules (Proven in Acme Sandbox):
- Apply & Destroy Ordering: Apply upstream producers first (
sample-program→infrastructure/cloudsql→backend→frontend); destroy in strict reverse order. - Who Owns a Cross-Repo IAM Grant? If Repo A runs before Repo B, Repo A cannot reference a Service Account created by Repo B (circular dependency). Place the IAM binding in whichever repo already has all required data available in the forward apply sequence (e.g.,
backend/infragrants its own GSA access to the SSO secret published byinfrastructure/infra). - Never Publish Raw Secrets to KV: Publish the Secret Manager resource ID (
google_secret_manager_secret.sso.id), and let the consumer grant/read it with its own IAM identity. - Stale KV Data After Destroy:
terraform destroydeletes cloud resources, not orphaned keys in external KV stores if a destroy script skips a step. Always clean KV contracts on teardown and verify live cloud resources independently.
- Apply & Destroy Ordering: Apply upstream producers first (
3. GCP Networking: Subnets, VPC Peering & IAP TCP Forwarding¶
- VPC Regional Subnets: In GCP, VPCs are global and subnets are regional. Two VMs in different subnets of the same VPC can communicate over internal IPs as long as an ingress
google_compute_firewallrule allows traffic from the peer subnet's CIDR. - Connecting Two Separate VPCs:
- VPC Peering (
google_compute_network_peering): Bi-directional (requires peering blocks on both VPCs: A→B and B→A), non-transitive, exchanges subnet routes automatically, no hourly tunnel charge. Still requires firewall rules on each VPC allowing the peer subnet CIDR. - HA VPN: Uses Cloud Routers + BGP sessions + encrypted IPsec tunnels; teaches dynamic routing and supports transitive/hybrid topologies, but bills hourly per tunnel.
- VPC Peering (
- SSH to Private VMs via Identity-Aware Proxy (IAP):
- VMs without external IPs cannot be reached directly from a laptop.
- Running
gcloud compute ssh vm-a --tunnel-through-iapproxies TCP port 22 through Google's IAP servers (35.235.240.0/20). - Mandatory Firewall Rule: GCP denies all ingress by default; you must allow TCP port
22fromsource_ranges = ["35.235.240.0/20"](and holdroles/iap.tunnelResourceAccessor), or SSH fails with error4033/4003.
4. GKE & Workload Identity Production Gotchas (High-Yield Interview Topics)¶
- Never Use the Default Compute Engine Service Account:
- It may not exist in newer/hardened GCP projects (
Failed precondition: verify if principal exists), and when it does exist, it carries broadroles/editorpermissions. - Always create a dedicated, least-privilege node GSA granted
roles/logging.logWriter,roles/monitoring.metricWriter,roles/monitoring.viewer, androles/artifactregistry.reader.
- It may not exist in newer/hardened GCP projects (
- The
remove_default_node_pool = trueTransient Pool Trap:- Separating
google_container_clusterandgoogle_container_node_poollets you resize/upgrade node pools independently without recreating the cluster. - Gotcha: Even with
remove_default_node_pool = true, GKE briefly creates a transient initial node pool during cluster bootstrap and deletes it immediately after. That transient pool usesnode_configongoogle_container_clusteritself! You must setnode_config { service_account = ... }on bothgoogle_container_clusterandgoogle_container_node_pool.
- Separating
- Workload Identity (Linking K8s SA ↔ GCP IAM SA):
- A Kubernetes
ServiceAccount(KSA) and a GCPgoogle_service_account(GSA) are two completely separate identities in two separate systems. Name matching does nothing. - Four pieces required for a pod to authenticate as a GSA:
- Cluster level:
workload_identity_config { workload_pool = "${var.project_id}.svc.id.goog" }ongoogle_container_cluster. - Node pool level:
workload_metadata_config { mode = "GKE_METADATA" }ongoogle_container_node_pool(runs the GKE metadata server DaemonSet on nodes). - GCP IAM binding: Grant
roles/iam.workloadIdentityUseron the GSA to member"serviceAccount:${var.project_id}.svc.id.goog[${namespace}/${ksa_name}]". - Kubernetes SA annotation: Stamp
iam.gke.io/gcp-service-account: <gsa-email>on the KubernetesServiceAccount.
- Cluster level:
- A Kubernetes
- GCP IAM vs. Kubernetes RBAC (
kubectlForbidden despiteroles/owner):- GCP IAM controls access to the GKE Cloud API; Kubernetes RBAC controls actions inside the cluster API server.
- If
kubectl get podsreturnsForbiddendespite havingroles/owner: (a) check for a stalekubeconfigauthenticated as a differentgcloudaccount (gcloud container clusters get-credentials+kubectl auth can-i), and (b) verifyClusterRoleBinding/RoleBindingexists for your identity.
- Node Capacity vs. Allocatable, Spot Readiness & Shared-Core Machine Types:
AllocatableCPU/memory is significantly lower thanCapacitybecause GKE reserves resources for the OS/kubelet and system DaemonSets (kube-proxy, logging,gke-metadata-server).- Spot Node Startup vs. Configuration Drift: When a GKE node pool reports
RUNNINGin GCP butkubectl get nodesshows zeroReadynodes, check node pool provisioning/Spot capacity state before assuming Terraform drift — Spot VMs can take extra time to allocate and register with the control plane. - Sizing Trap:
e2-micro,e2-small, ande2-mediumare all shared-core VMs with the same 2-vCPU ceiling — stepping frome2-smalltoe2-mediumincreases memory (2 GiB → 4 GiB) but adds zero allocatable vCPU. Move toe2-standard-2for dedicated vCPUs. - Image Architecture: Standard x86 GKE nodes require
linux/amd64container images (docker build --platform linux/amd64); pushing an Apple Siliconarm64-only manifest fails pod startup withno match for platform in manifest.
- Cloud SQL Auth Proxy Sidecar &
shell_scriptIn-Database Grant Bridge:- Passwordless IAM Database Auth: Set
cloudsql.iam_authentication = "on"ongoogle_sql_database_instanceand create agoogle_sql_userwithtype = "CLOUD_IAM_SERVICE_ACCOUNT"andname = trimsuffix(google_service_account.app.email, ".gserviceaccount.com")(Postgres IAM users must omit the.gserviceaccount.comsuffix). No DB password ever exists interraform.tfstate. - Custom CRUD Lifecycle Bridge (
scottwinkler/shell): Terraform'sgoogle_sql_usercreates the database principal, but cannot execute SQLGRANTstatements inside a private-IP database.backend/infra/iam.tfuses ashell_scriptresource withcreate/read/deletelifecycle commands to mint an OIDC ID token and call the producer's grant/revoke Cloud Functions, wired intohelm_releaseviadepends_onso pods never start before schema grants complete.
- Passwordless IAM Database Auth: Set
5. GitOps & CI/CD Execution Security¶
- Why CI/CD: Developers never hold state bucket read access or production GCP credentials on laptops. CI runs
terraform planon PRs using a read-only identity andterraform applyon protectedmainusing a write identity via Workload Identity Federation (OIDC → GCP STS, no JSON keys). - The PR Arbitrary Code Execution Attack Surface:
terraform planexecutes arbitrary code defined on the PR branch (externaldata sources, malicious module sources, orlocal-execifapplyruns).- An attacker can exfiltrate plaintext secrets from
terraform.tfstatevia network (curl) or print them to CI stdout. - Why CI Secret Masking Is Not Enough: CI log masking uses literal string matching (
***) and is trivially bypassed by encoding (echo $SECRET | base64). - Primary Defense-in-Depth Control: Mandatory human PR review and approval before triggering any CI
planjob, combined with isolated runners (restricted egress), least-privilegeplanSAs, and branch protection conditions enforced in the GCP WIFattribute_condition/principalSet.
6. Hypothesis-Ordered Debugging & Independent Verification¶
- Order hypotheses from cheapest/fastest to falsify before reading all code:
- Local process/environment & binary readiness (
command -v consul,pgrep consul,consul members,consul kv get,gcloud auth list). Always health-check provider-backed local dependencies before runningterraform plan/apply. - Active workspace & non-empty state ledger (
terraform workspace show,terraform state list— never trust the mere existence of aterraform.tfstatefile on disk, as an empty state file with"resources": []still passes file-existence checks). - Code/contract inspection (e.g., catching a hardcoded
/defaultConsul path next to a parameterized/${local.env_key}path).
- Local process/environment & binary readiness (
- Never trust a green
terraform applyas proof of system health:applysucceeding only proves the API accepted the request (ahelm_releaseor GKE cluster can reportRUNNINGwhile its node pool is missing or pods areCrashLoopBackOff). Always verify independently withkubectl get nodes,kubectl get pods,kubectl get sa -o jsonpath='{.metadata.annotations}', andgcloud.
Code Reference¶
# Allow SSH to private VMs (no public IP) via Google Identity-Aware Proxy (IAP)
resource "google_compute_firewall" "iap_ssh" {
name = "allow-iap-ssh"
network = google_compute_network.vpc_a.name
source_ranges = ["35.235.240.0/20"] # Google IAP TCP forwarding range
allow {
protocol = "tcp"
ports = ["22"]
}
}
# Bi-directional VPC Peering (both directions required for route exchange)
resource "google_compute_network_peering" "a_to_b" {
name = "peer-a-to-b"
network = google_compute_network.vpc_a.self_link
peer_network = google_compute_network.vpc_b.self_link
}
resource "google_compute_network_peering" "b_to_a" {
name = "peer-b-to-a"
network = google_compute_network.vpc_b.self_link
peer_network = google_compute_network.vpc_a.self_link
}
# 1. Dedicated least-privilege GKE Node Service Account (sample-program/infra/modules/gke/main.tf)
resource "google_service_account" "node" {
project = var.project_id
account_id = "${var.name}-node-sa"
}
resource "google_container_cluster" "main" {
name = var.name
location = var.zone
remove_default_node_pool = true
initial_node_count = 1
# CRITICAL: Transient initial node pool still reads `node_config` on the cluster resource!
node_config {
service_account = google_service_account.node.email
oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
workload_identity_config {
workload_pool = "${var.project_id}.svc.id.goog" # 1/4: Enable WI on cluster
}
}
resource "google_container_node_pool" "primary" {
name = "${var.name}-pool"
location = var.zone
cluster = google_container_cluster.main.name
node_count = var.node_count
node_config {
machine_type = var.machine_type # e2-standard-2 for real vCPU headroom
spot = var.spot
service_account = google_service_account.node.email
oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
workload_metadata_config {
mode = "GKE_METADATA" # 2/4: Enable GKE metadata server on node pool
}
}
}
# 2. Workload Identity Binding: GSA <-> KSA (backend/infra/iam.tf & Helm chart)
resource "google_service_account_iam_member" "workload_identity" {
service_account_id = google_service_account.backend.name
role = "roles/iam.workloadIdentityUser"
# 3/4: Bind K8s namespace + KSA name to GCP Service Account
member = "serviceAccount:${local.program_gcp.project}.svc.id.goog[${kubernetes_namespace_v1.backend.metadata[0].name}/${local.ksa_name}]"
}
# 4/4: K8s ServiceAccount carries annotation `iam.gke.io/gcp-service-account: <gsa-email>`
# Producer (sample-program/infra/main.tf) — Publishes explicit JSON contract
resource "consul_keys" "publish_outputs" {
key {
path = "acme-sampleapp/sample-program/default"
value = jsonencode({
outputs = {
gcp = {
us_central1 = {
for k, cluster in module.gke : k => {
project = var.project_id
gke = {
name = cluster.name
host = cluster.endpoint
cluster_ca_certificate = cluster.ca_certificate
}
}
}
}
}
})
}
}
# Consumer (backend/infra/main.tf) — Parameterized environment lookup
data "consul_keys" "remote_outputs" {
key {
name = "program"
path = "acme-sampleapp/sample-program/default"
}
key {
name = "infrastructure"
path = "acme-sampleapp/infrastructure/${local.infra_env_key}" # Parameterized, never hardcoded!
}
}