<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ChungWei Wei</title>
    <description>The latest articles on DEV Community by ChungWei Wei (@gordonwei).</description>
    <link>https://dev.to/gordonwei</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1198722%2Fd47a1095-72e1-4751-a0b7-342b4cf40045.jpg</url>
      <title>DEV Community: ChungWei Wei</title>
      <link>https://dev.to/gordonwei</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gordonwei"/>
    <language>en</language>
    <item>
      <title>[AI] LM Studio Couldn't Run This Model, So I Got It Running On My Own Mac</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sun, 27 Sep 2026 17:02:25 +0000</pubDate>
      <link>https://dev.to/gordonwei/ai-lm-studio-couldnt-run-this-model-so-i-got-it-running-on-my-own-mac-20k0</link>
      <guid>https://dev.to/gordonwei/ai-lm-studio-couldnt-run-this-model-so-i-got-it-running-on-my-own-mac-20k0</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Been Looking At New Models Lately, And Came Across Muse Glimmer, A 30B Model That’s Supposed To Be Really Good For Personal Use. Wanted To See If It Could Replace Gemma-4 As My Log-Summarizer Backend, Plus A Similar Role On My Personal Assistant. LM Studio Was Already Running On This Machine, So I Just Tried Loading It There. It Failed Immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No module named 'mlx_vlm.speculative.drafters.muse_glimmer'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Actually Checking, LM Studio’s Bundled MLX Runtime Was Already The Latest On Both Stable And Beta Channels. There Was No Update Coming To Fix This. The Model Was Just Too New For What LM Studio Was Shipping.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Wanted To Test Whether A New 30B MLX Model Could Replace Two Existing Local Models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root Cause&lt;/td&gt;
&lt;td&gt;LM Studio’s Bundled MLX Runtime Doesn’t Support The New Architecture Yet, No Update Available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Raw &lt;code&gt;mlx_vlm&lt;/code&gt; Pip Package’s In-Process API, Wrapped In A Small OpenAI-Compatible Shim Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;Model Now Serves Both Consumers Through The Same &lt;code&gt;/v1/chat/completions&lt;/code&gt; Contract They Already Spoke To LM Studio With&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Runtime Gap Turned Out To Be The Easy Part. The Real Surprise Was Finding Out MLX Itself Can’t Run Anywhere Except On The Machine In Front Of Me.&lt;/p&gt;

&lt;h1&gt;
  
  
  How It Broke (Root Cause)
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The Runtime Is Frozen, The Model Isn’t
&lt;/h2&gt;

&lt;p&gt;LM Studio Ships Its Own MLX Runtime As A Versioned Package (&lt;code&gt;mlx-llm-mac-arm64-apple-metal-advsimd&lt;/code&gt;, Currently &lt;code&gt;1.11.0&lt;/code&gt;). New Model Architectures On Hugging Face Show Up Faster Than LM Studio Can Ship Runtime Updates For Them. Muse Glimmer Uses A Speculative-Decoding Module Layout The Bundled Runtime’s Loader Simply Doesn’t Know About. Not A Config Problem, Not A Quantization Mismatch, Just Code That Isn’t There Yet.&lt;/p&gt;

&lt;p&gt;I Confirmed This Wasn’t Something I Could Fix By Updating LM Studio: Checked &lt;code&gt;lms runtime ls&lt;/code&gt; Against Both The Stable And Beta Channels, Same Version On Both. Nothing To Update To.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wanting To Centralize It Ran Into A Harder Wall
&lt;/h2&gt;

&lt;p&gt;My First Instinct Was To Run This As A Service On My Home Kubernetes Cluster (&lt;code&gt;rke2-home&lt;/code&gt;) So It’d Be Managed The Same Way As Everything Else. Checked The Node List:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rke-m01 / rke-n01–n05 all Ubuntu 20.04 x86 Linux

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Apple Silicon Anywhere. MLX Is An Apple Silicon + Metal Framework. It Doesn’t Run On Linux At All, And Critically, It Doesn’t Run Inside A Docker Container Even On A Mac, Because Docker Desktop For Mac Runs A Linux VM Underneath With No Metal GPU Passthrough. This Isn’t A Performance Tradeoff You Can Configure Around. If You Want MLX, It Runs As A Native macOS Process On Apple Silicon, Full Stop.&lt;/p&gt;

&lt;p&gt;That Ruled Out The Tidy Kubernetes Answer. The Model Had To Live On The Mac, Which Meant The “Service” Part Had To Come From macOS’s Own Tools.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Fix
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Skip The Runtime, Use The Library Directly
&lt;/h2&gt;

&lt;p&gt;The Raw &lt;code&gt;mlx_vlm&lt;/code&gt; Pip Package (&lt;code&gt;0.7.1&lt;/code&gt;) Supports Architectures The Bundled LM Studio Runtime Doesn’t. It’s The Same Underlying Library LM Studio Uses, Just Not Frozen At An Old Version. Its In-Process API Is Exactly What I Needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mlx_vlm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mlx-community/Muse-Glimmer-30B-4bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# load once
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;load()&lt;/code&gt; Only Needs To Run Once. Everything After That Is A &lt;code&gt;generate()&lt;/code&gt; Call Against An Already-Resident Model, Which Is The Whole Trick For Turning This Into A Server Instead Of A CLI That Reloads 19GB Of Weights Every Invocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap It So Nothing Downstream Has To Change
&lt;/h2&gt;

&lt;p&gt;Both Consumers (A Go Service And A Node-Based Personal Assistant) Already Spoke To LM Studio Through The Same Contract: POST &lt;code&gt;/v1/chat/completions&lt;/code&gt;, OpenAI Request/Response Shape. So The Shim Just Had To Speak That Same Contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat_completions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;to_mlx_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;# OpenAI shape -&amp;gt; mlx_vlm's chat history shape
&lt;/span&gt;    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;anyio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither Client Needed A Single Line Changed. They Just Point At A Different Port.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make It Actually Stay Up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;launchd&lt;/code&gt; Handles The “This Needs To Survive A Crash Or A Reboot” Part macOS-Natively. No Docker, No Extra Layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plist"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;k&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;y&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="l"&gt;KeepAlive&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;/k&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;y&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;tru&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;k&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;y&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="l"&gt;RunAtLoad&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;/k&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;y&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;tru&lt;/span&gt;&lt;span class="mh"&gt;e&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Killed The Process With &lt;code&gt;kill -9&lt;/code&gt; To Check This Actually Works Instead Of Trusting The Plist. New PID Came Up Within Seconds, Model Reloaded, &lt;code&gt;/health&lt;/code&gt; Went Green Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Serialization Detail That Matters
&lt;/h2&gt;

&lt;p&gt;MLX Generation Isn’t Safe To Call Concurrently Against The Same Model State. A Single &lt;code&gt;threading.Lock&lt;/code&gt; Around The &lt;code&gt;generate()&lt;/code&gt; Call Keeps Requests Queued Instead Of Corrupting Each Other. That’s Fine For A Single-User Local Shim, But It Would Need Real Batching For Anything With More Than One Caller At A Time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Week Later: Staying Up Isn’t The Same As Staying Fast
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;KeepAlive&lt;/code&gt; Solved The Crash Case. It Does Nothing For The Case Where The Process Is Alive But Barely Moving.&lt;/p&gt;

&lt;p&gt;A Week After Setup, I Noticed Every Alert Summary Had Quietly Moved To The Cloud Fallback. From The Outside The Shim Looked Fine: &lt;code&gt;/v1/models&lt;/code&gt; Answered In Under 0.1 Seconds, So The Health Probe Passed Every Time. The Log Told A Different Story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-22 16:13:40 INFO generated 1053 tokens in 162.3s (14.6 tok/s), prompt_tokens=573
2026-09-25 17:25:35 INFO generated 145 tokens in 10867.7s (6.3 tok/s), prompt_tokens=23484
2026-09-27 05:19:04 INFO generated 966 tokens in 6000.9s (0.8 tok/s), prompt_tokens=387

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same Model, Same Machine, 10 To 20 Times Slower. One Request Took 100 Minutes, And Earlier A Single 23K-Token Prompt Held The Generation Lock For Three Hours, So Anything That Arrived Behind It Had To Wait Too.&lt;/p&gt;

&lt;p&gt;The Most Likely Cause Is Memory Pressure. This Is A 32GB Mac Holding A Roughly 19GB Model Next To Everything Else I Run, And Swap Was Sitting At 6.7GB Of 8GB. That’s As Far As I Can Prove It. What I Did Confirm Is That A Restart Brought It Straight Back To About 15 Tokens/Sec.&lt;/p&gt;

&lt;p&gt;And While Retesting, I Tripped Over My Own Note Below: Sent &lt;code&gt;max_tokens=80&lt;/code&gt;, Got An Empty Reply, And For A Minute Thought The Shim Was Broken. The Reasoning Had Simply Eaten The Whole Budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Runtime Version Being “Latest” Doesn’t Mean It Supports The Latest Models.&lt;/strong&gt; LM Studio’s Runtime And The Models You Can Download Through Hugging Face Move On Independent Schedules. When Loading A Brand-New Architecture Fails With A Python Import Error Rather Than A Normal LM Studio Error Dialog, That’s The Tell: It’s Not Rejecting The Model, It Genuinely Doesn’t Have The Code Path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MLX Only Exists On The Metal Side Of The Fence.&lt;/strong&gt; Not Slower On Linux, Not Emulatable, Not Containerizable On The Same Mac It’s Running On. Anyone Planning To Put An MLX Workload Behind Kubernetes Needs To Know This Before Drawing The Architecture Diagram, Not After.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The In-Process API Was Already There, Just Not Documented As “Build A Server With This.”&lt;/strong&gt; Every Example I Found Was CLI-Shaped (&lt;code&gt;python -m mlx_vlm.generate&lt;/code&gt;), Which Reloads The Model Every Call. The &lt;code&gt;load()&lt;/code&gt;/&lt;code&gt;generate()&lt;/code&gt; Split Underneath That CLI Is What Makes A Persistent Server Possible, And It’s One Import Away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First Quality Test Looked Bad For The Wrong Reason.&lt;/strong&gt; Ran It Once With &lt;code&gt;max_tokens=50&lt;/code&gt; And Greedy Decoding And Got A Leaked Internal Channel Token Plus A Truncated Reasoning Trace. That Wasn’t The Model. It’s A Reasoning Model, And 50 Tokens Isn’t Enough Room For It To Finish Thinking Before The Budget Runs Out. Bumping To &lt;code&gt;max_tokens=4096&lt;/code&gt; And &lt;code&gt;temperature=0.2&lt;/code&gt; With A Realistic Prompt Produced Clean, Correctly-Structured Output Every Time After.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Real End-To-End Test Is The One That Matters, Not The Curl Test.&lt;/strong&gt; Manually Curling The Shim Looked Perfect. Only Firing An Actual Alert Through The Full Production Pipeline (Log Query → Retrieval → This Shim → Escalation Logic) Confirmed It Behaved Correctly Under Real Conditions, Including Correctly Recognizing When It Had No Real Evidence To Work With And Asking To Escalate Instead Of Guessing. Generation Took 98 Seconds For 1,125 Tokens On This Machine (~14.5 Tokens/Sec For A 30B Model), Which Is The Kind Of Number You Only Get By Actually Running It Against Real Load, Not A Short Test Prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Health Check That Answers Isn’t A Model That Works.&lt;/strong&gt; &lt;code&gt;/v1/models&lt;/code&gt; Proves The Process Is Alive, Not That It Can Generate At A Usable Speed. Tokens/Sec Per Request Is Already In The Log, And That’s The Number Worth Watching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A New Model Fails To Load In LM Studio With A Python Import Error&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;lms runtime ls&lt;/code&gt; Against Both Channels First. If It’s Already Latest, The Runtime Genuinely Doesn’t Support The Architecture Yet, Waiting Won’t Fix It&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You Need An MLX Model Reachable By Multiple Services&lt;/td&gt;
&lt;td&gt;Wrap It In A Minimal OpenAI-Compatible Shim With &lt;code&gt;load()&lt;/code&gt; Once + &lt;code&gt;generate()&lt;/code&gt; Per Request, Not A CLI Invocation Per Call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You’re Tempted To Put MLX Behind Kubernetes Or Docker&lt;/td&gt;
&lt;td&gt;Don’t. It’s Apple Silicon + Metal Native Only, No Container Path Exists, Plan For A &lt;code&gt;launchd&lt;/code&gt;-Managed Native Process Instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You’re Judging Model Quality From A Short Test&lt;/td&gt;
&lt;td&gt;Use A Realistic Prompt And Enough &lt;code&gt;max_tokens&lt;/code&gt; For The Model To Actually Finish. A Truncated Reasoning Model’s Output Tells You Nothing About Its Real Quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Local Model Endpoint Passes Health Checks But Downstream Keeps Falling Back&lt;/td&gt;
&lt;td&gt;Look At Per-Request Tokens/Sec In The Shim Log, Not The Health Endpoint. Cap Prompt Size So One Huge Request Can’t Hold The Lock For Hours, And Watch Swap On The Host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Blaizzy/mlx-vlm" rel="noopener noreferrer"&gt;mlx_vlm (GitHub: Blaizzy/mlx-vlm)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/docs/cli" rel="noopener noreferrer"&gt;LM Studio CLI (&lt;code&gt;lms&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mlx</category>
      <category>macos</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>[GCP]GCP Weekly Digest Report</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:00:18 +0000</pubDate>
      <link>https://dev.to/gordonwei/gcpgcp-weekly-digest-report-441c</link>
      <guid>https://dev.to/gordonwei/gcpgcp-weekly-digest-report-441c</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Had A Working GCP Weekly Digest Running On Google Apps Script For Weeks — Pulls Release Notes And Blog RSS Every Friday, Hands Them To Gemini To Write A Categorized Digest, Emails It Out, Archives A Copy To Google Drive. The Original Plan Was To Leave It Alone Until It Hit A Real Limit. Then A Different Reason To Migrate Showed Up: Wanted To Write This Up Next To The &lt;a href="https://dev.to/gordonwei/awsaws-weekly-digest-report-4o57-temp-slug-3945801"&gt;AWS Version&lt;/a&gt; As An “AWS vs GCP Serverless Digest Bot” Comparison, And Apps Script’s &lt;code&gt;GmailApp&lt;/code&gt;/&lt;code&gt;DriveApp&lt;/code&gt;/&lt;code&gt;ScriptApp.getOAuthToken()&lt;/code&gt; Assume Every Reader Already Lives Inside A Google Account — Rebuilt It On Cloud Run Instead, Skipping The Original Migration Threshold On Purpose.&lt;/p&gt;

&lt;p&gt;That Rebuild Alone Found Three Real Surprises And One Near-Miss. Then, With The Digest Itself Stable, Added The Other Half Of The Idea: An Opt-In Section That Points Google Cloud’s Own Recommender API At This Account And Has Gemini Turn The Findings Into Actual Advice — Deliberately Not Porting The AWS Sibling Project’s Hand-Written Detection Logic, Because GCP Already Ships A Better Version Of That Detection For Free. Getting It From “Written Against The Docs” To “Actually Tested” Surfaced Three More Problems, One Of Them The Kind That Looks Exactly Like Success Until You Know What To Check. And Finally, When SendGrid — The Email Provider This Whole Pipeline Depended On — Permanently Banned The Account, Replacing It With Gmail API Domain-Wide Delegation Found Two Last Blockers Neither Of Which Was A Bug In Any Line Of Code.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Cloud Run Job (Python 3.12) + Gemini 3.1 Flash-Lite (digest) + Cloud Recommender → Gemini (account advice) + Gmail API With Domain-Wide Delegation (delivery), Deployed From Source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Surprise 1&lt;/td&gt;
&lt;td&gt;The SDK The Original Plan Assumed (&lt;code&gt;google-cloud-aiplatform&lt;/code&gt;’s generative modules) Was Already Fully Retired — Not Deprecated-Soon, Actually Gone Two Months Before This Deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Surprise 2&lt;/td&gt;
&lt;td&gt;The Model The V1 Version Used (&lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;) Retires In Under Two Months — Copying It Forward Would’ve Broken A “Runs Every Friday” Job By Mid-October&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Surprise 3&lt;/td&gt;
&lt;td&gt;The GCP Blog RSS URL The Old Version Used Doesn’t Serve RSS Anymore — It 200s, But With An HTML Page, Not XML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Near-Miss&lt;/td&gt;
&lt;td&gt;Almost Ran A Local Gemini Auth Test Using Cached Credentials That Turned Out To Belong To A Completely Different Google Account And Project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advice Bug 1&lt;/td&gt;
&lt;td&gt;The Recommender API’s location wildcard &lt;code&gt;"-"&lt;/code&gt; doesn’t work — a clean &lt;code&gt;400 InvalidArgument&lt;/code&gt;, not a permissions problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advice Bug 2&lt;/td&gt;
&lt;td&gt;The first real verification run had every one of 42 (recommender × location) combinations fail with 403 (unset quota project + a disabled API) — and the script still reported “fetch succeeded, 0 results,” because a permission failure and a genuinely empty result look identical unless you specifically catch them differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advice Bug 3&lt;/td&gt;
&lt;td&gt;The default location list was missing the one zone holding this project’s only VM and only disk — silently zero compute-related advice, ever, for that entire category&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail Blocker 1&lt;/td&gt;
&lt;td&gt;SendGrid permanently banned the account; org policy &lt;code&gt;constraints/iam.disableServiceAccountKeyCreation&lt;/code&gt; then refused every attempt to download a Service Account key for the Gmail API replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail Blocker 2&lt;/td&gt;
&lt;td&gt;With IAM, code, and Workspace delegation all correct, the first real send still came back 403 — &lt;code&gt;Gmail API has not been used in project ... or it is disabled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;All three pieces confirmed end-to-end on real infrastructure: digest generated and archived, account advice correctly flagging real findings, email delivered via Gmail API keyless delegation with a confirmed message ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None Of The First Three Surprises Were Bugs In The Old Apps Script Code — They Were The Ground Having Moved Under A Ten-Week-Old Plan. The Advice Bugs And The Mail Blockers Are A Different Shape Entirely: Neither Came From Watching Something Break In Production. Both Came From Deliberately Verifying Assumptions Before Trusting Them — Because On This Particular Project, At This Particular Scale, Most Of These Failure Modes Would Otherwise Have Looked Exactly Like Success.&lt;/p&gt;

&lt;h1&gt;
  
  
  How It Broke (Root Cause)
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Surprise 1: The SDK The Migration Plan Assumed No Longer Exists
&lt;/h2&gt;

&lt;p&gt;The Original V2 Sketch Assumed &lt;code&gt;google-cloud-aiplatform&lt;/code&gt;. Went To Actually Write The Call And Found:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;google-cloud-aiplatform&lt;/code&gt;’s generative AI modules (&lt;code&gt;vertexai.generative_models&lt;/code&gt;, etc.) were deprecated June 24, 2025, and fully removed June 24, 2026.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two Months In The Past By The Time This Got Built. The Officially Recommended Path Is A Different Package Entirely — &lt;code&gt;google-genai&lt;/code&gt; — Unifying Access To Both The Gemini Developer API And What Google Now Calls The “Gemini Enterprise Agent Platform” (Confirmed Independently Via &lt;code&gt;gcloud services list --enabled&lt;/code&gt;, Which Lists &lt;code&gt;aiplatform.googleapis.com&lt;/code&gt; Under The Title &lt;strong&gt;“Agent Platform API”&lt;/strong&gt; , Not “Vertex AI API”). Then A Second, Smaller Trap: Two Web Searches Both Insisted &lt;code&gt;vertexai=True&lt;/code&gt; “Does Not Appear Anywhere In The Document.” Installed The Package And Read It Directly Instead Of Trusting Either Search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;__init__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# (self, *, enterprise=None, vertexai=None, api_key=None, credentials=None,
# project=None, location=None, debug_config=None, http_options=None)
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Parameters Exist. &lt;code&gt;enterprise&lt;/code&gt; Is Current, &lt;code&gt;vertexai&lt;/code&gt; Is Documented As A Legacy Alias. The Search Wasn’t Lying — It Just Answered With The New Name And Left Out That The Old One Still Quietly Works. Went With &lt;code&gt;enterprise=True&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 2: The Model From The Old Version Retires In Under Two Months
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt; Is What The Apps Script Version Used, With Explicit Instructions To Keep Using It Unless There Was A Good Reason Not To. Checked Anyway:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Gemini 2.5 Flash-Lite shuts down October 16, 2026 (Gemini Developer API) / October 20, 2026 (Agent Platform API).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Seven Weeks Of Runway On A Job Meant To Run Indefinitely, Once A Week, Unattended. Switched To &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt;, Currently Google’s Own “Most Cost-Efficient Gemini Model” Description — A Better Match For This Job’s Actual Usage Pattern Anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 3: The Blog RSS Feed The Old Code Points At Just… Isn’t RSS Anymore
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{content_type}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'https://cloud.google.com/blog/rss/'&lt;/span&gt;
&lt;span class="c"&gt;# 200 text/html; charset=utf-8&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200&lt;/code&gt;, So Nothing In The Old Error Handling Would Have Flagged This — The Content Type Had Silently Swapped At Some Point After V1 Was Written. Found The Real Feed Instead Of Guessing At URL Variations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://cloudblog.withgoogle.com/rss/'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 500
&lt;span class="c"&gt;# &amp;lt;?xml version="1.0" encoding="utf-8"?&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;# &amp;lt;rss version="2.0" xmlns:atom="..." xmlns:content="..." xmlns:dc="..." xmlns:media="..."&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Feed Carries The Same Namespaced Tags (&lt;code&gt;dc:&lt;/code&gt;, &lt;code&gt;content:&lt;/code&gt;, &lt;code&gt;media:&lt;/code&gt;) That Broke The AWS Sibling Project’s Hand-Rolled Namespace-Stripping Regex. Didn’t Repeat That Bug. The Document Is Well-Formed XML With Its Namespaces Correctly Declared — &lt;code&gt;ElementTree&lt;/code&gt; Handles That Natively. The Only Reason The AWS Version Needed String Surgery Was That It Manually (And Incompletely) Stripped The Declarations First. Skipped That Step Entirely; The Whole Bug Class Never Had A Chance To Exist Here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Near-Miss: Testing Gemini Auth With The Wrong Google Account’s Cached Credentials
&lt;/h2&gt;

&lt;p&gt;The Machine Already Had Application Default Credentials Cached From Unrelated Work On A Different Project. Checked First Instead Of Assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth application-default print-access-token&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# "email": "the-other-account@company.example"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed Wrong Account. Skipped The Local Test, Validated The Real Production Auth Path Instead — Through The Cloud Run Job’s Own Dedicated Service Account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building The Advice Half: Detection Belongs To Google, Not To Me
&lt;/h2&gt;

&lt;p&gt;With The Digest Stable, Added An Opt-In Section: Point Google Cloud Recommender At This Project (Idle Static IPs, Idle Disks, Idle VMs, Machine-Type Rightsizing, Committed-Use Discounts, Near-Zero Project Utilization) And Have Gemini Turn The Findings Into Prioritized Advice. The AWS Sibling Project Had To Hand-Write String-Matching Detection Logic Against Cost Explorer Usage-Type Names, Because AWS Has No Free Equivalent (Trusted Advisor’s Cost Checks Need Business Support). GCP Ships That Detection For Free And More Completely Than Any Hand-Written Pattern List Would — So The Code Comment In &lt;code&gt;account_context.py&lt;/code&gt; States The Principle Directly: &lt;em&gt;detection belongs to whatever’s deterministic; the model only explains.&lt;/em&gt; On AWS, “Deterministic” Meant Hand-Written String Matching. On GCP, It’s Google’s Own API. Porting The AWS Approach Over Would Have Meant Rebuilding A Worse Version Of Something Google Already Gives Away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advice Bug 1: The Location Wildcard That Looks Documented But Isn’t
&lt;/h2&gt;

&lt;p&gt;Most Recommenders Are Scoped Per Zone Or Per Region, And The Official API Reference Doesn’t Say Whether A Wildcard Location Works. Wrote The Code To Try &lt;code&gt;-&lt;/code&gt; First And Fall Back To Enumerating Locations On Failure — Then Actually Tested It:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_recommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;projects/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PROJECT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/locations/-/recommenders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="n"&gt;InvalidArgument&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Invalid&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean Rejection, Not A Permissions Problem — So The Fallback Path Became The Only Path, And The Speculative Wildcard-First Branch Was Deleted Rather Than Left In As Dead Code Nobody Would Ever Trust Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advice Bug 2: 42 Silent 403s That Reported Themselves As Success
&lt;/h2&gt;

&lt;p&gt;First Real Verification Run Against The Actual Project. Every Single One Of 42 (Recommender × Location) Combinations Failed — An Unset Quota Project Plus A Not-Yet-Enabled Recommender API — And The Original Code Caught All Exceptions The Same Way, So The Result Was A Clean Empty List. The Script’s Own Log Line Read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[advice] 掃過 6 個 recommender × 9 個 location，42 個組合不適用，取得 0 筆建議

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;“0 Results” And “42 Silent Permission Failures” Produce The Exact Same Output Shape If Every Exception Gets Caught The Same Way. That’s The Trap: A Genuinely Empty Recommendation List Is A Real, Expected, Even &lt;em&gt;Good&lt;/em&gt; Outcome For A Personal Project — So The Code Cannot Simply Alarm On Empty. It Has To Tell The Difference Between “Nothing To Report” And “Never Actually Asked” Without A Human Reading Every Response. The Fix Splits Exceptions Into Two Classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_SKIPPABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gexc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidArgument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gexc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# this combination doesn't apply — expected
&lt;/span&gt;&lt;span class="n"&gt;_FATAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gexc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PermissionDenied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gexc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unauthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gexc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResourceExhausted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# environment is broken — must surface
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus A Last-Resort Guard: If &lt;em&gt;Every&lt;/em&gt; Combination Gets Skipped, That’s Not “No Recommendations” — It’s “Never Successfully Asked Once,” Almost Certainly A Misconfigured Location List, And It Now Raises Instead Of Returning An Empty List That Would Have Rendered As “No Open Recommendations This Week” — Indistinguishable From The Genuinely Good Outcome It’s Impersonating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advice Bug 3: The Location List That Silently Excluded The Only Resources That Existed
&lt;/h2&gt;

&lt;p&gt;Fixing Bug 2 Meant The Fetch Actually Succeeded — And Then Returned Zero Compute-Related Recommendations Anyway, For A Different Reason. The Default Location List Didn’t Include The One Zone Holding This Project’s Only VM And Only Disk. Every Compute-Related Recommender Query Landed On Locations With Nothing In Them — Technically Successful, Structurally Guaranteed To Find Nothing. &lt;strong&gt;A Location List Missing One Entry Produces Exactly The Same Output As A Project With No Idle Resources.&lt;/strong&gt; There’s No Error For “You Forgot A Zone” — Only The Absence Of Findings That Should Have Existed. Caught Only By Cross-Checking The List Against Where This Project’s Actual Resources Live, Not By Anything The API Itself Could Have Signaled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mail Blocker 1: SendGrid Banned The Account, Then The Org Wouldn’t Let Anyone Download A Key
&lt;/h2&gt;

&lt;p&gt;SendGrid Permanently Banned The Account — Two Delivery Deferrals At Exactly 72.01 Hours Each, Followed By A Reactivation Request Denied For Good. Since &lt;code&gt;kmp.tw&lt;/code&gt;’s MX Records Already Point At Google, Gmail API With Domain-Wide Delegation Looked Like The Natural Replacement. Tried The Standard Recipe First — Dedicated Mailer Service Account, Domain-Wide Delegation Enabled, Download The JSON Key, Mount Through Secret Manager. &lt;code&gt;gcloud iam service-accounts keys create&lt;/code&gt; Refused Outright:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“If the &lt;code&gt;iam.disableServiceAccountKeyCreation&lt;/code&gt; constraint is enforced for your organization, then you can’t create keys for any service accounts in your organization.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not This Service Account — Any Service Account, Org-Wide. The Keyless Alternative Uses The Cloud Run Job’s Own Runtime Identity, Granted &lt;code&gt;roles/iam.serviceAccountTokenCreator&lt;/code&gt; Scoped Only To The Mailer SA Resource (Not Project-Wide). At Send Time, The IAM Credentials API’s &lt;code&gt;signJwt&lt;/code&gt; Signs An RFC 7523 JWT-Bearer Assertion (Subject &lt;code&gt;wei@kmp.tw&lt;/code&gt; — This Is The Actual Delegation Step) Which Gets Exchanged At &lt;code&gt;oauth2.googleapis.com/token&lt;/code&gt; For A Genuine, Short-Lived Access Token. No Private Key Is Ever Generated, Downloaded, Or Written To Disk Anywhere In The Chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mail Blocker 2: 403 After Every Other Piece Was Already Right
&lt;/h2&gt;

&lt;p&gt;Once Workspace Admin Console Authorization Was Done, The Token Exchange — Which Had Been Failing With &lt;code&gt;401 unauthorized_client&lt;/code&gt; The Whole Time Delegation Wasn’t Authorized — Started Returning Real Tokens. The Very Next Call, &lt;code&gt;users.messages.send&lt;/code&gt;, Came Back 403:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gmail API has not been used in project 354846332057 before or it is disabled.
Enable it by visiting
https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=354846332057
then retry. If you enabled this API recently, wait a few minutes for the
action to propagate to our systems and retry.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Entire Identity Chain Was Already Working. The Only Thing Wrong Was That &lt;code&gt;gmail.googleapis.com&lt;/code&gt; Had Simply Never Been Enabled On This Project Before, Because Nothing In It Had Ever Called Gmail API Until This Line Of Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;gmail.googleapis.com &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;amp-project-161400

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reran The Job. Real Send, Real Log Line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Gmail] 已送出，message id：1a067facf2c866b7
Email 已寄出
完成！

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth Noting: The Project Identifier In That URL (&lt;code&gt;354846332057&lt;/code&gt;) Is The Project &lt;strong&gt;Number&lt;/strong&gt; , Not The Project &lt;strong&gt;ID&lt;/strong&gt; (&lt;code&gt;amp-project-161400&lt;/code&gt;) — Searching Logs For The Project ID String Won’t Find This Error At All.&lt;/p&gt;

