05 — GCP architecture
Written for a reader learning GCP. Every name is pinned by C5 §5.6; this file explains why resources exist, never renames them. Every figure is a labelled ASSUMPTION — none is production data (fictional mid-size PSP). Traceability: NFR-02, NFR-05, NFR-12, NFR-13, NFR-14, A-12, SC-07, D-05, D-07.
5.1 The GCP concepts this project requires
| Concept | What it actually is | Why this project needs it | Failure mode when misunderstood |
|---|---|---|---|
| Project | Unit of billing, IAM, quota and naming; org/folder above it are optional | A dedicated project dlock-lab makes blast radius and teardown exact (§5.12) |
In a shared project a forgotten Cloud SQL instance bills forever and destroy can never be verified |
| Region / zone / scope | Region = Warsaw (europe-central2); zone = isolated failure domain inside it (-a/-b/-c), sub-ms apart. Resources are zonal (disk, ZONAL SQL), regional (subnet, REGIONAL SQL, Autopilot) or global (VPC) |
Consensus belongs across zones in one region; REGIONAL Cloud SQL keeps a synchronous standby in another zone | 3 etcd replicas in one zone survive nothing; cross-region quorum costs 20–150 ms per write; a “regional” cluster does not make a zonal database survive a zone loss |
| IAM | One sentence: principal may perform role on resource; bindings inherit downwards | Least privilege per workload (NFR-13) | The two mistakes below |
| VPC / subnet / secondary ranges | VPC global; subnet regional and owns the node range; pods and Services need separate named secondary ranges | Autopilot assigns every pod and Service an IP from those ranges | Missing secondary ranges — the most common first-run Autopilot failure (§5.6) |
| Private Services Access | Cloud SQL runs in Google’s VPC; PSA reserves a range in yours and peers the two | The only private path from pods to dlock-pg-* |
Instance creates fine and nothing can connect — the single most-skipped step |
| Cloud NAT | Outbound-only egress for nodes with no public IP | A private cluster cannot reach the internet, including public registries | ImagePullBackOff with an error that never mentions NAT |
| Service enablement | Every API is off until enabled per project (sqladmin, container, servicenetworking, secretmanager, artifactregistry, monitoring, logging) |
Bootstrap step, §5.5 | First apply in a fresh project dies with SERVICE_DISABLED |
The IAM mistake everyone makes, twice. (1) Granting roles/editor because something did not work — it
works, and now the project identity can delete the database. Grant the narrow role and read the error: GCP names
the exact missing permission, so least privilege is a two-minute loop. (2) Confusing the service account
as an identity (roles granted to it) with the service account as a resource (roles granted on
it, e.g. impersonation). Workload Identity needs both, which is why it is the step that fails (§5.9).
5.2 Target architecture
GCP project dlock-lab · region europe-central2 (Warsaw)
+-------------------------------------------------------------------------+
| VPC dlock-vpc | subnet 10.10.0.0/20 | pods 10.30/16 | services 10.40/20 |
| GKE Autopilot dlock-gke (regional, 3 zones) |
| | payout-executor x2 --> lock-server x3 ------ priv IP -> dlock-pg-lock
| | +--> payment-resource x1 (fence a) ------ priv IP -> dlock-pg-pay
| | +--> rail-proxy x1 (fence c, high-water) --> rail-stub x1 |
| | dlock-etcd StatefulSet x3, SSD PVC <-- client/peer |
| Artifact Registry (images) · Secret Manager (WI) · Prometheus/Logging |
| PSA 10.20.0.0/16 ==peering==> Google's VPC · Cloud NAT --> egress only |
+-------------------------------------------------------------------------+
Inventory — costs are ASSUMPTIONS, order-of-magnitude, list price, europe-central2 (detail §5.10):
| Resource (pinned name) | Purpose | Why this over the alternative | ~EUR/mo |
|---|---|---|---|
Project dlock-lab |
Billing + blast-radius + teardown boundary | A folder inside an existing project cannot be deleted as one unit | 0 |
VPC dlock-vpc, subnet dlock-subnet, secondary ranges pods / services |
Private L3 plus the pod and Service IP space Autopilot requires | Default VPC has auto-mode subnets everywhere and no secondary-range control | 0 |
PSA range + servicenetworking connection; Cloud Router + Cloud NAT |
Private path to Cloud SQL; egress for private nodes | Public IP + authorized networks exposes the DB (NFR-12); public nodes are cheaper but weaken posture | ~30 |
GKE Autopilot dlock-gke |
Runs ~11 pods across 3 zones | Standard means node pools we do not want to own (§5.3) | ~65 fee + 90–140 workload |
Cloud SQL dlock-pg-lock |
Lock backend lockdb (C1) |
REGIONAL — the synchronous standby is the durability claim behind NFR-05 | ~100 |
Cloud SQL dlock-pg-pay |
Protected resource paydb (C1) |
ZONAL — cost; failing it is not the experiment | ~50 |
dlock-etcd StatefulSet + 3×10 GB SSD PVC |
M3 backend; ModRevision is the token |
GCP has no managed etcd (§5.3) | ~7 |
Artifact Registry dlock, Secret Manager dlock-{lock,pay}-db-password, GCS dlock-tfstate |
Immutable <module>:<short-sha> images; DB credentials as projected env vars; Terraform state |
Public-registry pulls hit rate limits; no credential in source or state (NFR-12); local state loss is unrecoverable | ~0.3 |
| Managed Prometheus + Cloud Logging, billing budget + alert | Scrape /actuator/prometheus (C4 §4.9); cost guardrail before first apply (NFR-14) |
Self-hosted Prometheus is another StatefulSet to babysit | 0–5 |
5.3 Why each choice, defended
europe-central2 (Warsaw). Lowest RTT from the author, so the latency histograms
(C4 §4.2) measure the lock, not the distance; three zones suffice
for a 3-member Raft group. Cloud SQL and GKE must be co-located — a cross-region database adds tens of
milliseconds to every acquire and makes NFR-03 (p99 ≤ 50 ms) unreachable for reasons unrelated to the design.
GKE Autopilot, with the eviction trade-off accepted. Autopilot removes node pools, upgrades and “why is
my pod Pending” from a project about locks. The price is real: it bin-packs and evicts and gives no
node-placement control, so a 3-replica etcd StatefulSet will see more leader elections than on Standard.
Not hidden — reframed as a budgeted expense: an election costs 1–2 s of shard unavailability (NFR-02), so
N elections/month is a line item charged against the error budget, not an incident. Mitigations:
PodDisruptionBudget maxUnavailable: 1, topologySpreadConstraints across zones, safe-to-evict: "false" —
then measure the residual against the SLO; D-05 stays open until one month of data exists (SC-10).
Failure mode if unmeasured: the reframe becomes an excuse and a platform electing ten times a day gets
called fine.
Two Cloud SQL instances, REGIONAL + ZONAL. One instance for both databases would take out the lock backend
and the protected resource in the same event, destroying the experiment: you could no longer show that a
lock-backend failover preserves fences while the resource keeps serving. Hence dlock-pg-lock REGIONAL
(synchronous standby — NFR-05, INV-04) and dlock-pg-pay ZONAL. The euro cost of that decision is ~EUR
50/month, ~EUR 1.7/day for the standby alone — the price of the experiment, worth stating rather than
pretending HA is free.
Self-hosted etcd, because GCP has no managed etcd. The operational weight is data, not an accident: StatefulSet, zonal PVCs, headless Service, PDB, spread constraints, compaction and defrag, backup of a consensus store, election noise on a bin-packing platform. Build-versus-buy verdict: for a real PSP, if a managed strongly-consistent store exists (Spanner, or vendor-managed etcd/ZooKeeper), buy it — Raft’s correctness is not the hard part, its operations are; self-host only when the workload funds a team that owns it. This project self-hosts to earn the right to say that with numbers.
5.4 Terraform module inventory
Root module composes five children; Terraform 1.15 / google provider 7.x. Tables only, no HCL — the
implementer writes it in M5 (T-050…T-059). PodMonitoring is a Kubernetes CRD, not a Terraform resource (§5.7).
| Module | Purpose | Key inputs | Outputs |
|---|---|---|---|
network |
VPC, subnet, secondary ranges, PSA, NAT | project_id, region, subnet_cidr, pods_cidr, services_cidr, psa_cidr |
network_self_link, subnet_self_link, pods_range_name, services_range_name, psa_connection_id |
cloudsql (×2: sql_lock, sql_pay) |
Instance + database + user + password secret | instance_name, availability_type, tier, db_name, db_user, network_self_link, psa_connection_id |
private_ip, connection_name, secret_id |
gke |
Autopilot cluster + identity plumbing | cluster_name, region, network_self_link, subnet_self_link, pods_range_name, services_range_name |
cluster_endpoint, cluster_ca, workload_identity_pool |
artifacts |
Image storage | region, repo_id |
repo_url |
observability |
Managed metrics/logs, budget, alert channel | project_id, notification_email, budget_amount_eur, billing_account |
notification_channel_id |
| Module | Declared resources | Arguments that matter, and why |
|---|---|---|
network |
google_compute_network "this", google_compute_subnetwork "this" |
auto_create_subnetworks = false (auto mode makes a subnet in every region — noise and cost); secondary_ip_range named pods / services (the names GKE references); private_ip_google_access = true (Google APIs without NAT) |
network |
google_compute_global_address "psa", google_service_networking_connection "psa" |
purpose = VPC_PEERING, prefix_length = 16 reserves the range Cloud SQL lives in; the connection is the peering itself and must exist before Cloud SQL (§5.6 trap 1) |
network |
google_compute_router, google_compute_router_nat "this" |
All subnets, all IP ranges, AUTO_ONLY addresses, log errors only — egress for image pulls |
cloudsql |
google_sql_database_instance "this", google_sql_database "this", google_sql_user "app" |
availability_type REGIONAL vs ZONAL is the whole §5.3 argument; ipv4_enabled = false + private_network is the posture; backup_configuration with PITR on the lock instance; deletion_protection; depends_on the PSA connection; name = lockdb\|paydb, user lockapp\|payapp per C5 |
cloudsql |
random_password, google_secret_manager_secret[_version] |
secret_id = dlock-<db>-db-password — no password in .tfvars or source (NFR-12) |
gke |
google_container_cluster "this" |
enable_autopilot = true; ip_allocation_policy naming both secondary ranges (omitting them is §5.6 trap 4); private_cluster_config; release_channel = REGULAR; deletion_protection |
gke |
google_service_account "app", google_project_iam_member ×N, google_service_account_iam_member ×N |
account_id = dlock-app; only the narrow roles of §5.9 (NFR-13); roles/iam.workloadIdentityUser with member serviceAccount:dlock-lab.svc.id.goog[ns/ksa] — the on-the-resource direction of §5.1 |
artifacts |
google_artifact_registry_repository "this", google_project_iam_member |
repository_id = dlock, format = DOCKER, cleanup policy on untagged; roles/artifactregistry.reader so pulls need no key file |
observability |
google_project_service ×N, google_monitoring_notification_channel "email", google_billing_budget "this", google_logging_metric ×N |
Enable monitoring/logging/cloudresourcemanager; an alert needs a destination before it is written; budget thresholds 50/90/100 % with all_updates_rule (NFR-14); log metrics filter on event names from C4 §4.5 per C4 §4.7 |
5.5 State, the GCS backend, and the bootstrap chicken-and-egg
| Item | Decision | Why |
|---|---|---|
| Backend | gcs, bucket dlock-tfstate (C5 §5.5 is the source of truth for the name), prefix dev; locking automatic |
Local state on one laptop is a single point of loss; two concurrent applies cannot interleave |
| Versioning | On from the first run | The only cheap recovery from a bad apply or a truncated write |
| Secrets in state | Assume state holds the DB passwords in plaintext | Private bucket, uniform bucket-level access, never committed to Git (NFR-12) |
| Bootstrap | Project, billing link, gcloud services enable, and the state bucket created by hand once; all else Terraform |
You cannot store state in a bucket Terraform has not created, nor call a disabled API |
The chicken-and-egg in one line: the backend must exist before the code that would create it runs. The accepted
answer is a short documented bootstrap script, with the bucket deliberately outside the managed stack so
terraform destroy cannot delete the record of what it just destroyed. Remaining APIs are then declared as
google_project_service so the second apply is reproducible.
5.6 Terraform failure modes this stack teaches the hard way
| # | Failure mode | Symptom | Fix |
|---|---|---|---|
| 1 | google_service_networking_connection must exist before Cloud SQL, and the ordering is not always inferred |
Creation fails with an unhelpful peering error, or succeeds and is unreachable | Explicit depends_on from each SQL instance to the PSA connection |
| 2 | Destroying the PSA connection while an instance still uses it | destroy hangs a long time, then fails, leaving a half-torn stack |
Destroy databases first, then the network (§5.12 order) |
| 3 | deletion_protection defaults to true on Cloud SQL and GKE |
A terraform destroy that “does nothing” or refuses |
Set false and apply first; expect two steps |
| 4 | Missing secondary ranges in ip_allocation_policy |
Autopilot cluster creation fails outright — the most common first-run failure | Declare pods/services on the subnet, reference both by name |
| 5 | API not enabled, or a private cluster without NAT | SERVICE_DISABLED on first apply; every non-Artifact-Registry image ImagePullBackOff |
Bootstrap enablement (§5.5) — eventually consistent, so one retry is normal; Cloud NAT plus Private Google Access |
5.7 Kubernetes object inventory
Object name equals module name. Tables, not manifests; replica counts are project ASSUMPTIONS.
| Kind | Name | Purpose | Fields carrying design intent |
|---|---|---|---|
| Namespace | dlock |
One namespace, one PodMonitoring |
label app.kubernetes.io/part-of=dlock-lab |
| Deployment | lock-server ×3 |
The lock service | requests 500m/512Mi; containerPort 8080 named http-metrics; readiness /actuator/health/readiness (must fail when the backend is unreachable, C4 §4.10); liveness /actuator/health/liveness (must not touch the backend); topologySpreadConstraints zone maxSkew: 1; env LOCK_BACKEND |
| Deployment | payout-executor ×2 |
Claims and executes payouts — two replicas is the point, contention must be real | requests 500m/512Mi; no inbound Service; LOCK_CLIENT_SAFETYMARGIN (no underscore inside SAFETYMARGIN — C5 §5.7) |
| Deployment | payment-resource ×1, rail-proxy ×1, rail-stub ×1 |
Fence point (a); fence point (c) with persisted high-water; deliberately non-idempotent rail | requests 500m/512Mi and the named port on each; kill switches PAYMENT_FENCING_ENABLED, RAIL_PROXY_FENCING_ENABLED; RAIL_STUB_DUPLICATEACKRATE; PAYMENTS_DATASOURCE_URL → dlock-pg-pay private IP with *_PASSWORD projected from Secret Manager; annotation dlock-lab/fencing-enabled |
| StatefulSet | dlock-etcd ×3 |
M3 lock backend | volumeClaimTemplates 10 GB premium-rwo; requests 500m/1Gi; podAntiAffinity + topologySpreadConstraints zone; cluster-autoscaler.kubernetes.io/safe-to-evict: "false"; serviceName: dlock-etcd-headless; probes on /health |
| Service | dlock-etcd-headless; then ClusterIP for lock-server, payment-resource, rail-proxy, rail-stub |
Stable per-member DNS for peer URLs; in-cluster addressing for the rest | clusterIP: None on the headless one — Raft peers need identities, not a load balancer; every ClusterIP mirrors port name http-metrics from the container |
| PodDisruptionBudget | dlock-etcd-pdb |
Bounds voluntary disruption | maxUnavailable: 1 — with three members, quorum survives exactly one |
| ServiceAccount | one per workload | Workload Identity subject | iam.gke.io/gcp-service-account: dlock-app@dlock-lab.iam.gserviceaccount.com |
| PodMonitoring (CRD) | dlock |
Managed Prometheus scrape | spec.endpoints[].port: http-metrics (name, not number), interval: 30s, path: /actuator/prometheus, selector on part-of=dlock-lab |
| ConfigMap / Job | dlock-config / harness |
Non-secret config; runs correctness and benchmark scenarios | Mirrors C5 §5.1 defaults only; restartPolicy: Never and must not be evicted mid-run |
The named-port trap, restated because it costs hours: if the container port is unnamed, or named http while
PodMonitoring says http-metrics, the target never appears and every query returns empty — no error
anywhere. Verify in the order given in C4 §4.9.
5.8 Autopilot constraints that will bite
| Constraint | Consequence here |
|---|---|
| Every container must declare CPU and memory requests; Autopilot mutates missing or low values upward | You are billed the mutated value — always set requests explicitly |
| Minimum ≈250m CPU / 512Mi per pod; vCPU:GiB ratio held between 1:1 and 1:6.5; CPU rounded to 250m steps | An 11-pod topology has a cost floor; asking for 50m saves nothing and fine-grained sizing is wasted effort |
| No privileged pods, no host-path DaemonSets, no node SSH | Node-level debugging unavailable — observability must be in-process (NFR-08) |
| No node-placement control; bin-packs and evicts | The election budget of §5.3; PDB + spread + safe-to-evict are mitigations, not guarantees |
Only Google StorageClasses (premium-rwo SSD for the etcd PVC), and PVCs are zonal so a pod is pinned to its disk’s zone |
standard-rwo fsync latency inflates Raft commit latency and causes elections — the one storage choice with correctness-adjacent effects; an evicted etcd pod must reschedule in its disk’s zone, and capacity pressure there appears as long Pending |
| Regional cluster management fee charged whether or not workloads run; no scale-to-zero | Idle cluster ≈ EUR 2.2/day (ASSUMPTION); cost control is teardown, not scaling |
5.9 Workload Identity: the exact principal chain
Pod --uses--> KSA dlock/lock-server [annotation iam.gke.io/gcp-service-account]
v
GSA dlock-app@dlock-lab.iam.gserviceaccount.com
^ roles/iam.workloadIdentityUser ON the GSA, member =
| serviceAccount:dlock-lab.svc.id.goog[dlock/lock-server]
+-- role bindings --> Secret Manager, Monitoring, Logging, Trace, Artifact Registry
Two bindings, two directions; forgetting the second is the usual cause of PermissionDenied from a pod that “has the
right service account”.
| Role | Granted on | Why this is the least privilege that works |
|---|---|---|
roles/secretmanager.secretAccessor |
the two secrets, not the project | Read one version; cannot list, create or destroy secrets |
roles/monitoring.metricWriter |
project | Export only — a compromised pod cannot read dashboards |
roles/logging.logWriter |
project | Write structured events; cannot read others’ logs |
roles/cloudtrace.agent |
project | OTel spans (C4 §4.8) |
roles/artifactregistry.reader |
the dlock repo |
Pull images only |
roles/iam.workloadIdentityUser |
on dlock-app, member = each KSA |
The impersonation edge |
| Not granted | roles/editor, roles/cloudsql.admin, any *.admin |
Cloud SQL is reached by private IP with a DB password, so no IAM database role is needed at all |
Never download a service-account JSON key. A key in an image or a repo is the most common cloud credential leak there is, and it is unnecessary here (NFR-12, NFR-13).
5.10 An honest cost model for this topology
A deliberately larger topology than a minimal demo: ~11 pods, two Cloud SQL instances (one REGIONAL), a regional cluster
fee, Cloud NAT, three SSD PVCs. All figures are ASSUMPTIONS at list price for europe-central2; re-verify first.
| Line item | Configuration (ASSUMPTION) | ~EUR/month | ~EUR/day |
|---|---|---|---|
| Autopilot workloads | 11 pods, ~5.5 vCPU / 7 GiB requested (§5.7: 8 × 500m/512Mi + 3 etcd × 500m/1Gi) | 140–220 | 4.7–7.3 |
| GKE cluster management fee | one regional cluster, charged idle | ~65 | ~2.2 |
Cloud SQL dlock-pg-lock REGIONAL |
db-custom-1-3840, 10 GB SSD, HA |
~100 | ~3.3 |
Cloud SQL dlock-pg-pay ZONAL |
db-custom-1-3840, 10 GB SSD |
~50 | ~1.7 |
| Cloud NAT | one gateway, light traffic | ~30 | ~1.0 |
| etcd PVCs | 3 × 10 GB premium-rwo |
~7 | ~0.2 |
| Artifact Registry + Secret Manager + GCS state; Logging / Monitoring | < 1 GB total; project volume near the free tier | ~0.3; 0–5 | ~0.1 |
| Total, running continuously | ~390–480 | ~13–16 |
The line items nobody expects are the cluster management fee and Cloud NAT: both charge per hour for existing, not for being used, and both accrue while you sleep.
Recommendation — the one-focused-day pattern. Apply in the morning, run the whole experiment set (failover,
fencing, election measurement, backend benchmark), capture logs, metric screenshots and the comparison numbers, then
destroy the same day: ~EUR 13–16 for the day (ASSUMPTION — the daily column above), and it forces the IaC to
work end to end. A standing environment is D-07, costs ~EUR 390–480/month, and needs re-costing. Configure the budget alert before the first apply (NFR-14): EUR 50/month,
thresholds 50 / 90 / 100 %, email channel — and treat any unexpected morning charge as a §5.12 trigger.
5.11 Cheaper variants, and what each costs you
| Instead of | Use | Saves (ASSUMPTION) | What experiment you lose |
|---|---|---|---|
| Continuous run | Apply → run → destroy same day |
~95 % | Nothing but convenience — the recommended default |
dlock-pg-lock REGIONAL |
ZONAL | ~EUR 50/mo | The synchronous-standby failover proof for NFR-05 / INV-04 |
| Two SQL instances | One instance, two databases | ~EUR 50/mo | The separation argument itself — lock backend and resource now fail together |
| GKE Autopilot | Cloud Run for the stateless services | ~EUR 65/mo fee, plus scale-to-zero | No StatefulSet, so etcd moves elsewhere; loses the eviction/election study D-05 exists to settle |
| Self-hosted etcd | PostgreSQL backend only | ~EUR 7/mo + operational time | The M3 correctness answer and the measured backend comparison — a headline deliverable |
| Cloud NAT; Cloud SQL | Public nodes (project only); Postgres containers in-cluster | ~EUR 30/mo; ~EUR 150/mo | Posture, never acceptable outside a throwaway project; and managed failover, PITR and the credibility of the M1 backend |
| GCP entirely | docker compose locally (NFR-15) |
100 % | Zonal failure domains, managed failover, IAM, the SRE telemetry stack — keep this path for iteration, not for the deliverable |
5.12 Teardown and orphan verification
Ordered, because §5.6 trap 2 is a real twenty-minute hang:
| Step | Command | Note |
|---|---|---|
| 1 | terraform apply -var deletion_protection=false |
Trap 3 — clear protection on Cloud SQL and GKE first |
| 2 | kubectl delete namespace dlock |
Releases PVCs and any load balancers before the cluster goes |
| 3 | terraform destroy -target=module.sql_lock -target=module.sql_pay |
Databases before the peering |
| 4 | terraform destroy |
Everything else, including network and NAT |
| 5 | gcloud sql instances list · gcloud container clusters list · gcloud compute disks list · gcloud compute addresses list · gcloud compute routers list · gcloud artifacts repositories list |
Orphan sweep — retained PVC disks and reserved addresses are the usual survivors |
| 6–7 | project cost report the next morning, then gcloud projects delete dlock-lab |
The report is the only proof that billable resources reached zero (SC-07); project deletion is the guaranteed-complete teardown |
Why a dedicated project is the real answer. terraform destroy deletes only what state knows about: a
console-clicked resource, a kubectl-created load balancer, a PVC disk whose reclaim policy retained it, anything
created after the last successful apply — all survive, and in a shared project you can never prove the bill is
zero. In a dedicated project, project deletion removes everything unconditionally: one command, no inventory, no
argument. That is why §5.1 opens with the project, and why the state bucket sits deliberately outside the managed
stack (§5.5) — delete the project, keep the artifacts.