<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Google Cloud 的實作紀錄 on Tarragon</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/</link><description>Recent content in Google Cloud 的實作紀錄 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 25 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/index.xml" rel="self" type="application/rss+xml"/><item><title>GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/</guid><description>&lt;p>實測日期是 2026-10-24，在 GCP 測試專案 &lt;code>blog-verify-20261024&lt;/code>（zone &lt;code>asia-east1-b&lt;/code>，&lt;code>default&lt;/code> 網路）上，對一台 Debian 12 的 e2-micro 與一台 Windows Server 2022 的 e2-medium，用本機的 &lt;code>gcloud&lt;/code> 照這一篇的順序操作；IAM 條件與 OS Login 角色另外用第二個 Google 帳號登入驗證。&lt;/p>
&lt;p>這篇記錄把 VM 的管理員登入從防火牆對 &lt;code>0.0.0.0/0&lt;/code> 開放 22 與 3389 port，改成走 IAP TCP forwarding 的實際操作。內容依操作的順序是：切換前盤點依賴舊規則的連線；準備並實際登入一次救援入口（Serial Console 與本機救援帳號）；啟用 IAP API、開啟 IAP 的稽核紀錄、建立只放行 IAP 來源的防火牆規則與 network tag；授予開通道的 IAM 權限；停用舊規則、用 IAP 驗證新規則；刪除對外開放的規則；收斂 &lt;code>default-allow-internal&lt;/code> 與 &lt;code>default-allow-icmp&lt;/code>；啟用 OS Login；建 Cloud NAT 並移除外部 IP。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。IAP 的連線路徑、預設規則為什麼要換、切換的順序為什麼這樣排、IAM 授權的範圍、連不上時的依層判讀、OS Login 與 Cloud NAT 的機制，寫在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding&lt;/a>（下文稱概念篇），這裡不重寫。&lt;/p>
&lt;p>這一篇的指令以單台 VM 為對象。由 MIG 或 GKE 管理的 VM，同樣的設定改寫在 instance template 與 node pool 上，做法在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/" data-link-title="GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換" data-link-desc="要把 IAP 的 tag、OS Login 與移除外部 IP 套到 MIG 或 GKE 上，想照一次實跑過的順序做、看每一步換一種做法的結果時讀；也用在 MIG 重建之後 SSH 出現 REMOTE HOST IDENTIFICATION HAS CHANGED、kubectl debug 停在等待，或關掉 GKE 控制平面的 IP 端點之前要確認每個人都改連的時候">GCP 上 MIG 與 GKE 管理存取的實測紀錄&lt;/a>；Shared VPC 的 service project、用 Terraform 管理的專案，指令要改寫在別的位置；新專案從零開始則可以跳過盤點與停用驗證。這幾種環境的調整見概念篇開場之後「另外四種環境要先調整做法再往下讀」那一段。&lt;/p>
&lt;h2 id="切換前的盤點依賴舊規則的連線">切換前的盤點：依賴舊規則的連線&lt;/h2>
&lt;p>&lt;code>default-allow-ssh&lt;/code> 放行的不只是管理員本人的 SSH：CI 或部署腳本用外部 IP 直連 VM、Ansible 這類&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理&lt;/a>工具、監控程式的探測、第三方廠商的維護連線，都可能經過這條規則，而預設規則沒有開 logging，手上沒有紀錄可以查出它們。盤點分兩步。先對舊規則開啟 logging，列出經過它的候選來源：&lt;/p></description><content:encoded><![CDATA[<p>實測日期是 2026-10-24，在 GCP 測試專案 <code>blog-verify-20261024</code>（zone <code>asia-east1-b</code>，<code>default</code> 網路）上，對一台 Debian 12 的 e2-micro 與一台 Windows Server 2022 的 e2-medium，用本機的 <code>gcloud</code> 照這一篇的順序操作；IAM 條件與 OS Login 角色另外用第二個 Google 帳號登入驗證。</p>
<p>這篇記錄把 VM 的管理員登入從防火牆對 <code>0.0.0.0/0</code> 開放 22 與 3389 port，改成走 IAP TCP forwarding 的實際操作。內容依操作的順序是：切換前盤點依賴舊規則的連線；準備並實際登入一次救援入口（Serial Console 與本機救援帳號）；啟用 IAP API、開啟 IAP 的稽核紀錄、建立只放行 IAP 來源的防火牆規則與 network tag；授予開通道的 IAM 權限；停用舊規則、用 IAP 驗證新規則；刪除對外開放的規則；收斂 <code>default-allow-internal</code> 與 <code>default-allow-icmp</code>；啟用 OS Login；建 Cloud NAT 並移除外部 IP。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。IAP 的連線路徑、預設規則為什麼要換、切換的順序為什麼這樣排、IAM 授權的範圍、連不上時的依層判讀、OS Login 與 Cloud NAT 的機制，寫在 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding</a>（下文稱概念篇），這裡不重寫。</p>
<p>這一篇的指令以單台 VM 為對象。由 MIG 或 GKE 管理的 VM，同樣的設定改寫在 instance template 與 node pool 上，做法在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/" data-link-title="GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換" data-link-desc="要把 IAP 的 tag、OS Login 與移除外部 IP 套到 MIG 或 GKE 上，想照一次實跑過的順序做、看每一步換一種做法的結果時讀；也用在 MIG 重建之後 SSH 出現 REMOTE HOST IDENTIFICATION HAS CHANGED、kubectl debug 停在等待，或關掉 GKE 控制平面的 IP 端點之前要確認每個人都改連的時候">GCP 上 MIG 與 GKE 管理存取的實測紀錄</a>；Shared VPC 的 service project、用 Terraform 管理的專案，指令要改寫在別的位置；新專案從零開始則可以跳過盤點與停用驗證。這幾種環境的調整見概念篇開場之後「另外四種環境要先調整做法再往下讀」那一段。</p>
<h2 id="切換前的盤點依賴舊規則的連線">切換前的盤點：依賴舊規則的連線</h2>
<p><code>default-allow-ssh</code> 放行的不只是管理員本人的 SSH：CI 或部署腳本用外部 IP 直連 VM、Ansible 這類<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具、監控程式的探測、第三方廠商的維護連線，都可能經過這條規則，而預設規則沒有開 logging，手上沒有紀錄可以查出它們。盤點分兩步。先對舊規則開啟 logging，列出經過它的候選來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 對舊規則開啟 logging，之後每一次放行都會寫進 Cloud Logging</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --enable-logging
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --enable-logging
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 觀察一段時間後，列出經由舊規則連進來的來源 IP 與次數（RDP 把規則名稱換成 default-allow-rdp）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND jsonPayload.rule_details.reference:&#34;default-allow-ssh&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>7d --format<span class="o">=</span><span class="s2">&#34;value(jsonPayload.connection.src_ip)&#34;</span> <span class="p">|</span> sort <span class="p">|</span> uniq -c</span></span></code></pre></div><p><code>--freshness=7d</code> 是查詢往回看的時間長度，依自己的部署週期調整，要涵蓋到一次完整的部署與排程工作。實際依賴這條規則的連線，要從 VM 上的成功登入紀錄找（理由見概念篇〈切換到 IAP 的順序〉）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 Linux VM 上列出這段期間成功的 SSH 登入：帳號、來源 IP 與金鑰指紋</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo journalctl -u ssh -u sshd --since <span class="s2">&#34;7 days ago&#34;</span> <span class="p">|</span> grep <span class="s2">&#34;Accepted publickey&#34;</span></span></span></code></pre></div><p>每一行的格式是 <code>Accepted publickey for &lt;user&gt; from &lt;ip&gt; ... &lt;金鑰指紋&gt;</code>，用帳號與金鑰指紋就認得出是哪個人或哪個自動化工具。Windows VM 看事件檢視器的安全性紀錄裡事件 ID 4624、登入類型 10（遠端互動，也就是 RDP）的事件。找出來的非人員來源要先改走 IAP 才能停用舊規則：CI 與自動化工具用服務帳號開 IAP 通道，一般 <code>ssh</code> 的工具照概念篇〈IAP TCP forwarding 的連線路徑與來源範圍〉的 <code>ProxyCommand</code> 改設定。</p>
<ul>
<li><strong>沒做時</strong>：停用舊規則的那一刻，依賴它的 CI、監控與廠商連線跟管理員的一起被拒；這些失敗出現在部署或排程執行時，不一定在切換當下被看到（從規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：觀察期縮短或省略，換到的是切換提早，付出的是只盤點到觀察期內發生過的連線，一個月跑一次的排程會漏掉；只看防火牆紀錄、不看 VM 上的登入紀錄，換到的是不必登入每一台 VM，付出的是 CI 這類來源 IP 每次不同的連線認不出來（兩者都從紀錄的形狀推論，這次沒有實跑）。</li>
</ul>
<h2 id="準備救援入口serial-console-與本機救援帳號">準備救援入口：Serial Console 與本機救援帳號</h2>
<p><a href="/blog/devops/infra/knowledge-cards/serial-console/" data-link-title="Serial Console（序列主控台）" data-link-desc="SSH 因為防火牆、sshd 設定或磁碟滿而連不進主機，或要規劃管理通道斷掉時的備用路徑時，查序列主控台與頻外管理介面的用法與它本身的暴露面">Serial Console</a> 透過 VM 的虛擬序列埠提供登入，不經過 VM 的網路設定與 sshd，在防火牆 tag 漏加、sshd 設定改壞、OS Login 設錯、磁碟滿導致 sshd 起不來時當作救援入口。它用 VM 內的本機帳號密碼登入，與 metadata 金鑰、OS Login 都無關，所以救援帳號要在 VM 還正常時就建好：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在每台 Linux VM 上建一個設了密碼的本機救援帳號（RHEL 系把 sudo 群組換成 wheel）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo useradd -m rescue <span class="o">&amp;&amp;</span> sudo passwd rescue <span class="o">&amp;&amp;</span> sudo usermod -aG sudo rescue
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認 sshd 仍然只收金鑰，救援帳號的密碼只在 Serial Console 上有效</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo sshd -T <span class="p">|</span> grep -i passwordauthentication</span></span></code></pre></div><p><code>rescue</code> 是自己取的帳號名稱。密碼用密碼管理工具逐台隨機產生、存在團隊的密碼庫，不同 VM 不共用同一組；持有救援密碼的人離開團隊時，逐台輪替。<code>sshd -T</code> 印出 sshd 實際生效的設定，那一行要是 <code>passwordauthentication no</code>，否則救援帳號的密碼也能從 SSH 登入。Windows VM 的序列主控台（Special Administration Console）用 Windows 的本機管理員帳號登入，要事先確認那個帳號的密碼在手上。</p>
<p>Serial Console 的互動登入預設關閉，平時保持關閉，救援時才對單一 VM 開啟：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 救援時對單一 VM 開啟 Serial Console 並連上</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata serial-port-enable<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute connect-to-serial-port &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 救援結束後關回去</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata serial-port-enable<span class="o">=</span>FALSE</span></span></code></pre></div><p><code>&lt;instance-name&gt;</code> 與 <code>&lt;zone&gt;</code> 用 <code>gcloud compute instances list</code> 查，第一欄是 VM 名稱、第二欄是 zone。建好救援帳號之後，對一台 VM 照上面的指令開啟、用 <code>rescue</code> 登入並執行一次 <code>sudo -v</code> 確認 sudo 可用，再關回去；這次在 Debian 12 的 VM 上實跑，登入與 sudo 都成功。Windows VM 的 SAC 這次沒有實跑。</p>
<ul>
<li><strong>沒做時</strong>：停用舊規則、或之後啟用 OS Login 時任何一項設錯，VM 沒有不經過網路與 sshd 的入口；剩下的路是在 metadata 設 <code>startup-script</code>（Linux）或 <code>windows-startup-script-ps1</code>（Windows）、重開機以 root 或 SYSTEM 執行來建帳號或改回設定，或把開機磁碟卸下掛到另一台 VM 修改，兩條都要重開機（Compute Engine 的啟動腳本與救援文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>救援帳號寫進自訂映像檔，換到的是新建的 VM 一開始就有，付出的是密碼雜湊在每台 VM 上相同、要輪替就要重做映像（推論，這次沒有實跑）。</li>
<li>靠 startup-script 臨時建帳號，換到的是平時不留密碼，付出的是救援時要重開機，而且 startup-script 存在 metadata 裡，有讀取權限的人都看得到內容（Compute Engine 的 metadata 文件，這次沒有實跑）。</li>
<li>組織強制了 <code>compute.disableSerialPortAccess</code> 時，救援名單要有能對專案解除這條政策的人，見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a> 的〈組織層級的護欄：Organization Policy 與階層式防火牆〉。</li>
</ul>
</li>
</ul>
<h2 id="啟用-iap-api開啟稽核紀錄並建立只放行-iap-來源的防火牆規則">啟用 IAP API、開啟稽核紀錄並建立只放行 IAP 來源的防火牆規則</h2>
<p>IAP 端點以 API 的形式在專案上開關，沒有啟用時，<code>--tunnel-through-iap</code> 的連線在 IAP 端點那一步就開不起來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 啟用 IAP API（每個專案一次）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud services <span class="nb">enable</span> iap.googleapis.com</span></span></code></pre></div><p>同一步在 Google Cloud 控制台的「IAM 與管理 &gt; 稽核記錄」為 Cloud Identity-Aware Proxy 開啟 Data Access 紀錄裡的「管理員讀取」（<code>ADMIN_READ</code>）類型。開通道時 IAP 檢查的權限 <code>iap.tunnelInstances.accessViaIAP</code> 歸在這個類型，每一次開通道會留下一筆方法名稱為 <code>AuthorizeUser</code> 的紀錄。開啟之後經由 IAP 連一次，查到這次連線的紀錄才算開對了類型（<code>protoPayload.resourceName</code> 是 VM 的執行個體 ID）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="s1">&#39;protoPayload.serviceName=&#34;iap.googleapis.com&#34; AND protoPayload.methodName=&#34;AuthorizeUser&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --format<span class="o">=</span><span class="s2">&#34;value(timestamp,protoPayload.authenticationInfo.principalEmail,protoPayload.resourceName)&#34;</span></span></span></code></pre></div><p>也在同一步確認所有要開通道的 Google 帳號都已強制兩步驟驗證，理由見概念篇〈IAP 的 IAM 授權範圍：誰拿得到通道〉最後兩段。</p>
<ul>
<li><strong>沒做時</strong>（不開 <code>ADMIN_READ</code>）：開通道的帳號與目標 VM 沒有 VM 以外的紀錄，事後調查只剩 Admin Activity 裡的設定變更與 VM 上的 sshd 紀錄；開啟之前的通道紀錄查不回來（Cloud Audit Logs 文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：只開「資料讀取」（<code>DATA_READ</code>）實測過：連了十次左右，紀錄零筆。兩種都開，換到的是不必判斷權限歸在哪一類，付出的是紀錄量與費用增加（推論，這次沒有量）。</li>
</ul>
<p>新規則只開需要的 port，並用 target tag 限定作用的 VM：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 放行 IAP 的 IPv4 來源連到帶指定 tag 的 Linux VM 的 22 port</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute firewall-rules create allow-ssh-from-iap <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --action<span class="o">=</span>allow <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --rules<span class="o">=</span>tcp:22 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --source-ranges<span class="o">=</span>35.235.240.0/20 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --target-tags<span class="o">=</span>&lt;ssh-via-iap-tag&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --enable-logging
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 有 Windows VM 時，另建一條放行 3389 port 的規則，用另一個 tag</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">gcloud compute firewall-rules create allow-rdp-from-iap <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --action<span class="o">=</span>allow <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --rules<span class="o">=</span>tcp:3389 <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --source-ranges<span class="o">=</span>35.235.240.0/20 <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>  --target-tags<span class="o">=</span>&lt;rdp-via-iap-tag&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>  --enable-logging</span></span></code></pre></div><ul>
<li><code>allow-ssh-from-iap</code>、<code>allow-rdp-from-iap</code>：規則名稱，自己取，同一個專案裡不能重複。</li>
<li><code>&lt;network-name&gt;</code>：VM 所在的 VPC 名稱，預設網路就是 <code>default</code>，自建網路用 <code>gcloud compute networks list</code> 查。</li>
<li><code>&lt;ssh-via-iap-tag&gt;</code>、<code>&lt;rdp-via-iap-tag&gt;</code>：自己取的 <a href="/blog/devops/infra/knowledge-cards/network-tag/" data-link-title="Network Tag（GCP 網路標籤）" data-link-desc="GCP 防火牆規則套不到某台 VM、新建的 VM 連不上，或要決定防火牆規則用 tag 還是服務帳號選 VM 時，查 network tag 怎麼讓防火牆規則選出要套用的 VM">network tag</a> 名稱，例如 <code>ssh-iap</code>、<code>rdp-iap</code>。SSH 與 RDP 用不同的 tag，Linux VM 就不會被順便開了 3389。</li>
<li><code>--enable-logging</code>：讓防火牆把這條規則的每一次放行寫進 Cloud Logging，驗證新規則與之後稽核都要用到。</li>
<li><code>--source-ranges=35.235.240.0/20</code>：Google 公開的 IAP 來源位址段，每個專案都相同；<code>--rules</code> 依通道填要開的 port（SSH 是 <code>tcp:22</code>、RDP 是 <code>tcp:3389</code>）。這兩條規則沒有寫 <code>--priority</code>，預設是 1000（實測），比 <code>default-allow-ssh</code> 的 65534 先被評估。</li>
</ul>
<p>自建的 custom-mode VPC 有 IPv6 VM 時，再建一條名稱不同（例如 <code>allow-ssh-from-iap-v6</code>）、<code>--source-ranges=2600:2d00:1:7::/64</code>、其餘引數相同的規則。接著對每台 VM 加上對應的 tag，VM 分布在不同 zone 時逐台以各自的 <code>--zone</code> 執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances add-tags &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tags<span class="o">=</span>&lt;ssh-via-iap-tag&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-tags &lt;windows-instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tags<span class="o">=</span>&lt;rdp-via-iap-tag&gt;</span></span></code></pre></div><ul>
<li><strong>沒做時</strong>（漏加 tag）：停用舊規則之後，IAP 回 <code>4003</code>（failed to connect to backend），Connectivity Tests 指出擋下它的是 <code>default-deny-ingress</code>。</li>
<li><strong>替代做法</strong>：
<ul>
<li>省略 <code>--target-tags</code>，換到的是不必逐台加 tag，付出的是 IAP 來源連得到網路裡每一台 VM 的 22 port（照規則的作用推論，這次沒有實跑）。</li>
<li>把 <code>--rules</code> 寫成 <code>all</code>，換到的是不必列 port，付出的是 IAP 來源連得到每一台 VM 的每一個 port，等於把 <code>default-allow-internal</code> 的寬度複製給 IAP（照規則的作用推論，這次沒有實跑）。</li>
<li>改用 <code>--target-service-accounts</code> 以服務帳號選 VM，換到的是 tag 不能被持有 VM 修改權限的人隨手加上（能編輯 VM 的人改得了它的 tag），付出的是同一個服務帳號的 VM 全部一起套用，而且 VM 換服務帳號要停機（VPC 防火牆規則文件，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="授予開通道的-iam-權限">授予開通道的 IAM 權限</h2>
<p>開通道的權限是 IAM role <code>roles/iap.tunnelResourceAccessor</code>。下面的指令把它綁在專案層級，並用 IAM Conditions（在角色綁定上附加條件，條件成立時這個綁定才生效）限定可以連的 port 或期限；條件的寫法依環境選一種：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 只有 Linux VM：條件限定只能連 22 port</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22,title=iap-ssh-only&#39;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 同時有 Windows VM：條件放行 22 與 3389</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 || destination.port == 3389,title=iap-ssh-rdp&#39;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 只允許連特定一台 VM 的 22 port</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 &amp;&amp; destination.ip == &#34;&lt;vm-internal-ip&gt;&#34;,title=iap-ssh-one-vm&#39;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 外包或臨時存取：到期之後綁定自動失效</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;contractor-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 &amp;&amp; request.time &lt; timestamp(&#34;&lt;expiry-time&gt;&#34;),title=iap-ssh-until-expiry&#39;</span></span></span></code></pre></div><ul>
<li><code>&lt;project-id&gt;</code>：VM 所在的專案 ID，用 <code>gcloud config get-value project</code> 查目前設定的是哪一個。</li>
<li><code>&lt;admin-email&gt;</code>、<code>&lt;contractor-email&gt;</code>：要給權限的人的 Google 帳號；給一群人時把 <code>user:</code> 換成 <code>group:</code> 加 Google Group 的位址，CI 這類機器身分換成 <code>serviceAccount:</code> 加服務帳號的位址。</li>
<li><code>&lt;vm-internal-ip&gt;</code>：VM 的內部 IP，<code>gcloud compute instances list</code> 的 <code>INTERNAL_IP</code> 欄查得到。</li>
<li><code>&lt;expiry-time&gt;</code>：綁定失效的時間，RFC 3339 格式，例如 <code>2027-03-31T00:00:00Z</code>。</li>
<li><code>title</code>：這個條件的顯示名稱，自己取，會出現在 IAM 頁面上。</li>
</ul>
<p>這次用第二個帳號實測：沒有這個角色時開通道回 <code>4033</code>；條件只放行 22 時連 3389 回 <code>4033</code>（Owner 帳號對照回 <code>4003</code>，代表 Owner 不受條件限制、而是被防火牆擋下）；限定單一 VM 與過期的條件連續三分鐘回 <code>4033</code>，正對照在 75 秒內放行。IAM 綁定從送出到生效約 60 到 120 秒，這段時間內的 <code>4033</code> 不代表條件寫錯。</p>
<p>Linux VM 的 <code>gcloud compute ssh</code> 另外要 VM 端的登入權限：還沒啟用 OS Login 時，<code>gcloud compute ssh</code> 把公鑰寫進專案或 VM 的 metadata（實測寫進專案 metadata），所以非 Owner 的帳號要有修改 metadata 的權限（例如 <code>roles/compute.instanceAdmin.v1</code>），VM 掛著服務帳號時另外要那個服務帳號的 <code>roles/iam.serviceAccountUser</code>；已經啟用 OS Login 時，改由〈啟用 OS Login：先授角色、單台先行、再做專案層級〉的登入角色決定。兩種情形都要讀取 VM 與專案資訊的權限（例如 <code>roles/compute.viewer</code>）。</p>
<ul>
<li><strong>沒做時</strong>：非 Owner 的管理員開通道回 <code>4033</code>。Owner 不需要這一步，所以團隊全員都是 Owner 時，這一步看起來不必做，而 IAM Conditions 對 Owner 不生效，條件寫了也限不住任何人（Owner 連 3389 回 <code>4003</code> 而不是 <code>4033</code>）。</li>
<li><strong>替代做法</strong>：不加條件，換到的是指令短、不必維護條件，付出的是持有角色的人連得到專案裡每台 VM 的每個 port（只要防火牆放行）；綁給 <code>domain:</code> 或大型群組，換到的是不必逐人授權，付出的是整個組織的帳號都能開通道（兩者都從角色的作用推論，這次沒有實跑）。取捨的判斷見概念篇〈IAP 的 IAM 授權範圍：誰拿得到通道〉。</li>
</ul>
<h2 id="停用舊規則用-iap-驗證新規則">停用舊規則，用 IAP 驗證新規則</h2>
<p>先在舊規則還在時，每一位管理員各自用自己的帳號連一次，確認 IAM 權限：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Linux：透過 IAP 通道 SSH 進 VM</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute ssh &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Windows：把 VM 的 3389 port 經 IAP 轉到本機的 13389，再用 RDP 用戶端連 localhost:13389</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute start-iap-tunnel &lt;windows-instance-name&gt; <span class="m">3389</span> --local-host-port<span class="o">=</span>localhost:13389 --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p>加上 <code>--tunnel-through-iap</code> 時，<code>gcloud</code> 改走 IAP、不連 VM 的外部 IP；VM 還有外部 IP 時，少了這個參數的連線走的是舊路徑。這一次連線驗證的是 IAM：IAP 端點會拒絕沒有權限的帳號。防火牆規則與 tag 這一次驗不到，因為舊規則的 <code>0.0.0.0/0</code> 也放行 IAP 的位址段。接著停用舊規則（規則仍在，只是不生效），由一位管理員對 Linux 與 Windows 各一台 VM 再連一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --disabled
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --disabled
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute ssh &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap</span></span></code></pre></div><p>這一次連得上，代表放行連線的是新規則；前提是專案裡沒有其他來源涵蓋 <code>35.235.240.0/20</code> 的允許規則，用概念篇〈預設網路自帶的防火牆規則〉的列出規則指令確認。這次實測停用之後經由 IAP 連得上、不加 <code>--tunnel-through-iap</code> 的直連被拒，Windows 經 IAP 的 3389 回了 X.224 Connection Confirm。新規則建立時加了 <code>--enable-logging</code>，可以查防火牆紀錄確認命中的是哪一條規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查最近一小時經由 IAP 規則放行的連線</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND jsonPayload.rule_details.reference:&#34;allow-ssh-from-iap&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --limit<span class="o">=</span><span class="m">10</span></span></span></code></pre></div><p>規則剛開啟 logging 的最初幾分鐘，紀錄裡的 <code>rule_details</code> 可能是空的（實測前兩筆沒有、約五分鐘後的三筆有），照本節的順序正好落在這段時間，所以上面這條查不到不代表新規則沒有命中。這時改用來源 IP 查，結果與 <code>rule_details</code> 是否已經填入無關；驗 RDP 的規則時把 <code>22</code> 換成 <code>3389</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND ip_in_net(jsonPayload.connection.src_ip, &#34;35.235.240.0/20&#34;) AND jsonPayload.connection.dest_port=22&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --limit<span class="o">=</span><span class="m">10</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;value(timestamp,jsonPayload.connection.src_ip,jsonPayload.rule_details.reference)&#34;</span></span></span></code></pre></div><p>連不上時，用下面的指令把舊規則恢復，再照概念篇〈IAP 通道連不上時的依層判讀〉找出是哪一層；防火牆規則透過 GCP 的 API 修改，SSH 連不上也改得回來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --no-disabled
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --no-disabled</span></span></code></pre></div><p><code>4003</code> 時要用 Connectivity Tests 查是哪一條規則或政策擋下：<code>gcloud network-management connectivity-tests create &lt;test-name&gt; --source-ip-address=35.235.240.1 --destination-instance=projects/&lt;project-id&gt;/zones/&lt;zone&gt;/instances/&lt;instance-name&gt; --destination-port=22 --protocol=TCP</code>，<code>&lt;test-name&gt;</code> 自己取，<code>--source-ip-address</code> 只收單一 IP，取 IAP 位址段裡的任一個。</p>
<ul>
<li><strong>主控台的 SSH 按鈕</strong>（SSH-in-browser）：官方〈Connect to Linux VMs using IAP〉寫明它只在 VM 沒有外部 IP 時才走 IAP。所以 VM 還帶外部 IP 時，停用舊規則之後，用主控台按鈕的人連不上，而 <code>--tunnel-through-iap</code> 驗起來是通的，這不代表新規則壞了；外部 IP 移除之後，主控台按鈕改走 IAP（照官方文件推出，這次沒有實跑）。盤點時把用主控台按鈕登入的人一起找出來，切換期間請他們改用 <code>--tunnel-through-iap</code>。</li>
<li><strong>沒做時</strong>（只在舊規則還在時驗證）：連得上的那一次可能是舊規則放行的，tag 漏加或規則寫錯都測不出來，要到刪除舊規則之後才發現（照規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>不停用、只查防火牆紀錄裡命中的規則名稱，換到的是驗證期間外部來源照常連得到，付出的是要等 <code>rule_details</code> 填入，而且兩條規則都放行時紀錄記的是先被評估的那一條（推論，這次沒有在兩條並存時查）。</li>
<li>直接刪除舊規則再驗證，換到的是少一步，付出的是出錯時要重建規則，重建時可能寫錯；停用的規則用 <code>--no-disabled</code> 一行恢復原樣。</li>
</ul>
</li>
</ul>
<h2 id="刪除對外開放的規則">刪除對外開放的規則</h2>
<p>刪除之前，列出還沒帶 tag 的 VM，兩份清單都要是空的（只有 Linux 或只有 Windows 時，查對應的那一份）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances list --filter<span class="o">=</span><span class="s2">&#34;NOT tags.items=&lt;ssh-via-iap-tag&gt;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances list --filter<span class="o">=</span><span class="s2">&#34;NOT tags.items=&lt;rdp-via-iap-tag&gt;&#34;</span></span></span></code></pre></div><p>清單裡出現的是另一種作業系統的 VM 時合理（Linux VM 本來就不帶 RDP 的 tag），其餘的要先補上 tag 再刪規則。專案裡還沒有任何 VM 帶 tag 時，<code>gcloud</code> 會先印一行 <code>WARNING: The following filter keys were not present in any resource : tags.items</code>，下面列出的 VM 清單仍然正確（實測）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules delete default-allow-ssh default-allow-rdp</span></span></code></pre></div><p>刪除之後才發現有漏掉的連線來源時，照原樣把規則建回來，再回頭處理（實測建得回來）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules create default-allow-ssh --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:22 --source-ranges<span class="o">=</span>0.0.0.0/0 --priority<span class="o">=</span><span class="m">65534</span> --enable-logging
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute firewall-rules create default-allow-rdp --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:3389 --source-ranges<span class="o">=</span>0.0.0.0/0 --priority<span class="o">=</span><span class="m">65534</span> --enable-logging</span></span></code></pre></div><ul>
<li><strong>沒做時</strong>（停用之後不刪）：停用的規則不放行任何連線，效果與刪除相同；留著的代價是有人用 <code>--no-disabled</code> 一行就恢復全開（照規則的作用推論，這次沒有實跑）。Security Command Center 的 <code>OPEN_SSH_PORT</code> finding 是否把停用的規則算進去，這次沒有確認。</li>
<li><strong>替代做法</strong>：用 <code>gcloud compute firewall-rules update default-allow-ssh --source-ranges=&lt;固定的管理網段&gt;</code> 把來源收窄、不刪除，換到的是保留一條直連的路，付出的是這條路的授權仍然是來源 IP，分辨不出人（取捨見 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a> 的〈網路可達性的做法：管理 port 對誰開放〉，這次沒有實跑）。</li>
</ul>
<h2 id="收斂-default-allow-internal-與-default-allow-icmp">收斂 default-allow-internal 與 default-allow-icmp</h2>
<p><code>default-allow-internal</code> 要先確認 VM 之間實際需要哪些 port（應用連資料庫、節點之間的叢集通訊）再改成具體的規則。改法是為每一組需要的連線各建一條規則，例如應用連 PostgreSQL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules create allow-app-to-db --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:5432 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --source-tags<span class="o">=</span>&lt;app-tag&gt; --target-tags<span class="o">=</span>&lt;db-tag&gt;</span></span></code></pre></div><p><code>&lt;app-tag&gt;</code>、<code>&lt;db-tag&gt;</code> 是自己取的 tag，加在應用與資料庫 VM 上；<code>tcp:5432</code> 換成實際的服務 port。每一組需要的連線都建好之後，再刪 <code>default-allow-internal</code>。<code>default-allow-icmp</code> 的處理看用途：監控或除錯流程會用 <code>ping</code> 確認 VM 存活時，保留並把來源縮到內部網段與監控主機（<code>gcloud compute firewall-rules update default-allow-icmp --source-ranges=10.128.0.0/9,&lt;monitoring-host-ip&gt;/32</code>，<code>&lt;monitoring-host-ip&gt;</code> 是發 ping 的監控主機的 IP）；沒有這種用途就一起刪除。這一節的指令這次沒有實跑。</p>
<ul>
<li><strong>沒做時</strong>：同一個網路裡的 VM 之間不受 port 限制，一台 VM 被入侵之後能直接連到其他 VM 的資料庫與管理 port；管理通道本身不受影響，所以這一步可以排進另一次變更（照規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：直接刪除 <code>default-allow-internal</code>，換到的是一步收完，付出的是 VM 之間的服務連線當場中斷；整個網路改成自己規劃的 custom-mode VPC，換到的是預設規則一次清掉，付出的是 VM 要搬網路，見概念篇〈預設網路自帶的防火牆規則〉（兩者都是推論，這次沒有實跑）。</li>
</ul>
<h2 id="啟用-os-login先授角色單台先行再做專案層級">啟用 OS Login：先授角色、單台先行、再做專案層級</h2>
<p>啟用 OS Login 之後，VM 不再接受 metadata 裡的金鑰（實測：metadata 金鑰被拒），順序為什麼是先授角色、單台先行，見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a> 的〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉。先把登入角色授予每一位管理員與每一個要登入 VM 的服務帳號；VM 掛著服務帳號時，每一個登入者另外需要那個服務帳號的 <code>roles/iam.serviceAccountUser</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 授予管理員 OS Login 的登入角色：一般登入與含 sudo 的兩種擇一</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/compute.osLogin
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/compute.osAdminLogin
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># CI 或自動化工具的服務帳號同樣要授予（需要 sudo 時改用 osAdminLogin）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>serviceAccount:&lt;ci-service-account-email&gt; --role<span class="o">=</span>roles/compute.osLogin
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 查 VM 掛的服務帳號，並授予登入者使用它的權限</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">gcloud compute instances describe &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;get(serviceAccounts[0].email)&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">gcloud iam service-accounts add-iam-policy-binding &lt;service-account-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/iam.serviceAccountUser</span></span></code></pre></div><p><code>&lt;ci-service-account-email&gt;</code> 是 CI 或自動化工具開通道用的服務帳號，<code>&lt;service-account-email&gt;</code> 是上一條 <code>describe</code> 查出來、VM 掛的那個服務帳號，兩者不同。這次用第二個帳號實測：缺 <code>serviceAccountUser</code> 時 <code>Permission denied (publickey)</code>，補上之後 20 秒內登入；<code>osLogin</code> 不能 sudo、<code>osAdminLogin</code> 可以。</p>
<p>接著先對一台 VM 啟用，讓每一位非 Owner 的管理員各自用自己的帳號連通並確認 sudo 可用，CI 也對這台跑一次部署，再做專案層級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 先對單一 VM 啟用，連通確認</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata enable-oslogin<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認之後在專案層級啟用（VM 層級的設定會蓋過專案層級）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute project-info add-metadata --metadata enable-oslogin<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 連不上時還原（修改 metadata 走 API，不需要登入 VM）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata enable-oslogin<span class="o">=</span>FALSE
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud compute project-info add-metadata --metadata enable-oslogin<span class="o">=</span>FALSE</span></span></code></pre></div><p>專案層級啟用之後再盤點兩件事。VM 層級設了 <code>enable-oslogin=FALSE</code> 的 VM 會蓋過專案設定、仍然讀 metadata 金鑰（實測），用下面這條列出它們的名稱，逐台改成 <code>TRUE</code> 或移除這個鍵：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances list --format<span class="o">=</span>json <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="p">|</span> jq -r <span class="s1">&#39;.[] | select(any(.metadata.items[]?; .key==&#34;enable-oslogin&#34; and (.value|ascii_downcase)==&#34;false&#34;)) | .name&#39;</span></span></span></code></pre></div><p>專案 metadata 的 <code>ssh-keys</code> 仍然留著前任與現任成員的公鑰，OS Login 一旦還原成 <code>FALSE</code> 它們就又能用，確認全部 VM 都啟用之後用 <code>gcloud compute project-info remove-metadata --keys=ssh-keys</code> 清掉（實測）。</p>
<ul>
<li><strong>沒做時</strong>（不啟用 OS Login）：登入驗證仍是 metadata 裡的公鑰，寫在專案 metadata 的公鑰對專案裡每台 VM 都生效，成員離職時漏刪一把，那把金鑰就持續能登入整個專案（OS Login 文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>直接在專案層級啟用、不經過單台，換到的是少一輪驗證，付出的是角色漏授時鎖住的是整個專案的每一台 VM；只由 Owner 驗證，看不出漏授，因為 Owner 持有全部權限（推論，這次沒有實跑）。</li>
<li>改用 SSH 憑證（OpenSSH CA），換到的是主機不全在 GCP 時也能用同一套，付出的是要維運 CA，見 <a href="/blog/devops/infra/03-network-foundation/openssh-ca-setup/" data-link-title="OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證" data-link-desc="用 OpenSSH 自帶的 ssh-keygen 與 sshd 建立 SSH 憑證的完整流程：使用者 CA 與主機 CA 的金鑰怎麼產生與保管、sshd 的 TrustedUserCAKeys 與 AuthorizedPrincipalsFile、簽發時的識別字串與有效期、憑證外洩時用 KRL 撤銷，以及用主機憑證取代逐台確認指紋，以及導入順序與兩個會讓所有人登不進去的設定：AuthorizedPrincipalsFile 的讀取權限、RevokedKeys 指的檔案不存在">OpenSSH 內建 CA 的 SSH 憑證導入</a>。</li>
<li>再加 <code>enable-oslogin-2fa=TRUE</code> 要求每次登入多一次第二因素，同樣先對單一 VM 設定驗證，連不上時改回 <code>FALSE</code>。付出的是每一位登入者的 Google 帳號都要先啟用兩步驟驗證，沒有啟用的會登不進去；而 OS Login 文件寫明兩步驟驗證不套用在服務帳號上，CI 這類登入者不受它約束（OS Login 文件，這次沒有實跑）。換到的是帳號密碼或本機的 refresh token 外洩時，登入還要第二因素（推論，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="建-cloud-nat-並移除外部-ip">建 Cloud NAT 並移除外部 IP</h2>
<p>Cloud NAT 承接沒有外部 IP 的 VM 往外的連線，建在 Cloud Router 上，以 region 為單位生效。哪些出站連線改由 Cloud NAT 承接、哪些改走 Private Google Access，以及出站的驗證為什麼要在移除外部 IP 之後做，見概念篇〈拿掉外部 IP 之後的出站路徑：Cloud NAT 與 Private Google Access〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在 VM 所在的 region 建 Cloud Router，再在上面建 Cloud NAT（出站 IP 自動配發）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute routers create &lt;router-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; --region<span class="o">=</span>&lt;region&gt;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud compute routers nats create &lt;nat-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --router<span class="o">=</span>&lt;router-name&gt; --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --auto-allocate-nat-external-ips <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --nat-all-subnet-ip-ranges
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 外部服務要求來源 IP 白名單時：先預留靜態 IP，NAT 改用它出站</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud compute addresses create &lt;nat-ip-name&gt; --region<span class="o">=</span>&lt;region&gt;
</span></span><span class="line"><span class="ln">11</span><span class="cl">gcloud compute routers nats create &lt;nat-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --router<span class="o">=</span>&lt;router-name&gt; --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --nat-external-ip-pool<span class="o">=</span>&lt;nat-ip-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --nat-all-subnet-ip-ranges</span></span></code></pre></div><ul>
<li><code>&lt;router-name&gt;</code>、<code>&lt;nat-name&gt;</code>、<code>&lt;nat-ip-name&gt;</code>：自己取的資源名稱。</li>
<li><code>&lt;region&gt;</code>：VM 所在 zone 去掉最後的字母那一段，例如 zone <code>asia-east1-b</code> 對應 region <code>asia-east1</code>；VM 分布在幾個 region 就要各建一組。</li>
<li><code>--auto-allocate-nat-external-ips</code> 讓 GCP 自動配發出站用的外部 IP，這些 IP 可能變動；<code>--nat-external-ip-pool</code> 改用預留的靜態 IP。</li>
<li><code>--nat-all-subnet-ip-ranges</code> 讓這個 region 裡所有 subnet 都走這個 NAT，只想讓部分 subnet 出站時改用 <code>--nat-custom-subnet-ip-ranges</code> 逐一列出。</li>
</ul>
<p>VM 要存取 Cloud Storage、Artifact Registry 這類 Google API 時，subnet 開啟 Private Google Access，就能用內部 IP 連到 Google API，不經過 Cloud NAT：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute networks subnets update &lt;subnet-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --region<span class="o">=</span>&lt;region&gt; --enable-private-ip-google-access</span></span></code></pre></div><p><code>&lt;subnet-name&gt;</code> 用 <code>gcloud compute networks subnets list --filter=&quot;region:&lt;region&gt;&quot;</code> 查；<code>default</code> 網路在每個 region 各有一個名為 <code>default</code> 的 subnet。</p>
<p>外部 IP 設定在 VM 網卡的 access config 上，<code>delete-access-config</code> 要靠名稱指定移除哪一個，而這個名稱依 VM 建立的方式不同，所以先查出來。對外提供服務的 VM 移除之前，要先把流量入口換到負載平衡器；只做管理用途的 VM 可以直接移除。先在一台 VM 上移除、確認，再做其餘的 VM：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查 VM 第一張網卡的 access config 名稱</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances describe &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;get(networkInterfaces[0].accessConfigs[0].name)&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 用查到的名稱移除外部 IP</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud compute instances delete-access-config &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --access-config-name<span class="o">=</span><span class="s2">&#34;&lt;access-config-name&gt;&#34;</span></span></span></code></pre></div><p>移除之後經由 IAP 連進這台 VM，執行 <code>curl -s https://ifconfig.me</code> 印出出站時對方看到的來源 IP；那個 IP 列在 <code>gcloud compute routers get-nat-ip-info &lt;router-name&gt; --region=&lt;region&gt;</code> 的清單裡，就代表出站走了 Cloud NAT。這次實測 <code>--auto-allocate-nat-external-ips</code> 配到的 IP 正好是這台 VM 剛釋放的外部 IP，移除前後 <code>curl</code> 印出同一個 IP，<code>gcloud compute addresses list</code> 的 <code>PURPOSE</code> 欄是 <code>NAT_AUTO</code>，所以判斷依據是那份清單，而不是移除前後的 IP 有沒有變。連不到網際網路時，用 <code>gcloud compute instances add-access-config &lt;instance-name&gt; --zone=&lt;zone&gt;</code> 把外部 IP 加回去，再回頭查 Cloud NAT 的 region 與 subnet 設定。</p>
<ul>
<li><strong>沒做時</strong>（保留外部 IP）：管理通道已經只放行 IAP，SSH 照樣安全；外部 IP 仍讓 VM 的其他 port 只靠防火牆擋，而防火牆規則一寫寬，那台 VM 就直接暴露（照規則的作用推論，這次沒有實跑）。Security Command Center 有標出帶公開 IP 的資源的 finding 類別，VM 不需要對外時移除之後少一類要處理的 finding（Security Health Analytics 文件）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>反過來先移除 IP、再建 NAT，換到的是驗證不受外部 IP 干擾，付出的是移除到 NAT 建好之間 VM 連不到網際網路，<code>apt update</code> 這類操作會失敗（推論，這次沒有實跑）。</li>
<li>不建 Cloud NAT、只開 Private Google Access，換到的是不經過 NAT、不產生 NAT 的閘道時數與資料處理費，付出的是 VM 只連得到 Google API，連不到外部的套件來源與第三方 API（Cloud NAT 定價與 Private Google Access 文件，這次沒有實跑）。</li>
<li>用 <code>--nat-external-ip-pool</code> 改用預留的靜態 IP，換到的是外部服務可以把這個固定的 IP 列進白名單；Cloud NAT 定價頁寫明 NAT 使用的外部 IP 不論靜態或自動配發都按 IP 計費，差別在靜態 IP 從 NAT 拿下來之後仍然保留在專案裡、要另外釋放（Cloud NAT 定價與 IP 位址文件，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<p>移除之後，這台 VM 只剩 IAP 一條管理路徑，連線一律加 <code>--tunnel-through-iap</code>；救援入口是〈準備救援入口：Serial Console 與本機救援帳號〉建好的那一條。</p>
]]></content:encoded></item><item><title>GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/</guid><description>&lt;p>實測日期是 2026-10-24，在 GCP 測試專案 &lt;code>blog-verify-20261024&lt;/code>（zone &lt;code>asia-east1-b&lt;/code>）上：MIG 用 Debian 12 的 e2-micro，GKE 是 zonal 的 Standard 叢集（版本 1.36.4-gke、Container-Optimized OS 節點）；控制平面的 IAM 檢查另外用第二個帳號的 access token 驗證。&lt;code>kubectl debug node&lt;/code> 與 Pod Security Admission 另外在本機的 kind（Kubernetes v1.37.0、kubectl v1.37.1）上實跑。&lt;/p>
&lt;p>這篇記錄把 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄&lt;/a>（下文稱 IAP 實作文章）的三項設定（IAP 用的 network tag、OS Login、移除外部 IP）套到 MIG 與 GKE 上的實際操作。內容依操作的順序是：建一份帶這三項設定的新 instance template，先讓一台 VM 換成新版（canary）再讓整組更新；用 &lt;code>recreate-instances&lt;/code> 重建一台壞掉的 VM、比對重建之後換掉的主機金鑰；template 本身寫錯時退回上一版；在 GKE 上建一個專門給節點除錯用的 namespace，用 &lt;code>kubectl debug node&lt;/code> 查看節點；node pool 的 private nodes、network tag 與 OS Login；控制平面的 authorized networks，以及改連 DNS-based endpoint、關掉 IP 端點的兩步切換。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。逐台設定為什麼在這兩種環境裡會消失、MIG 救援的取捨、GKE 的兩個存取面、&lt;code>kubectl debug node&lt;/code> 的權限邊界，以及控制平面的 authorized networks、DNS-based endpoint 與 private endpoint 三種存取方式的差別，寫在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取&lt;/a>（下文稱概念篇），這裡不重寫。&lt;/p>
&lt;p>MIG 與 GKE 由 Terraform 這類 IaC 管理時，下面的 &lt;code>gcloud&lt;/code> 修改要改寫在程式碼裡，CLI 上只執行 &lt;code>describe&lt;/code> 這類讀取用的指令；這篇的指令在沒有 IaC 的環境照著執行，有 IaC 的環境把它們當成欄位對照。instance template 不能修改、又被 MIG 引用著，所以 IaC 改 template 時，取代的順序要另外設成先建新的再刪舊的（Terraform 是 &lt;code>name_prefix&lt;/code> 搭配 &lt;code>lifecycle { create_before_destroy = true }&lt;/code>），否則先刪舊 template 的那一步會因為它還在使用中而失敗；canary 對應 &lt;code>google_compute_instance_group_manager&lt;/code> 的兩個 &lt;code>version&lt;/code> 區塊。這一段是推論，這次沒有用 Terraform 實跑。&lt;/p></description><content:encoded><![CDATA[<p>實測日期是 2026-10-24，在 GCP 測試專案 <code>blog-verify-20261024</code>（zone <code>asia-east1-b</code>）上：MIG 用 Debian 12 的 e2-micro，GKE 是 zonal 的 Standard 叢集（版本 1.36.4-gke、Container-Optimized OS 節點）；控制平面的 IAM 檢查另外用第二個帳號的 access token 驗證。<code>kubectl debug node</code> 與 Pod Security Admission 另外在本機的 kind（Kubernetes v1.37.0、kubectl v1.37.1）上實跑。</p>
<p>這篇記錄把 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>（下文稱 IAP 實作文章）的三項設定（IAP 用的 network tag、OS Login、移除外部 IP）套到 MIG 與 GKE 上的實際操作。內容依操作的順序是：建一份帶這三項設定的新 instance template，先讓一台 VM 換成新版（canary）再讓整組更新；用 <code>recreate-instances</code> 重建一台壞掉的 VM、比對重建之後換掉的主機金鑰；template 本身寫錯時退回上一版；在 GKE 上建一個專門給節點除錯用的 namespace，用 <code>kubectl debug node</code> 查看節點；node pool 的 private nodes、network tag 與 OS Login；控制平面的 authorized networks，以及改連 DNS-based endpoint、關掉 IP 端點的兩步切換。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。逐台設定為什麼在這兩種環境裡會消失、MIG 救援的取捨、GKE 的兩個存取面、<code>kubectl debug node</code> 的權限邊界，以及控制平面的 authorized networks、DNS-based endpoint 與 private endpoint 三種存取方式的差別，寫在 <a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a>（下文稱概念篇），這裡不重寫。</p>
<p>MIG 與 GKE 由 Terraform 這類 IaC 管理時，下面的 <code>gcloud</code> 修改要改寫在程式碼裡，CLI 上只執行 <code>describe</code> 這類讀取用的指令；這篇的指令在沒有 IaC 的環境照著執行，有 IaC 的環境把它們當成欄位對照。instance template 不能修改、又被 MIG 引用著，所以 IaC 改 template 時，取代的順序要另外設成先建新的再刪舊的（Terraform 是 <code>name_prefix</code> 搭配 <code>lifecycle { create_before_destroy = true }</code>），否則先刪舊 template 的那一步會因為它還在使用中而失敗；canary 對應 <code>google_compute_instance_group_manager</code> 的兩個 <code>version</code> 區塊。這一段是推論，這次沒有用 Terraform 實跑。</p>
<h2 id="建新的-instance-template現行設定照抄再加-tagos-login-與不配外部-ip">建新的 instance template：現行設定照抄，再加 tag、OS Login 與不配外部 IP</h2>
<p>instance template 建立之後不能修改，<code>instance-templates create</code> 從預設值開始建、不沿用舊 template 的任何設定，所以先把現行 template 讀出來，逐項帶進新的那一份。這三項設定為什麼要寫進 template、不能在 VM 上逐台改，見概念篇〈autohealing、滾動更新與擴增後消失的逐台設定：network tag、OS Login metadata 與外部 IP〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 讀出現行 template 的完整設定，新 template 要逐項帶上</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute instance-templates describe &lt;current-template-name&gt; --format<span class="o">=</span>yaml
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 建一份新的 instance template：現行設定照抄，再加上 IAP 用的 tag、啟用 OS Login、不配外部 IP</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">gcloud compute instance-templates create &lt;new-template-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --machine-type<span class="o">=</span>&lt;machine-type&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --image-family<span class="o">=</span>&lt;image-family&gt; --image-project<span class="o">=</span>&lt;image-project&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --boot-disk-size<span class="o">=</span>&lt;boot-disk-size&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; --subnet<span class="o">=</span>&lt;subnet-name&gt; --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --service-account<span class="o">=</span>&lt;service-account-email&gt; --scopes<span class="o">=</span>&lt;scopes&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --labels<span class="o">=</span>&lt;labels&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --tags<span class="o">=</span>&lt;ssh-via-iap-tag&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --metadata<span class="o">=</span>enable-oslogin<span class="o">=</span>TRUE <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --metadata-from-file<span class="o">=</span>startup-script<span class="o">=</span>&lt;startup-script-file&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --no-address
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 比對新舊兩份的設定：差異只該是刻意改的 tag、enable-oslogin 與 accessConfigs</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">diff &lt;<span class="o">(</span>gcloud compute instance-templates describe &lt;current-template-name&gt; --format<span class="o">=</span><span class="s2">&#34;yaml(properties)&#34;</span><span class="o">)</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>     &lt;<span class="o">(</span>gcloud compute instance-templates describe &lt;new-template-name&gt; --format<span class="o">=</span><span class="s2">&#34;yaml(properties)&#34;</span><span class="o">)</span></span></span></code></pre></div><ul>
<li><code>&lt;current-template-name&gt;</code>：MIG 目前用的 template，<code>gcloud compute instance-groups managed describe &lt;mig-name&gt; --zone=&lt;zone&gt;</code> 的 <code>instanceTemplate</code> 欄。</li>
<li><code>&lt;new-template-name&gt;</code>：新 template 的名稱，自己取；template 不能改名也不能改內容，常見的做法是名稱帶版本或日期。</li>
<li><code>--machine-type</code>、<code>--image-family</code>、<code>--image-project</code>、<code>--boot-disk-size</code>、<code>--network</code>、<code>--subnet</code>、<code>--region</code>、<code>--service-account</code>、<code>--scopes</code>、<code>--labels</code>、<code>--metadata-from-file=startup-script=</code>：照 <code>describe</code> 讀出來的值填，<code>describe</code> 裡還有其他欄位（額外的磁碟、GPU、其他 metadata 鍵）也要對應的旗標一起帶上。<code>--region</code> 是 <code>--subnet</code> 所在的 region。<code>describe</code> 的欄位與旗標不是一對一：映像在 <code>disks[].initializeParams.sourceImage</code>，值可能是一個特定映像的完整路徑，也可能是 <code>.../images/family/&lt;family&gt;</code> 這種 family 的路徑（實測：用 <code>--image-family</code> 建的 template 讀出來是 <code>.../images/family/debian-12</code>），兩種都可以原樣交給 <code>--image=&lt;sourceImage 的值&gt;</code>，取代 <code>--image-family</code> 與 <code>--image-project</code>；服務帳號在 <code>serviceAccounts[].email</code>，template 用的是 Compute Engine 預設服務帳號時這一欄顯示 <code>default</code> 而不是 email（實測），原樣寫成 <code>--service-account=default</code>，建出的 template 用的仍是同一個預設服務帳號；startup-script 在 <code>metadata.items</code> 裡 <code>key: startup-script</code> 的 <code>value</code>，先把它存成檔案再交給 <code>--metadata-from-file</code>；regional 的 template 讀取時 <code>describe</code> 要加 <code>--region=&lt;region&gt;</code>。</li>
<li><code>--tags=&lt;ssh-via-iap-tag&gt;</code>：與 IAP 規則的 <code>--target-tags</code> 相同的 tag。新 template 只帶這裡列出的 tag，所以現行 template 已經有的其他 tag（<code>describe</code> 輸出的 <code>tags.items</code>，例如負載平衡器健康檢查用的）要一起列進來，逗號分隔。</li>
<li><code>--metadata=enable-oslogin=TRUE</code>：讓這個 template 建出的 VM 都啟用 OS Login；專案層級已經啟用時可以省略。</li>
<li><code>--no-address</code>：不配外部 IP。先確認流量入口已經在負載平衡器上，<code>gcloud compute backend-services list --format=&quot;table(name, backends[].group)&quot;</code> 的 <code>backends</code> 欄要有這個 MIG。這一點只能事先查：服務若是靠 VM 的外部 IP 對外，〈先讓一台 VM 換成新 template（canary），再讓整組更新〉換上新版的那一台只是收不到流量、不容易察覺，canary 這一步驗不出來，整組更新之後服務就斷了。出站改由 Cloud NAT 承接，所以 Cloud NAT 要在滾動更新之前建好。已經存在的 VM 在滾動更新把它換成新版時才失去外部 IP。</li>
</ul>
<p>這次用 <code>--service-account=default</code> 建的新 template，與現行 template 的 <code>properties</code> 比對，只差刻意改的 tag、<code>enable-oslogin</code> 與 <code>accessConfigs</code>。</p>
<ul>
<li><strong>沒做時</strong>（漏帶 <code>describe</code> 裡的欄位）：漏掉的欄位回到預設值，常見的後果是 VM 換成預設的服務帳號而失去存取權限，或啟動腳本沒有執行（從 <code>create</code> 由預設值開始建這一點推論，這次沒有實跑）；上面的 <code>diff</code> 會把漏掉的欄位列出來。</li>
<li><strong>替代做法</strong>：
<ul>
<li>用 <code>gcloud compute instance-templates create &lt;new-template-name&gt; --source-instance=&lt;instance-name&gt; --source-instance-zone=&lt;zone&gt;</code> 從一台現有的 VM 產生 template，換到的是不必逐項抄旗標；付出的是這條指令只能另外覆寫磁碟的定義，tag、OS Login 與 <code>--no-address</code> 要再以它為基礎建第二份，而來源 VM 有靜態外部 IP 時，那個位址會被抄進 template（Compute Engine〈Create instance templates〉，這次沒有實跑）。</li>
<li>在專案層級設 <code>enable-oslogin=TRUE</code>、不寫進 template，換到的是 template 少一個鍵，付出的是 template 的行為依賴專案的設定，搬到別的專案就變了（推論，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="先讓一台-vm-換成新-templatecanary再讓整組更新">先讓一台 VM 換成新 template（canary），再讓整組更新</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 先讓一台 VM 用新 template（canary），其餘維持現行 template</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instance-groups managed rolling-action start-update &lt;mig-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --version<span class="o">=</span><span class="nv">template</span><span class="o">=</span>&lt;current-template-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --canary-version<span class="o">=</span><span class="nv">template</span><span class="o">=</span>&lt;new-template-name&gt;,target-size<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --zone<span class="o">=</span>&lt;zone&gt;
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 那一台驗證通過之後，讓整組換成新 template</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud compute instance-groups managed rolling-action start-update &lt;mig-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --version<span class="o">=</span><span class="nv">template</span><span class="o">=</span>&lt;new-template-name&gt; --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><ul>
<li><code>--version=template=</code>：canary 那一行放<strong>現行</strong>的 template，代表其餘 VM 維持原狀；整組更新那一行放新的 template。兩行的 <code>--version</code> 放的 template 相反。</li>
<li><code>--canary-version=template=&lt;new-template-name&gt;,target-size=1</code>：讓一台 VM 換成新 template；<code>target-size</code> 也可以寫百分比（<code>10%</code>），條件是 MIG 有 10 台以上（Compute Engine〈Rolling out updates〉）。</li>
<li><code>&lt;mig-name&gt;</code>、<code>--zone</code>：MIG 的名稱與所在 zone，<code>gcloud compute instance-groups managed list</code> 查得到；regional MIG 把 <code>--zone</code> 換成 <code>--region</code>。</li>
</ul>
<p><code>start-update</code> 送出之後 MIG 在背景替換 VM，指令本身不等它完成。用下面兩條確認替換做完、並找出 canary 那一台：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 等到每台 VM 都換成指定的版本才返回</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instance-groups managed wait-until &lt;mig-name&gt; --version-target-reached --zone<span class="o">=</span>&lt;zone&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 列出 MIG 裡的 VM 與各自用的 template，INSTANCE_TEMPLATE 欄是新 template 的那一台就是 canary</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute instance-groups managed list-instances &lt;mig-name&gt; --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p>實測 <code>wait-until</code> 在替換做完之後才返回，<code>list-instances</code> 的 <code>INSTANCE_TEMPLATE</code> 欄只有一台是新 template。找到 canary 那一台之後，經由 IAP 連進去，確認 OS Login 登得進去、出站經過 Cloud NAT（驗證方式見 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈建 Cloud NAT 並移除外部 IP〉）。整組更新之後同樣用 <code>wait-until</code> 等它完成。</p>
<ul>
<li><strong>沒做時</strong>（不經過 canary、直接整組更新）：template 的 tag 或 OS Login 寫錯時，整組 VM 一起連不上（照滾動更新的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>只改 tag 與 metadata 時加 <code>--minimal-action=refresh</code>：Compute Engine〈Rolling out updates〉寫明 refresh 不停止 VM，可以套用 metadata、labels、tags 這類項目，換到的是 VM 不重建、主機金鑰不變；付出的是 <code>--no-address</code> 移除外部 IP 不在那份清單上，很可能仍要重建（照官方文件推出，這次沒有實跑）。</li>
<li><code>--type=opportunistic</code> 只在 VM 重建或擴增時才套用新 template，換到的是不主動中斷任何一台，付出的是組內新舊混雜，舊的 VM 一直沒有新的 tag（照官方文件推出，這次沒有實跑）。</li>
<li>整組更新時加 <code>--max-surge=1 --max-unavailable=0</code>，每一批先多建一台新的、再刪一台舊的（實測先建後刪），換到的是更新期間可用台數不減少，付出的是多一台 VM 的費用，而且這兩個旗標只控制速度，更新仍會把整組換完。regional MIG 的 <code>--max-surge</code> 用固定數字時，GCP 只接受 0 或不小於 zone 數的值，錯誤訊息是 <code>has to be either 0 or at least equal to the number of zones</code>，所以分布在兩個以上 zone 的 regional MIG，<code>--max-surge=1</code> 會被拒絕。</li>
</ul>
</li>
</ul>
<h2 id="重建一台壞掉的-vmrecreate-instances-與重建之後的主機金鑰">重建一台壞掉的 VM：recreate-instances 與重建之後的主機金鑰</h2>
<p>一台 VM 連不上、而原因是那台 VM 自己壞了（sshd 設定改壞、磁碟滿），讓 MIG 依 template 重建它：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 依 template 重建一台 VM：開機磁碟換成依 MIG 目前的版本設定新建的一顆，VM 重新開機</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instance-groups managed recreate-instances &lt;mig-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --instances<span class="o">=</span>&lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p><code>&lt;instance-name&gt;</code> 是要重建的那台 VM，<code>gcloud compute instance-groups managed list-instances &lt;mig-name&gt; --zone=&lt;zone&gt;</code> 列出 MIG 裡的 VM。實測重建在稽核紀錄裡是一次 <code>instances.update</code>（沒有 delete 與第二次 insert），VM 沿用原本的名稱與執行個體 ID，<code>gcloud compute instances describe</code> 的建立時間也不變，所以確認重建做完要看開機磁碟：MIG 建的 VM 開機磁碟預設與 VM 同名，<code>gcloud compute disks describe &lt;instance-name&gt; --zone=&lt;zone&gt; --format=&quot;value(creationTimestamp)&quot;</code> 印出的時間落在重建的那一刻，就代表磁碟已經換新。canary 期間重建的 VM 用哪一版，由 MIG 依兩個版本各自的 <code>target-size</code> 分配，實測重建的那一台沿用了原版本。</p>
<p>重建之後第一次 <code>gcloud compute ssh</code> 出現 <code>WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!</code> 並拒絕連線（實測），原因與這個警告為什麼不能直接略過，見 <a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a> 的〈MIG 的救援：重建、退回舊版 template 與 Serial Console 各自適用的情形〉。比對的來源是 VM 回報到 guest attributes 的主機金鑰，專案或 template 的 metadata 要事先設了 <code>enable-guest-attributes=TRUE</code>（專案層用 <code>gcloud compute project-info add-metadata --metadata enable-guest-attributes=TRUE</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 讀出 VM 回報的 ED25519 主機金鑰指紋，與警告訊息裡 &#34;The fingerprint for the ED25519 key sent by the remote host is&#34; 下一行的 SHA256 值比對</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances get-guest-attributes &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query-path<span class="o">=</span>hostkeys/ssh-ed25519 --format<span class="o">=</span><span class="s2">&#34;value(value)&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  <span class="p">|</span> awk <span class="s1">&#39;{print &#34;ssh-ed25519 &#34; $1}&#39;</span> <span class="p">|</span> ssh-keygen -lf -
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 兩個指紋相同之後，移除這台 VM 的舊紀錄，再用 gcloud compute ssh 連線</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">ssh-keygen -R compute.&lt;instance-id&gt; -f ~/.ssh/google_compute_known_hosts</span></span></code></pre></div><p><code>&lt;instance-id&gt;</code> 是 <code>gcloud compute instances describe &lt;instance-name&gt; --zone=&lt;zone&gt; --format=&quot;value(id)&quot;</code> 印出的數字。<code>ssh-keygen -R</code> 會把原本的檔案備份成同目錄的 <code>google_compute_known_hosts.old</code>。實測啟用 guest attributes 之後，gcloud 不會自動更新已經存在的 known_hosts 紀錄，讀出的 ED25519 指紋與警告裡的一致，移除舊紀錄之後即可連線。兩個指紋不同時不要移除，先確認連到的 IP 仍是這台 VM 的位址，以及連線是否經過了 IAP 以外的代理；兩項都正常而指紋仍不同時，改從 <a href="/blog/devops/infra/knowledge-cards/serial-console/" data-link-title="Serial Console（序列主控台）" data-link-desc="SSH 因為防火牆、sshd 設定或磁碟滿而連不進主機，或要規劃管理通道斷掉時的備用路徑時，查序列主控台與頻外管理介面的用法與它本身的暴露面">Serial Console</a> 登入 VM、讀 <code>/etc/ssh/ssh_host_ed25519_key.pub</code> 的指紋再比一次。警告寫的是 ECDSA 時，指令裡的 <code>ED25519</code> 與 <code>ssh-ed25519</code> 分別換成 <code>ECDSA</code> 與 <code>ecdsa-sha2-nistp256</code>。</p>
<ul>
<li><strong>沒做時</strong>（不設 <code>enable-guest-attributes</code>）：重建之後沒有不經過這條 SSH 連線的地方可以比對主機金鑰，只剩從 Serial Console 登入讀指紋，或不比對就移除舊紀錄；不比對就移除，等於關掉主機金鑰檢查（推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>從 Serial Console 登入修那台 VM，換到的是保留現場，付出的是要事先在映像檔裡放救援帳號（救援帳號的密碼雜湊寫在映像檔裡，不寫進 template 的 startup-script，理由與共用密碼雜湊的代價見概念篇〈MIG 的救援：重建、退回舊版 template 與 Serial Console 各自適用的情形〉）；登入與 sudo 的做法見 IAP 實作文章的〈準備救援入口：Serial Console 與本機救援帳號〉。</li>
<li>VM 上有不能丟的狀態時，MIG 的 stateful 設定（stateful policy 或 per-instance config）把指定的磁碟、IP 與 metadata 標為要保留，<code>recreate-instances</code> 重建時會沿用它們（Compute Engine〈Stateful MIGs〉，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="template-本身寫錯時退回上一版">template 本身寫錯時退回上一版</h2>
<p>重建修不好的是 template 本身的錯：新版 template 的 tag 寫錯、啟動腳本有錯，重建出來的每一台都一樣壞。這種錯要退回上一版 template，所以舊版 template 留到新版驗證完之後再刪：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 退回上一版 template：整組換回舊版；canary 期間執行時，只有 canary 那幾台被換回</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instance-groups managed rolling-action start-update &lt;mig-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --version<span class="o">=</span><span class="nv">template</span><span class="o">=</span>&lt;previous-template-name&gt; --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p><code>&lt;previous-template-name&gt;</code> 是新版之前那一份 template 的名稱，<code>gcloud compute instance-templates list</code> 列得出來。這次實測退回成功。Compute Engine〈Rolling out updates〉寫明沒有專門的退回指令，退回就是送一次指向舊 template 的更新。</p>
<ul>
<li><strong>沒做時</strong>（新版一驗證就刪掉舊 template）：退回時要用 <code>describe</code> 讀過的舊設定重建一份 template，而舊 template 已經刪掉、讀不到了（從 template 不能修改、刪了就沒有這兩點推論，這次沒有實跑）。</li>
</ul>
<h2 id="gke-節點除錯建專用的-namespace用-kubectl-debug-node-查看節點">GKE 節點除錯：建專用的 namespace，用 kubectl debug node 查看節點</h2>
<p>節點除錯集中在一個刻意保留為 <code>privileged</code> 等級、只有維運群組能建 Pod 的 namespace，由叢集管理員事先建好：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建一個專門給節點除錯用的 namespace，並允許特權 Pod</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kubectl create namespace &lt;namespace&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">kubectl label namespace &lt;namespace&gt; pod-security.kubernetes.io/enforce<span class="o">=</span>privileged</span></span></code></pre></div><p><code>&lt;namespace&gt;</code> 自己取，例如 <code>node-debug</code>；建好之後只把 <a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a> 的〈kubectl debug node 的權限邊界：RBAC 與 Pod Security Admission〉列的那組 <code>pods</code> 與 <code>pods/attach</code> 權限綁給維運群組。接著在這個 namespace 裡對節點開除錯 Pod：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在指定節點上開一個除錯用的 Pod，節點的根目錄掛在 /host</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kubectl debug node/&lt;node-name&gt; -it --image<span class="o">=</span>ubuntu --profile<span class="o">=</span>sysadmin --namespace<span class="o">=</span>&lt;namespace&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 進到 Pod 之後，切換到節點的根目錄，像登入節點一樣查看</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">chroot /host</span></span></code></pre></div><ul>
<li><code>&lt;node-name&gt;</code>：節點名稱，<code>kubectl get nodes</code> 的第一欄。</li>
<li><code>--image=ubuntu</code>：除錯 Pod 用的映像檔，換成組織允許、帶著除錯工具的映像檔。</li>
<li><code>--profile=sysadmin</code>：讓除錯 Pod 以<a href="/blog/backend/knowledge-cards/privileged-container/" data-link-title="Privileged Container（特權容器）" data-link-desc="在 Pod 設定裡看到 securityContext.privileged=true、要判斷某個除錯或系統元件為什麼需要它，或評估誰能建這種 Pod 等於持有節點 root 權限時，查特權容器能做什麼與它的權限邊界">特權容器（privileged container）</a>執行，多給的是需要額外 Linux capability 的操作，例如用 <code>nsenter</code> 進入節點 PID 1 的 namespace、掛載檔案系統；只查看檔案與 log 時，預設的 <code>general</code> profile 就夠了（<code>kubectl debug --help</code> 的 <code>--profile</code> 說明寫著預設值）。</li>
<li><code>--namespace=&lt;namespace&gt;</code>：除錯 Pod 建在上面建好的那個 namespace；不給時落在 kubeconfig 目前設定的 namespace（通常是 <code>default</code>）。</li>
</ul>
<p>這個 Pod 用完不會自動刪除，結束後用 <code>kubectl delete pod &lt;debug-pod-name&gt; --namespace=&lt;namespace&gt;</code> 刪掉（<code>kubectl get pods --namespace=&lt;namespace&gt;</code> 找名稱以 <code>node-debugger-</code> 開頭的那一個）。實測 GKE 上未設 label 的 <code>default</code> 接受除錯 Pod、設了 <code>baseline</code> 的 namespace 拒絕；kind 上 <code>privileged</code> 的 namespace 接受。</p>
<ul>
<li><strong>沒做時</strong>（不建專用 namespace）：沒有設 Pod Security label 的 namespace（包括 <code>default</code>）在 GKE Standard 上接受特權 Pod，任何能在 <code>default</code> 建 Pod 的帳號都能用這個方法以 root 操作節點。少了 <code>list</code> 或 <code>watch</code> 權限時，Pod 已經建好而 kubectl 停在等待、反覆印出 <code>cannot list resource &quot;pods&quot;</code>；少了 <code>pods/attach</code> 時 Pod 建得出來卻進不去；兩種都會留下一個在跑的除錯 Pod，要用 <code>kubectl delete pod</code> 刪掉（kind 上實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>用 <code>--profile=baseline</code> 或 <code>restricted</code>，換到的是建得進 <code>baseline</code> 以上的 namespace，付出的是看不到節點的檔案。</li>
<li>查節點層的狀況先看 Cloud Logging 裡的節點紀錄，換到的是不必在節點上開任何 Pod，付出的是只看得到已經送進 Logging 的紀錄（推論，這次沒有實跑）。GKE Autopilot 預設拒絕特權 Pod，也不提供節點 SSH，只剩這一條（GKE〈Autopilot overview〉，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="node-poolprivate-nodesnetwork-tag-與-os-login">node pool：private nodes、network tag 與 OS Login</h2>
<p>節點仍要 SSH 時（GKE Standard），把 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的三項設定搬到 node pool 上：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 既有 node pool 改成節點只有內部 IP（GKE 依升級策略逐批重建節點）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud container node-pools update &lt;pool-name&gt; --cluster<span class="o">=</span>&lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; --enable-private-nodes
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 讀出現有的 tag，再用整份清單設定（這個指令整組取代使用者設定的 tag）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud container node-pools describe &lt;pool-name&gt; --cluster<span class="o">=</span>&lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; --format<span class="o">=</span><span class="s2">&#34;value(config.tags)&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud container node-pools update &lt;pool-name&gt; --cluster<span class="o">=</span>&lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; --tags<span class="o">=</span>&lt;tag1&gt;,&lt;tag2&gt;</span></span></code></pre></div><ul>
<li><code>&lt;pool-name&gt;</code>、<code>&lt;cluster-name&gt;</code>：<code>gcloud container node-pools list --cluster=&lt;cluster-name&gt; --location=&lt;location&gt;</code> 與 <code>gcloud container clusters list</code> 查得到。<code>&lt;location&gt;</code> 是叢集所在的 zone 或 region。</li>
<li><code>--enable-private-nodes</code>：先確認節點所在 subnet 的 region 已經有 Cloud NAT，換成內部 IP 之後節點的出站都經過它；沒有它時節點拉不到外部 registry 的映像，工作負載起不來。GKE 依 node pool 的升級策略逐批重建節點，所以排在可以承受節點輪替的時段做。新叢集在 <code>gcloud container clusters create</code> 加 <code>--enable-private-nodes</code> 作為整個叢集的預設。實測既有 node pool 的這項變更重建了節點，出站走 Cloud NAT、tag 保留。</li>
<li><code>--tags=&lt;tag1&gt;,&lt;tag2&gt;</code>：實測整組取代（gcloud 的確認提示也這樣寫），GKE 自動建立的防火牆規則用的 tag 保留，節點沒有重建。</li>
</ul>
<p>OS Login 的授權方式同單台 VM，見 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈啟用 OS Login：先授角色、單台先行、再做專案層級〉。專案層級的 <code>enable-oslogin=TRUE</code> 是否套用到 GKE Standard 的節點，GKE 自己的兩份文件寫法相反，所以啟用之後經由 IAP 實際登入一個節點確認：登入後執行 <code>whoami</code>，看到的是由 Google 帳號 email 轉換而來的 OS Login 帳號，才表示這個 node pool 的節點套用了 OS Login；看到的是 metadata 金鑰對應的本機帳號，代表節點沒有套用，SSH 仍由 metadata 金鑰控制。這次在 1.36.4-gke 的節點上看到的是 OS Login 帳號，這是單一版本的觀察。</p>
<ul>
<li><strong>沒做時</strong>（只在單一節點上 <code>add-tags</code>）：節點升級或自動修復重建之後 tag 消失，IAP 規則套不到它（照概念篇〈autohealing、滾動更新與擴增後消失的逐台設定：network tag、OS Login metadata 與外部 IP〉的機制推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：不開節點 SSH、只用 <code>kubectl debug node</code>，換到的是不必維護節點的 tag、OS Login 與 IAP 規則，付出的是能建除錯 Pod 的帳號就等於節點的 root，權限邊界移到 RBAC 與 Pod Security Admission。</li>
</ul>
<h2 id="控制平面authorized-networks或改連-dns-based-endpoint-再關掉-ip-端點">控制平面：authorized networks，或改連 DNS-based endpoint 再關掉 IP 端點</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># authorized networks：只放行列出的來源 IP 範圍連到控制平面的 IP 端點</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud container clusters update &lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --enable-master-authorized-networks --master-authorized-networks<span class="o">=</span>&lt;cidr1&gt;,&lt;cidr2&gt;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># DNS-based endpoint 的啟用：IP 端點仍然開著</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud container clusters update &lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --enable-dns-access
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 關掉 IP 端點：每個人與每條 CI 都改連網域名稱端點之後才執行</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud container clusters update &lt;cluster-name&gt; --location<span class="o">=</span>&lt;location&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --no-enable-ip-access</span></span></code></pre></div><ul>
<li><code>&lt;cidr1&gt;,&lt;cidr2&gt;</code>：允許的來源 IP 範圍，<a href="/blog/devops/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a> 格式，單一個 IP 寫成 <code>/32</code>。實測以 Private Service Connect 建立的叢集拒絕文件範例用的保留網段 <code>203.0.113.0/24</code>，錯誤訊息寫明只接受在網際網路上可路由的範圍。<code>--master-authorized-networks</code> 每次都整組取代舊清單，執行這條指令的人與 CI 的出口 IP 要列在裡面：實測漏列時指令一生效 <code>kubectl</code> 就逾時，<code>gcloud</code> 不經過這個端點，用同一條指令改回清單就恢復。</li>
<li><code>--enable-dns-access</code> 與 <code>--no-enable-ip-access</code> 分成兩次執行：寫在同一條指令裡會在啟用的同時關掉 IP 端點，還在用 IP 端點的人與 CI 當場連不上。<code>--no-enable-ip-access</code> 關掉的是公開與內部兩個 IP 端點，VPC 內用內部 IP 連 API server 的跳板機、自架 runner 與 Terraform 也在要改的範圍內。</li>
</ul>
<p>改連 DNS-based endpoint 時，每個人在本機執行 <code>gcloud container clusters get-credentials &lt;cluster-name&gt; --location=&lt;location&gt; --dns-endpoint</code>，讓 kubeconfig 指向那個網域名稱。確認有沒有改連：每個人與每條 CI 用的 kubeconfig，<code>kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'</code> 輸出的是網域名稱而不是 IP。DNS-based endpoint 預設只接受 Google 的 IAM 憑證，CI 或工具若用 kubeconfig 裡靜態的 Kubernetes ServiceAccount token 或 client certificate，要另外在 <code>clusters update</code> 加 <code>--enable-k8s-tokens-via-dns</code> 或 <code>--enable-k8s-certs-via-dns</code>。</p>
<p>實測：DNS endpoint 的兩步切換不受 IP 清單限制；<code>--no-enable-ip-access</code> 之後 IP 端點逾時（對照組：開啟且來源在清單內時 HTTP 401），DNS 照常；<code>clusters update --enable-ip-access</code> 恢復。用第二個帳號以 <code>--token</code> 帶入它的 access token：沒有 IAM 角色時 DNS endpoint 回 Google 前端的 403 HTML；只有 <code>roles/container.clusterViewer</code> 時進到 API server，列節點回 <code>cannot list resource &quot;nodes&quot;</code>，訊息裡寫著它要的 IAM 權限 <code>container.nodes.list</code>。</p>
<ul>
<li><strong>沒做時</strong>（維持公開端點、不設任何限制）：任何地方都連得到 API server，只靠 Kubernetes 的認證與 RBAC 擋下沒有權限的請求，等於網路可達性全開（照 GKE〈Access the control plane〉推出，這次沒有在公開端點上量）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>authorized networks 換到的是設定一條指令，付出的是 IP 會變動時要一直改、分辨不出個人；設了之後，GCP 上其他客戶的外部 IP 是否放行由 <code>--enable-google-cloud-access</code>／<code>--no-enable-google-cloud-access</code> 決定。實測沒碰過這兩個旗標時，<code>describe</code> 沒有 <code>gcpPublicCidrsAccessEnabled</code> 欄位，這時實際是否放行沒有驗證；變更可能要幾個小時才生效（GKE 文件，這次沒有實跑）。</li>
<li>private endpoint（<code>--enable-private-endpoint</code>）換到的是控制平面只剩內部 IP，付出的是從 VPC 外管理時要經過 VPN 或跳板機（GKE 文件，這次沒有實跑）。三種端點的比較見概念篇〈GKE 控制平面的存取：authorized networks、DNS-based endpoint 與 private endpoint〉。</li>
</ul>
</li>
</ul>
]]></content:encoded></item><item><title>GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/</guid><description>&lt;p>實測日期是 2026-10-24，在 GCP 測試專案 &lt;code>blog-verify-20261024&lt;/code> 上分兩次實跑：第一次建出停損開關並觸發一次；第二次另開一個可拋棄的目標專案，驗預算的範圍與抵免、電子郵件通知管道、停損開關放在另一個專案、拆開驗證、心跳監看與恢復。帳單帳戶的幣別是新台幣，帳戶上有抵免；測試帳號底下沒有組織。&lt;/p>
&lt;p>這篇記錄在 GCP 上建預算警示與停損開關（由預算通知觸發、把專案從帳單帳戶解除連結的函式）的實際操作。內容依操作的順序是：建立限定一個專案的預算；建接收通知的 Pub/Sub 主題並把預算接上；建服務帳號並授予解除帳單連結與讀取帳單狀態的角色；函式的程式；部署函式並指定兩個服務帳號、執行個體上限與重試；授予觸發器呼叫函式的權限；停損開關放在另一個專案時要改的指令；用第一則真實通知與兩則測試訊息做兩段驗收；設心跳監看；超支停用之後的恢復。每一步寫出這次的取值與實測結果，以及漏掉或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。費用失控的來源與帳單資料的延遲、預算警示與強制執行支出上限的作用範圍、兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種、停損開關為什麼要放在另一個專案、驗收為什麼分兩段，寫在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/" data-link-title="GCP 上的雲端費用失控防護：預算警示、強制執行支出上限與自動停用帳單的作用範圍、反應時間與判斷" data-link-desc="GCP 上為費用失控設防的機制：預算警示寄出通知的時機、範圍、抵免與通知對象、強制執行支出上限涵蓋的服務與暫停期間，以預算通知觸發函式停止用量的兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種，帳單資料延遲對這些機制反應時間的影響，停損開關放在另一個專案的理由，以及驗收為什麼分成真實通知與測試訊息兩段、停用後的恢復為什麼要先調預算">GCP 上的雲端費用失控防護&lt;/a>（下文稱概念篇），這裡不重寫。&lt;/p>
&lt;p>停損開關的效果是一次自己造成的全面停機。建之前先照 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/" data-link-title="GCP 上的雲端費用失控防護：預算警示、強制執行支出上限與自動停用帳單的作用範圍、反應時間與判斷" data-link-desc="GCP 上為費用失控設防的機制：預算警示寄出通知的時機、範圍、抵免與通知對象、強制執行支出上限涵蓋的服務與暫停期間，以預算通知觸發函式停止用量的兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種，帳單資料延遲對這些機制反應時間的影響，停損開關放在另一個專案的理由，以及驗收為什麼分成真實通知與測試訊息兩段、停用後的恢復為什麼要先調預算">GCP 上的雲端費用失控防護&lt;/a> 的〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉判斷這個專案該不該建、建哪一種。下文以停損開關為例；只停止花錢的資源那一種的取捨與實測結果（跨全部 zone 停 VM、約兩分鐘 &lt;code>TERMINATED&lt;/code>）寫在那一節，它的函式本體這篇沒有收。&lt;/p>
&lt;p>停損開關部署了卻沒有動作時，成因依呼叫鏈的段落分在這幾節：通知裡的 &lt;code>costAmount&lt;/code> 沒有越過預算（抵免、帳單資料延遲、&lt;code>--filter-projects&lt;/code> 的範圍）見〈建立限定一個專案的預算〉；通知沒有送到主題或主題接錯預算，見〈建接收通知的主題並把預算接上〉；函式收到了卻沒有解除連結，見〈建服務帳號並授予兩個角色〉〈部署函式：兩個服務帳號、執行個體上限與重試〉〈授予觸發器呼叫函式的權限〉；要確認是哪一段，照〈兩段驗收：第一則真實通知，再發兩則測試訊息〉。&lt;/p>
&lt;p>下面的指令用到三個本文取的名稱：主題 &lt;code>budget-alerts&lt;/code>、服務帳號 &lt;code>budget-killswitch-sa&lt;/code>、函式 &lt;code>budget-killswitch&lt;/code>。換成別的名稱時，同一個名稱出現的每一條指令都要一起換。&lt;code>&amp;lt;project-id&amp;gt;&lt;/code> 指的是放停損開關、同時也是要被停用帳單的那個專案；兩者分開的情形見〈停損開關放在另一個專案時要改的指令〉。&lt;/p>
&lt;h2 id="建立限定一個專案的預算">建立限定一個專案的預算&lt;/h2>
&lt;p>建立預算的人要通過兩道權限檢查：實測沒有任何角色的帳號先被 &lt;code>--billing-project&lt;/code> 那個專案擋下（缺 &lt;code>serviceusage.services.use&lt;/code>，訊息寫 &lt;code>USER_PROJECT_DENIED&lt;/code>）；補上 &lt;code>roles/serviceusage.serviceUsageConsumer&lt;/code> 約一分鐘後，才換成帳單帳戶的權限不足。所以建立預算的人要有那個專案的 &lt;code>serviceusage.services.use&lt;/code>，以及帳單帳戶上能管理預算的角色（帳單帳戶管理員或帳單帳戶費用管理員）。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查帳單帳戶的 ID 與幣別：預算金額用的是這個幣別&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">gcloud billing accounts list
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">gcloud billing accounts describe &amp;lt;billing-account-id&amp;gt; --format&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;value(currencyCode)&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># gcloud 透過 --billing-project 指定的專案呼叫預算 API，那個專案要先啟用這個 API&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">gcloud services &lt;span class="nb">enable&lt;/span> billingbudgets.googleapis.com --project&lt;span class="o">=&lt;/span>&amp;lt;project-id&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立預算：金額寫成「數字加幣別代碼」，幣別要與帳單帳戶相同&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">gcloud billing budgets create --billing-account&lt;span class="o">=&lt;/span>&amp;lt;billing-account-id&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --display-name&lt;span class="o">=&lt;/span>&amp;lt;budget-display-name&amp;gt; --budget-amount&lt;span class="o">=&lt;/span>&amp;lt;amount&amp;gt;&amp;lt;currency-code&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --filter-projects&lt;span class="o">=&lt;/span>projects/&amp;lt;project-id&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --threshold-rule&lt;span class="o">=&lt;/span>&lt;span class="nv">percent&lt;/span>&lt;span class="o">=&lt;/span>0.5 --threshold-rule&lt;span class="o">=&lt;/span>&lt;span class="nv">percent&lt;/span>&lt;span class="o">=&lt;/span>0.9 --threshold-rule&lt;span class="o">=&lt;/span>&lt;span class="nv">percent&lt;/span>&lt;span class="o">=&lt;/span>1.0 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --threshold-rule&lt;span class="o">=&lt;/span>&lt;span class="nv">percent&lt;/span>&lt;span class="o">=&lt;/span>1.0,basis&lt;span class="o">=&lt;/span>forecasted-spend &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --billing-project&lt;span class="o">=&lt;/span>&amp;lt;project-id&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;code>&amp;lt;billing-account-id&amp;gt;&lt;/code>：&lt;code>gcloud billing accounts list&lt;/code> 的第一欄，是三段各六個英數字、以 &lt;code>-&lt;/code> 分隔的字串，例如 &lt;code>000000-AAAAAA-111111&lt;/code>（這是佔位值）。&lt;/li>
&lt;li>&lt;code>&amp;lt;budget-display-name&amp;gt;&lt;/code>：自己取的名稱，只用來在主控台與通知裡辨識這個預算。&lt;/li>
&lt;li>&lt;code>&amp;lt;amount&amp;gt;&amp;lt;currency-code&amp;gt;&lt;/code>：例如帳單帳戶的幣別是新台幣時寫成 &lt;code>320TWD&lt;/code>。主控台建立預算的表單只顯示一個 &lt;code>$&lt;/code>，不顯示幣別；幣別是新台幣的帳戶在那裡填 &lt;code>10&lt;/code>，門檻是新台幣 10 元而不是 10 美元。&lt;/li>
&lt;li>&lt;code>--filter-projects&lt;/code>：預算只計算這些專案的花費。&lt;code>gcloud billing budgets describe&lt;/code> 的 &lt;code>budgetFilter.projects&lt;/code> 存的是專案編號，不是專案 ID。&lt;/li>
&lt;li>門檻比例：50% 與 90% 留給收到通知的人處理的時間，100% 對齊花費達到預算的那一刻，預測花費的門檻提早反映速度異常；比例依處理一次所需的時間自己調整。&lt;/li>
&lt;li>&lt;code>--billing-project&lt;/code>：gcloud 呼叫預算 API 所用的專案，不影響預算的範圍。&lt;/li>
&lt;/ul>
&lt;p>接停損開關的預算要另外建，不接到既有的帳戶層級警示預算上；它的金額設成這個專案本期能承受的最高花費，減掉帳單資料延遲期間多出的費用，估法見概念篇〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>沒做時&lt;/strong>（不給 &lt;code>--filter-projects&lt;/code>）：&lt;code>gcloud billing budgets create --help&lt;/code> 寫著預算涵蓋所有專案，通知裡的 &lt;code>costAmount&lt;/code> 是帳單帳戶下所有專案的合計；接上停損開關時，別的專案超支會停掉這一個（從程式的判斷規則推論，這次沒有實跑）。&lt;/li>
&lt;li>&lt;strong>替代做法&lt;/strong>：
&lt;ul>
&lt;li>加 &lt;code>--credit-types-treatment=exclude-all-credits&lt;/code>，換到的是以抵免前的金額計算、大額抵免期間門檻照樣觸發，實測同一段期間扣掉抵免的帳戶層級預算報 &lt;code>costAmount&lt;/code> 0.05、&lt;code>exclude-all-credits&lt;/code> 的預算報 0.37；付出的是免費方案的用量也以原價算進花費（推論：&lt;code>FREE_TIER&lt;/code> 是預算接受的抵免類型，這次沒有量）。&lt;/li>
&lt;li>改用 &lt;code>--credit-types-treatment=include-specified-credits&lt;/code> 搭配 &lt;code>--filter-credit-types=FREE_TIER&lt;/code>，換到的是只讓免費方案的用量照常扣掉；類型寫錯時回 &lt;code>INVALID_ARGUMENT: ... is not a valid credit type string&lt;/code>，&lt;code>--help&lt;/code> 不列出可用的類型，以 Cloud Billing 文件為準。&lt;/li>
&lt;li>加 &lt;code>--notifications-rule-monitoring-notification-channels=&amp;lt;channel-resource-name&amp;gt;&lt;/code>，把通知另外寄到 Cloud Monitoring 的電子郵件通知管道，換到的是值班的人收得到，付出的是只支援電子郵件類型：接上 webhook 類型的管道時只回 &lt;code>INVALID_ARGUMENT: Request contains an invalid argument&lt;/code>，訊息不說原因。&lt;code>&amp;lt;channel-resource-name&amp;gt;&lt;/code> 形如 &lt;code>projects/&amp;lt;project-id&amp;gt;/notificationChannels/&amp;lt;channel-id&amp;gt;&lt;/code>，在主控台的 Monitoring 通知管道頁面查得到。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="建接收通知的主題並把預算接上">建接收通知的主題並把預算接上&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">gcloud services &lt;span class="nb">enable&lt;/span> pubsub.googleapis.com cloudfunctions.googleapis.com run.googleapis.com &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> cloudbuild.googleapis.com artifactregistry.googleapis.com eventarc.googleapis.com &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> cloudbilling.googleapis.com --project&lt;span class="o">=&lt;/span>&amp;lt;project-id&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立主題，並讓接停損開關的預算把通知送過來&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">gcloud pubsub topics create budget-alerts --project&lt;span class="o">=&lt;/span>&amp;lt;project-id&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">gcloud billing budgets update &amp;lt;budget-resource-name&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --notifications-rule-pubsub-topic&lt;span class="o">=&lt;/span>projects/&amp;lt;project-id&amp;gt;/topics/budget-alerts &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --billing-project&lt;span class="o">=&lt;/span>&amp;lt;project-id&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>&amp;lt;budget-resource-name&amp;gt;&lt;/code> 是 &lt;code>gcloud billing budgets list --billing-account=&amp;lt;billing-account-id&amp;gt; --billing-project=&amp;lt;project-id&amp;gt; --format=&amp;quot;value(name)&amp;quot;&lt;/code> 印出的完整名稱，形如 &lt;code>billingAccounts/&amp;lt;id&amp;gt;/budgets/&amp;lt;uuid&amp;gt;&lt;/code>，與建立時給的顯示名稱是兩個值。設定通知目標時，GCP 會自動讓預算服務的系統帳號 &lt;code>billing-budget-alert@system.gserviceaccount.com&lt;/code> 擁有這個主題的發布權限，&lt;code>gcloud pubsub topics get-iam-policy budget-alerts --project=&amp;lt;project-id&amp;gt;&lt;/code> 看得到這條綁定。&lt;/p></description><content:encoded><![CDATA[<p>實測日期是 2026-10-24，在 GCP 測試專案 <code>blog-verify-20261024</code> 上分兩次實跑：第一次建出停損開關並觸發一次；第二次另開一個可拋棄的目標專案，驗預算的範圍與抵免、電子郵件通知管道、停損開關放在另一個專案、拆開驗證、心跳監看與恢復。帳單帳戶的幣別是新台幣，帳戶上有抵免；測試帳號底下沒有組織。</p>
<p>這篇記錄在 GCP 上建預算警示與停損開關（由預算通知觸發、把專案從帳單帳戶解除連結的函式）的實際操作。內容依操作的順序是：建立限定一個專案的預算；建接收通知的 Pub/Sub 主題並把預算接上；建服務帳號並授予解除帳單連結與讀取帳單狀態的角色；函式的程式；部署函式並指定兩個服務帳號、執行個體上限與重試；授予觸發器呼叫函式的權限；停損開關放在另一個專案時要改的指令；用第一則真實通知與兩則測試訊息做兩段驗收；設心跳監看；超支停用之後的恢復。每一步寫出這次的取值與實測結果，以及漏掉或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。費用失控的來源與帳單資料的延遲、預算警示與強制執行支出上限的作用範圍、兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種、停損開關為什麼要放在另一個專案、驗收為什麼分兩段，寫在 <a href="/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/" data-link-title="GCP 上的雲端費用失控防護：預算警示、強制執行支出上限與自動停用帳單的作用範圍、反應時間與判斷" data-link-desc="GCP 上為費用失控設防的機制：預算警示寄出通知的時機、範圍、抵免與通知對象、強制執行支出上限涵蓋的服務與暫停期間，以預算通知觸發函式停止用量的兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種，帳單資料延遲對這些機制反應時間的影響，停損開關放在另一個專案的理由，以及驗收為什麼分成真實通知與測試訊息兩段、停用後的恢復為什麼要先調預算">GCP 上的雲端費用失控防護</a>（下文稱概念篇），這裡不重寫。</p>
<p>停損開關的效果是一次自己造成的全面停機。建之前先照 <a href="/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/" data-link-title="GCP 上的雲端費用失控防護：預算警示、強制執行支出上限與自動停用帳單的作用範圍、反應時間與判斷" data-link-desc="GCP 上為費用失控設防的機制：預算警示寄出通知的時機、範圍、抵免與通知對象、強制執行支出上限涵蓋的服務與暫停期間，以預算通知觸發函式停止用量的兩種處置（解除帳單連結、只停止花錢的資源）該選哪一種，帳單資料延遲對這些機制反應時間的影響，停損開關放在另一個專案的理由，以及驗收為什麼分成真實通知與測試訊息兩段、停用後的恢復為什麼要先調預算">GCP 上的雲端費用失控防護</a> 的〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉判斷這個專案該不該建、建哪一種。下文以停損開關為例；只停止花錢的資源那一種的取捨與實測結果（跨全部 zone 停 VM、約兩分鐘 <code>TERMINATED</code>）寫在那一節，它的函式本體這篇沒有收。</p>
<p>停損開關部署了卻沒有動作時，成因依呼叫鏈的段落分在這幾節：通知裡的 <code>costAmount</code> 沒有越過預算（抵免、帳單資料延遲、<code>--filter-projects</code> 的範圍）見〈建立限定一個專案的預算〉；通知沒有送到主題或主題接錯預算，見〈建接收通知的主題並把預算接上〉；函式收到了卻沒有解除連結，見〈建服務帳號並授予兩個角色〉〈部署函式：兩個服務帳號、執行個體上限與重試〉〈授予觸發器呼叫函式的權限〉；要確認是哪一段，照〈兩段驗收：第一則真實通知，再發兩則測試訊息〉。</p>
<p>下面的指令用到三個本文取的名稱：主題 <code>budget-alerts</code>、服務帳號 <code>budget-killswitch-sa</code>、函式 <code>budget-killswitch</code>。換成別的名稱時，同一個名稱出現的每一條指令都要一起換。<code>&lt;project-id&gt;</code> 指的是放停損開關、同時也是要被停用帳單的那個專案；兩者分開的情形見〈停損開關放在另一個專案時要改的指令〉。</p>
<h2 id="建立限定一個專案的預算">建立限定一個專案的預算</h2>
<p>建立預算的人要通過兩道權限檢查：實測沒有任何角色的帳號先被 <code>--billing-project</code> 那個專案擋下（缺 <code>serviceusage.services.use</code>，訊息寫 <code>USER_PROJECT_DENIED</code>）；補上 <code>roles/serviceusage.serviceUsageConsumer</code> 約一分鐘後，才換成帳單帳戶的權限不足。所以建立預算的人要有那個專案的 <code>serviceusage.services.use</code>，以及帳單帳戶上能管理預算的角色（帳單帳戶管理員或帳單帳戶費用管理員）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 查帳單帳戶的 ID 與幣別：預算金額用的是這個幣別</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud billing accounts list
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">gcloud billing accounts describe &lt;billing-account-id&gt; --format<span class="o">=</span><span class="s2">&#34;value(currencyCode)&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># gcloud 透過 --billing-project 指定的專案呼叫預算 API，那個專案要先啟用這個 API</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud services <span class="nb">enable</span> billingbudgets.googleapis.com --project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 建立預算：金額寫成「數字加幣別代碼」，幣別要與帳單帳戶相同</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">gcloud billing budgets create --billing-account<span class="o">=</span>&lt;billing-account-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --display-name<span class="o">=</span>&lt;budget-display-name&gt; --budget-amount<span class="o">=</span>&lt;amount&gt;&lt;currency-code&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --filter-projects<span class="o">=</span>projects/&lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --threshold-rule<span class="o">=</span><span class="nv">percent</span><span class="o">=</span>0.5 --threshold-rule<span class="o">=</span><span class="nv">percent</span><span class="o">=</span>0.9 --threshold-rule<span class="o">=</span><span class="nv">percent</span><span class="o">=</span>1.0 <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --threshold-rule<span class="o">=</span><span class="nv">percent</span><span class="o">=</span>1.0,basis<span class="o">=</span>forecasted-spend <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --billing-project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><ul>
<li><code>&lt;billing-account-id&gt;</code>：<code>gcloud billing accounts list</code> 的第一欄，是三段各六個英數字、以 <code>-</code> 分隔的字串，例如 <code>000000-AAAAAA-111111</code>（這是佔位值）。</li>
<li><code>&lt;budget-display-name&gt;</code>：自己取的名稱，只用來在主控台與通知裡辨識這個預算。</li>
<li><code>&lt;amount&gt;&lt;currency-code&gt;</code>：例如帳單帳戶的幣別是新台幣時寫成 <code>320TWD</code>。主控台建立預算的表單只顯示一個 <code>$</code>，不顯示幣別；幣別是新台幣的帳戶在那裡填 <code>10</code>，門檻是新台幣 10 元而不是 10 美元。</li>
<li><code>--filter-projects</code>：預算只計算這些專案的花費。<code>gcloud billing budgets describe</code> 的 <code>budgetFilter.projects</code> 存的是專案編號，不是專案 ID。</li>
<li>門檻比例：50% 與 90% 留給收到通知的人處理的時間，100% 對齊花費達到預算的那一刻，預測花費的門檻提早反映速度異常；比例依處理一次所需的時間自己調整。</li>
<li><code>--billing-project</code>：gcloud 呼叫預算 API 所用的專案，不影響預算的範圍。</li>
</ul>
<p>接停損開關的預算要另外建，不接到既有的帳戶層級警示預算上；它的金額設成這個專案本期能承受的最高花費，減掉帳單資料延遲期間多出的費用，估法見概念篇〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉。</p>
<ul>
<li><strong>沒做時</strong>（不給 <code>--filter-projects</code>）：<code>gcloud billing budgets create --help</code> 寫著預算涵蓋所有專案，通知裡的 <code>costAmount</code> 是帳單帳戶下所有專案的合計；接上停損開關時，別的專案超支會停掉這一個（從程式的判斷規則推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>加 <code>--credit-types-treatment=exclude-all-credits</code>，換到的是以抵免前的金額計算、大額抵免期間門檻照樣觸發，實測同一段期間扣掉抵免的帳戶層級預算報 <code>costAmount</code> 0.05、<code>exclude-all-credits</code> 的預算報 0.37；付出的是免費方案的用量也以原價算進花費（推論：<code>FREE_TIER</code> 是預算接受的抵免類型，這次沒有量）。</li>
<li>改用 <code>--credit-types-treatment=include-specified-credits</code> 搭配 <code>--filter-credit-types=FREE_TIER</code>，換到的是只讓免費方案的用量照常扣掉；類型寫錯時回 <code>INVALID_ARGUMENT: ... is not a valid credit type string</code>，<code>--help</code> 不列出可用的類型，以 Cloud Billing 文件為準。</li>
<li>加 <code>--notifications-rule-monitoring-notification-channels=&lt;channel-resource-name&gt;</code>，把通知另外寄到 Cloud Monitoring 的電子郵件通知管道，換到的是值班的人收得到，付出的是只支援電子郵件類型：接上 webhook 類型的管道時只回 <code>INVALID_ARGUMENT: Request contains an invalid argument</code>，訊息不說原因。<code>&lt;channel-resource-name&gt;</code> 形如 <code>projects/&lt;project-id&gt;/notificationChannels/&lt;channel-id&gt;</code>，在主控台的 Monitoring 通知管道頁面查得到。</li>
</ul>
</li>
</ul>
<h2 id="建接收通知的主題並把預算接上">建接收通知的主題並把預算接上</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud services <span class="nb">enable</span> pubsub.googleapis.com cloudfunctions.googleapis.com run.googleapis.com <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  cloudbuild.googleapis.com artifactregistry.googleapis.com eventarc.googleapis.com <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  cloudbilling.googleapis.com --project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 建立主題，並讓接停損開關的預算把通知送過來</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud pubsub topics create budget-alerts --project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln">7</span><span class="cl">gcloud billing budgets update &lt;budget-resource-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --notifications-rule-pubsub-topic<span class="o">=</span>projects/&lt;project-id&gt;/topics/budget-alerts <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --billing-project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><p><code>&lt;budget-resource-name&gt;</code> 是 <code>gcloud billing budgets list --billing-account=&lt;billing-account-id&gt; --billing-project=&lt;project-id&gt; --format=&quot;value(name)&quot;</code> 印出的完整名稱，形如 <code>billingAccounts/&lt;id&gt;/budgets/&lt;uuid&gt;</code>，與建立時給的顯示名稱是兩個值。設定通知目標時，GCP 會自動讓預算服務的系統帳號 <code>billing-budget-alert@system.gserviceaccount.com</code> 擁有這個主題的發布權限，<code>gcloud pubsub topics get-iam-policy budget-alerts --project=&lt;project-id&gt;</code> 看得到這條綁定。</p>
<p>預算接上主題之後，GCP 一天會送好幾次通知過來，不論花費有沒有越過門檻。第一則送到的時間兩次實跑不同：第一次實跑約 8 分鐘，第二次新建的預算約 35 分鐘；Cloud Billing 文件說第一則可能要數小時。通知的內容如下，<code>alertThresholdExceeded</code> 這類欄位要等越過門檻才會出現：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;budgetDisplayName&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;budget-display-name&gt;&#34;</span><span class="p">,</span> <span class="nt">&#34;costAmount&#34;</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span> <span class="nt">&#34;costIntervalStart&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-10-01T07:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="nt">&#34;budgetAmount&#34;</span><span class="p">:</span> <span class="mf">320.0</span><span class="p">,</span> <span class="nt">&#34;budgetAmountType&#34;</span><span class="p">:</span> <span class="s2">&#34;SPECIFIED_AMOUNT&#34;</span><span class="p">,</span> <span class="nt">&#34;currencyCode&#34;</span><span class="p">:</span> <span class="s2">&#34;TWD&#34;</span><span class="p">}</span></span></span></code></pre></div><p>主題原本接在別的預算上時，只有一種做法拿得掉：<code>gcloud billing budgets update</code> 沒有清除 Pub/Sub 通知的旗標，要對預算 API 送 PATCH，<code>updateMask</code> 設成 <code>notificationsRule.pubsubTopic</code>、<code>notificationsRule.pubsubTopic</code> 設成空字串。這次用這個方式拿掉了；送出的完整指令沒有留下來，請求的形狀以 Cloud Billing Budget API 的 <code>budgets.patch</code> 文件為準。</p>
<ul>
<li><strong>沒做時</strong>（一個主題接了好幾個預算）：本文的函式不分辨通知來自哪一個預算，任何一個超支都會讓它停掉 <code>TARGET_PROJECT_ID</code>（從程式的判斷規則推論，這次沒有實跑）。要依預期花費收通知，另建一個不接主題的預算。</li>
</ul>
<h2 id="建服務帳號並授予兩個角色">建服務帳號並授予兩個角色</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">gcloud iam service-accounts create budget-killswitch-sa --project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 解除專案的帳單連結</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>serviceAccount:budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/billing.projectManager
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 讀取專案的帳單狀態（getBillingInfo 需要 resourcemanager.projects.get）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>serviceAccount:budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/browser</span></span></code></pre></div><p><code>roles/billing.projectManager</code> 只包含兩個權限，建立與刪除專案的帳單連結（<code>gcloud iam roles describe roles/billing.projectManager</code> 列得出來），讀取帳單狀態不在裡面。<code>roles/browser</code> 包含 <code>resourcemanager.projects.get</code>，整個角色是讀取專案、資料夾與組織的中繼資料以及專案 IAM policy 的六個權限，不包含讀取專案內資源的權限。執行部署指令的人另外要有這個服務帳號的 <code>roles/iam.serviceAccountUser</code>，才能把它指定給函式與觸發器；專案擁有者本來就有。要管多個專案時，每個專案各建一個限定在該專案的預算、一個主題與一個函式部署，兩個角色在每個專案各綁一次。</p>
<ul>
<li><strong>沒做時</strong>（只給 <code>roles/billing.projectManager</code>）：函式在解除連結之前先讀一次帳單狀態，那一步回 403「The caller does not have permission」；函式部署成功、狀態 <code>ACTIVE</code>，停損開關卻不會解除帳單連結。</li>
<li><strong>替代做法</strong>：拿掉函式裡讀帳單狀態的那一步、只留解除連結，換到的是少一個角色，付出的是重複的通知會對已經停用的專案再送一次解除連結（推論，這次沒有實跑）。</li>
</ul>
<h2 id="判斷並解除帳單連結的函式">判斷並解除帳單連結的函式</h2>
<p>下面的程式存成 <code>&lt;source-dir&gt;/main.py</code>，判斷規則只看實際花費：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">base64</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">import</span> <span class="nn">functions_framework</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">from</span> <span class="nn">googleapiclient</span> <span class="kn">import</span> <span class="n">discovery</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">PROJECT_ID</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;TARGET_PROJECT_ID&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">PROJECT_NAME</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;projects/</span><span class="si">{</span><span class="n">PROJECT_ID</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">def</span> <span class="nf">should_disable</span><span class="p">(</span><span class="n">notification</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="c1"># 只看實際花費：改看預測花費時，一次短暫的尖峰就可能讓函式停掉正常的服務</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">cost</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">notification</span><span class="p">[</span><span class="s2">&#34;costAmount&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">budget</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">notification</span><span class="p">[</span><span class="s2">&#34;budgetAmount&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">KeyError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">,</span> <span class="ne">ValueError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;warning&#34;</span><span class="p">:</span> <span class="s2">&#34;unreadable notification, ignored&#34;</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">return</span> <span class="n">budget</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">cost</span> <span class="o">&gt;=</span> <span class="n">budget</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">def</span> <span class="nf">billing_enabled</span><span class="p">(</span><span class="n">billing</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="n">info</span> <span class="o">=</span> <span class="n">billing</span><span class="o">.</span><span class="n">projects</span><span class="p">()</span><span class="o">.</span><span class="n">getBillingInfo</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">PROJECT_NAME</span><span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">return</span> <span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;billingEnabled&#34;</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="nd">@functions_framework.cloud_event</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="k">def</span> <span class="nf">stop_billing</span><span class="p">(</span><span class="n">cloud_event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="c1"># 讀不懂的通知不構成停機的理由；直接結束，不拋出例外，免得 --retry 讓它一直重送</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="n">notification</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">cloud_event</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;message&#34;</span><span class="p">][</span><span class="s2">&#34;data&#34;</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">KeyError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">,</span> <span class="ne">ValueError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;warning&#34;</span><span class="p">:</span> <span class="s2">&#34;undecodable message, ignored&#34;</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;received&#34;</span><span class="p">:</span> <span class="n">notification</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">should_disable</span><span class="p">(</span><span class="n">notification</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;none&#34;</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="c1"># 呼叫 Cloud Billing API 的錯誤不攔，讓 --retry 重送這則通知</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="n">billing</span> <span class="o">=</span> <span class="n">discovery</span><span class="o">.</span><span class="n">build</span><span class="p">(</span><span class="s2">&#34;cloudbilling&#34;</span><span class="p">,</span> <span class="s2">&#34;v1&#34;</span><span class="p">,</span> <span class="n">cache_discovery</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">billing_enabled</span><span class="p">(</span><span class="n">billing</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;none&#34;</span><span class="p">,</span> <span class="s2">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;billing already disabled&#34;</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="c1"># billingAccountName 給空字串，就是把專案從帳單帳戶解除連結</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="n">billing</span><span class="o">.</span><span class="n">projects</span><span class="p">()</span><span class="o">.</span><span class="n">updateBillingInfo</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="n">name</span><span class="o">=</span><span class="n">PROJECT_NAME</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;billingAccountName&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;billing disabled&#34;</span><span class="p">,</span> <span class="s2">&#34;project&#34;</span><span class="p">:</span> <span class="n">PROJECT_ID</span><span class="p">}))</span></span></span></code></pre></div><p>同一個目錄放一份 <code>requirements.txt</code>，列出 <code>functions-framework</code> 與 <code>google-api-python-client</code>。實測對主題發一則不是 JSON 的訊息，函式留下一筆 <code>undecodable message, ignored</code> 的警告、請求回 200，之後沒有重送。</p>
<ul>
<li><strong>沒做時</strong>（讀不懂的訊息拋出例外）：加了 <code>--retry</code> 之後，那則訊息會被一直重送（從重試政策的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>（判斷規則）：改看 <code>forecastThresholdExceeded</code>，換到的是更早解除連結、被濫用時損失更少，付出的是一次正常的流量尖峰也可能讓整個專案停擺；改成「讀不懂就解除連結」，換到的是格式異常時偏向保護費用，付出的是通知格式變動時就會誤停（兩者都是推論，這次沒有實跑）。現在的寫法在誤停與漏停兩種錯誤裡最不會誤停，代價是被濫用時要等到實際花費寫進帳單資料才動作。</li>
</ul>
<h2 id="部署函式兩個服務帳號執行個體上限與重試">部署函式：兩個服務帳號、執行個體上限與重試</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud functions deploy budget-killswitch --gen2 --region<span class="o">=</span>&lt;region&gt; --runtime<span class="o">=</span>python312 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --source<span class="o">=</span>&lt;source-dir&gt; --entry-point<span class="o">=</span>stop_billing --trigger-topic<span class="o">=</span>budget-alerts <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --service-account<span class="o">=</span>budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --trigger-service-account<span class="o">=</span>budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --set-env-vars<span class="o">=</span><span class="nv">TARGET_PROJECT_ID</span><span class="o">=</span>&lt;project-id&gt; --max-instances<span class="o">=</span><span class="m">1</span> --retry <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><ul>
<li>
<p><code>&lt;region&gt;</code>：函式所在的 region，例如 <code>asia-east1</code>；它與預算或要停用帳單的專案在哪個區域無關。</p>
</li>
<li>
<p><code>--runtime</code>：Python 的版本，可用的值由 <code>gcloud functions runtimes list --region=&lt;region&gt;</code> 列出；舊版本停止支援之後部署會被拒絕。</p>
</li>
<li>
<p><code>&lt;source-dir&gt;</code>：放 <code>main.py</code> 與 <code>requirements.txt</code> 的本機目錄。</p>
</li>
<li>
<p><code>--entry-point</code>：程式裡加了 <code>@functions_framework.cloud_event</code> 的那個函式的名稱。</p>
</li>
<li>
<p><code>--service-account</code>：函式執行時的身分，函式以它呼叫 Cloud Billing API。</p>
</li>
<li>
<p><code>--trigger-service-account</code>：Eventarc 觸發器呼叫函式時的身分。不給這個旗標時用哪個帳號，<code>gcloud functions deploy --help</code> 寫的是專案的 Compute Engine 預設服務帳號，而這次實測用的是 <code>--service-account</code> 那一個，兩者不同，所以明確指定，部署後用 <code>gcloud functions describe budget-killswitch --region=&lt;region&gt; --project=&lt;project-id&gt;</code> 輸出的 <code>eventTrigger.serviceAccountEmail</code> 確認。</p>
</li>
<li>
<p><code>TARGET_PROJECT_ID</code>：要停用帳單的專案。</p>
</li>
<li>
<p><code>--max-instances=1</code>：同一時間最多只啟動一個執行個體，超支之後一天好幾則通知依序處理，不會同時送出多個解除連結的呼叫。</p>
</li>
<li>
<p><code>--retry</code>：讓 Pub/Sub 依觸發器的<a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">重試政策</a>在幾分鐘內重送失敗的通知，一次暫時性的錯誤（剛授予的權限還沒生效）不必等到下一則通知。不會自己恢復的錯誤（權限少了一個、專案的帳單連結被鎖定）重試也不會成功，那一類只能靠〈兩段驗收：第一則真實通知，再發兩則測試訊息〉在上線前找出來。</p>
</li>
<li>
<p><strong>沒做時</strong>（不加 <code>--retry</code>）：觸發器的重試政策是 <code>RETRY_POLICY_DO_NOT_RETRY</code>（<code>gcloud functions describe</code> 輸出的 <code>eventTrigger.retryPolicy</code>）。函式在讀取帳單狀態時拋出例外，Cloud Run 的請求紀錄卻是 HTTP 200，Pub/Sub 因此把那則超出預算的通知標為已確認收訊（<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>），之後不再送；下一次有機會解除連結，要等數小時後的下一則預算通知，這段期間帳單照常計費。</p>
</li>
<li>
<p><strong>沒做時</strong>（不加 <code>--max-instances=1</code>）：超支之後同時到的幾則通知可能各自啟動一個執行個體、各送一次解除連結（推論，這次沒有實跑）。</p>
</li>
</ul>
<h2 id="授予觸發器呼叫函式的權限">授予觸發器呼叫函式的權限</h2>
<p>第二代的 Cloud Run functions 跑在 Cloud Run 上，Eventarc 觸發器以 <code>--trigger-service-account</code> 指定的服務帳號身分呼叫它，而那個服務帳號預設沒有呼叫權限：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 第一個 budget-killswitch 是函式名稱：第二代函式在 Cloud Run 上的服務與函式同名</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud run services add-iam-policy-binding budget-killswitch --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>serviceAccount:budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/run.invoker --project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><ul>
<li><strong>沒做時</strong>：Cloud Run 以 403 擋下每一次推送，紀錄寫著「The IAM principal lacks {run.routes.invoke} permission」，函式一次都不會執行，而 <code>gcloud functions describe</code> 顯示的狀態仍是 <code>ACTIVE</code>。</li>
<li><strong>替代做法</strong>：觸發器改用另一個只有 <code>roles/run.invoker</code> 的服務帳號，換到的是呼叫權限與解除帳單連結的權限分在兩個身分上，付出的是多管一個服務帳號（推論，這次沒有實跑）。</li>
</ul>
<h2 id="停損開關放在另一個專案時要改的指令">停損開關放在另一個專案時要改的指令</h2>
<p>主題、函式與服務帳號放在一個專門放帳單工具的管理專案、要停用帳單的是另一個專案時，上面的指令照下面改，其餘不變：</p>
<ul>
<li>主題、服務帳號、函式、觸發器的呼叫權限：<code>--project=&lt;project-id&gt;</code> 與服務帳號位址裡的 <code>&lt;project-id&gt;</code> 都填管理專案。</li>
<li>服務帳號的兩個角色：<code>gcloud projects add-iam-policy-binding</code> 的第一個引數改成被保護的專案，<code>--member</code> 仍是管理專案裡的服務帳號。</li>
<li>部署：<code>--set-env-vars=TARGET_PROJECT_ID=</code> 填被保護的專案。</li>
<li>預算：<code>--filter-projects=projects/</code> 填被保護的專案。</li>
</ul>
<p>第二次實跑把函式放在 <code>blog-verify-20261024</code>、<code>TARGET_PROJECT_ID</code> 指向另一個可拋棄的專案：超出預算的訊息讓被保護的專案在 9 秒後停用，管理專案的帳單維持啟用，緊接著的低於預算訊息照常處理。這樣放的效果與代價見概念篇〈停損開關放在另一個專案〉。</p>
<ul>
<li><strong>沒做時</strong>（停損開關跑在被保護的同一個專案）：解除連結之後，查詢函式狀態的 API 直接回 403，訊息要求確認帳單帳戶；函式本身在停用後約一分鐘內仍處理了下一則通知，之後的行為這次沒有觀察。兩段驗收的兩則測試訊息要照順序發。</li>
</ul>
<h2 id="兩段驗收第一則真實通知再發兩則測試訊息">兩段驗收：第一則真實通知，再發兩則測試訊息</h2>
<p>驗收分成預算到 Pub/Sub 主題、主題之後兩段，各用不同的訊息確認，理由見概念篇〈驗收：真實通知確認預算到 Pub/Sub 主題的一段，測試訊息確認主題之後的一段〉。</p>
<p><strong>Pub/Sub 主題之前的那一段，用第一則真實的預算通知確認。</strong> 接上主題之後等第一則通知，用下面這條查詢找到一筆 <code>received</code> 紀錄，確認 <code>budgetDisplayName</code> 是接停損開關的那個預算，再拿它的 <code>costAmount</code> 對照帳單報表上這個專案本期的費用：通知的值接近 0 而報表不是，就是抵免設定或 <code>--filter-projects</code> 設錯了。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出函式收到的通知；--freshness 依等待的時間調整</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="s1">&#39;resource.type=&#34;cloud_run_revision&#34; AND resource.labels.service_name=&#34;budget-killswitch&#34; AND jsonPayload.received:*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1d --format<span class="o">=</span><span class="s2">&#34;json(timestamp,jsonPayload.received)&#34;</span> --project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><p><strong>Pub/Sub 主題之後的那一段，用兩則測試訊息確認。</strong> 低於預算的那一則確認訊息送得到、判斷邏輯會跑；停損開關跑在被保護的同一個專案時，它要排在超出預算那一則之前。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud pubsub topics publish budget-alerts --project<span class="o">=</span>&lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --message<span class="o">=</span><span class="s1">&#39;{&#34;costAmount&#34;: 15.0, &#34;budgetAmount&#34;: 320.0, &#34;currencyCode&#34;: &#34;TWD&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 等一兩分鐘再查：紀錄寫入有延遲；預期 action 是 none；--freshness=10m 只看最近十分鐘</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="s1">&#39;resource.type=&#34;cloud_run_revision&#34; AND resource.labels.service_name=&#34;budget-killswitch&#34; AND jsonPayload.action:*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>10m --format<span class="o">=</span><span class="s2">&#34;value(timestamp,jsonPayload.action)&#34;</span> --project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><p>這條查詢回傳零筆時，有三種可能：紀錄還沒寫入、觸發器被 403 擋下、查詢條件寫錯。把條件裡的 <code>AND jsonPayload.action:*</code> 換成 <code>AND logName:&quot;run.googleapis.com%2Frequests&quot;</code>、<code>--format</code> 換成 <code>&quot;value(timestamp,httpRequest.status)&quot;</code>，查同一個服務的請求紀錄：有 403 的請求代表缺〈授予觸發器呼叫函式的權限〉那條綁定，查不到任何請求代表訊息沒有送到觸發器。</p>
<p>低於預算的訊息在判斷規則那一步就結束，讀取帳單狀態與解除帳單連結的權限一次都沒有行使。超出預算的那一則才會走到它們，結果看帳單狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud pubsub topics publish budget-alerts --project<span class="o">=</span>&lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --message<span class="o">=</span><span class="s1">&#39;{&#34;costAmount&#34;: 350.0, &#34;budgetAmount&#34;: 320.0, &#34;currencyCode&#34;: &#34;TWD&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 等一兩分鐘再查；預期 billingEnabled 變成 false、billingAccountName 變成空字串</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud billing projects describe &lt;project-id&gt;</span></span></code></pre></div><p>測試訊息裡的 <code>budgetAmount</code> 大於 0、而且小於或等於 <code>costAmount</code> 時，函式就會解除帳單連結，與預算實際設定的金額無關。實測從發出超出預算的訊息到 <code>billingEnabled</code> 變成 <code>false</code> 約十三秒。驗收完用 <code>gcloud billing projects link &lt;project-id&gt; --billing-account=&lt;billing-account-id&gt;</code> 重新連結，連結完成才算驗收結束。這次驗收會實際讓專案停擺，排在停機可以接受的時段做；之後每一次修改函式、角色或觸發器，都再做一次。</p>
<ul>
<li>
<p><strong>沒做時</strong>（只看部署成功與 <code>ACTIVE</code>）：〈建服務帳號並授予兩個角色〉的 <code>roles/browser</code>、〈部署函式：兩個服務帳號、執行個體上限與重試〉的 <code>--retry</code>、〈授予觸發器呼叫函式的權限〉的 <code>roles/run.invoker</code>，實測時各自讓停損開關在狀態顯示正常時失效過一次。</p>
</li>
<li>
<p><strong>替代做法</strong>（專案有停用之後不能遺失的資料時）：拆開驗證，第二次實跑做過。</p>
<ul>
<li>在可拋棄的專案上完整觸發一次：另外部署一份複本，函式名稱與主題都換掉（例如 <code>budget-killswitch-test</code> 與 <code>budget-alerts-test</code>），<code>TARGET_PROJECT_ID</code> 指向一個可拋棄的空專案，兩個角色綁在那個空專案上，再對它發超出預算的訊息。複本若沿用正式的函式名稱，部署會蓋掉正式的函式；訊息若發到正式的主題，被停用的就是正式專案。</li>
<li>在正式專案上只確認權限：<code>gcloud policy-intelligence troubleshoot-policy iam //cloudresourcemanager.googleapis.com/projects/&lt;project-id&gt; --principal-email=&lt;sa-email&gt; --permission=&lt;permission&gt;</code>。<code>&lt;sa-email&gt;</code> 是 <code>budget-killswitch-sa@&lt;project-id&gt;.iam.gserviceaccount.com</code>，<code>&lt;permission&gt;</code> 依序填 <code>resourcemanager.projects.deleteBillingAssignment</code> 與 <code>resourcemanager.projects.get</code>；執行的專案要先啟用 <code>policytroubleshooter.googleapis.com</code>。輸出的 <code>overallAccessState</code> 是 <code>CAN_ACCESS</code> 才算有，沒有綁角色的服務帳號回 <code>CANNOT_ACCESS</code>。</li>
</ul>
<p>拆開驗證仍沒有行使正式專案上那一次 <code>updateBillingInfo</code>，驗收紀錄要寫明缺了這一段。</p>
</li>
</ul>
<h2 id="心跳監看函式停止收到通知時告警">心跳監看：函式停止收到通知時告警</h2>
<p>預算一天會送好幾則通知，函式的紀錄長時間沒有出現帶 <code>action</code> 欄位的紀錄就通知，讓函式停止收到通知這件事本身也成為訊號。心跳監看建在第三個專案：停損開關正常觸發之後，被保護的專案整個停擺，告警放在那裡會一起停；也不放在管理專案，管理專案失效時要有人知道。實測的做法是用 <code>gcloud logging metrics create</code> 把帶 <code>action</code> 欄位的那類紀錄做成日誌指標，再在 Cloud Monitoring 建一條缺席條件（condition absent）的告警；缺席條件的時間窗上限是 23 小時 30 分，設成 24 小時會被拒，所以時間窗設 23 小時。設好之後暫時把時間窗縮到幾分鐘，確認通知送到收的人手上，再改回 23 小時：實測時間窗設 3 分鐘，最後一筆紀錄之後 4 到 9 分鐘告警送達。這次建指標與告警政策的完整指令沒有留下來，日誌指標的 filter 用〈兩段驗收：第一則真實通知，再發兩則測試訊息〉那條 <code>jsonPayload.action:*</code> 的查詢條件。</p>
<ul>
<li><strong>沒做時</strong>：別人調整了角色（例如權限盤點時收掉 <code>roles/browser</code>）或鎖定了專案的帳單連結時，部署的人不會知道要重驗，停損開關一直顯示 <code>ACTIVE</code>（推論，這次沒有實跑）。</li>
</ul>
<h2 id="超支停用之後的恢復">超支停用之後的恢復</h2>
<p>恢復之前先調預算的理由（<code>costAmount</code> 是本期累計值，只重新連結的話下一則通知會讓函式再解除一次）與依停用原因分開的順序，見概念篇〈解除連結之後恢復服務的順序〉。</p>
<p><strong>服務自己出錯</strong>：修好程式之後，先調高預算金額或刪掉函式，再重新連結：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud billing budgets update &lt;budget-resource-name&gt; --budget-amount<span class="o">=</span>&lt;amount&gt;&lt;currency-code&gt; --billing-project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud billing projects link &lt;project-id&gt; --billing-account<span class="o">=</span>&lt;billing-account-id&gt;</span></span></code></pre></div><p><strong>資源被別人濫用</strong>：重新連結之前，把預算金額調成目前的 <code>costAmount</code> 加上清理期間能承受的金額，並先撤銷外洩的 API 金鑰與服務帳號金鑰：實測帳單停用期間 <code>gcloud services api-keys list</code> 與 <code>delete</code> 都照常可用。重新連結之後列出並刪除不屬於自己的 VM、instance template 與 MIG，確認乾淨之後再把預算調回原本的金額。</p>
<p>實測停用約兩分半鐘再重新連結，Cloud Router、Cloud NAT、函式與它的觸發器都還在、設定沒變（這次沒有觀察 VM 與資料庫）；停用期間 <code>gcloud compute routers list</code> 回傳零筆，重新連結之後 Cloud Router 仍在。重新連結之後 4 分鐘內沒有再被停用：停用期間對主題發布的訊息照樣被接受，而函式在停用後的那一分鐘內就把它處理掉了（回報帳單已停用），沒有留到重新連結之後。</p>
<ul>
<li><strong>替代做法</strong>：刪掉函式再重新連結，換到的是不必估清理期間的金額，付出的是清理期間沒有停損開關；被濫用的情形下，這段時間正是攻擊者可能再開資源的時候（推論，這次沒有實跑）。</li>
</ul>
]]></content:encoded></item></channel></rss>