[GCP] Three Traps Migrating GKE Gateway API To A Global LB
Situation
Needed The PRD Domain’s Certificate To Auto-Renew, Which Meant Switching GatewayClass From Regional (gke-l7-regional-external-managed) To Global (gke-l7-global-external-managed) — Certificate Manager’s Google-Managed Auto-Renewing Certs Only Support Global. That Night I Hit Three Traps Back To Back: Certificate Manager’s Annotation Syntax Is Completely Different, GCPBackendPolicy Can Only Bind One Service And Two Of Mine Fought Over It, And Global Security Policy’s Default Rule Is Allow, Not Deny. Five Days Later, Adding An Internal Gateway, The Second Trap Bit Me Again — Verbatim. That’s When I Knew It Wasn’t Bad Luck, It Was A Rule.
Result First:
| Trigger | PRD Needed Certificate Manager Auto-Renewing Certs, Only Supported On Global GatewayClass |
| Traps Hit | ① Certificate Manager Annotation Syntax (GWCER105) ② GCPBackendPolicy Conflict (Silent Failure, No Error At Apply Time) ③ Global Security Policy Defaults To Allow |
| Repeat Confirmation | 5 Days Later, Adding An Internal Gateway, Trap ② Fired Again, Identical Symptom |
| Is This A GCP Design Flaw | Not Really — Regional And Global GatewayClass Map To Fundamentally Different Underlying Infrastructure, The Docs Just Don’t Spell Out The Cost Of Switching |
| Outcome | Global LB Migration Succeeded, PRD Cert Auto-Renewal Working |
Gateway API’s Three-Layer Model, Quick Primer
If You Haven’t Touched Gateway API Yet: It Splits Ingress’s “One Resource Rules Everything” Mess Into Three Layers — GatewayClass (Vendor Declares Who Implements It, Here That’s GKE), Gateway (Infra Team Owns The Entry Point, Protocol, TLS), HTTPRoute (Developers Own The Routing Rules). Every Trap In This Post Happened At The GatewayClass Layer — Same GKE Gateway Controller, But Regional And Global GatewayClass Map To Completely Different Underlying GCP Resources (Regional External LB vs. Global External LB). Syntax, Limits, Behavior — None Of It Carries Over. Switching Between Them Isn’t Tweaking A Parameter, It’s Swapping Out The Whole Underlying Mechanism.
Trap One: Certificate Manager Mounts Differently On Regional vs Global
Copied The Regional Syntax First, Got This Immediately:
Error GWCER105: invalid TLS option key "networking.gke.io/certificate-map"
The Two GatewayClasses Don’t Share A Certificate-Mounting Syntax At All:
| GatewayClass | Correct Syntax |
|---|---|
gke-l7-regional-external-managed |
listeners[].tls.options["networking.gke.io/pre-shared-certs"] (Regional Manages Its Own SSL Certificates, Doesn’t Support Certificate Manager) |
gke-l7-global-external-managed |
Not Under listeners.tls At All — Goes On The Gateway Object’s metadata.annotations["networking.gke.io/certmap"], And That Listener Must Have No tls Block Whatsoever |
It’s Not A Typo’d Key. It’s Two Entirely Different Mounting Mechanisms — One Lives At The Listener Level Under tls.options, The Other Lives At The Gateway Metadata Level As An Annotation. Different Locations Entirely. Copy The Regional Syntax Onto Global And It’s Guaranteed To Blow Up.
Trap Two: GCPBackendPolicy Only Binds One Service — And Fails Silently
This Is The Nastiest Of The Three Because It Doesn’t Error Out At kubectl apply Or helm upgrade Time:
kubectl get gcpbackendpolicy <name> -n <namespace> -o jsonpath='{.status}'
# conflicted with GCPBackendPolicy "xxx" of higher precedence, hence not applied
The Reason: A K8s Service — Regardless Of Whether It Backs A Regional Or Global Backend Service — Can Only Be Bound By One GCPBackendPolicy. If Both A Regional And A Global Gateway’s HTTPRoute Point At The Same Service (My Case: Each Side Needed A Different Cloud Armor Policy Attached), Whichever Got Created First Wins, And The Second One Just Silently Fails — No Error At Apply Time, You Have To Go Check status.conditions To Even See It. My Fix At The Time Was Going With “Only One Gateway Serves This Service At A Time” — Fully Tearing Down The Regional Gateway’s Resources So Global Could Claim The GCPBackendPolicy Binding. The Other Option Is Giving Each Gateway Its Own Dedicated Service (Same Pod Selector, Two ClusterIP Services).
Five Days Later, The Repeat: Adding An Internal Gateway (For Private Connectivity), I Hit The Exact Same Trap — PRD’s Main Service Had A Global Policy Attached, Which Couldn’t Bind To The Regional Backend Service The Internal Gateway Generated, Same SetSecurityPolicy ... does not exist Error. This Time I Just Applied What I’d Already Learned: Give The Internal Gateway Its Own Dedicated Service, Don’t Reuse The Main One. Actually Getting Bit A Second Time Was A Good Thing — It Proved “One Service, One GCPBackendPolicy” Wasn’t A One-Off Quirk Of This Particular Migration. It’s A Checklist Item For Every New Gateway You Ever Add.
Trap Three: Global Security Policy Defaults To Allow, Not Deny
gcloud compute security-policies describe <policy-name> --format=yaml
# priority 2147483647 "default rule" defaults to action: allow — your allowlist does nothing
Regional Policy (google_compute_region_security_policy) Doesn’t Have This Problem, But The Moment You Create A Global Policy (google_compute_security_policy), GCP Auto-Generates A Priority 2147483647 allow Default Rule — And That Priority Collides With Whatever Deny-All Rule You Meant To Create By Hand (Cannot have rules with the same priorities). The Right Fix Isn’t Deleting And Recreating — It’s terraform import-ing The Auto-Generated Rule Into State, Then Using Terraform To Flip The Action To Deny:
terraform import google_compute_security_policy_rule.<resource_name> \
"projects/<project>/global/securityPolicies/<policy-name>/priority/2147483647"
terraform plan # Should Show An In-Place Update: action: allow -> deny(403)
terraform apply
Notes
GWCER105Isn’t A Typo’d Key, It’s Two Entirely Different Cert-Mounting Mechanisms. Regional Uses Listener-Leveltls.options, Global Uses A Gateway-Metadata-Level Annotation — Different Locations Entirely. Copy One Onto The Other, It Breaks.- GCPBackendPolicy Conflicts Are The Nastiest Of The Three Because They Don’t Error At Apply Time. You Only See “Conflicted With … Of Higher Precedence, Hence Not Applied” By Checking
status.conditions— First Created Wins, Second One Fails Silently. - Import The Global Security Policy’s Auto-Generated Allow Rule Into Terraform State, Don’t Delete And Recreate It. The Priority Is A Fixed Value (
2147483647) That Collides With Any Hand-Built Deny-All Rule. - Global LB Provisioning Takes Noticeably Longer Than Regional. Regional Usually Programs In 1-3 Minutes, Google’s Docs Say Global Can Take 10-15 (Config Has To Propagate To Global GFE Nodes), And Even After
Programmed: True, TLS Taking Full Effect At The Edge Can Take A Few More Minutes — IfcurlThrowsSSL_ERROR_SYSCALLButopenssl s_clientTests Fine, It’s Usually Just Still Propagating. Wait It Out. - This Same Migration Also Hit A Helm Field Manager Conflict, Which I Pushed Through With
--server-side --force-conflictsAnd Ended Up Wiping A Namespace As A Result — That’s A Completely Different Mechanism (Helm 3’s Release Tracking/Delete Logic), Covered In A Separate Post, Not Repeated Here.
The Rule: Check Service Sharing Before Adding Any New Gateway
The Real Value Of This Post Isn’t The Three Traps Individually — It’s That Trap Two Got Confirmed Twice. Hit It Once, You’d Chalk It Up To “Just This Migration’s Bad Luck.” Hit It Again Five Days Later Adding An Internal Gateway, And Now It’s Confirmed: This Is A Checklist Item For Every New Gateway You Ever Add, Not A One-Time Fluke. External Or Internal, Regional Or Global — Before Adding A New Gateway, Check Whether It’ll Share A Service With An Existing One. If It Does, Pick One Of Trap Two’s Two Fixes (“Only One Gateway Serves This Service At A Time” Or “Give Each Gateway Its Own Service”) Upfront, Don’t Wait For The Silent Failure To Send You Digging.
Real-World Application
| Scenario | What To Do |
|---|---|
| Any Environment Running GKE Gateway API (Not Just Regional/Global Mixed) | Before Adding Any New Gateway (External/Internal, Regional/Global), Check Whether It’ll Share A Service With An Existing Gateway. If So, Pick One Of Trap Two’s Two Fixes Upfront — Don’t Find Out Via status.conditions After The Fact |
| Teams Setting Up Auto-Renewing Certificates | Confirm Upfront That Certificate Manager Only Supports Global GatewayClass — Regional Is Stuck With Manually-Managed Pre-Shared Certs. This Constraint Decides Whether You’re Migrating At All, It’s Not A Choice Of Syntax |
| Environments Managing Cloud Armor / Security Policy Via Terraform | When Creating A Global Policy, Always Check Whether The Auto-Generated Allow Default Rule Got Imported Into State — Don’t Assume It Behaves Like A Regional Policy |
| Any GCP Resource Migration (Not Just Gateway API) | The Difference Between “I Just Happened To Hit This” And “This Is A Rule” Usually Only Becomes Clear On A Second, Independent Occurrence — When Something Looks Like A Coincidence, Write It Down Instead Of Closing The Ticket, So You Recognize It Instantly The Next Time |
Reference:
- Secure a Gateway | GKE
- GatewayClass capabilities | GKE
- GCPBackendPolicy | GKE Gateway API
- Real Incident: Internal Service, PRD Regional→Global Migration; Internal Gateway Repeat Confirmation