&lt;h1&gt;
  
  
  Notes
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A rebrand can be real even when it sounds like a hallucination.&lt;/strong&gt; &lt;code&gt;aiplatform.googleapis.com&lt;/code&gt; Titled “Agent Platform API,” Vertex AI’s Own Docs Saying They’re “No Longer Being Updated” — Both Confirmed Independently Through &lt;code&gt;gcloud services list&lt;/code&gt;, Not Assumed From A Search Summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a web search and the actual installed package disagree, install the package.&lt;/strong&gt; Ten Seconds Of &lt;code&gt;inspect.signature()&lt;/code&gt; Settled What Two Searches Got Confidently Wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“It returns 200” is not the same as “it still works.”&lt;/strong&gt; True Of A Dead RSS Feed Silently Serving HTML, And True Of An API Call That Reports “Success” While Having Caught Every Failure The Same Way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scariest bugs in this project all share one shape: a genuinely good outcome and a broken one produce identical output.&lt;/strong&gt; An Empty Recommendation List Because There’s Nothing To Flag, And An Empty List Because Every Call 403’d — Both Print The Same Sentence Unless The Code Is Specifically Built To Tell Them Apart. A Missing Location In A Config List Produces The Same Silence As A Project With No Idle Resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain-wide delegation fails at two independent points that don’t look alike.&lt;/strong&gt; Missing Workspace Authorization Fails At Token Exchange With &lt;code&gt;401 unauthorized_client&lt;/code&gt;. An API Never Enabled On The GCP Project Fails Later, At The Actual Call, With &lt;code&gt;403 SERVICE_DISABLED&lt;/code&gt;. Fixed In Two Completely Different Consoles By Two Completely Different People.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Porting a sibling project’s workaround without asking why it existed there imports the wrong lesson.&lt;/strong&gt; The AWS Version Needed Hand-Written Detection Because AWS Has No Free Equivalent. Copying That Pattern To GCP Would Have Meant Reinventing A Worse Version Of Something Google Already Provides.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Migrating code that calls a managed AI SDK, based on a plan written more than a few weeks ago&lt;/td&gt;
&lt;td&gt;Re-check the SDK’s current deprecation status before writing a single line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A model ID was chosen previously with instructions to “keep using it unless there’s a reason not to”&lt;/td&gt;
&lt;td&gt;Check the model’s specific retirement date, not just whether it works today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An RSS/feed URL worked when a project doc was first written&lt;/td&gt;
&lt;td&gt;Re-verify with &lt;code&gt;curl -sL -o /dev/null -w "%{http_code} %{content_type}"&lt;/code&gt; before reusing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Porting a workaround from a sibling project on a different cloud&lt;/td&gt;
&lt;td&gt;Check whether the &lt;em&gt;reason&lt;/em&gt; it existed still applies — sometimes the platform already solved it natively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A machine has cached credentials for multiple accounts across unrelated projects&lt;/td&gt;
&lt;td&gt;Verify identity (&lt;code&gt;tokeninfo&lt;/code&gt;, etc.) before the first real API call in a new session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catching every exception from a bulk API scan the same way&lt;/td&gt;
&lt;td&gt;Split “this combination doesn’t apply” from “the environment is broken” — an empty result and a wall of silent permission failures must not look identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A config list enumerates locations/regions/zones to scan&lt;/td&gt;
&lt;td&gt;Cross-check it against where resources actually live — a missing entry produces silence, not an error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An org enforces &lt;code&gt;constraints/iam.disableServiceAccountKeyCreation&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Don’t request an exception — go keyless via IAM Credentials API &lt;code&gt;signJwt&lt;/code&gt; with a narrowly-scoped &lt;code&gt;serviceAccountTokenCreator&lt;/code&gt; grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Google API call fails with &lt;code&gt;SERVICE_DISABLED&lt;/code&gt; / “has not been used in project”&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;gcloud services list --enabled&lt;/code&gt; before assuming it’s another auth problem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/vertex-ai/generative-ai/docs/sdks/overview" rel="noopener noreferrer"&gt;Google Gen AI SDK overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/vertex-ai/generative-ai/docs/release-notes" rel="noopener noreferrer"&gt;Vertex AI / Gemini Enterprise Agent Platform release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/run/docs/create-jobs" rel="noopener noreferrer"&gt;Cloud Run: create and schedule jobs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt" rel="noopener noreferrer"&gt;IAM Credentials API — signJwt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/iam/docs/troubleshoot-org-policies" rel="noopener noreferrer"&gt;Troubleshoot organization policy constraints — disableServiceAccountKeyCreation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/a/answer/162106" rel="noopener noreferrer"&gt;Control API access with domain-wide delegation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send" rel="noopener noreferrer"&gt;Gmail API — users.messages.send&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/GordonWei/gcp-weekly-digest" rel="noopener noreferrer"&gt;GCP Digest Weekly Report Github URL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gcp</category>
      <category>gemini</category>
      <category>serverless</category>
      <category>gmail</category>
    </item>
    <item>
      <title>[K8s] I Watched A VPA Recommendation For A Month — It Never Moved, Because It Was Never Really A Recommendation</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sat, 12 Sep 2026 13:43:25 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8s-i-watched-a-vpa-recommendation-for-a-month-it-never-moved-because-it-was-never-really-a-73</link>
      <guid>https://dev.to/gordonwei/k8s-i-watched-a-vpa-recommendation-for-a-month-it-never-moved-because-it-was-never-really-a-73</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://dev.to/gordonwei/k8svertical-pod-autoscaler-isnt-a-cloud-feature-i-put-it-on-a-bare-rke2-cluster-and-its-first-4d23-temp-slug-5022537"&gt;Part 1&lt;/a&gt; Ended With Two Unresolved Questions: A 250Mi Memory Target For A Dashboard Container Actually Using Only 24Mi, Sitting Suspiciously Close To The 256Mi Limit Already On The Deployment, With No Citable Reason Why. And A CPU Number That Had Never Once Caught My Attention, The One I’d Waved Through Without Checking. The Plan Was To Let It Accumulate History And Come Back To See Which Way The Memory Target Went. A Month Later It Went A Third Way I Hadn’t Considered, The Answer Had Nothing To Do With The Limit, And It Closed Both Questions At Once.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Observation Window&lt;/td&gt;
&lt;td&gt;2026-08-06 To 2026-09-07, One VPA Object In &lt;code&gt;updateMode: "Off"&lt;/code&gt;, Untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What I Expected&lt;/td&gt;
&lt;td&gt;Target Converges Down Toward Real Usage, Or Stays Anchored Near The 256Mi Limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What Happened&lt;/td&gt;
&lt;td&gt;Target Never Moved. &lt;code&gt;lowerBound&lt;/code&gt;, &lt;code&gt;upperBound&lt;/code&gt; And &lt;code&gt;uncappedTarget&lt;/code&gt; All Converged Onto It&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual Root Cause&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--pod-recommendation-min-memory-mb&lt;/code&gt;, Default &lt;code&gt;250&lt;/code&gt;. The Target Was The Floor, Not A Calculation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The 256Mi Limit Resemblance&lt;/td&gt;
&lt;td&gt;Coincidence. The Floor Is 250MiB Regardless Of What The Container’s Limit Is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The CPU Number Part 1 Left Open&lt;/td&gt;
&lt;td&gt;Same Story. &lt;code&gt;--pod-recommendation-min-cpu-millicores&lt;/code&gt; Defaults To &lt;code&gt;25&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h1&gt;
  
  
  What A Month Of History Actually Changed
&lt;/h1&gt;

&lt;p&gt;Same Object, Same &lt;code&gt;updateMode: "Off"&lt;/code&gt;, Nothing Touched In Between:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Day 0 (2026-08-06)&lt;/th&gt;
&lt;th&gt;Day 32 (2026-09-07)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Actual Usage&lt;/td&gt;
&lt;td&gt;2m / 24Mi&lt;/td&gt;
&lt;td&gt;1m / 26Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25m / 250Mi&lt;/td&gt;
&lt;td&gt;25m / 250Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lowerBound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25m / 250Mi&lt;/td&gt;
&lt;td&gt;25m / 250Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;upperBound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;640m / 2013Mi&lt;/td&gt;
&lt;td&gt;25m / 250Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Only Number That Moved Is The Upper Bound, And It Moved A Long Way. That Part Matches What Part 1 Predicted: The Confidence Multiplier Inflates The Upper Bound When There’s Barely Any History Behind It, And Decays Toward 1 As Real Observation Accumulates. A Month In, It’s Done Decaying.&lt;/p&gt;

&lt;p&gt;What Part 1 Got Wrong Was Assuming The Target Was A Calculation That Would Also Move Once Confidence Resolved. It Wasn’t, So It Didn’t.&lt;/p&gt;

&lt;p&gt;The Tell Is In The Full Status, Which I Should Have Read More Carefully The First Time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;lastTransitionTime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-08-06T03:51:58Z"&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;True"&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RecommendationProvided&lt;/span&gt;
  &lt;span class="na"&gt;recommendation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;containerRecommendations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp&lt;/span&gt;
      &lt;span class="na"&gt;lowerBound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;25m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;250Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;25m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;250Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;uncappedTarget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;25m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;250Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;upperBound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;25m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;250Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four Fields. One Value. A 50th Percentile Estimate, A 90th Percentile Estimate And A 95th Percentile Estimate Do Not Naturally Land On The Same Number For A Workload With Real Variance In It. When They Do, You’re Not Looking At Three Estimates. You’re Looking At Something Clamping All Three To The Same Place.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Answer To Part 1’s Open Question
&lt;/h1&gt;

&lt;p&gt;The Recommender On This Cluster Runs With No Resource-Related Flags At All:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy &lt;span class="nt"&gt;-n&lt;/span&gt; vpa-system vpa-vertical-pod-autoscaler-recommender &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.template.spec.containers[0].args}'&lt;/span&gt;
&lt;span class="c"&gt;# ["--v=4","--stderrthreshold=info","--leader-elect=true", ...leader election only...]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which Means Every Recommendation It Produces Is Shaped By Defaults. Two Of Those Defaults Are The Whole Story:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Help Text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--pod-recommendation-min-cpu-millicores&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;“Minimum CPU Recommendation For A Pod”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--pod-recommendation-min-memory-mb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;250&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;“Minimum Memory Recommendation For A Pod”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Flag Is Named &lt;code&gt;mb&lt;/code&gt;, But The Recommender Multiplies It By &lt;code&gt;1024*1024&lt;/code&gt;, Not &lt;code&gt;1000*1000&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;minMemory&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScaleResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MemoryAmountFromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minMemoryMb&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;fraction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;250 × 1024 × 1024 Is Exactly 250Mi. Not “Approximately”, Not “Close To”. The Number VPA Reported Is The Default Floor Rendered Back Verbatim.&lt;/p&gt;

&lt;p&gt;So The Actual Chain For This Container Is: Take The 90th Percentile Of Peak Memory Per 24h Window Over 8 Windows (Roughly 26Mi Here), Add The 15% Safety Margin (&lt;code&gt;--recommendation-margin-fraction&lt;/code&gt;, Default &lt;code&gt;0.15&lt;/code&gt;, Getting Us To Around 30Mi), Then Raise It To The Minimum Because 30Mi Is Below 250Mi. Every Estimator In The Chain Gets The Same Floor Applied, Which Is Why The Lower And Upper Bounds Sit There Too Once Their Confidence Multipliers Stop Distorting Them.&lt;/p&gt;

&lt;p&gt;And The Part I Spent Real Time Being Suspicious About In Part 1, The 250Mi Target Sitting Six Mebibytes Under The Container’s 256Mi Limit, Is A Coincidence. The Floor Is 250MiB Whether The Container’s Limit Is 256Mi, 4Gi, Or Absent Entirely. Two Numbers Landed Near Each Other And I Read A Relationship Into It That Isn’t There.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;target == lowerBound == upperBound&lt;/code&gt; Is A Signal, Not A Coincidence.&lt;/strong&gt; It Means Every Estimate In The Chain Fell Below The Configured Minimum And Got Clamped. The Recommendation Is Telling You About VPA’s Defaults, Not About Your Workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Month Of History Made The Recommendation More Confident Without Making It More Useful.&lt;/strong&gt; Confidence And Usefulness Are Different Axes. The Bounds Collapsing Onto The Target Looks Like Convergence, And It Is, But It Converged Onto A Constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Floor Is Per-Pod And Adjustable.&lt;/strong&gt; If You Want VPA To Say Something Meaningful About Workloads Smaller Than 250Mi, Those Two Flags Are Where To Look. Worth Knowing Before Concluding VPA “Doesn’t Work” On Small Containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  The Number I Waved Through In Part 1
&lt;/h1&gt;

&lt;p&gt;Part 1 Closed With An Admission: I Had Gone Digging Into The Memory Recommendation And Never Checked The CPU One, For No Better Reason Than That &lt;code&gt;25m&lt;/code&gt; Looked Harmless Next To 2m Of Actual Usage. Here Is What Was Sitting Behind It.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;25m&lt;/code&gt; Is &lt;code&gt;--pod-recommendation-min-cpu-millicores&lt;/code&gt;, Default &lt;code&gt;25&lt;/code&gt;. The Same Floor, Doing The Same Thing To The Same Kind Of Small Workload. &lt;strong&gt;Both Halves Of That First Recommendation Were Defaults. Neither Was A Measurement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Sorting Mechanism Is The Part Worth Keeping. Both Targets Were About 10x Actual Usage. One Of Them Looked Alarming, So It Got A Source Dive. The Other Looked Plausible, So It Got A Sentence Of Explanation And A Pass. &lt;strong&gt;Plausibility Is Not Verification, And A Number That Matches What You Expected Is Precisely The One Nobody Checks.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check Whether A Recommendation Sits Exactly On A Documented Default Before Explaining Why It’s Reasonable.&lt;/strong&gt; It Takes One Lookup, And “Reasonable-Looking” Is Precisely The Condition Under Which Nobody Bothers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Real-World Application
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reading Any VPA Recommendation For A Small Container&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Compare The Numbers Against &lt;code&gt;25m&lt;/code&gt; And &lt;code&gt;250Mi&lt;/code&gt; First. If They Match Exactly, You’re Reading Floors And The Recommendation Carries No Information About That Workload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All Three Bounds Report The Same Value&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not A Converged, High-Confidence Answer. It Means Every Estimate Landed Below The Minimum And Got Clamped To It&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Running VPA Across A Cluster Of Mostly Small Workloads&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Applying Recommendations Wholesale Will Inflate Requests Toward 250Mi Each. Either Lower &lt;code&gt;--pod-recommendation-min-memory-mb&lt;/code&gt; Or Treat Small Workloads As Out Of Scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Evaluating Whether VPA Has “Learned” Your Workload Yet&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Watch The Upper Bound, Not The Target. The Upper Bound Is Where The Confidence Multiplier Lives, So It’s The Field That Actually Reflects Accumulated History&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inheriting A VPA Install Someone Else Set Up&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read The Recommender’s Args Before Interpreting Any Output. Every Number You’re Looking At Is Shaped By Flags That Are Invisible In The VPA Object Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/gordonwei/k8svertical-pod-autoscaler-isnt-a-cloud-feature-i-put-it-on-a-bare-rke2-cluster-and-its-first-4d23-temp-slug-5022537"&gt;Part 1: VPA Isn’t A Cloud Feature — I Put It On A Bare RKE2 Cluster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/autoscaler/blob/vertical-pod-autoscaler-1.7.1/vertical-pod-autoscaler/pkg/recommender/config/config.go" rel="noopener noreferrer"&gt;VPA Recommender Config Flags (v1.7.1 source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/autoscaler/blob/vertical-pod-autoscaler-1.7.1/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go" rel="noopener noreferrer"&gt;VPA Recommender Logic (v1.7.1 source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Setup: Home RKE2 Cluster, VPA 1.7.1, Observed 2026-08-06 → 2026-09-07&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>vpa</category>
      <category>autoscaling</category>
      <category>rke</category>
    </item>
    <item>
      <title>[K8s]Vertical Pod Autoscaler Isn't A Cloud Feature — I Put It On A Bare RKE2 Cluster And Its First Answer Was 10x Reality</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Mon, 07 Sep 2026 17:09:14 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8svertical-pod-autoscaler-isnt-a-cloud-feature-i-put-it-on-a-bare-rke2-cluster-and-its-first-1mjd</link>
      <guid>https://dev.to/gordonwei/k8svertical-pod-autoscaler-isnt-a-cloud-feature-i-put-it-on-a-bare-rke2-cluster-and-its-first-1mjd</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Had Already Set Up VPA On A GKE Workload (A ClickHouse Backend, After It OOM’d Twice) And Assumed — Wrongly — That Vertical Pod Autoscaler Was Something GKE Provided As A Managed Feature. Turns Out The “Enable” Button On GKE Is Just Google’s Wrapper Around The Same Open-Source &lt;code&gt;kubernetes/autoscaler&lt;/code&gt; Project Anyone Can Install Anywhere. Wanted To Confirm That For Real, So I Put It On A 6-Node Homelab RKE2 Cluster With No Cloud Provider In Sight.&lt;/p&gt;

&lt;p&gt;It Went In Clean. What Wasn’t Clean Was The First Recommendation It Handed Back For A Live Workload — Ten Times The Container’s Actual Memory Usage, Within Minutes Of Being Installed.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Goal&lt;/td&gt;
&lt;td&gt;Confirm VPA Works On Vanilla Self-Managed Kubernetes, Not Just Cloud-Managed Clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prerequisite Check&lt;/td&gt;
&lt;td&gt;Metrics Server — Already There. RKE2 Ships &lt;code&gt;rke2-metrics-server&lt;/code&gt; As A Default Addon, Unlike Vanilla &lt;code&gt;kubeadm&lt;/code&gt; Clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install Method&lt;/td&gt;
&lt;td&gt;Official In-Repo Helm Chart, Not The &lt;code&gt;vpa-up.sh&lt;/code&gt; Shell Script — Skips Hand-Rolling Webhook Certs With &lt;code&gt;openssl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Footprint&lt;/td&gt;
&lt;td&gt;6 Pods (2x Recommender / Updater / Admission-Controller), ~8m CPU + ~59Mi Memory Combined — A Rounding Error On 2-vCPU Nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Surprise&lt;/td&gt;
&lt;td&gt;Pointed It At A Real, Low-Stakes Workload (Headlamp). Actual Usage: 24Mi. VPA’s First Target: &lt;strong&gt;250Mi&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turns Out The Gap Isn’t A Bug — It’s What VPA Looks Like When It’s Being Deliberately, Structurally Cautious With A Number It Doesn’t Have Enough Data To Trust Yet. Which Is Its Own Kind Of Interesting, Once You Go Looking For Why.&lt;/p&gt;

&lt;h1&gt;
  
  
  Confirming VPA Is Cloud-Agnostic
&lt;/h1&gt;

&lt;p&gt;The Claim To Verify: GKE’s &lt;code&gt;--enable-vertical-pod-autoscaling&lt;/code&gt; Flag Isn’t A GKE-Native Feature — It’s A Managed Install Of The Same &lt;code&gt;kubernetes/autoscaler&lt;/code&gt; Sub-Project (&lt;code&gt;vertical-pod-autoscaler&lt;/code&gt;) That Ships On GitHub For Anyone To Run.&lt;/p&gt;

&lt;p&gt;Two Prerequisites, Checked On The RKE2 Side Before Assuming Anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl get pods -n kube-system | grep metric
# rke2-metrics-server-56b57b8dfb-ggnb9 1/1 Running

kubectl get apiservices | grep metrics
# v1beta1.metrics.k8s.io kube-system/rke2-metrics-server True

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metrics Server Was Already Running — RKE2 Bundles It By Default (Vanilla &lt;code&gt;kubeadm&lt;/code&gt; Doesn’t, And That’s Usually The First Blocker People Hit Installing VPA On A DIY Cluster). One Fewer Thing To Set Up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Install Paths, Picked The Less Manual One
&lt;/h2&gt;

&lt;p&gt;The Official Repo Offers Both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;hack/vpa-up.sh&lt;/code&gt; — Clones The Whole Monorepo, Shells Out To &lt;code&gt;openssl&lt;/code&gt; To Generate The Admission Webhook’s TLS Cert By Hand, Applies Raw Manifests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charts/vertical-pod-autoscaler&lt;/code&gt; — An Actual Helm Chart Living In The Same Repo, Where Cert Generation Is A Helm Hook Job Instead Of A Shell Script Step You Run Yourself
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/kubernetes/autoscaler.git
helm &lt;span class="nb"&gt;install &lt;/span&gt;vpa autoscaler/vertical-pod-autoscaler/charts/vertical-pod-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; vpa-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six Pods Come Up: Recommender x2, Updater x2, Admission-Controller x2 (Chart Default Replica Count, For HA). Combined Real-World Footprint After A Few Minutes Settled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME CPU(cores) MEMORY(bytes)
vpa-...-admission-controller-cx99z 1m 10Mi
vpa-...-admission-controller-pjjlm 1m 10Mi
vpa-...-recommender-qsng2 2m 6Mi
vpa-...-recommender-zzgdv 1m 14Mi
vpa-...-updater-hn74p 1m 13Mi
vpa-...-updater-kww9b 2m 6Mi

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;~8m CPU, ~59Mi Memory, Total, Across All Six. On Nodes With 2 vCPUs Each, That’s Nothing. Whatever Hesitation I Had About Homelab Node Headroom Before Installing Turned Out To Be Unfounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Installing VPA Does Nothing By Itself.&lt;/strong&gt; The Components Sit Idle Until You Create An Actual &lt;code&gt;VerticalPodAutoscaler&lt;/code&gt; Object Pointing At A Workload. No Object, No Recommendation, No Resizing — Just Six Quiet Pods.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  The Part I Didn’t Expect: The First Real Recommendation
&lt;/h1&gt;

&lt;p&gt;Picked Headlamp (A Kubernetes Dashboard Deployment Already Running In The Cluster) As A Deliberately Low-Stakes Target — It Collects A Lot Of Cluster State Continuously, So It Seemed Like A Reasonable Thing To Watch. Attached A VPA Object In &lt;code&gt;updateMode: "Off"&lt;/code&gt; — Recommendation-Only, Never Touches The Running Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;autoscaling.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VerticalPodAutoscaler&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp-vpa&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp&lt;/span&gt;
  &lt;span class="na"&gt;updatePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;updateMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Off"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checked Actual Usage Right Before Applying It:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME CPU(cores) MEMORY(bytes)
headlamp-fb548559f-zs8h2 2m 24Mi

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configured Resources On The Deployment At The Time: &lt;code&gt;requests: 50m/64Mi&lt;/code&gt;, &lt;code&gt;limits: 250m/256Mi&lt;/code&gt;. Already Comfortably Over-Provisioned Relative To Real Usage, By Eye.&lt;/p&gt;

&lt;p&gt;Under A Minute After The VPA Object Landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;status:&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;recommendation:&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;containerRecommendations:&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;containerName:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;headlamp&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;lowerBound:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cpu:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="err"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;memory:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="err"&gt;Mi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;target:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cpu:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="err"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;memory:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="err"&gt;Mi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;upperBound:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cpu:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="err"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;memory:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2013Mi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Targets Land At Roughly 10x Actual Usage: 25m Against 2m Of CPU, 250Mi Against 24Mi Of Memory. Only One Of Them Bothered Me. &lt;strong&gt;250Mi&lt;/strong&gt; , Sitting Six Mebibytes Under The Container’s Existing 256Mi Limit, Looked Like Something That Needed Explaining, And An Upper Bound North Of 2Gi For A Dashboard App Idling At 24Mi Made It Look Worse. &lt;code&gt;25m&lt;/code&gt; Looked Harmless, And Was Even Lower Than The Container’s Current 50m Request, So I Read It As A Sane Safety Margin And Moved On.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Noting That Deliberately, Because It Matters Later: I Went Digging Into The Memory Number And Never Checked The CPU One, For No Better Reason Than That It Looked Reasonable.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Memory Recommendations Are Built To Run High
&lt;/h2&gt;

&lt;p&gt;Went Digging Into The Recommender’s Own Source Rather Than Guess:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory Targets Are Computed From The 90th Percentile Of &lt;em&gt;Peak&lt;/em&gt; Usage Per Aggregation Window (Default 8 Days), Not Average Usage.&lt;/strong&gt; CPU Is Compressible — Throttling A Container That Briefly Needs More CPU Just Slows It Down. Memory Isn’t — Exceed The Limit And The Kernel OOM-Kills The Container. The Recommender’s Math Is Deliberately Asymmetric For This Reason: It Would Rather Overestimate Memory Than Cause An OOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With Very Little Observation History, The Confidence-Interval Math Pulls The Bounds Apart On Purpose, Never Toward A Smaller Number.&lt;/strong&gt; The Lower Bound’s Confidence Multiplier Collapses Toward The Target Itself When History Is Thin (Effectively: “Don’t Suggest Shrinking Something You Haven’t Watched Long Enough To Trust”). The Upper Bound’s Multiplier Does The Opposite — Balloons Toward An Enormous Number When History Is Thin, Which Is Exactly The ~2Gi Ceiling Showing Up Here. Both Directions Of “I’m Not Sure Yet” Get Resolved The Same Way: Toward Not Under-Provisioning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What That Doesn’t Fully Explain, And What I’m Deliberately Not Papering Over: &lt;em&gt;Why The Target Itself Landed At Almost Exactly 250Mi&lt;/em&gt; — Suspiciously Close To The Container’s Existing 256Mi Limit — Rather Than Somewhere Nearer The 24Mi It’s Actually Using Right Now. Didn’t Find A Clean, Citable Answer To That One In The Time I Spent On It. Possible It’s An Artifact Of How The Very First Recommendation Gets Computed Before A Full Aggregation Window Has Elapsed; Possible It’s Something Else Entirely. Leaving It Genuinely Open Rather Than Inventing A Tidy Explanation That Sounds Right But Isn’t Verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Confident-Looking Number Isn’t The Same As A Confident Number.&lt;/strong&gt; VPA Handed Back A Specific-Looking &lt;code&gt;250Mi&lt;/code&gt;, Not A Vague Range — But It Had Observed The Workload For Under A Minute. The Precision Of The Output Format Doesn’t Tell You Anything About How Much To Trust It Yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory And CPU Are Computed By Deliberately Different Maths.&lt;/strong&gt; Memory Targets A High Percentile Of Peak Usage Specifically To Avoid OOMs; CPU Is Comparatively Relaxed Because Contention Only Throttles. Worth Knowing As Background — Though It Does Not, On Its Own, Explain The Specific Numbers Above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Number You Don’t Question Is The One To Watch.&lt;/strong&gt; Both Targets Here Are About 10x Actual Usage, But Only The Memory One Looked Alarming Enough To Investigate. That Asymmetry Is In The Reader, Not In The Data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  What Happens Next
&lt;/h1&gt;

&lt;p&gt;This Is Deliberately Posted Before The Story’s Finished. The Whole Point Of Confidence-Weighted Recommendations Is That They’re Supposed To Get Better With More History — VPA’s Own &lt;code&gt;LowConfidence&lt;/code&gt; Condition Exists Specifically To Flag When A Recommendation Shouldn’t Be Trusted Yet. Letting This Run Against Real (Light) Traffic, Then Coming Back To See Whether The Memory Target Actually Converges Down Toward The 24Mi It’s Really Using, Or Whether It Stays Anchored Near The Configured Limit Regardless Of Real Usage. And While I’m There, Checking The CPU Number I Waved Through. Part 2 Covers Both.&lt;/p&gt;

&lt;h1&gt;
  
  
  Real-World Application
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deciding Whether VPA Is Worth Installing Outside A Cloud-Managed Cluster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It’s Fully Open-Source And Cloud-Agnostic — The Only Real Prerequisite Is Metrics Server. Check For It First; Some Distros (RKE2) Bundle It, Some Don’t&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Installing VPA On Any Self-Managed Cluster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Use The In-Repo Helm Chart Over &lt;code&gt;vpa-up.sh&lt;/code&gt; Unless You Have A Specific Reason Not To — Same Components, Less Manual Cert Handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reading A Brand-New VPA Recommendation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check The &lt;code&gt;RecommendationProvided&lt;/code&gt; / &lt;code&gt;LowConfidence&lt;/code&gt; Conditions Before Acting On Any Number — A Recommendation Minutes Old Carries Very Different Weight Than One With Days Of History Behind It&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A Recommendation That Looks Wrong Next To One That Looks Fine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check Both. Memory And CPU Are Computed By Different Maths (Peak Percentile Versus A More Relaxed CPU Path), But That Doesn’t Mean The Reasonable-Looking Number Has Been Verified — It Means It Hasn’t Been Questioned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/autoscaler/tree/master/vertical-pod-autoscaler" rel="noopener noreferrer"&gt;kubernetes/autoscaler — Vertical Pod Autoscaler&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/autoscaler/blob/master/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go" rel="noopener noreferrer"&gt;VPA Recommender Logic (source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Setup: Home RKE2 Cluster (6 Nodes), 2026-08-06 — Part 2 Pending Longer Observation Window&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>rke</category>
      <category>vpa</category>
      <category>autoscaling</category>
    </item>
    <item>
      <title>[AWS]AWS Weekly Digest Report</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Thu, 03 Sep 2026 17:45:22 +0000</pubDate>
      <link>https://dev.to/gordonwei/awsaws-weekly-digest-report-2d9k</link>
      <guid>https://dev.to/gordonwei/awsaws-weekly-digest-report-2d9k</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Had Been Watching A LinkedIn Account Post AWS Updates Every Week For A While — Always Just Updates, And Kept Wondering If There Was A More Human Way To Deliver Them. So, Riding The AI Wave, Built A Lambda + Bedrock + SES + S3 Pipeline: Every Friday It Pulls The AWS What’s New And Blog RSS Feeds, Hands Them To Amazon Nova Pro To Write A Categorized Digest, Emails It Out, Archives A Copy To S3 — And Also Has Claude Opus Look At This Account’s Actual Resource Usage And Suggest Optimizations.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sam build&lt;/code&gt; Passed. Template Validated. Everything Looked Done On Paper. First Real Deploy Still Found Two Bugs That Zero Amount Of Re-Reading The Code Would Have Surfaced, Because Both Only Show Up Once Real AWS Resources Are Talking To Each Other. The Account-Advice Feature Had A Longer Road: Two Prompt Versions That Produced Nothing Useful, A Model Swap That Revealed A Genuine Quality Gap Once The Prompt Was Actually Good, And Two More Bugs That Only Surfaced When Prepping To Open-Source The Project And Asking Whether Any Of It Would Hold Up On A Bigger Account.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Lambda (Python 3.12, arm64) + Amazon Bedrock (Nova Pro For The Digest Body, Claude Opus 5 For Account Advice, Both Via Cross-Region Inference) + SES + S3, Deployed Via SAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Deploy Blocker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CAPABILITY_IAM&lt;/code&gt; Wasn’t Enough — Template Declares An Explicit IAM &lt;code&gt;RoleName&lt;/code&gt;, Which Bumps The Requirement To &lt;code&gt;CAPABILITY_NAMED_IAM&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug 1&lt;/td&gt;
&lt;td&gt;IAM Policy For &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; Was Scoped To The Wrong ARN — Substituted The Cross-Region Inference Profile ID Straight Into The &lt;code&gt;foundation-model&lt;/code&gt; Resource, Which Isn’t How The Underlying Model ARN Is Actually Shaped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug 2&lt;/td&gt;
&lt;td&gt;RSS Parser Silently Returned Zero Blog Posts Every Single Run — A Namespace-Stripping Regex Only Half Did Its Job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two Failed Advice Prompts&lt;/td&gt;
&lt;td&gt;Service names alone produced five lines of pure filler; adding usage-type granularity gave real signal, but the model still missed the two most valuable findings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model Comparison&lt;/td&gt;
&lt;td&gt;Same already-good prompt, same data, only the model swapped — Nova Pro forced a “savings” framing on 4 of 5 findings despite an explicit instruction not to; Claude Opus 5 cross-referenced multiple signals into real findings and correctly stayed quiet where there was nothing to save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug 3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GetCostAndUsage&lt;/code&gt; caps at 1,000 groups per response with no pagination handling — a bigger account would silently lose entries with zero error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug 4&lt;/td&gt;
&lt;td&gt;The pattern-flagging step scanned only the already-truncated top-4-per-service display list — the smallest, most-signal-bearing usage types were exactly the ones most likely to fall outside it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How I Found Them&lt;/td&gt;
&lt;td&gt;The first two by actually invoking the deployed function and reading CloudWatch Logs line by line, not just checking &lt;code&gt;StatusCode: 200&lt;/code&gt;. The last two by asking “would this hold up on a bigger account?” before shipping the code to anyone else’s AWS account — not by watching anything break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;End-to-end run confirmed: 54 What’s New items + 3 blog posts parsed, Nova Pro digest generated, Opus 5 account advice generated (flag count on the same account went 7 → 14), saved to S3, emailed via SES&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither Of The First Two Bugs Broke &lt;code&gt;sam build&lt;/code&gt;. Neither Broke &lt;code&gt;sam validate&lt;/code&gt;. Both Only Existed The Moment Real IAM Policies Met Real Bedrock APIs And Real RSS Bytes. The Last Two Were Quieter Still — This Account Is Structurally Too Small To Ever Trigger AWS’s Own Pagination Cap, So Both Were Found By Asking, Not By Watching Something Fail.&lt;/p&gt;

&lt;h1&gt;
  
  
  How It Broke (Root Cause)
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Bug 1: The Cross-Region Inference Profile ARN Trap
&lt;/h2&gt;

&lt;p&gt;Nova Pro On Bedrock Is Set Up To Use Cross-Region Inference — You Call It With An Inference Profile ID Like &lt;code&gt;us.amazon.nova-pro-v1:0&lt;/code&gt; Instead Of A Plain Model ID, And Bedrock Load-Balances The Actual Request Across A Handful Of Regions Behind The Scenes. The IAM Policy I’d Written Assumed The Profile ID Was Also The Model’s Identity For Permission Purposes, So It Built The Resource ARN Like This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:${BedrockRegion}::foundation-model/${BedrockModelId}'&lt;/span&gt;
  &lt;span class="c1"&gt;# BedrockModelId = us.amazon.nova-pro-v1:0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First Real Invoke After Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccessDeniedException: User: arn:aws:sts::...:assumed-role/aws-weekly-digest-role.../aws-weekly-digest
is not authorized to perform: bedrock:InvokeModel on resource:
arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look Closely At That Resource — No &lt;code&gt;us.&lt;/code&gt; Prefix. When You Invoke Through A Cross-Region Inference Profile, IAM Doesn’t Just Check Permission On The Profile Itself. It Also Checks Permission On The Underlying Foundation Model That The Profile Actually Routes The Request To — And That Underlying Model’s ARN Never Carries The &lt;code&gt;us.&lt;/code&gt;/&lt;code&gt;eu.&lt;/code&gt;/&lt;code&gt;apac.&lt;/code&gt; Prefix. That Prefix Only Exists On The Profile ID, Not On The Real Model.&lt;/p&gt;

&lt;p&gt;Didn’t Want To Guess At Which Regions It Actually Routes To, So Asked The API Directly Instead Of Assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bedrock&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_inference_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inferenceProfileIdentifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;us.amazon.nova-pro-v1:0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;models&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;modelArn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;us&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;east&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;foundation&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;amazon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nova&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pro&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;us&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;west&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;foundation&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;amazon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nova&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pro&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;us&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;east&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;foundation&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;amazon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nova&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pro&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three Destination Regions, Not One. The Original Policy Only Granted &lt;code&gt;us-east-1&lt;/code&gt;, So Even Fixing The Model ID Without Fixing The Region Coverage Would Have Left An Intermittent Failure Waiting To Happen Depending On Which Region Bedrock Decided To Route To That Day. Fixed It By Explicitly Granting The Real Foundation-Model ARNs With A Region Wildcard, Plus The Inference-Profile ARN Separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:*::foundation-model/amazon.nova-pro-v1:0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:*::foundation-model/amazon.nova-lite-v1:0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:*::foundation-model/anthropic.claude-3-haiku-20240307-v1:0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:bedrock:${BedrockRegion}:${AWS::AccountId}:inference-profile/*'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redeployed, Reinvoked — Clean Pass Through Bedrock On The Second Try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: The RSS Parser That Silently Gave Up On Every Blog Post
&lt;/h2&gt;

&lt;p&gt;The AWS Blog RSS Feed Declares A Pile Of XML Namespaces On Its Root &lt;code&gt;&amp;lt;rss&amp;gt;&lt;/code&gt; Tag — &lt;code&gt;dc:&lt;/code&gt;, &lt;code&gt;content:&lt;/code&gt;, &lt;code&gt;slash:&lt;/code&gt;, &lt;code&gt;atom:&lt;/code&gt;, And So On. To Dodge Dealing With Namespace-Aware &lt;code&gt;ElementTree&lt;/code&gt; Parsing, The Original Code Just Regex-Stripped The &lt;code&gt;xmlns&lt;/code&gt; Declarations Off The Root Element Before Parsing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\s+xmlns(?::[a-z]+)?=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]*&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problem Is, Stripping The &lt;em&gt;Declarations&lt;/em&gt; Doesn’t Strip The &lt;em&gt;Prefixed Tags&lt;/em&gt; Still Sitting Inside Every &lt;code&gt;&amp;lt;item&amp;gt;&lt;/code&gt; — Things Like &lt;code&gt;&amp;lt;dc:creator&amp;gt;Jeff Barr&amp;lt;/dc:creator&amp;gt;&lt;/code&gt; Are Still There. Once The &lt;code&gt;xmlns:dc="..."&lt;/code&gt; Declaration That Would Have Told The Parser What &lt;code&gt;dc:&lt;/code&gt; Means Is Gone, &lt;code&gt;ElementTree&lt;/code&gt; Hits The First &lt;code&gt;&amp;lt;dc:...&amp;gt;&lt;/code&gt; Tag And Throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unbound prefix: line 5, column 1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Function Already Had A Try/Except Around The Whole Fetch, By Design — If The Blog Feed Is Down Or Malformed, The Digest Should Still Ship With Just The What’s New Section Instead Of Failing The Whole Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[Blog] 跳過：&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which Is Exactly What It Did. Every Single Invoke, Silently, From The First Line Of Code. &lt;code&gt;statusCode: 200&lt;/code&gt;, Email Sent, S3 File Written — Just With A Permanently Empty Blog Section, And No Symptom Visible Anywhere Except A CloudWatch Log Line You’d Only See If You Went Looking.&lt;/p&gt;

&lt;p&gt;Fix Wasn’t Complicated Once Found — Strip The Prefix Off The Tags Too, Not Just The Declarations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\s+xmlns(?::[a-z]+)?=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]*&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/?[a-zA-Z0-9]+:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;dc:creator&amp;gt;&lt;/code&gt; Becomes &lt;code&gt;&amp;lt;creator&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;/dc:creator&amp;gt;&lt;/code&gt; Becomes &lt;code&gt;&amp;lt;/creator&amp;gt;&lt;/code&gt;, And Since The Code Only Ever Reads &lt;code&gt;title&lt;/code&gt; / &lt;code&gt;link&lt;/code&gt; / &lt;code&gt;description&lt;/code&gt; / &lt;code&gt;pubDate&lt;/code&gt; Off Each Item Anyway, Renaming Tags It Never Touches Costs Nothing. Redeployed, Reinvoked — Blog Count Went From &lt;code&gt;0 篇&lt;/code&gt; To &lt;code&gt;3 篇&lt;/code&gt; In The Log Line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Failed Prompts Before Account Advice Was Worth Reading
&lt;/h2&gt;

&lt;p&gt;Once The Digest Itself Was Stable, Added An Opt-In Section: Feed This Account’s Actual Cost Explorer Usage To Bedrock, Append Optimization Advice To The Email. Version 1 Fed The Model Nothing But Service Names And Got Back Five Lines Of Pure Filler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[AWS WAF]&lt;/strong&gt;：現況推測：可能沒有設定 WAF 規則來保護應用程式。建議動作：設定 WAF 規則⋯⋯&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“Possibly Not Configured, Recommend Configuring” — True Of Any Account, Because The Model Had Nothing But A Name To Guess From. Adding A Second &lt;code&gt;GroupBy&lt;/code&gt; Dimension On &lt;code&gt;USAGE_TYPE&lt;/code&gt; Changed The Input Entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Amazon Virtual Private Cloud
    APS1-PublicIPv4:IdleAddress = 0.0892 ← the name says Idle
Amazon Elastic Container Service for Kubernetes
    APE2-AmazonEKS-Hours:extendedSupport = 0.479 ← paying extra to run an EOL version

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real Signal Now — But The Model Still Missed The Two Most Valuable Findings And Spent A Slot On A Low-Value CloudFront Check That Wasn’t Backed By Anything In The Data. Fix Moved Detection Out Of The Prompt Entirely: String-Match Known-Waste Usage-Type Patterns (&lt;code&gt;IdleAddress&lt;/code&gt;, &lt;code&gt;extendedSupport&lt;/code&gt;, &lt;code&gt;NatGateway-Hours&lt;/code&gt;) In Code, Flag Them Before The Model Ever Sees Them, Let The Model Only Explain What’s Already Flagged. Third Version Got All Five Right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nova Pro vs Claude Opus 5: Same Data, Two Different Levels Of Reasoning
&lt;/h2&gt;

&lt;p&gt;With The Prompt Already At Its Best Version, Swapping Only &lt;code&gt;BEDROCK_MODEL_ID&lt;/code&gt; From Nova Pro To Claude Opus 5 (&lt;code&gt;us.anthropic.claude-opus-5&lt;/code&gt;) Produced Two Different Kinds Of Output From The Same Data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nova Pro&lt;/strong&gt; : Four Of Five Findings Led With “Expected Effect: Cost Savings” — Directly Against What The Prompt Said — On An Account That Spent $0.0014 Over Ninety Days. Each Finding Explained In Isolation, No Cross-Referencing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Opus 5&lt;/strong&gt; : &lt;code&gt;2,160 = 24 × 90&lt;/code&gt; → Two Regions Each Running A DynamoDB Table Continuously For The Full Window. &lt;code&gt;extendedSupport&lt;/code&gt; Hours Exactly Equal To Total Runtime → The Cluster Launched On An Outdated Version From Day One, Not Aged Into One. Zero &lt;code&gt;BoxUsage&lt;/code&gt; In The Regions Holding The Flagged Snapshots → The Instances Are Long Gone, Only Backups Are Billing. And Correctly Stayed Quiet Once IPv4 Usage Matched The Load Balancer Count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost, Measured, Not Estimated&lt;/strong&gt; :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Per Run&lt;/th&gt;
&lt;th&gt;Per Year (52 Runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nova Pro&lt;/td&gt;
&lt;td&gt;$0.0035&lt;/td&gt;
&lt;td&gt;$0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;$0.164 (in 4,296 / out 5,702, thinking included)&lt;/td&gt;
&lt;td&gt;$8.53&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;47x More Expensive, $8.35 More A Year In Absolute Terms — Against The Cost Explorer API Call Itself Running $0.01/Week ($0.52/Year). At This Job’s Scale, Model Choice Was Never A Cost Problem, Only A Quality One. Production Switched To Opus 5 After This Comparison.&lt;/p&gt;

&lt;p&gt;Switching Wasn’t Free, Though. Two Things Broke On The First Real Call: The Existing Claude Branch Sent A &lt;code&gt;temperature&lt;/code&gt; Parameter On Every Call, And Opus 5 Has Dropped Sampling Parameters Entirely — A Flat 400. The Bare Model ID Also 400’d (“On-demand throughput isn’t supported”) Until Switched To The Same Inference-Profile Form Nova Already Used (&lt;code&gt;us.anthropic.claude-opus-5&lt;/code&gt;). Adaptive Thinking Pushed A Single Advice Call To 82.9 Seconds, Which — Stacked On Top Of The Digest’s Own ~118 Seconds — Meant The Lambda Timeout Had To Double From 300s To 600s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Bugs A $0.0014 Account Was Never Going To Trigger
&lt;/h2&gt;

&lt;p&gt;Prepping To Open-Source The Project Meant Asking A Question The Account Running It Couldn’t Answer On Its Own: What Happens At Actual Scale?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3 — No Pagination On Cost Explorer Results.&lt;/strong&gt; &lt;code&gt;GetCostAndUsage&lt;/code&gt; Caps At 1,000 Groups Per Response, The Original Code Only Ever Read The First Page. The Failure Is The Dangerous Kind: The List Comes Back Shorter, With Nothing Indicating Anything Was Cut. Fixed With A Straightforward Pagination Loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 4 — Flagging Ran Against The Already-Truncated Display List.&lt;/strong&gt; The Display Step Trims Each Service To Its Top 4 Usage Types To Keep The Prompt Readable — But The Flagging Function Was Iterating That Same Trimmed List, Meaning The Smallest, Most Valuable Signals Were Exactly The Ones Most Likely To Be Cut First (An Idle IP At Usage &lt;code&gt;0.089&lt;/code&gt; Sits Nowhere Near A Top-4-By-Cost Cutoff). Fixed By Scanning The Full List For Flags While Still Only Displaying The Top 4 Per Service. Flag Count On The Same Test Account Went From 7 To 14.&lt;/p&gt;

&lt;h1&gt;
  
  
  Notes
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A gracefully-degrading &lt;code&gt;except&lt;/code&gt; block is also a great place for a bug to live forever, invisibly.&lt;/strong&gt; The whole point of catching that exception was so one broken feed wouldn’t take down the whole digest — which worked exactly as designed. But “worked as designed” and “worked correctly” turned out to be two different things, and only reading the actual log line told them apart. &lt;code&gt;StatusCode: 200&lt;/code&gt; was never going to say that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM permission errors for Bedrock cross-region inference profiles don’t show up until the first real &lt;code&gt;InvokeModel&lt;/code&gt; call.&lt;/strong&gt; &lt;code&gt;sam validate&lt;/code&gt; doesn’t know what ARN a profile ID actually resolves to at runtime — that information only exists behind the &lt;code&gt;GetInferenceProfile&lt;/code&gt; API, so there’s no way to catch this at template-authoring time short of actually calling it and checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CAPABILITY_IAM&lt;/code&gt; vs &lt;code&gt;CAPABILITY_NAMED_IAM&lt;/code&gt; is a one-line fix but a fully opaque error the first time you hit it.&lt;/strong&gt; The moment a SAM template gives an &lt;code&gt;AWS::IAM::Role&lt;/code&gt; an explicit &lt;code&gt;RoleName&lt;/code&gt; (instead of letting CloudFormation generate one), the required capability silently changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model producing generic advice is a signal problem before it’s a model problem.&lt;/strong&gt; A cheap model fed good signal beats an expensive model fed a list of names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Following an explicit instruction turned out to separate the two models more than any factual error did.&lt;/strong&gt; The prompt said not to force a savings framing on this account. Nova ignored that on four of five findings; Opus followed it, and additionally did cross-referencing that the flagging logic alone never asked either model to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both scale-dependent bugs look identical to correct behavior until they don’t.&lt;/strong&gt; Same root cause as the RSS bug: success and silent-partial-failure produce identical-looking output. And neither was found by watching something break — both came from deliberately asking “would this hold up bigger?” before handing the code to someone else’s AWS account.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing an IAM policy for a Bedrock Cross-Region Inference Profile ID&lt;/td&gt;
&lt;td&gt;Don’t string-substitute the profile ID into a &lt;code&gt;foundation-model&lt;/code&gt; ARN. Call &lt;code&gt;GetInferenceProfile&lt;/code&gt; to see the real destination model ARNs and grant those explicitly (or wildcard the region on a specific model ID)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex-stripping XML namespaces to avoid namespace-aware parsing&lt;/td&gt;
&lt;td&gt;Strip the prefix off every tag occurrence too, not just the &lt;code&gt;xmlns&lt;/code&gt; declarations on the root — otherwise the declarations disappear but the prefixed tags they were describing don’t&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAM template declares an explicit &lt;code&gt;RoleName&lt;/code&gt; on an &lt;code&gt;AWS::IAM::Role&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;CAPABILITY_NAMED_IAM&lt;/code&gt;, not &lt;code&gt;CAPABILITY_IAM&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any code path with a silent &lt;code&gt;except: return []&lt;/code&gt; for graceful degradation&lt;/td&gt;
&lt;td&gt;After a “successful” deploy, still read the actual logs of a real invoke — don’t just trust &lt;code&gt;StatusCode: 200&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handing an LLM an account’s resource list and expecting useful advice&lt;/td&gt;
&lt;td&gt;Names alone produce advice that fits every account equally. Add usage-type or config-level granularity or the output degenerates to “consider configuring X” for anything on the list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calling any AWS list/describe API expected to scale with account size&lt;/td&gt;
&lt;td&gt;Check the documented page-size cap and implement pagination even if the test account never reaches it — a truncated-but-valid response produces no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Building a “top N for display” step on top of a detection step&lt;/td&gt;
&lt;td&gt;Run detection against the full, unfiltered list; truncate only at display time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switching to a newer Claude generation on Bedrock&lt;/td&gt;
&lt;td&gt;Confirm it still accepts &lt;code&gt;temperature&lt;/code&gt;, use an inference-profile model ID, and re-measure latency — adaptive thinking can multiply a single call’s runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html" rel="noopener noreferrer"&gt;Amazon Bedrock — Supported cross-Region inference profiles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/machine-learning/securing-amazon-bedrock-cross-region-inference-geographic-and-global/" rel="noopener noreferrer"&gt;Securing Amazon Bedrock cross-Region inference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html" rel="noopener noreferrer"&gt;AWS Cost Explorer — GetCostAndUsage API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html" rel="noopener noreferrer"&gt;Claude Opus 4.7 on Amazon Bedrock — model card&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/GordonWei/aws-weekly-digest" rel="noopener noreferrer"&gt;AWS Digest Weekly Report Github URL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>lambda</category>
      <category>serverless</category>
    </item>
    <item>
      <title>[K8S] The Datasource Fix That Didn't Take</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:21:55 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8s-the-datasource-fix-that-didnt-take-48n7</link>
      <guid>https://dev.to/gordonwei/k8s-the-datasource-fix-that-didnt-take-48n7</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Needed To Wire All Of Akamai’s Data Into An Existing Grafana On GKE. Split Into Two Dashboards: CDN Data Rode On A Plugin That Was Already There, And WAF/Bot Logs Went Through A Small Proxy I Wrote Myself, Because The Only Grafana Plugin That Speaks Akamai’s SIEM API Is A Paid Third-Party One. Didn’t Want To Pay For It. (Cheapskate.)&lt;/p&gt;

&lt;p&gt;Both pieces hit the same kind of bug: something that looks fixed, deploys clean, and still doesn’t work — with nothing useful in the logs to explain why.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Self-hosted Grafana on GKE. Akamai CDN traffic via the official plugin (unsigned, but already running), Akamai SIEM security events via a custom FastAPI proxy + the generic &lt;code&gt;yesoreyeram-infinity-datasource&lt;/code&gt; plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Plugin dashboard showed “No Data” even after the plugin loaded correctly. SIEM dashboard showed nothing at all, no error either&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Cause (Trap One)&lt;/td&gt;
&lt;td&gt;The plugin’s frontend called a legacy ID-based API route this Grafana version no longer serves — five separate call sites, not one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Cause (Trap Two + Three)&lt;/td&gt;
&lt;td&gt;The SIEM datasource’s &lt;code&gt;url&lt;/code&gt; was nested one level too deep, so requests went out with no host in them — and Grafana’s log had nothing to say about it, because the request never actually left. Fixing that in the YAML and redeploying changed &lt;em&gt;nothing&lt;/em&gt;, because Grafana’s file-based datasource provisioner only applies YAML to a datasource that doesn’t exist in its database yet. Change the file for one that’s already there, and it’s silently ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Patch all five call sites, not just the obvious one; move &lt;code&gt;url&lt;/code&gt; to the top level; and for an &lt;em&gt;already-provisioned&lt;/em&gt; datasource, call the API directly to force the update — editing the YAML alone doesn’t do it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these failed the same way: no crash, no error, just nothing happening, and the fix looking correct right up until it silently wasn’t.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap One: An Unsigned Plugin’s Frontend Was Still Calling A Route That Doesn’t Exist Anymore
&lt;/h1&gt;

&lt;p&gt;Akamai’s own plugin isn’t in Grafana’s signed catalog, so it has to load with &lt;code&gt;GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS&lt;/code&gt; set. That part’s expected. What wasn’t expected: the plugin loaded fine, registered fine, and every panel just said “No Data.”&lt;/p&gt;

&lt;p&gt;The frontend (&lt;code&gt;module.js&lt;/code&gt;) was still building requests against Grafana’s old datasource-ID resource route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/datasources/{id}/resources/...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Grafana version only serves the &lt;code&gt;uid&lt;/code&gt;-based route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/datasources/uid/{uid}/resources/...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Patched the obvious one, the datasource’s &lt;code&gt;getBackendDataSourceUrl&lt;/code&gt; helper, and redeployed. Still “No Data.” Turns out that helper wasn’t the only caller. The panel that actually fetches chart data calls the same pattern directly, twice, inline, in its own &lt;code&gt;query()&lt;/code&gt; method, completely separate from the helper function. Four more call sites total, found only by grepping the whole bundle for the route pattern instead of trusting that one fix would propagate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part that made this worse than a normal bug hunt&lt;/strong&gt; : fixing it and reloading the page didn’t show the fix either, at first. Grafana caches a plugin’s frontend assets keyed off the unchanged &lt;code&gt;version&lt;/code&gt; string in &lt;code&gt;plugin.json&lt;/code&gt;. Same version number, same cached JS, no matter how many times the container gets rebuilt underneath it. Had to stamp a fresh version string on every build just to be sure the browser wasn’t lying to me about what was actually running.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Two: A &lt;code&gt;url&lt;/code&gt; One Level Too Deep, And A Log That Had Nothing To Say About It
&lt;/h1&gt;

&lt;p&gt;The SIEM side is a small FastAPI proxy that signs requests with Akamai’s EdgeGrid auth and hands Grafana back plain JSON, read by the generic (and actually signed) Infinity datasource plugin. Wired the datasource’s provisioning YAML, deployed, opened the dashboard: empty panels, no error banner, nothing in Grafana’s logs.&lt;/p&gt;

&lt;p&gt;The mistake was one level of YAML nesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wrong — url nested inside jsonData&lt;/span&gt;
&lt;span class="na"&gt;jsonData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://akamai-siem-proxy.monitoring.svc/siem&lt;/span&gt;

&lt;span class="c1"&gt;# right — url belongs at the datasource's own top level&lt;/span&gt;
&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://akamai-siem-proxy.monitoring.svc/siem&lt;/span&gt;
&lt;span class="na"&gt;jsonData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;url&lt;/code&gt; in the wrong place, the top-level field Grafana actually reads stayed empty, and Infinity built its request as &lt;code&gt;https:///siem?...&lt;/code&gt; — a URL with no host at all. Grafana’s own logs had nothing about this because the request never left the pod. Only way to actually see the failure was to bypass the dashboard and call &lt;code&gt;POST /api/ds/query&lt;/code&gt; directly, simulating the exact panel query by hand, which finally returned the real error: &lt;code&gt;http: no Host in request URL&lt;/code&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Three: The Fix That Didn’t Take
&lt;/h1&gt;

&lt;p&gt;Moved &lt;code&gt;url&lt;/code&gt; to the top level, committed, let the GitOps pipeline sync, restarted Grafana. Opened the dashboard again. Still empty.&lt;/p&gt;

&lt;p&gt;Checked the datasource’s actual stored config via the API — &lt;code&gt;url&lt;/code&gt; was still blank, exactly as before the fix. Compared the restart’s logs against a Grafana pod that had just been created fresh: the fresh one logs a &lt;code&gt;logger=provisioning.datasources&lt;/code&gt; line while it applies the YAML. The restarted one didn’t log that line at all. It wasn’t re-reading the file. It wasn’t touching that datasource.&lt;/p&gt;

&lt;p&gt;Grafana’s file-based provisioner only writes a datasource’s config into its database the first time it sees that datasource. After that, the database row is authoritative, and the provisioner leaves it alone on every subsequent restart — even if the underlying YAML changed. There’s no flag for “please re-apply this,” and the docs don’t call this out anywhere obvious. The only way to actually change an already-provisioned datasource is to hit its API directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"admin:&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_PW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"http://localhost:3000/api/datasources/uid/&amp;lt;uid&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"...","type":"...","access":"proxy","uid":"...","url":"...","jsonData":{...}}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call updates the database row directly, and it survives the next restart. Editing the YAML and redeploying is now step one of a two-step process for this cluster, not the whole fix — and it’ll stay that way for any datasource that already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;url&lt;/code&gt; field that’s technically valid YAML in the wrong nesting level fails with no error, only silence.&lt;/strong&gt; Grafana had no reason to complain — from its side, the field it expected was simply absent, and the datasource plugin just built a broken request quietly. Simulating the exact panel query via &lt;code&gt;POST /api/ds/query&lt;/code&gt; surfaced the real error instantly; trusting the dashboard UI or the pod logs alone would not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A provisioner that only applies on first-create is a trap disguised as a convenience feature.&lt;/strong&gt; It’s designed to avoid clobbering changes made through the UI, which is reasonable — but it means “I fixed the config file” and “the running system reflects that fix” are two different claims for anything already provisioned, and nothing tells you they’ve diverged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unsigned plugin’s frontend deserves the same scrutiny as its backend.&lt;/strong&gt; The obvious fix (one helper function) looked complete. Grepping the whole bundle for every occurrence of the broken pattern found four more places doing the exact same thing, undocumented and un-abstracted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin asset caching keyed off a version string you control is easy to forget you control.&lt;/strong&gt; If a rebuilt container doesn’t seem to have picked up a frontend change, check whether the cache key (&lt;code&gt;plugin.json&lt;/code&gt;’s &lt;code&gt;version&lt;/code&gt;) actually changed before assuming the build failed.&lt;/li&gt;
&lt;li&gt;Once both pieces were actually working, a follow-on problem showed up that’s really the same theme again: a 12-hour SIEM query window can return 10,000+ events, and neither an unbounded table panel nor a naive “just get the count” query survive that volume without a real answer for how much data crosses the wire. Capping the table panel and adding a count-only mode that never materializes the full event list fixed it, and made the eventual fix look, in hindsight, exactly as inevitable as the query volume problem was invisible beforehand.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;An unsigned/custom Grafana plugin shows “No Data” after loading correctly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grep the entire frontend bundle for every API route pattern the backend actually serves, not just the one helper function that looks like the obvious entry point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A rebuilt plugin doesn’t seem to reflect a frontend change&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check whether &lt;code&gt;plugin.json&lt;/code&gt;’s &lt;code&gt;version&lt;/code&gt; actually changed — Grafana caches plugin assets keyed off that string, independent of what’s really on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A provisioned datasource/panel returns empty with nothing in any log&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bypass the dashboard and call &lt;code&gt;POST /api/ds/query&lt;/code&gt; directly with the exact query the panel sends — it surfaces backend errors the UI silently swallows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changing a datasource’s provisioning YAML for an already-existing datasource&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Don’t trust a restart to apply it. Confirm via &lt;code&gt;logger=provisioning.datasources&lt;/code&gt; in the pod’s boot log, or just call the datasource API directly (&lt;code&gt;PUT /api/datasources/uid/&amp;lt;uid&amp;gt;&lt;/code&gt;) to be sure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A dashboard panel might pull a large, unbounded result set&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Decide up front what each panel actually needs — a count doesn’t need the full payload — and enforce that at the data source, not just with a Grafana-side limit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources" rel="noopener noreferrer"&gt;Grafana — Provisioning Data Sources&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grafana.com/docs/grafana/latest/administration/plugin-management/#plugin-signatures" rel="noopener noreferrer"&gt;Grafana — Plugin Signature Verification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Incident: Self-hosted Grafana on GKE, Akamai CDN + SIEM integration, 2026-08&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>grafana</category>
      <category>gitops</category>
    </item>
    <item>
      <title>[K8S] The OIDC Fallback That Worked By Luck</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Mon, 17 Aug 2026 18:55:36 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8s-the-oidc-fallback-that-worked-by-luck-2npk</link>
      <guid>https://dev.to/gordonwei/k8s-the-oidc-fallback-that-worked-by-luck-2npk</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;After I got Keycloak wired up, logging into the dashboard started getting stuck. No error, no crash log. Just one of those situations where you feel like you’re missing something obvious and can’t see it. Today I tried again, and this time there was a clear error in the log: &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Kubernetes dashboard (Headlamp) doing OIDC login against Keycloak, OIDC client config wired through a Helm chart’s “external secret” mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Symptom (4 days earlier)&lt;/td&gt;
&lt;td&gt;Login just stalled after redirecting to the identity provider. No error, nothing actionable in the logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second Symptom (this time)&lt;/td&gt;
&lt;td&gt;Identity provider rejected the callback outright: &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Cause&lt;/td&gt;
&lt;td&gt;The chart only wires &lt;code&gt;client-id&lt;/code&gt;/&lt;code&gt;client-secret&lt;/code&gt;/&lt;code&gt;issuer-url&lt;/code&gt; unconditionally in external-secret mode. The callback-URL flag needs a &lt;em&gt;separate&lt;/em&gt;, non-secret values field that I’d never set. Without it, the backend falls back to building the callback URL from the &lt;em&gt;incoming request’s own scheme&lt;/em&gt;. Correct most of the time, wrong exactly when someone reaches the app over plain HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Set the callback URL explicitly as a plain (non-secret) values field instead of relying on the Secret alone, the same string is already public in the identity provider’s client config anyway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both symptoms trace back to the same root cause.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap One: A Chart’s OIDC Options Aren’t All Wired The Same Way
&lt;/h1&gt;

&lt;p&gt;The chart supports pointing OIDC config at an existing Secret instead of writing credentials into &lt;code&gt;values.yaml&lt;/code&gt; directly. Sane for anything that shouldn’t sit in git. Point it at a Secret with &lt;code&gt;OIDC_CLIENT_ID&lt;/code&gt;, &lt;code&gt;OIDC_CLIENT_SECRET&lt;/code&gt;, &lt;code&gt;OIDC_ISSUER_URL&lt;/code&gt;, and &lt;code&gt;OIDC_CALLBACK_URL&lt;/code&gt;, and the assumption is the app reads all four the same way.&lt;/p&gt;

&lt;p&gt;It doesn’t. Reading the chart’s actual deployment template (not just its &lt;code&gt;values.yaml&lt;/code&gt; comments) turned up the real logic: in external-secret mode, &lt;code&gt;client-id&lt;/code&gt;, &lt;code&gt;client-secret&lt;/code&gt;, and &lt;code&gt;issuer-url&lt;/code&gt; get wired into CLI flags &lt;strong&gt;unconditionally&lt;/strong&gt;. The callback URL only gets wired if a &lt;em&gt;separate&lt;/em&gt; values field (not anything in the Secret) is non-empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ne&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="nv"&gt;oidc.callbackURL&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ne&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="nv"&gt;callbackURL&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
- "-oidc-callback-url=$(OIDC_CALLBACK_URL)"
&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$oidc.callbackURL&lt;/code&gt; here is &lt;code&gt;config.oidc.callbackURL&lt;/code&gt; in values, a plain string I’d never set. Because the Secret already had the value and that seemed like the obvious place for it. The Secret key existed. The chart just never looked at it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix that should be step one, not step three&lt;/strong&gt; : for any chart that supports “point me at your own Secret” mode, read the actual template logic for which fields are wired unconditionally and which need an extra trigger. Don’t assume parallel-looking config fields all follow the same code path just because they’re documented next to each other.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Two: A Silent Fallback Is Worse Than A Hard Failure
&lt;/h1&gt;

&lt;p&gt;With that flag missing, the backend doesn’t error out. It falls back to constructing the callback URL from the request that’s currently hitting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;getOidcCallbackURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;HeadlampConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OidcCallbackURL&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OidcCallbackURL&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...falls back to r.Host + a scheme guessed from&lt;/span&gt;
    &lt;span class="c"&gt;// X-Forwarded-Proto, or TLS state, or a default&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This “usually correct” fallback is exactly why the first failure, four days earlier, produced no error: most requests arrived over HTTPS, the guessed scheme happened to match what the identity provider expected, and login silently worked &lt;em&gt;by accident&lt;/em&gt; almost every time. The one thing that actually broke it was the app being reachable over plain HTTP with no forced redirect to HTTPS, and that wasn’t something I’d have thought to check. Nothing about “OIDC login” pointed at “check whether this app forces HTTPS.”&lt;/p&gt;

&lt;p&gt;Reproducing it made the mechanism obvious once I tried both entry points on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# over HTTPS: the callback URL happens to match what the IdP expects&lt;/span&gt;
curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="s2"&gt;"https://headlamp.example.internal/oidc?cluster=main"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'redirect_uri=[^&amp;amp;"]+'&lt;/span&gt;
&lt;span class="c"&gt;# redirect_uri=https%3A%2F%2Fheadlamp.example.internal%2Foidc-callback&lt;/span&gt;

&lt;span class="c"&gt;# over plain HTTP: no forced redirect, so this request actually goes through as-is&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://headlamp.example.internal/oidc?cluster=main"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'redirect_uri=[^&amp;amp;"]+'&lt;/span&gt;
&lt;span class="c"&gt;# redirect_uri=http%3A%2F%2Fheadlamp.example.internal%2Foidc-callback ← doesn't match what's registered&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code path, same app. The result depends on which door you walked through: it passes testing whenever the tester happens to use the “normal” path, and only breaks for whoever doesn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A fallback that’s “usually right” is a worse bug than one that’s always wrong.&lt;/strong&gt; Always-wrong gets caught on the first test. Usually-right survives testing and shows up for someone else, later, as something that looks unrelated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config fields that live in the same block of a values file don’t necessarily follow the same code path.&lt;/strong&gt; If a chart supports pulling secrets from an external Secret, check the template for which keys are actually read unconditionally versus which need an extra explicit trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a bug is intermittent, ask what’s different about the environment between “works” and “doesn’t,”&lt;/strong&gt; not just what’s different in the app’s own logs. In this case the answer wasn’t in Headlamp or Keycloak at all. It was in whether the entry point enforced HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vague stuck-screen bug and a specific rejected-parameter error can be the same root cause,&lt;/strong&gt; just surfaced through two different paths. Don’t assume a clearer error message later means a new bug. Check whether it’s the old one finally showing its face.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Any app config that supports “point me at your own Secret” for OIDC/SAML/etc.&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read the actual template/source for which fields are wired unconditionally versus which need a separate non-secret trigger; don’t assume documented-together means wired-together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A fallback/default value that depends on request context (scheme, host, headers)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Treat it as a source of intermittent bugs by default; pin the value explicitly wherever it’s cheap to do so, especially values that aren’t actually secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A bug that “used to just not work” and now fails with a clear error&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check whether an upstream/downstream config changed in a way that makes the &lt;em&gt;same&lt;/em&gt; underlying defect surface differently; don’t treat every clearer symptom as a brand-new investigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Any service reachable over both HTTP and HTTPS with no forced redirect&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Test scheme-dependent behavior (OIDC callbacks, cookie flags, CORS) over &lt;em&gt;both&lt;/em&gt; entry points on purpose, not just the one you normally use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens" rel="noopener noreferrer"&gt;Kubernetes — Authenticating with OpenID Connect Tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.keycloak.org/server/configuration" rel="noopener noreferrer"&gt;Keycloak Server Administration Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Incident: Home RKE2 cluster, Headlamp OIDC login via Keycloak&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>oidc</category>
      <category>helm</category>
      <category>keycloak</category>
    </item>
    <item>
      <title>[GCP] GKE Internal Gatewa With Google Managed Cert Trap</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sun, 09 Aug 2026 13:32:54 +0000</pubDate>
      <link>https://dev.to/gordonwei/gcp-gke-internal-gatewa-with-google-managed-cert-trap-2l7i</link>
      <guid>https://dev.to/gordonwei/gcp-gke-internal-gatewa-with-google-managed-cert-trap-2l7i</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;A Recent Task Was To Convert An Existing External Environment On GCP Into An Internal One. The Whole Setup Runs On GKE With Gateway API Handling Load Balancing. The Assumption Going In Was That A Google-Managed Certificate Would Attach To The Gateway API Internal LB The Same Way It Already Worked On The Gateway API External LB. That Assumption Ran Into Trouble Immediately.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;td&gt;Attach An Existing Google-Managed Certificate Manager Cert To An Internal Regional Gateway, Same As Already Works On The External One&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual&lt;/td&gt;
&lt;td&gt;GKE’s Gateway Controller Silently Refuses — Treats The Certificate Reference As If It Doesn’t Exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root Cause&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gke-l7-rilb&lt;/code&gt; Does Not Support Google-Managed Certificates At All. Verified Against The Official GatewayClass Capabilities Table, Then Confirmed Live By Actually Trying It&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Twist&lt;/td&gt;
&lt;td&gt;The Underlying GCP Product — A Regional Internal Application Load Balancer — Has No Such Restriction. The Gap Is Specific To GKE’s Gateway API &lt;em&gt;Controller&lt;/em&gt;, Not The Infrastructure It’s Built On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Bypassed The Controller Entirely — Built The Regional Internal ALB Directly Via Raw Compute Engine Resources, Reusing The Existing Backend Service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turns Out “It Already Works On The External One” Was Never Good Evidence For Internal. Two GatewayClasses, Same API Surface, Same Cluster, Completely Different Certificate Support — And Nothing About The YAML You’d Write Tells You That Up Front.&lt;/p&gt;

&lt;h1&gt;
  
  
  Finding Out The Hard Way
&lt;/h1&gt;

&lt;p&gt;First Move Was The Obvious One — Add The Certificate Manager Annotation To A Gateway And See What Happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;networking.gke.io/certificate-manager-certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-managed-cert&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gke-l7-rilb&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied Cleanly. &lt;code&gt;Accepted: True&lt;/code&gt;. Then &lt;code&gt;Programmed&lt;/code&gt; Flipped To &lt;code&gt;False&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error GWCER106: Gateway "..." is invalid, err: no certificates specified for protocol "HTTPS"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read That Twice. The Annotation Is Right There. The Certificate Exists, Is Active, Has A Valid Expiry Date Months Out. GKE’s Own Error Message Is Reporting It As Though The Field Were Empty — Not “Certificate Invalid,” Not “Certificate Not Found,” Just &lt;em&gt;Not Specified&lt;/em&gt;. That Phrasing Is The Tell: The Controller Isn’t Rejecting The Certificate. It’s Not Recognizing The Annotation As Applicable To This GatewayClass At All, And Falling Back To Its Generic “You Didn’t Give Me A Cert” Path.&lt;/p&gt;

&lt;p&gt;Went To The Actual GatewayClass Capabilities Documentation Rather Than Assume From The Error Text Alone. The Table Is Explicit For Internal Regional (&lt;code&gt;gke-l7-rilb&lt;/code&gt;) — Three Supported Certificate Mechanisms Listed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes Secret-Based Certificates&lt;/li&gt;
&lt;li&gt;Self-Managed Compute Engine SSL Certificates&lt;/li&gt;
&lt;li&gt;Self-Managed Certificate Manager Certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google-Managed Certificates — Whether The Classic Compute Engine Kind Or The Certificate Manager Kind — Aren’t On That List For This GatewayClass. They Are For The External GatewayClasses (Both Regional And Global). That Asymmetry Is The Whole Bug: It’s Not That Internal Load Balancers Can’t Do Google-Managed Certs As A Category. It’s That &lt;em&gt;This Specific GatewayClass’s Controller Implementation&lt;/em&gt; Never Wired Up That Path.&lt;/p&gt;

&lt;h1&gt;
  
  
  Notes
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Working External Example Told Me Nothing Useful About Internal.&lt;/strong&gt; Same Cluster, Same Kind Of Certificate, Same General Idea Of “Attach A Cert To A Gateway” — And Zero Transferable Confidence, Because The Actual Support Matrix Is Keyed Per GatewayClass, Not Per Feature Category.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“No Certificates Specified” From A Config That Clearly Specifies One Is A Strong Signal The Field Is Being Ignored, Not Rejected.&lt;/strong&gt; Worth Distinguishing Early — It Changes Where You Go Looking For Answers (Capability Docs, Not Certificate Troubleshooting).&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  The Twist: The Underlying Load Balancer Doesn’t Have This Problem
&lt;/h1&gt;

&lt;p&gt;Before Accepting “Can’t Be Done,” Checked Whether The Restriction Was Really About The Load Balancer Product Or Just The Kubernetes-Level Abstraction Sitting On Top Of It. Google’s Own Certificate Manager Documentation Answered That Directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To deploy the regional Google-managed certificate to a regional external Application Load Balancer or &lt;strong&gt;regional internal Application Load Balancer&lt;/strong&gt; , attach it directly to the target proxy.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute target-https-proxies update PROXY_NAME &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;LOCATION &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-manager-certificates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;CERTIFICATE_NAME

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Caveat About Backend Type, No Mention Of GKE At All. The Regional Internal Application Load Balancer — As A GCP Product — Fully Supports Google-Managed Certificates On Its &lt;code&gt;target-https-proxy&lt;/code&gt;. GKE’s Gateway API Just Never Exposed That Capability Through Its Own CRD For This GatewayClass. Two Completely Separate Layers: The Infrastructure Underneath Is Capable; The Kubernetes-Native Interface On Top Of It Has A Gap.&lt;/p&gt;

&lt;p&gt;Confirmed It’s Not A Doc-Reading Error By Actually Building One — A Throwaway Regional Internal Backend Service, URL Map, Target HTTPS Proxy With That Exact Certificate Attached Via &lt;code&gt;--certificate-manager-certificates&lt;/code&gt;, Forwarding Rule. It Came Up Fine. TLS Terminated Correctly, Traffic Reached The Backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Kubernetes Abstraction’s Limitation Is Not Automatically The Underlying Cloud Product’s Limitation.&lt;/strong&gt; GKE Gateway, Terraform’s Google Provider, &lt;code&gt;gcloud&lt;/code&gt;, The Web Console — Each Is A Different Surface Over The Same Compute Engine Resources, And Each Can Lag The Others In Feature Coverage. When Something “Isn’t Supported,” Worth Asking &lt;em&gt;Which Layer&lt;/em&gt; Is Actually Making That Claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Fix Path That Worked Was Prefigured In The Error Message’s Vagueness.&lt;/strong&gt; “No Certificates Specified” Rather Than A Certificate-Specific Rejection Was The Clue That The Real Gap Was In What The Controller &lt;em&gt;Understood&lt;/em&gt;, Not In The Certificate Itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Working Around A Controller Gap, Not A Product Limitation
&lt;/h1&gt;

&lt;p&gt;Given The Existing Gateway’s IP Was Already Depended On By Another Internal Service With The Address Hardcoded — Not Something To Touch Casually, And Recreating The Gateway Object Offered No Guarantee Of Reclaiming The Same IP Afterward — Chose Not To Rebuild It At All. Instead, Added A Second, Independent Entry Point Built From Raw Compute Resources, Pointed At The Exact Same Backend Service The Existing Gateway Already Used, With A Brand New Reserved Internal Address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute addresses create new-internal-addr &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REGION &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SUBNET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SHARED_LOADBALANCER_VIP

gcloud compute url-maps create new-internal-urlmap &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REGION &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--default-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EXISTING_BACKEND_SERVICE

gcloud compute target-https-proxies create new-internal-proxy &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REGION &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url-map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new-internal-urlmap &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-manager-certificates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;CERTIFICATE_NAME

gcloud compute forwarding-rules create new-internal-fr &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REGION &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancing-scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;INTERNAL_MANAGED &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NETWORK &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SUBNET &lt;span class="nt"&gt;--address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new-internal-addr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ports&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443 &lt;span class="nt"&gt;--target-https-proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new-internal-proxy &lt;span class="nt"&gt;--target-https-proxy-region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REGION

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero Existing Resources Touched. The Original HTTP-Only Gateway Kept Serving Its Dependent Exactly As Before. The New HTTPS Entry Point Came Up Pointed At The Same Backend, TLS Terminating Correctly, Verified From Inside The Cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reusing An Existing Backend Service Across Both The Old Gateway-Managed Path And The New Hand-Built One Meant Zero New NEGs, Zero New Health Checks — Genuinely Additive, Not A Parallel Copy Of The Whole Stack.&lt;/strong&gt; A GCP Backend Service Can Be Referenced By Multiple URL Maps Simultaneously; No Need To Duplicate It Per Frontend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Rebuild The Load Balancer” And “Recreate The Same Kubernetes Object” Are Not The Same Sentence, Even Though They Sound Like It.&lt;/strong&gt; The Actual Fork In The Road Was Never About Rebuilding Infrastructure — It Was About Which Control Plane Manages It Going Forward, And What Certificate Type That Choice Locks You Into.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Which One To Use
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attaching Any Certificate To A GKE Gateway API Object&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check The Official GatewayClass Capabilities Table For That Specific Class First — Don’t Infer From A Working Example On A Different GatewayClass In The Same Cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Getting &lt;code&gt;GWCER106&lt;/code&gt; Or Any “No Certificate Specified” Error On A Gateway That Clearly References One&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Treat It As A Sign The Field Isn’t Recognized For This GatewayClass, Not As A Certificate Problem — Go Check Capability Docs Before Re-Checking The Certificate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A Kubernetes Abstraction Claims A Cloud Feature Isn’t Supported&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Verify Against The Underlying Cloud Product’s Own Documentation Before Accepting It As A Hard Limit — The Gap May Be In The K8s-Level Controller’s Feature Coverage, Not The Infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Need Google-Managed Cert Support On A Regional Internal ALB Backing A GKE Service, And The GatewayClass Won’t Cooperate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Build The Load Balancer’s Frontend (URL Map + Target HTTPS Proxy + Forwarding Rule) Directly Via Terraform/&lt;code&gt;gcloud&lt;/code&gt;, Pointed At The Existing GKE-Managed Backend Service — No Need To Duplicate Backends Or NEGs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;An Existing Internal IP Has Downstream Consumers With It Hardcoded&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Don’t Recreate That Load Balancer Object To Add A Feature — Reserving The Exact Same IP Afterward Isn’t Guaranteed. Add A Second, Independent Entry Point Instead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/kubernetes-engine/docs/how-to/gatewayclass-capabilities" rel="noopener noreferrer"&gt;GKE — GatewayClass Capabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/certificate-manager/docs/deploy-google-managed-regional" rel="noopener noreferrer"&gt;Certificate Manager — Deploy A Regional Google-Managed Certificate&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gcp</category>
      <category>gke</category>
      <category>gatewayapi</category>
      <category>loadbalancing</category>
    </item>
    <item>
      <title>[K8S] The Time My Critical Security Bug Was Just kubectl Lying To Me</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sun, 02 Aug 2026 17:53:58 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8s-the-time-my-critical-security-bug-was-just-kubectl-lying-to-me-41j3</link>
      <guid>https://dev.to/gordonwei/k8s-the-time-my-critical-security-bug-was-just-kubectl-lying-to-me-41j3</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;My Home Lab Originally Only Used LLDAP For User Authentication, But Since The Systems I Wanted To Integrate Later Only Spoke OIDC, I Put Keycloak In Front Of It As A Bridge, With Everything Else Talking To Keycloak Instead. Wiring That Up And Testing It Turned Up Something Interesting: Got The LDAP Federation Wired Up, Got The OIDC Client Configured, Got Kubernetes Itself Trusting The Issuer. Then, Testing Whether Any Of It Actually Enforced Anything, I Ran One &lt;code&gt;kubectl&lt;/code&gt; Command With A Made-Up Token String, And Got Back A Full Pod List. For About Thirty Seconds I Was Convinced I’d Found A Cluster-Wide Auth Bypass.&lt;/p&gt;

&lt;p&gt;I Hadn’t. The Bug Was In My Test, Not My Cluster.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Keycloak bridging LDAP (LLDAP) to OIDC, Kubernetes API server configured to trust the OIDC issuer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Symptom&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl --token="garbage"&lt;/code&gt; returned a full, valid pod list — looked like anonymous cluster access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Cause&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo kubectl&lt;/code&gt; silently picked up root’s existing admin kubeconfig; client-cert auth (TLS layer) authenticated the request before the bogus Bearer token was ever checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Also Hit&lt;/td&gt;
&lt;td&gt;LDAP Federation schema mismatch — tutorial’s default &lt;code&gt;objectClass&lt;/code&gt; values don’t match what LLDAP actually uses, sync silently imports zero users, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Test with raw &lt;code&gt;curl&lt;/code&gt; + explicit Bearer header, bypassing kubectl entirely; fix schema by querying the real directory with &lt;code&gt;ldapsearch&lt;/code&gt; instead of copying tutorial defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both traps share the same root lesson: &lt;strong&gt;when a config is supposed to match another system’s exact shape (a schema, an auth chain), verify against the real thing, don’t trust the tutorial default, and don’t trust a test tool that might be quietly injecting its own credentials.&lt;/strong&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap One: LDAP Federation’s Schema Match Is Exact, And Fails Silently
&lt;/h1&gt;

&lt;p&gt;Keycloak’s LDAP User Federation builds its user-search filter from a field called &lt;code&gt;userObjectClasses&lt;/code&gt;, roughly &lt;code&gt;(&amp;amp;(objectClass=X)(objectClass=Y)(uid=%s))&lt;/code&gt;. Nearly every tutorial, including Keycloak’s own docs, defaults this to &lt;code&gt;inetOrgPerson, organizationalPerson&lt;/code&gt;, because that’s the common AD/OpenLDAP combination.&lt;/p&gt;

&lt;p&gt;My LDAP server is &lt;strong&gt;LLDAP&lt;/strong&gt; (a lightweight LDAP implementation), and its actual user entries look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ldapsearch &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; ldap://ldap.internal:3890 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"uid=svc-bind,ou=people,dc=example,dc=internal"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;password&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"ou=people,dc=example,dc=internal"&lt;/span&gt; &lt;span class="s2"&gt;"(uid=someuser)"&lt;/span&gt;


objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: mailAccount
objectclass: person

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;organizationalPerson&lt;/code&gt;. Copy the tutorial default in, and Keycloak’s search filter requires a class that literally doesn’t exist on any LLDAP user. The search always returns empty. &lt;strong&gt;The sync runs, reports &lt;code&gt;added: 0&lt;/code&gt;, and throws no error at all.&lt;/strong&gt; It just quietly does nothing. Switching to &lt;code&gt;inetOrgPerson, person&lt;/code&gt; (both actually present) fixed it on the next sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix that should be step one, not step three&lt;/strong&gt; : run &lt;code&gt;ldapsearch&lt;/code&gt; against the real directory before configuring anything that has to match its schema. Don’t assume the tutorial’s defaults apply — especially once you’re off mainstream AD/OpenLDAP and onto something like LLDAP, FreeIPA, or 389-ds.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Two: OIDC Login Working Doesn’t Mean Kubernetes Trusts It
&lt;/h1&gt;

&lt;p&gt;Getting Headlamp to redirect to Keycloak, authenticate against LDAP, and come back with a valid ID token only proves the &lt;strong&gt;app&lt;/strong&gt; recognizes you. Headlamp uses that ID token directly as a Bearer token against the Kubernetes API (it’s not proxying through its own ServiceAccount). There’s a separate, K8s-native trust relationship that has nothing to do with Keycloak or Headlamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# RKE2: /etc/rancher/rke2/config.yaml&lt;/span&gt;
&lt;span class="na"&gt;kube-apiserver-arg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oidc-issuer-url=https://keycloak.example.internal/realms/homelab"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oidc-client-id=headlamp"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oidc-username-claim=email"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oidc-groups-claim=groups"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oidc-ca-file=/path/to/internal-ca.pem"&lt;/span&gt; &lt;span class="c1"&gt;# required if the issuer uses a self-signed cert&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;oidc-ca-file&lt;/code&gt; isn’t optional if your issuer runs on a self-signed cert.&lt;/strong&gt; The API server fetches the OIDC discovery document and JWKS over its own system trust chain — it won’t inherit trust from anywhere else, and skipping this is a guaranteed &lt;code&gt;x509&lt;/code&gt; failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Successful OIDC authentication is not authorization.&lt;/strong&gt; &lt;code&gt;oidc-username-claim&lt;/code&gt; only answers “who is this,” RBAC still needs an explicit &lt;code&gt;ClusterRoleBinding&lt;/code&gt; for that identity. A user with zero bindings logs into Keycloak and Headlamp just fine, then gets &lt;code&gt;403 Forbidden&lt;/code&gt; on every actual API call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Trap Three: My Security Test Was Testing The Wrong Thing
&lt;/h1&gt;

&lt;p&gt;Verifying whether OIDC actually gated the Kubernetes API, I ran this first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"garbage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://127.0.0.1:6443 &lt;span class="nt"&gt;--insecure-skip-tls-verify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# → 200 OK, full pod list&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A made-up string as a token, and it returned everything. For a moment that reads as “this cluster has no auth at all.”&lt;/p&gt;

&lt;p&gt;What actually happened: under &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;kubectl&lt;/code&gt; defaults to reading root’s own kubeconfig, which already contains a valid admin &lt;strong&gt;client certificate&lt;/strong&gt;. &lt;code&gt;--token&lt;/code&gt; and &lt;code&gt;--server&lt;/code&gt; are override flags that get merged into the same AuthInfo object. They don’t clear out a client-cert field that’s already sitting there. And TLS client-cert authentication happens at the handshake, before the request even carries an HTTP-level Bearer header. &lt;strong&gt;The request was authenticated as admin via mTLS the entire time. The garbage token was never actually evaluated.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was to stop trusting a tool that might be silently carrying other credentials, and test the protocol directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# bogus token — should be 401&lt;/span&gt;
curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer garbage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://127.0.0.1:6443/api/v1/namespaces/default/pods
&lt;span class="c"&gt;# → 401 ✓&lt;/span&gt;

&lt;span class="c"&gt;# real OIDC token, no ClusterRoleBinding — should be 403&lt;/span&gt;
curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;real_id_token&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://127.0.0.1:6443/api/v1/namespaces/default/pods
&lt;span class="c"&gt;# → 403, with a message naming the identity and the denied verb ✓&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;401 and 403 that clearly trace back to the token’s content are trustworthy evidence. A &lt;code&gt;200&lt;/code&gt; for an identity that shouldn’t have access is a signal to question the &lt;em&gt;test setup&lt;/em&gt; first. It’s not a reason to immediately write up a vulnerability report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LDAP schema mismatches fail silently, not loudly.&lt;/strong&gt; &lt;code&gt;added: 0&lt;/code&gt; with zero errors looks like “nothing to sync,” not “your filter is wrong.” Query the real directory before configuring anything schema-dependent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“The app logged me in” and “Kubernetes trusts this identity” are two separate trust relationships&lt;/strong&gt; , configured in two separate places, and neither implies the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication success is not authorization.&lt;/strong&gt; Always confirm there’s an RBAC binding before declaring an OIDC integration “working.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sudo&lt;/code&gt; + &lt;code&gt;kubectl&lt;/code&gt; silently inherits root’s existing kubeconfig credentials.&lt;/strong&gt; Any override flag you pass (&lt;code&gt;--token&lt;/code&gt;, &lt;code&gt;--server&lt;/code&gt;) gets merged into that AuthInfo, not substituted for it — client-cert auth wins regardless of what you put in &lt;code&gt;--token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a security test returns a result that looks like “no protection at all,” the first suspect should be the test tool, not the system.&lt;/strong&gt; High-level CLIs (&lt;code&gt;kubectl&lt;/code&gt;, most cloud CLIs) auto-load credentials from multiple sources; test with the rawest possible client (&lt;code&gt;curl&lt;/code&gt;, explicit headers) before trusting a surprising result.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  How To Prevent It
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Any LDAP/AD integration&lt;/strong&gt; (SSO bridges, monitoring system directory auth, CI directory lookups)&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;ldapsearch -x&lt;/code&gt; (or your language’s LDAP client) against the real directory before configuring schema-matching fields like &lt;code&gt;userObjectClasses&lt;/code&gt;/&lt;code&gt;memberOf&lt;/code&gt; — especially off mainstream AD/OpenLDAP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Adding an OIDC identity provider in front of Kubernetes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remember “app trusts OIDC” and “kube-apiserver trusts OIDC” are configured separately; confirm &lt;code&gt;oidc-username-claim&lt;/code&gt; maps to whatever field you’ll actually bind RBAC to, and don’t forget &lt;code&gt;oidc-ca-file&lt;/code&gt; for self-signed issuers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manually verifying whether a token/credential check actually works&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Test with the tool closest to the raw protocol (&lt;code&gt;curl&lt;/code&gt; + manual headers) before trusting a high-level CLI — unless you’ve confirmed it isn’t silently carrying credentials you forgot about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Personal projects / CTF / homelab wanting hands-on enterprise IAM experience&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Keycloak’s LDAP Federation + OIDC Client + &lt;code&gt;kube-apiserver --oidc-*&lt;/code&gt; is one of the cheapest ways to actually operate a full centralized-identity flow end to end, and every step is independently testable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.keycloak.org/server/configuration" rel="noopener noreferrer"&gt;Keycloak Server Administration Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens" rel="noopener noreferrer"&gt;Kubernetes — Authenticating with OpenID Connect Tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Incident: Home RKE2 cluster, Keycloak LDAP→OIDC bridge deployment&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>oidc</category>
      <category>ldap</category>
      <category>keycloak</category>
    </item>
    <item>
      <title>[K8S] Migrating From Envoy Gateway To Istio Ambient</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sun, 26 Jul 2026 12:19:12 +0000</pubDate>
      <link>https://dev.to/gordonwei/k8s-migrating-from-envoy-gateway-to-istio-ambient-476o</link>
      <guid>https://dev.to/gordonwei/k8s-migrating-from-envoy-gateway-to-istio-ambient-476o</guid>
      <description>&lt;h2&gt;
  
  
  Situation
&lt;/h2&gt;

&lt;p&gt;My Home RKE2 Cluster Had Envoy Gateway Handling North-South Traffic, Routed To Whatever Service Needed It. It Worked Fine. What It Didn’t Have Was Any Answer For East-West: The Handful Of Services Calling Each Other Internally Were Doing It In Plaintext, No Identity, No mTLS. I’d Been Meaning To Add A Service Mesh For A While, Mainly Because This Is Exactly The Kind Of Thing A Real Company Would Actually Do, And “Migrate A Live Ingress From One Implementation To Another, Zero Downtime” Is About As Real-Company As It Gets. Decided To Fully Replace Envoy Gateway With Istio’s Own Gateway In Ambient Mode, Rather Than Just Bolting Istio’s Mesh On The Side, And Did It The Same Day.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before&lt;/td&gt;
&lt;td&gt;Envoy Gateway, north-south only, no mesh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decision&lt;/td&gt;
&lt;td&gt;Fully replace with Istio Gateway (not “add mesh, leave ingress alone”) — same mTLS either way, this route also removes a system and doubles as ingress-migration practice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Services Migrated&lt;/td&gt;
&lt;td&gt;4 — Gitea (plain manifest), Bifrost/Headlamp/Keycloak (GitOps-managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downtime&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Biggest Surprise&lt;/td&gt;
&lt;td&gt;The existing wildcard TLS cert needed zero changes — &lt;code&gt;certificateRefs&lt;/code&gt; is a Gateway API field, not an Envoy or Istio proprietary mechanism&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual Risk&lt;/td&gt;
&lt;td&gt;Not the TLS cert (that was fine) — it was almost editing GitOps-managed routes by hand and getting silently reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Two Routes Look Different In Effort, Not In What You Get
&lt;/h2&gt;

&lt;p&gt;Without Comparing Service Mesh Options Against An Existing Gateway API Setup, It’s Easy To Assume Ripping Out The Current Ingress Controller Is What “Unlocks” Mesh Features Like mTLS. Gateway API (Who Handles Ingress) And Service Mesh (Who Handles Service-To-Service) Are Separate Concerns. You Can Install Istio’s Data Plane Next To An Untouched Envoy Gateway And Get Full mTLS Between Meshed Pods, No Ingress Changes Required. The Only Reason To Additionally Swap The Gateway Itself Is Consolidation (One Control Plane Instead Of Two) And, In This Case, The Migration Practice. If Someone’s Evaluating This For A Real Team And The Answer They’re Looking For Is “Which Gives Us More Mesh Capability” — Neither, They’re The Same. The Question Is Really “Do We Also Want To Consolidate Ingress.”&lt;/p&gt;

&lt;p&gt;The Data Plane, Meanwhile, Went With Ambient Mode Over Sidecars. A Node-Level &lt;code&gt;ztunnel&lt;/code&gt; Handles mTLS/L4 Instead Of A Proxy Container In Every Pod. My Nodes Aren’t Great, And Ambient’s Whole Pitch Is Lower Overhead At That Scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap One (That Wasn’t): The Wildcard Cert Just Worked
&lt;/h2&gt;

&lt;p&gt;I Expected To Re-Issue Or At Least Re-Mount The Existing &lt;code&gt;*.internal&lt;/code&gt; Wildcard TLS Cert When Switching Gateway Implementations. It Turns Out &lt;code&gt;certificateRefs&lt;/code&gt; — The Field That Points A Gateway’s TLS Listener At A Kubernetes Secret — Is Part Of The Gateway API Spec Itself, Not Something Envoy Gateway Or Istio Each Bolt On Differently. Same Secret, Same Field Name, Different Controller Reading It:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-gateway&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
      &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-tls&lt;/span&gt; &lt;span class="c1"&gt;# same Secret the old Envoy Gateway used&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Only Thing Needed Was A &lt;code&gt;ReferenceGrant&lt;/code&gt; In The Secret’s Namespace, Because The New &lt;code&gt;Gateway&lt;/code&gt; Object Lives In A Different Namespace (&lt;code&gt;istio-system&lt;/code&gt;) Than Where The Cert Secret Happened To Be Parked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReferenceGrant&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-istio-system-read-tls&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-system&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-tls&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth Calling Out: I’d Assumed Before Starting That A &lt;code&gt;ReferenceGrant&lt;/code&gt; Was Already In Use Somewhere In The Existing Setup. It Wasn’t. The Old Gateway And The Secret Had Always Lived In The Same Namespace, So Cross-Namespace Referencing Had Simply Never Come Up Before. Good Reminder That “This Pattern Is Probably Already Handled Somewhere” Is A Guess, Not A Fact, Until You Actually Grep For It.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap Two: GitOps-Managed Routes Will Silently Revert Your Manual Edit
&lt;/h2&gt;

&lt;p&gt;Three Of My Four Services (An AI Gateway, A K8s Dashboard, And An Identity Provider, I’ll Call Them Bifrost, Headlamp, And Keycloak, Their Real Names) Had Their &lt;code&gt;HTTPRoute&lt;/code&gt; Managed By Flux — Which I Hadn’t Front-Of-Mind When I Started Testing. The Fourth, A Self-Hosted Gitea, Was A Plain Manual Manifest, So I Just &lt;code&gt;kubectl patch&lt;/code&gt;-Ed It Directly, No Problem. For The Other Three, Editing The Live Object By Hand Would Have Worked For Exactly As Long As It Took Flux’s Next Reconcile Loop To Notice The Drift And Put It Back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This gets reverted on the next reconcile if the Kustomization has prune: true&lt;/span&gt;
&lt;span class="c1"&gt;# and the source repo still has the old parentRef&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main-gateway&lt;/span&gt; &lt;span class="c1"&gt;# &amp;lt;- edit this live, Flux puts it right back&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actual Fix Was Editing The Source Repo, Not The Cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &amp;lt;gitops-repo&amp;gt;
&lt;span class="c"&gt;# change parentRefs from main-gateway to istio-gateway in the HTTPRoute manifest&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Migrate HTTPRoute to Istio Gateway"&lt;/span&gt;
git push
kubectl annotate kustomization &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system &lt;span class="se"&gt;\&lt;/span&gt;
  reconcile.fluxcd.io/requestedAt&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None Of This Is Istio-Specific. It’s The Standard “Where’s The Actual Source Of Truth” Question A GitOps Setup Forces On Every Infra Change. But It’s Easy To Forget In The Moment When You’re Focused On Testing A New Gateway Config And Just Want To See If It Works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating Without Touching DNS: Run Both Gateways In Parallel
&lt;/h2&gt;

&lt;p&gt;The Zero-Downtime Part Came From Never Touching Production Routing Until The New Path Was Already Proven. MetalLB Handed The New Istio Gateway Its Own IP, Separate From The Old One, So Both Gateways Ran Side By Side. Testing The New Path Didn’t Require Touching DNS At All — &lt;code&gt;curl --resolve&lt;/code&gt; Fakes The DNS Answer For A Single Request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# hits the NEW gateway directly, real hostname for correct SNI/cert matching&lt;/span&gt;
curl &lt;span class="nt"&gt;--cacert&lt;/span&gt; ca.pem &lt;span class="nt"&gt;--resolve&lt;/span&gt; myservice.internal:443:&amp;lt;new-gateway-ip&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://myservice.internal/
&lt;span class="c"&gt;# 200&lt;/span&gt;

&lt;span class="c"&gt;# side-by-side against the OLD gateway, still serving real production traffic&lt;/span&gt;
curl &lt;span class="nt"&gt;--cacert&lt;/span&gt; ca.pem &lt;span class="nt"&gt;--resolve&lt;/span&gt; myservice.internal:443:&amp;lt;old-gateway-ip&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://myservice.internal/
&lt;span class="c"&gt;# 200&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only After Every Service Came Back &lt;code&gt;200&lt;/code&gt; On The New Path Did I Touch The Authoritative DNS Records (Two Servers, Both Had To Be Updated And Both Individually Verified — More On Why Below) And Point Them At The New Gateway’s IP. Repeated Per Service: Confirm On The New Path → Flip DNS For That One Service → Confirm Again On The Real Hostname → Move To The Next. Only After All Four Were Confirmed Live On The New Gateway Did I Uninstall The Old One.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;certificateRefs&lt;/code&gt; Is A Gateway API Spec Field, Not An Envoy Or Istio Proprietary Mechanism.&lt;/strong&gt; Switching Gateway Implementations Doesn’t Require Touching TLS Certs At All, As Long As The New Controller Also Implements Gateway API (Most Do). Confirm This Before Assuming A Cert Migration Task Even Exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;ReferenceGrant&lt;/code&gt; You’ve Never Needed Before Doesn’t Mean It’s Already Handled Elsewhere.&lt;/strong&gt; It Might Just Mean The Situation Requiring It Never Came Up. Check, Don’t Assume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps-Managed Resources Revert Manual Edits On The Next Reconcile.&lt;/strong&gt; This Applies To Any Ingress/Route Migration In A Flux Or ArgoCD-Managed Cluster, Not Just This One — Identify Which Resources Are GitOps-Owned Before Touching Anything Live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl --resolve&lt;/code&gt; Lets You Fully Validate A New Ingress Path — TLS, Routing, Backend — Without Touching DNS Or Risking Live Traffic.&lt;/strong&gt; Flip DNS Only After Every Service Independently Passes On The New Path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actual Resource Overhead Was Smaller Than Expected.&lt;/strong&gt; My Cluster’s Nodes Are Tight On Memory (One Physical Host Running All Five VMs, Already Low On Free RAM). I Was Ready To Abort Or Scale Back If Ambient’s Per-Node &lt;code&gt;ztunnel&lt;/code&gt; Made That Worse. Real Numbers: 1-4 Percentage Points Of Memory Increase Per Node. Worth Measuring Before Assuming A Resource-Constrained Environment Can’t Handle It.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Real-World Application
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Evaluating Whether To Add A Service Mesh On Top Of An Existing Gateway API Setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remember Gateway API And Service Mesh Are Orthogonal — You Can Add Mesh Capability (mTLS, Tracing) Without Touching Ingress At All. Only Consolidate The Two If You Specifically Want One Fewer System To Run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Migrating Ingress In Any Cluster Using Flux Or ArgoCD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Identify Which Routes Are GitOps-Managed Before Touching Anything. Edit The Source Repo, Not The Live Object — Manual Edits To Managed Resources Get Silently Reverted On The Next Reconcile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Any Zero-Downtime Ingress/LB Migration&lt;/strong&gt; (Not Just Gateway API)&lt;/td&gt;
&lt;td&gt;Run Old And New In Parallel On Separate IPs, Validate The New Path With &lt;code&gt;curl --resolve&lt;/code&gt; Or Equivalent Per-Request DNS Override, And Only Flip Real DNS After Every Route Independently Passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resource-Constrained Clusters Considering Ambient Mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Don’t Assume The Overhead Is Prohibitive Without Measuring — Take A &lt;code&gt;kubectl top nodes&lt;/code&gt; Baseline Before Installing, Compare After. The Actual Number May Be Smaller Than The Sidecar-Model Reputation Suggests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://istio.io/latest/docs/ambient/" rel="noopener noreferrer"&gt;Istio — Ambient Mode&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://istio.io/latest/docs/tasks/traffic-management/ingress/gateway-api/" rel="noopener noreferrer"&gt;Istio — Gateway API Support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Incident: Home Lab RKE2 Cluster, Envoy Gateway → Istio Ambient Migration&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>k8s</category>
      <category>istio</category>
      <category>servicemesh</category>
      <category>gatewayapi</category>
    </item>
    <item>
      <title>[GCP] Three Traps Migrating GKE Gateway API To A Global LB</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sat, 11 Jul 2026 16:58:38 +0000</pubDate>
      <link>https://dev.to/gordonwei/gcp-three-traps-migrating-gke-gateway-api-to-a-global-lb-p7p</link>
      <guid>https://dev.to/gordonwei/gcp-three-traps-migrating-gke-gateway-api-to-a-global-lb-p7p</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Needed The PRD Domain’s Certificate To Auto-Renew, Which Meant Switching GatewayClass From Regional (&lt;code&gt;gke-l7-regional-external-managed&lt;/code&gt;) To Global (&lt;code&gt;gke-l7-global-external-managed&lt;/code&gt;). 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.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;PRD Needed Certificate Manager Auto-Renewing Certs, Only Supported On Global GatewayClass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traps Hit&lt;/td&gt;
&lt;td&gt;① Certificate Manager Annotation Syntax (&lt;code&gt;GWCER105&lt;/code&gt;) ② GCPBackendPolicy Conflict (Silent Failure, No Error At Apply Time) ③ Global Security Policy Defaults To Allow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeat Confirmation&lt;/td&gt;
&lt;td&gt;5 Days Later, Adding An Internal Gateway, Trap ② Fired Again, Identical Symptom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is This A GCP Design Flaw&lt;/td&gt;
&lt;td&gt;Not Really. Regional And Global GatewayClass Map To Fundamentally Different Underlying Infrastructure, The Docs Just Don’t Spell Out The Cost Of Switching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;Global LB Migration Succeeded, PRD Cert Auto-Renewal Working&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h1&gt;
  
  
  Gateway API’s Three-Layer Model, Quick Primer
&lt;/h1&gt;

&lt;p&gt;If You Haven’t Touched Gateway API Yet: It Splits Ingress’s “One Resource Rules Everything” Mess Into Three Layers: &lt;code&gt;GatewayClass&lt;/code&gt; (Vendor Declares Who Implements It, Here That’s GKE), &lt;code&gt;Gateway&lt;/code&gt; (Infra Team Owns The Entry Point, Protocol, TLS), &lt;code&gt;HTTPRoute&lt;/code&gt; (Developers Own The Routing Rules). Every Trap In This Post Happened At The &lt;code&gt;GatewayClass&lt;/code&gt; 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.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap One: Certificate Manager Mounts Differently On Regional vs Global
&lt;/h1&gt;

&lt;p&gt;Copied The Regional Syntax First, Got This Immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error GWCER105: invalid TLS option key "networking.gke.io/certificate-map"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Two GatewayClasses Don’t Share A Certificate-Mounting Syntax At All:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GatewayClass&lt;/th&gt;
&lt;th&gt;Correct Syntax&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gke-l7-regional-external-managed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;listeners[].tls.options["networking.gke.io/pre-shared-certs"]&lt;/code&gt; (Regional Manages Its Own SSL Certificates, Doesn’t Support Certificate Manager)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gke-l7-global-external-managed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not Under &lt;code&gt;listeners.tls&lt;/code&gt; At All.&lt;/strong&gt; Goes On The Gateway Object’s &lt;code&gt;metadata.annotations["networking.gke.io/certmap"]&lt;/code&gt;, And That Listener &lt;strong&gt;Must Have No &lt;code&gt;tls&lt;/code&gt; Block Whatsoever&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It’s Not A Typo’d Key. It’s Two Entirely Different Mounting Mechanisms. One Lives At The Listener Level Under &lt;code&gt;tls.options&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Two: GCPBackendPolicy Only Binds One Service, And Fails Silently
&lt;/h1&gt;

&lt;p&gt;This Is The Nastiest Of The Three Because &lt;strong&gt;It Doesn’t Error Out At &lt;code&gt;kubectl apply&lt;/code&gt; Or &lt;code&gt;helm upgrade&lt;/code&gt; Time&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get gcpbackendpolicy &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status}'&lt;/span&gt;
&lt;span class="c"&gt;# conflicted with GCPBackendPolicy "xxx" of higher precedence, hence not applied&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Reason: A K8s Service (Regardless Of Whether It Backs A Regional Or Global Backend Service) &lt;strong&gt;Can Only Be Bound By One GCPBackendPolicy&lt;/strong&gt;. 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 &lt;code&gt;status.conditions&lt;/code&gt; 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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five Days Later, The Repeat&lt;/strong&gt; : 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 &lt;code&gt;SetSecurityPolicy ... does not exist&lt;/code&gt; 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.&lt;/p&gt;

&lt;h1&gt;
  
  
  Trap Three: Global Security Policy Defaults To Allow, Not Deny
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute security-policies describe &amp;lt;policy-name&amp;gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yaml
&lt;span class="c"&gt;# priority 2147483647 "default rule" defaults to action: allow, your allowlist does nothing&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regional Policy (&lt;code&gt;google_compute_region_security_policy&lt;/code&gt;) Doesn’t Have This Problem, But The Moment You Create A Global Policy (&lt;code&gt;google_compute_security_policy&lt;/code&gt;), GCP Auto-Generates A Priority &lt;code&gt;2147483647&lt;/code&gt; &lt;code&gt;allow&lt;/code&gt; Default Rule. That Priority Collides With Whatever Deny-All Rule You Meant To Create By Hand (&lt;code&gt;Cannot have rules with the same priorities&lt;/code&gt;). The Right Fix Isn’t Deleting And Recreating. It’s &lt;strong&gt;&lt;code&gt;terraform import&lt;/code&gt;-ing&lt;/strong&gt; The Auto-Generated Rule Into State, Then Using Terraform To Flip The Action To Deny:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform import google_compute_security_policy_rule.&amp;lt;resource_name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"projects/&amp;lt;project&amp;gt;/global/securityPolicies/&amp;lt;policy-name&amp;gt;/priority/2147483647"&lt;/span&gt;
terraform plan &lt;span class="c"&gt;# Should Show An In-Place Update: action: allow -&amp;gt; deny(403)&lt;/span&gt;
terraform apply

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GWCER105&lt;/code&gt; Isn’t A Typo’d Key, It’s Two Entirely Different Cert-Mounting Mechanisms.&lt;/strong&gt; Regional Uses Listener-Level &lt;code&gt;tls.options&lt;/code&gt;, Global Uses A Gateway-Metadata-Level Annotation. Different Locations Entirely. Copy One Onto The Other, It Breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCPBackendPolicy Conflicts Are The Nastiest Of The Three Because They Don’t Error At Apply Time.&lt;/strong&gt; You Only See “Conflicted With … Of Higher Precedence, Hence Not Applied” By Checking &lt;code&gt;status.conditions&lt;/code&gt;. First Created Wins, Second One Fails Silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import The Global Security Policy’s Auto-Generated Allow Rule Into Terraform State, Don’t Delete And Recreate It.&lt;/strong&gt; The Priority Is A Fixed Value (&lt;code&gt;2147483647&lt;/code&gt;) That Collides With Any Hand-Built Deny-All Rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global LB Provisioning Takes Noticeably Longer Than Regional.&lt;/strong&gt; 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 &lt;code&gt;Programmed: True&lt;/code&gt;, TLS Taking Full Effect At The Edge Can Take A Few More Minutes. If &lt;code&gt;curl&lt;/code&gt; Throws &lt;code&gt;SSL_ERROR_SYSCALL&lt;/code&gt; But &lt;code&gt;openssl s_client&lt;/code&gt; Tests Fine, It’s Usually Just Still Propagating. Wait It Out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This Same Migration Also Hit A Helm Field Manager Conflict&lt;/strong&gt; , Which I Pushed Through With &lt;code&gt;--server-side --force-conflicts&lt;/code&gt; And 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  The Rule: Check Service Sharing Before Adding Any New Gateway
&lt;/h1&gt;

&lt;p&gt;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 &lt;strong&gt;Checklist Item For Every New Gateway You Ever Add&lt;/strong&gt; , 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.&lt;/p&gt;

&lt;h1&gt;
  
  
  Real-World Application
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What To Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Any Environment Running GKE Gateway API&lt;/strong&gt; (Not Just Regional/Global Mixed)&lt;/td&gt;
&lt;td&gt;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 &lt;code&gt;status.conditions&lt;/code&gt; After The Fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Teams Setting Up Auto-Renewing Certificates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;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&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Environments Managing Cloud Armor / Security Policy Via Terraform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;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&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Any GCP Resource Migration&lt;/strong&gt; (Not Just Gateway API)&lt;/td&gt;
&lt;td&gt;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&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/kubernetes-engine/docs/how-to/secure-gateway" rel="noopener noreferrer"&gt;Secure a Gateway | GKE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/kubernetes-engine/docs/how-to/gatewayclass-capabilities" rel="noopener noreferrer"&gt;GatewayClass capabilities | GKE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/kubernetes-engine/docs/how-to/gatewayclass-capabilities#gcpbackendpolicy" rel="noopener noreferrer"&gt;GCPBackendPolicy | GKE Gateway API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real Incident: Internal Service, PRD Regional→Global Migration; Internal Gateway Repeat Confirmation&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gcp</category>
      <category>gke</category>
      <category>gatewayapi</category>
      <category>k8s</category>
    </item>
    <item>
      <title>[GCP] Deploy vLLM On Cloud Run (GPU &amp; CPU)</title>
      <dc:creator>ChungWei Wei</dc:creator>
      <pubDate>Sat, 11 Jul 2026 04:02:10 +0000</pubDate>
      <link>https://dev.to/gordonwei/gcp-deploy-vllm-on-cloud-run-gpu-cpu-3b5m</link>
      <guid>https://dev.to/gordonwei/gcp-deploy-vllm-on-cloud-run-gpu-cpu-3b5m</guid>
      <description>&lt;h1&gt;
  
  
  Situation
&lt;/h1&gt;

&lt;p&gt;Want To Deploy vLLM On Cloud Run To Serve An LLM. Two Paths: GPU Mode (For Real Service) Or CPU Mode (For Tiny Model / Architecture Test Only). Both Fully Tested End To End.&lt;/p&gt;

&lt;p&gt;Result First:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPU Mode&lt;/th&gt;
&lt;th&gt;CPU Mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Support Level&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;Experimental&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tested Model&lt;/td&gt;
&lt;td&gt;Gemma-4-E2B-it (L4)&lt;/td&gt;
&lt;td&gt;Qwen2.5-0.5B-Instruct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response Time&lt;/td&gt;
&lt;td&gt;6.1s&lt;/td&gt;
&lt;td&gt;114s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Effort&lt;/td&gt;
&lt;td&gt;Low, Use Official Image&lt;/td&gt;
&lt;td&gt;High, ~56 Min Multi-Stage Build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use Case&lt;/td&gt;
&lt;td&gt;Real Service, Demo&lt;/td&gt;
&lt;td&gt;Architecture Test Only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GPU Is About 19x Faster Than CPU. CPU Mode Works, But Don’t Expect Speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To (GPU Mode)
&lt;/h2&gt;

&lt;p&gt;Requirement:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;L4&lt;/th&gt;
&lt;th&gt;RTX PRO 6000 Blackwell&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Min CPU&lt;/td&gt;
&lt;td&gt;4 (8 Recommended)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Min Memory&lt;/td&gt;
&lt;td&gt;16 GiB (32 GiB Recommended)&lt;/td&gt;
&lt;td&gt;80 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;asia-southeast1 / us-central1 / europe-west1 / europe-west4 / us-east4&lt;/td&gt;
&lt;td&gt;asia-southeast1 / us-central1 / europe-west4 / asia-south2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing&lt;/td&gt;
&lt;td&gt;Instance-Based Billing Required&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;First-Time GPU Deploy Get A Default Quota (3 GPU / Project / Region).&lt;/p&gt;

&lt;p&gt;Deploy With This Command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy vllm-gpu-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-docker.pkg.dev/vertex-ai/vertex-vision-model-garden-dockers/pytorch-vllm-serve:gemma4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32Gi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nvidia-l4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-gpu-zonal-redundancy&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-cpu-throttling&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--execution-environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gen2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--startup-probe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcpSocket.port&lt;span class="o"&gt;=&lt;/span&gt;8080,initialDelaySeconds&lt;span class="o"&gt;=&lt;/span&gt;60,timeoutSeconds&lt;span class="o"&gt;=&lt;/span&gt;240,failureThreshold&lt;span class="o"&gt;=&lt;/span&gt;2,periodSeconds&lt;span class="o"&gt;=&lt;/span&gt;240 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-allow-unauthenticated&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vllm,serve,google/gemma-4-E2B-it,--enforce-eager,--enable-chunked-prefill,--enable-prefix-caching,--generation-config=auto,--enable-auto-tool-choice,--tool-call-parser=gemma4,--reasoning-parser=gemma4,--dtype=bfloat16,--max-num-seqs=64,--gpu-memory-utilization=0.9,--tensor-parallel-size=1,--port=8080,--host=0.0.0.0"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test It:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-identity-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://vllm-gpu-service-xxx.asia-southeast1.run.app/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"google/gemma-4-E2B-it","messages":[{"role":"user","content":"用一句話介紹你自己"}],"max_tokens":100}'&lt;/span&gt;


&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;:&lt;span class="s2"&gt;"我是一個由 Google DeepMind 開發的開放權重大型語言模型，名為 Gemma 4。"&lt;/span&gt;&lt;span class="o"&gt;}}]&lt;/span&gt;,
 &lt;span class="s2"&gt;"usage"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"prompt_tokens"&lt;/span&gt;:14,&lt;span class="s2"&gt;"completion_tokens"&lt;/span&gt;:23,&lt;span class="s2"&gt;"total_tokens"&lt;/span&gt;:37&lt;span class="o"&gt;}}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total Round Trip: 6.1s.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don’t Use The Generic &lt;code&gt;docker.io/vllm/vllm-openai:latest&lt;/code&gt; Image.&lt;/strong&gt; It’s Built Against A Newer CUDA Than Cloud Run L4’s Driver (535.x / CUDA 12.2) Supports, Fails With &lt;code&gt;RuntimeError: The NVIDIA driver on your system is too old (found version 12020)&lt;/code&gt;. Use Google’s Own Rebuilt Model Garden Image Above Instead — This Is Also Why The Official Codelab Never Uses The Generic Image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This Image’s Entrypoint Doesn’t Auto-Prepend &lt;code&gt;vllm&lt;/code&gt;.&lt;/strong&gt; It’s A Launcher Script (&lt;code&gt;gcs_download_launcher.sh&lt;/code&gt;) That &lt;code&gt;exec&lt;/code&gt; Whatever You Give It. Args Must Start With &lt;code&gt;vllm,serve,...&lt;/code&gt;, Not Just &lt;code&gt;serve,...&lt;/code&gt;, Or You Get &lt;code&gt;serve: command not found&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--enforce-eager&lt;/code&gt; Is Required, Not Optional, Unless You Extend The Startup Probe A Lot.&lt;/strong&gt; Without It, vLLM Does &lt;code&gt;torch.compile&lt;/code&gt; + CUDA Graph Capture Across Batch Sizes 1 To 128 On First Boot, Which Takes Over 5 Minutes — Longer Than Most Startup Probe Configs, So The Deploy Just Rolls Back. With It, Cold Start Is Under A Minute, At The Cost Of Slightly Slower Steady-State Throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port Is 8080 Here, Not vLLM’s Usual 8000.&lt;/strong&gt; Match &lt;code&gt;--port&lt;/code&gt; To Whatever The Image Actually Listens On.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--max-instances&lt;/code&gt; Does Not Prevent Cold Start&lt;/strong&gt; , It’s Only A Concurrency Ceiling. Use &lt;code&gt;--min-instances=1&lt;/code&gt; If Cold Start Is Unacceptable — That Means Paying For A GPU Instance 24/7. Without It, The Service Scales To Zero When Idle, No Charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How To (CPU Mode)
&lt;/h2&gt;

&lt;p&gt;Only Makes Sense For Very Small Models. &lt;code&gt;pip install vllm&lt;/code&gt; Assumes CUDA, Won’t Run Inference On A CPU-Only Host. Official Path Is Building From &lt;code&gt;docker/Dockerfile.cpu&lt;/code&gt; — Don’t Hand-Roll A Simplified Version, Pull The Real File:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/vllm-project/vllm.git
&lt;span class="nb"&gt;cd &lt;/span&gt;vllm

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build With This &lt;code&gt;cloudbuild.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gcr.io/cloud-builders/docker'&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DOCKER_BUILDKIT=1'&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;build'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-f'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vllm/docker/Dockerfile.cpu'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--target'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vllm-openai'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--build-arg'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;max_jobs=8'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-t'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;asia-southeast1-docker.pkg.dev/$PROJECT_ID/YOUR_REPO/vllm-cpu:latest'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vllm'&lt;/span&gt;

&lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;asia-southeast1-docker.pkg.dev/$PROJECT_ID/YOUR_REPO/vllm-cpu:latest'&lt;/span&gt;

&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;machineType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;E2_HIGHCPU_32'&lt;/span&gt;

&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;7200s&lt;/span&gt;


&lt;span class="s"&gt;gcloud builds submit --config=cloudbuild.yaml --region=asia-southeast1 .&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy With This Command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy vllm-cpu-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-docker.pkg.dev/YOUR_PROJECT/YOUR_REPO/vllm-cpu:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10Gi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-env-vars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;VLLM_CPU_KVCACHE_SPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4,VLLM_ENABLE_V1_MULTIPROCESSING&lt;span class="o"&gt;=&lt;/span&gt;0,VLLM_HOST_IP&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1,GLOO_SOCKET_IFNAME&lt;span class="o"&gt;=&lt;/span&gt;lo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Qwen/Qwen2.5-0.5B-Instruct,--port=8000,--host=0.0.0.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--startup-probe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcpSocket.port&lt;span class="o"&gt;=&lt;/span&gt;8000,initialDelaySeconds&lt;span class="o"&gt;=&lt;/span&gt;60,timeoutSeconds&lt;span class="o"&gt;=&lt;/span&gt;240,failureThreshold&lt;span class="o"&gt;=&lt;/span&gt;1,periodSeconds&lt;span class="o"&gt;=&lt;/span&gt;240 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-allow-unauthenticated&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test It:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-identity-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://vllm-cpu-service-xxx.asia-southeast1.run.app/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"Qwen/Qwen2.5-0.5B-Instruct","messages":[{"role":"user","content":"用一句話介紹你自己"}],"max_tokens":100}'&lt;/span&gt;


&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;:&lt;span class="s2"&gt;"我是由阿里云开发的超大规模语言模型，我叫通义千问。我的目标是帮助用户理解和创造。"&lt;/span&gt;&lt;span class="o"&gt;}}]&lt;/span&gt;,
 &lt;span class="s2"&gt;"usage"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"prompt_tokens"&lt;/span&gt;:34,&lt;span class="s2"&gt;"completion_tokens"&lt;/span&gt;:27,&lt;span class="s2"&gt;"total_tokens"&lt;/span&gt;:61&lt;span class="o"&gt;}}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total: 114s, 34 Prompt Tokens + 27 Completion Tokens, 0.5B Model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Build’s Default Docker Builder Doesn’t Have BuildKit Enabled.&lt;/strong&gt; The Official Dockerfile Uses &lt;code&gt;RUN --mount=type=cache,...&lt;/code&gt; (BuildKit-Only), Fails With &lt;code&gt;the --mount option requires BuildKit&lt;/code&gt;. Add &lt;code&gt;env: ['DOCKER_BUILDKIT=1']&lt;/code&gt; To The Build Step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Build Machine Types Are All High-CPU-Low-Memory&lt;/strong&gt; (&lt;code&gt;e2-highcpu-32&lt;/code&gt; = 32 vCPU / 32GB RAM, No High-Mem Option Exists). Default &lt;code&gt;MAX_JOBS=32&lt;/code&gt; OOM-Kills The Build Machine Mid-Compile (&lt;code&gt;status "INTERNAL_ERROR"&lt;/code&gt;, Not A Normal Build Failure) During The Triton-CPU Stage. Lower It With &lt;code&gt;--build-arg max_jobs=8&lt;/code&gt;. Full Build Then Takes About 56 Min.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;.git&lt;/code&gt; In The Build Context.&lt;/strong&gt; vLLM Uses &lt;code&gt;setuptools-scm&lt;/code&gt; To Derive Its Version From Git History — Strip &lt;code&gt;.git&lt;/code&gt; To Save Upload Size And The Build Fails Hard With &lt;code&gt;setuptools-scm was unable to detect version&lt;/code&gt;. A &lt;code&gt;--depth 1&lt;/code&gt; Shallow Clone Is Enough, Costs About 40MB Extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vllm serve&lt;/code&gt; Takes The Model Name As A Positional Argument&lt;/strong&gt; , Not A &lt;code&gt;--model=&lt;/code&gt; Flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model Loads Fine But The Port Never Opens&lt;/strong&gt; (&lt;code&gt;shm_broadcast.py: No available shared memory broadcast block found in 60 seconds&lt;/code&gt;) — vLLM’s Multi-Process Architecture Needs &lt;code&gt;/dev/shm&lt;/code&gt; For IPC, And Cloud Run’s Default Is Too Small. Mounting A Bigger In-Memory Volume At &lt;code&gt;/dev/shm&lt;/code&gt; Does Not Work, Cloud Run Rejects It Outright (&lt;code&gt;Mount paths [/dev/shm] are in one of disallowed mount points [/dev, /proc, /sys]&lt;/code&gt;). The Actual Fix Is Avoiding The Dependency, Not Enlarging It: &lt;code&gt;--set-env-vars=VLLM_ENABLE_V1_MULTIPROCESSING=0,VLLM_HOST_IP=127.0.0.1,GLOO_SOCKET_IFNAME=lo&lt;/code&gt;. First Var Keeps The Engine Mostly In One Process; The Other Two Force &lt;code&gt;torch.distributed&lt;/code&gt;’s TCP Rendezvous Onto Loopback, Because It Otherwise Times Out Retrying Inside Cloud Run’s gVisor Sandboxed Network (&lt;code&gt;[c10d] The server socket ... has timed out, will retry&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which One To Use
&lt;/h2&gt;

&lt;p&gt;GPU Mode For Anything Real — Just Remember To Use Google’s Model Garden Image, Not The Generic &lt;code&gt;vllm/vllm-openai&lt;/code&gt;. CPU Mode Hits Deeper Issues (shm Architecture Limits, gVisor Sandbox Network Compatibility) And Is Really Only Good For Validating The Architecture Or A Very-Low-Traffic Demo, Not Production.&lt;/p&gt;

&lt;p&gt;Cloud Run Now Support GPU (NVIDIA L4 / RTX PRO 6000 Blackwell) Directly, No Need To Go Through GKE Anymore. GPU Mode Just Means Adding &lt;code&gt;--gpu&lt;/code&gt; And &lt;code&gt;--gpu-type&lt;/code&gt; To A Normal &lt;code&gt;gcloud run deploy&lt;/code&gt;, Cloud Run Handles The Driver And Scheduling — That’s What Makes The GPU Path Above So Much Simpler Than It Used To Be.&lt;/p&gt;

&lt;p&gt;Reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/run/docs/configuring/services/gpu" rel="noopener noreferrer"&gt;GPU support for services | Cloud Run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelabs.developers.google.com/codelabs/how-to-run-inference-cloud-run-gpu-vllm" rel="noopener noreferrer"&gt;How to run LLM inference on Cloud Run GPUs with vLLM | Google Codelabs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.vllm.ai/en/stable/getting_started/installation/cpu/" rel="noopener noreferrer"&gt;CPU - vLLM Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/vllm-project/vllm/blob/main/docker/Dockerfile.cpu" rel="noopener noreferrer"&gt;vllm/docker/Dockerfile.cpu · vllm-project/vllm (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/run/docs/configuring/services/in-memory-volume-mounts" rel="noopener noreferrer"&gt;Configure in-memory volume mounts for services | Cloud Run&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gcp</category>
      <category>vllm</category>
      <category>container</category>
    </item>
  </channel>
</rss>
