<?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>Kill-Switch on Tarragon</title><link>https://tarrragon.github.io/blog/tags/kill-switch/</link><description>Recent content in Kill-Switch 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/tags/kill-switch/index.xml" rel="self" type="application/rss+xml"/><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><item><title>GCP 上的雲端費用失控防護：預算警示、強制執行支出上限與自動停用帳單的作用範圍、反應時間與判斷</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/</link><pubDate>Sat, 24 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/spend-protection-gcp-budget-killswitch/</guid><description>&lt;p>這篇整理在 GCP 上為費用失控設防的機制與判斷依據，範圍從專案已經連結到一個帳單帳戶開始。&lt;a href="https://tarrragon.github.io/blog/devops/operations/08-cost-management/dev-environment-cost/" data-link-title="開發環境成本控制" data-link-desc="壓非 production 環境的隱形浪費時，用排程關機、用完即拆的 ephemeral 環境、CI 跑 spot、以及 per-team 成本上限">開發環境成本控制&lt;/a>的〈Per-team 成本上限〉說明了為什麼要給團隊與環境設一個費用的上限；這一篇處理那個上限在 GCP 上由哪些機制執行、每一個機制在費用超過門檻時實際做了什麼，以及從費用發生到它動作之間隔了多久。三個機制分別是預算警示（花費達門檻時寄通知）、強制執行支出上限（暫停指定服務的新用量），以及由預算通知觸發的處置（由程式把專案從帳單帳戶解除連結，或只停止花錢的資源）；另外寫停損開關放在另一個專案的理由、驗收為什麼分兩段，以及停用之後的恢復順序。建預算、主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與恢復的逐步指令與實跑結果，在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄&lt;/a>。&lt;/p>
&lt;p>文中的觀察來自 2026 年 10 月在一個 GCP 測試專案上的實跑；標「未實跑」的做法沒有在實跑中執行；引用 Cloud Billing 文件或 &lt;code>gcloud&lt;/code> 說明的行為，出處寫在句子裡。主控台的選項與服務清單會變動，以操作當下看到的為準。&lt;/p>
&lt;h2 id="費用失控的來源與反應時間">費用失控的來源與反應時間&lt;/h2>
&lt;p>費用失控指的是費用增加的速度快過人發現它、處理它的速度。來源分成兩類，兩類的處置方式不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>服務自己出錯&lt;/strong>：程式對失敗的請求無限重試、一個事件觸發的函式又產生同一種事件而形成遞迴、自動擴縮允許的最大執行個體數設得太高。費用花在自己的服務上，修好程式之後費用就停止增加。&lt;/li>
&lt;li>&lt;strong>資源被別人濫用&lt;/strong>：外洩的 API 金鑰（API key）被拿去呼叫付費 API、被入侵的 VM 被拿去挖礦。費用花在攻擊者的用途上，處理的人要先切斷攻擊者能用的資源，再處理入侵。&lt;/li>
&lt;/ul>
&lt;p>兩類的共同難處在時間：費用從發生到任何機制動作，中間隔著一段延遲。一個防護機制從費用發生到動作，要經過三段：雲端把用量算成費用、寫進帳單資料；帳單資料觸發通知；通知觸發處置（人或程式）。把用量算成費用那一段的延遲由雲端決定，GCP 的帳單主控台寫著費用資料要數小時後才會顯示、有時超過 24 小時。實測時一個專案已經跑了幾個小時的 VM 與 GKE 叢集，那段期間送到的預算通知裡 &lt;code>costAmount&lt;/code>（本期累計花費）仍是 &lt;code>0.0&lt;/code>；那個預算用預設的抵免設定，所以這次分不出 &lt;code>0.0&lt;/code> 來自延遲還是抵免。&lt;/p>
&lt;p>預算警示與由預算通知觸發的處置讀的是帳單資料，反應時間至少是這段延遲。強制執行支出上限依的是 GCP 估算的費用，Cloud Billing 文件說這份估算比帳單報表上的費用快得多，但仍不是即時，延遲期間超出上限的部分照常計費。這三個機制的費用最後停在哪裡也不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>強制執行支出上限與解除帳單連結&lt;/strong>：費用停止增加，總額是觸發的金額再加上延遲期間已經發生的費用。&lt;/li>
&lt;li>&lt;strong>只停止花錢的資源&lt;/strong>：被停掉的資源不再計費，磁碟與儲存照常計費。&lt;/li>
&lt;li>&lt;strong>預算警示&lt;/strong>：不停止任何用量，費用停在哪裡取決於收到通知的人多快處理。&lt;/li>
&lt;/ul>
&lt;p>要在費用發生的當下限制它，得靠不經過帳單資料的限制，它們也決定了延遲期間最多能多花多少：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>能用多少&lt;/strong>：API 的配額、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">速率限制（rate limit）&lt;/a>、服務允許的最大執行個體數（例如 Cloud Run 的 &lt;code>--max-instances&lt;/code>、MIG 的最大台數），以及 Compute Engine 的區域 CPU 與 GPU 配額。配額調到實際用量附近，被入侵的帳號能開的 VM 台數就有上限。&lt;/li>
&lt;li>&lt;strong>能建什麼&lt;/strong>：組織政策（Organization Policy，GCP 繁體中文介面寫作「機構政策」）可以限制專案能啟用的 API，搭配 Compute Engine 的自訂限制條件可以限制能用的機型，前提是專案屬於一個組織（未實跑：測試帳號底下沒有組織）。&lt;/li>
&lt;li>&lt;strong>金鑰能做什麼&lt;/strong>：API 金鑰的使用範圍限定可呼叫的 API 與來源；實測 &lt;code>gcloud services api-keys create&lt;/code> 不給 &lt;code>--api-target&lt;/code> 時被拒，新建的金鑰一定帶 API 範圍。金鑰被盜用時，先撤銷或限制那一把金鑰，影響範圍比停用整個專案的帳單小。&lt;/li>
&lt;/ul>
&lt;h2 id="預算警示門檻依據範圍抵免與通知對象">預算警示：門檻依據、範圍、抵免與通知對象&lt;/h2>
&lt;p>預算（budget）是建在帳單帳戶上的一組門檻：本期花費達到預算的某個比例時，GCP 寄通知給帳單帳戶的管理員。門檻有兩種依據，&lt;strong>實際花費&lt;/strong>（已經寫進帳單資料的費用）與&lt;strong>預測花費&lt;/strong>（GCP 依目前的速度推估到期末的費用）；預測花費的門檻通常比實際花費的門檻早觸發，代價是一次短暫的尖峰也可能觸發預測花費的門檻。預算只寄通知，扣款照常進行。建立指令（&lt;code>gcloud billing budgets create&lt;/code> 加 &lt;code>--filter-projects&lt;/code>、&lt;code>--threshold-rule&lt;/code>）與它的兩道權限檢查，在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄&lt;/a> 的〈建立限定一個專案的預算〉。&lt;/p>
&lt;p>決定一個預算會不會在對的時候通知對的人，有三件事：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>範圍&lt;/strong>：預算用 &lt;code>--filter-projects&lt;/code> 限定要計算的專案。&lt;code>gcloud&lt;/code> 說明寫著不給這個旗標時涵蓋所有專案，通知裡的 &lt;code>costAmount&lt;/code> 就是帳單帳戶下所有專案的合計：別的專案的花費也會讓它越過門檻，而這個專案自己的超支被合計稀釋。&lt;/li>
&lt;li>&lt;strong>抵免&lt;/strong>：&lt;code>gcloud&lt;/code> 說明寫著預設把帳戶上的抵免扣掉之後才算花費。帳戶上有大額抵免（新創方案、試用額度）時，&lt;code>costAmount&lt;/code> 在抵免用完之前都接近 0，門檻在抵免被燒光的整段期間都不會觸發。抵免本身也是要保護的錢時，建立預算時加 &lt;code>--credit-types-treatment=exclude-all-credits&lt;/code>，花費以抵免前的金額計算，代價是免費方案的用量也會以原價算進去（推論：&lt;code>FREE_TIER&lt;/code> 是預算接受的抵免類型，這次沒有量）；只想讓部分抵免照常扣掉時，改用 &lt;code>include-specified-credits&lt;/code> 搭配 &lt;code>--filter-credit-types&lt;/code>。試用額度用完或試用期結束之後，帳單帳戶與資源會被停用、停止還是開始計費，GCP 與 Oracle Cloud、AWS 的規則見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較&lt;/a> 的〈試用結束後帳號與資源的處置〉。&lt;/li>
&lt;li>&lt;strong>通知對象&lt;/strong>：通知預設寄給帳單帳戶的管理員與帳單使用者，建立預算時先確認這兩個角色的成員信箱有人在看。值班的人通常不在這兩個角色裡，要讓他們收到，把通知另外寄到 Cloud Monitoring 的電子郵件通知管道（Cloud Billing 文件寫明預算只支援電子郵件類型的管道）；要送到簡訊、聊天工具或 webhook，改由 Pub/Sub 主題接一段轉送程式。&lt;/li>
&lt;/ul>
&lt;p>預算另外可以把通知送到一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">Pub/Sub&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">主題（topic）&lt;/a>，由預算通知觸發的處置就是從這個主題接收通知；接法在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄&lt;/a> 的〈建接收通知的主題並把預算接上〉。預算警示沒有辦法手動送出一封測試用的門檻通知，第一封真實的門檻通知寄到之前，「通知送得到收的人」這一段沒有驗證過；寄到時確認收到的是誰、多久後有人看到。&lt;/p>
&lt;h2 id="強制執行支出上限涵蓋的服務範圍與暫停期間">強制執行支出上限：涵蓋的服務、範圍與暫停期間&lt;/h2>
&lt;p>在帳單主控台的「Budgets &amp;amp; caps」頁面（2026 年 10 月主控台顯示的名稱）建立預算時，表單裡有一個「強制執行支出上限（適用於某些服務）」的選項：花費達到上限時，GCP 暫停表單裡選定的那個專案、那一項服務的新用量，直到這個預算期間結束（以月為期的預算是到月底，下一期自動重設）或有人手動解除為止。它是帳單功能裡不必自己寫程式就能暫停用量的機制，範圍有四個限制：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>一個上限只涵蓋一個專案裡的一項服務&lt;/strong>：要保護多項服務，就每項各建一個。&lt;/li>
&lt;li>&lt;strong>只涵蓋清單上的服務&lt;/strong>：2026 年 10 月建立表單時列出的是 Cloud Run、Cloud Run functions、Gemini API 與 Vertex AI 四項，Compute Engine 與 GKE 不在清單上。表單的服務下拉選單就是涵蓋清單，建立之前打開看一次。&lt;/li>
&lt;li>&lt;strong>持續性資源的固定費用不暫停&lt;/strong>：Cloud Billing 文件說明，與持續性資源綁在一起的固定用量不會被暫停，執行也有延遲，超出的部分照常計費。&lt;/li>
&lt;li>&lt;strong>以抵免前的金額計算&lt;/strong>：Cloud Billing 文件寫明上限的花費不扣抵免與折扣，與預算的預設抵免設定相反。帳戶有抵免、而上限與預算的金額相近時，上限會先達到。&lt;/li>
&lt;/ul>
&lt;p>工作負載跑在清單上的服務時，這個選項不必寫程式；它暫停的是這項服務的新用量，所以對正式服務同樣是一次停機，只是範圍限於這一項服務。是否建立，依〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉比較的兩種損失（多付的費用，與服務停止、資料可能遺失）來判斷。一個專案同時有清單上的服務與不在清單上的服務時，兩種做法可以並用：清單上的服務各建一個支出上限，在估算費用達到上限時先暫停那一項服務；不在清單上的資源由只停止花錢的資源那一種處置接手。改用解除帳單連結的話，停掉的是整個專案，清單上的服務也一起停。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理在 GCP 上為費用失控設防的機制與判斷依據，範圍從專案已經連結到一個帳單帳戶開始。<a href="/blog/devops/operations/08-cost-management/dev-environment-cost/" data-link-title="開發環境成本控制" data-link-desc="壓非 production 環境的隱形浪費時，用排程關機、用完即拆的 ephemeral 環境、CI 跑 spot、以及 per-team 成本上限">開發環境成本控制</a>的〈Per-team 成本上限〉說明了為什麼要給團隊與環境設一個費用的上限；這一篇處理那個上限在 GCP 上由哪些機制執行、每一個機制在費用超過門檻時實際做了什麼，以及從費用發生到它動作之間隔了多久。三個機制分別是預算警示（花費達門檻時寄通知）、強制執行支出上限（暫停指定服務的新用量），以及由預算通知觸發的處置（由程式把專案從帳單帳戶解除連結，或只停止花錢的資源）；另外寫停損開關放在另一個專案的理由、驗收為什麼分兩段，以及停用之後的恢復順序。建預算、主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與恢復的逐步指令與實跑結果，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a>。</p>
<p>文中的觀察來自 2026 年 10 月在一個 GCP 測試專案上的實跑；標「未實跑」的做法沒有在實跑中執行；引用 Cloud Billing 文件或 <code>gcloud</code> 說明的行為，出處寫在句子裡。主控台的選項與服務清單會變動，以操作當下看到的為準。</p>
<h2 id="費用失控的來源與反應時間">費用失控的來源與反應時間</h2>
<p>費用失控指的是費用增加的速度快過人發現它、處理它的速度。來源分成兩類，兩類的處置方式不同：</p>
<ul>
<li><strong>服務自己出錯</strong>：程式對失敗的請求無限重試、一個事件觸發的函式又產生同一種事件而形成遞迴、自動擴縮允許的最大執行個體數設得太高。費用花在自己的服務上，修好程式之後費用就停止增加。</li>
<li><strong>資源被別人濫用</strong>：外洩的 API 金鑰（API key）被拿去呼叫付費 API、被入侵的 VM 被拿去挖礦。費用花在攻擊者的用途上，處理的人要先切斷攻擊者能用的資源，再處理入侵。</li>
</ul>
<p>兩類的共同難處在時間：費用從發生到任何機制動作，中間隔著一段延遲。一個防護機制從費用發生到動作，要經過三段：雲端把用量算成費用、寫進帳單資料；帳單資料觸發通知；通知觸發處置（人或程式）。把用量算成費用那一段的延遲由雲端決定，GCP 的帳單主控台寫著費用資料要數小時後才會顯示、有時超過 24 小時。實測時一個專案已經跑了幾個小時的 VM 與 GKE 叢集，那段期間送到的預算通知裡 <code>costAmount</code>（本期累計花費）仍是 <code>0.0</code>；那個預算用預設的抵免設定，所以這次分不出 <code>0.0</code> 來自延遲還是抵免。</p>
<p>預算警示與由預算通知觸發的處置讀的是帳單資料，反應時間至少是這段延遲。強制執行支出上限依的是 GCP 估算的費用，Cloud Billing 文件說這份估算比帳單報表上的費用快得多，但仍不是即時，延遲期間超出上限的部分照常計費。這三個機制的費用最後停在哪裡也不同：</p>
<ul>
<li><strong>強制執行支出上限與解除帳單連結</strong>：費用停止增加，總額是觸發的金額再加上延遲期間已經發生的費用。</li>
<li><strong>只停止花錢的資源</strong>：被停掉的資源不再計費，磁碟與儲存照常計費。</li>
<li><strong>預算警示</strong>：不停止任何用量，費用停在哪裡取決於收到通知的人多快處理。</li>
</ul>
<p>要在費用發生的當下限制它，得靠不經過帳單資料的限制，它們也決定了延遲期間最多能多花多少：</p>
<ul>
<li><strong>能用多少</strong>：API 的配額、<a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">速率限制（rate limit）</a>、服務允許的最大執行個體數（例如 Cloud Run 的 <code>--max-instances</code>、MIG 的最大台數），以及 Compute Engine 的區域 CPU 與 GPU 配額。配額調到實際用量附近，被入侵的帳號能開的 VM 台數就有上限。</li>
<li><strong>能建什麼</strong>：組織政策（Organization Policy，GCP 繁體中文介面寫作「機構政策」）可以限制專案能啟用的 API，搭配 Compute Engine 的自訂限制條件可以限制能用的機型，前提是專案屬於一個組織（未實跑：測試帳號底下沒有組織）。</li>
<li><strong>金鑰能做什麼</strong>：API 金鑰的使用範圍限定可呼叫的 API 與來源；實測 <code>gcloud services api-keys create</code> 不給 <code>--api-target</code> 時被拒，新建的金鑰一定帶 API 範圍。金鑰被盜用時，先撤銷或限制那一把金鑰，影響範圍比停用整個專案的帳單小。</li>
</ul>
<h2 id="預算警示門檻依據範圍抵免與通知對象">預算警示：門檻依據、範圍、抵免與通知對象</h2>
<p>預算（budget）是建在帳單帳戶上的一組門檻：本期花費達到預算的某個比例時，GCP 寄通知給帳單帳戶的管理員。門檻有兩種依據，<strong>實際花費</strong>（已經寫進帳單資料的費用）與<strong>預測花費</strong>（GCP 依目前的速度推估到期末的費用）；預測花費的門檻通常比實際花費的門檻早觸發，代價是一次短暫的尖峰也可能觸發預測花費的門檻。預算只寄通知，扣款照常進行。建立指令（<code>gcloud billing budgets create</code> 加 <code>--filter-projects</code>、<code>--threshold-rule</code>）與它的兩道權限檢查，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈建立限定一個專案的預算〉。</p>
<p>決定一個預算會不會在對的時候通知對的人，有三件事：</p>
<ul>
<li><strong>範圍</strong>：預算用 <code>--filter-projects</code> 限定要計算的專案。<code>gcloud</code> 說明寫著不給這個旗標時涵蓋所有專案，通知裡的 <code>costAmount</code> 就是帳單帳戶下所有專案的合計：別的專案的花費也會讓它越過門檻，而這個專案自己的超支被合計稀釋。</li>
<li><strong>抵免</strong>：<code>gcloud</code> 說明寫著預設把帳戶上的抵免扣掉之後才算花費。帳戶上有大額抵免（新創方案、試用額度）時，<code>costAmount</code> 在抵免用完之前都接近 0，門檻在抵免被燒光的整段期間都不會觸發。抵免本身也是要保護的錢時，建立預算時加 <code>--credit-types-treatment=exclude-all-credits</code>，花費以抵免前的金額計算，代價是免費方案的用量也會以原價算進去（推論：<code>FREE_TIER</code> 是預算接受的抵免類型，這次沒有量）；只想讓部分抵免照常扣掉時，改用 <code>include-specified-credits</code> 搭配 <code>--filter-credit-types</code>。試用額度用完或試用期結束之後，帳單帳戶與資源會被停用、停止還是開始計費，GCP 與 Oracle Cloud、AWS 的規則見 <a href="/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較</a> 的〈試用結束後帳號與資源的處置〉。</li>
<li><strong>通知對象</strong>：通知預設寄給帳單帳戶的管理員與帳單使用者，建立預算時先確認這兩個角色的成員信箱有人在看。值班的人通常不在這兩個角色裡，要讓他們收到，把通知另外寄到 Cloud Monitoring 的電子郵件通知管道（Cloud Billing 文件寫明預算只支援電子郵件類型的管道）；要送到簡訊、聊天工具或 webhook，改由 Pub/Sub 主題接一段轉送程式。</li>
</ul>
<p>預算另外可以把通知送到一個 <a href="/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">Pub/Sub</a> <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">主題（topic）</a>，由預算通知觸發的處置就是從這個主題接收通知；接法在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈建接收通知的主題並把預算接上〉。預算警示沒有辦法手動送出一封測試用的門檻通知，第一封真實的門檻通知寄到之前，「通知送得到收的人」這一段沒有驗證過；寄到時確認收到的是誰、多久後有人看到。</p>
<h2 id="強制執行支出上限涵蓋的服務範圍與暫停期間">強制執行支出上限：涵蓋的服務、範圍與暫停期間</h2>
<p>在帳單主控台的「Budgets &amp; caps」頁面（2026 年 10 月主控台顯示的名稱）建立預算時，表單裡有一個「強制執行支出上限（適用於某些服務）」的選項：花費達到上限時，GCP 暫停表單裡選定的那個專案、那一項服務的新用量，直到這個預算期間結束（以月為期的預算是到月底，下一期自動重設）或有人手動解除為止。它是帳單功能裡不必自己寫程式就能暫停用量的機制，範圍有四個限制：</p>
<ul>
<li><strong>一個上限只涵蓋一個專案裡的一項服務</strong>：要保護多項服務，就每項各建一個。</li>
<li><strong>只涵蓋清單上的服務</strong>：2026 年 10 月建立表單時列出的是 Cloud Run、Cloud Run functions、Gemini API 與 Vertex AI 四項，Compute Engine 與 GKE 不在清單上。表單的服務下拉選單就是涵蓋清單，建立之前打開看一次。</li>
<li><strong>持續性資源的固定費用不暫停</strong>：Cloud Billing 文件說明，與持續性資源綁在一起的固定用量不會被暫停，執行也有延遲，超出的部分照常計費。</li>
<li><strong>以抵免前的金額計算</strong>：Cloud Billing 文件寫明上限的花費不扣抵免與折扣，與預算的預設抵免設定相反。帳戶有抵免、而上限與預算的金額相近時，上限會先達到。</li>
</ul>
<p>工作負載跑在清單上的服務時，這個選項不必寫程式；它暫停的是這項服務的新用量，所以對正式服務同樣是一次停機，只是範圍限於這一項服務。是否建立，依〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉比較的兩種損失（多付的費用，與服務停止、資料可能遺失）來判斷。一個專案同時有清單上的服務與不在清單上的服務時，兩種做法可以並用：清單上的服務各建一個支出上限，在估算費用達到上限時先暫停那一項服務；不在清單上的資源由只停止花錢的資源那一種處置接手。改用解除帳單連結的話，停掉的是整個專案，清單上的服務也一起停。</p>
<h2 id="由預算通知觸發的處置解除帳單連結與只停止花錢的資源">由預算通知觸發的處置：解除帳單連結與只停止花錢的資源</h2>
<p>這一節的做法是讓預算通知觸發一段程式，由程式在花費達到預算時停止用量。停止用量有兩種做法，差別在停掉的範圍：</p>
<ul>
<li><strong>解除帳單連結</strong>：程式呼叫 Cloud Billing API 把專案從帳單帳戶解除連結。專案沒有連結帳單帳戶時，GCP 停止專案裡所有的服務（包括免費方案的服務），費用也就停止增加。這段程式在下文稱為<strong>停損開關</strong>（kill switch）。</li>
<li><strong>只停止花錢的資源</strong>：程式改成停止專案裡執行中的 VM、把 MIG 的目標台數設成 0、把 GKE node pool 縮到 0 個節點。Cloud Billing 文件的〈Control resource usage with notifications〉就是這種做法，範例是逐台停止一個 zone 裡執行中的 VM。實測改用 <code>instances().aggregatedList</code> 跨全部 zone 列出 <code>RUNNING</code> 的 VM 再逐台 <code>stop</code>，服務帳號只給 <code>roles/compute.instanceAdmin.v1</code>：訊息發出後函式立刻送出停止，VM 約兩分鐘後變成 <code>TERMINATED</code>，帳單仍是啟用、開機磁碟仍在。資料留在磁碟與儲存裡、那部分照常計費，所以費用不會歸零；代價是程式只停它列得出來的資源，漏掉一種資源型別，或攻擊者另外開的新資源，那部分照常計費。</li>
</ul>
<p>呼叫鏈上的 Eventarc 是 GCP 把事件（這裡是主題裡的訊息）轉成對函式的呼叫的服務；第二代的 Cloud Run functions 以 Eventarc 觸發器接主題。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">預算（帳單帳戶）── 通知 ──&gt; Pub/Sub 主題 ── 推送 ──&gt; Eventarc 觸發器 ── 呼叫 ──&gt; Cloud Run functions 函式 ──&gt; 解除帳單連結，或停止花錢的資源</span></span></code></pre></div><p>兩種做法的預算、主題、觸發器與重試設定相同，差別在函式本體、服務帳號的角色與驗收的終點：改成只停止花錢的資源時，函式改成呼叫 Compute Engine 與 GKE 的 API，服務帳號改給能修改那些資源的角色（VM 與 MIG 例如 <code>roles/compute.instanceAdmin.v1</code>，GKE node pool 的縮放要 container 的權限，以 <code>gcloud iam roles describe</code> 確認），驗收的終點改成超出預算的訊息之後 <code>gcloud compute instances list</code> 的狀態變成 <code>TERMINATED</code>、MIG 的目標台數與 node pool 的節點數變成 0，每一個 zone 與資源型別各確認一次（MIG 與 node pool 的部分未實跑）。</p>
<p>建之前先決定要不要建、建哪一種，因為停損開關的效果是一次自己造成的全面停機：VM、資料庫、函式、Kubernetes 叢集一起停擺，停損開關本身跑在同一個專案時也會停。Cloud Billing 文件另外警告，停用帳單時資源可能被永久刪除，也不保證服務能恢復，所以這是一次可能遺失資料的停機。判斷方式是比較兩種損失：「被濫用或程式出錯而多付一筆費用」與「這個專案的服務停止、資料可能遺失」，哪一種比較大。</p>
<p>判斷的單位是專案，因為停損開關停的是整個專案。混合了正式服務與沙箱的專案照正式服務判斷；想對沙箱建停損開關，就先把沙箱搬到自己的專案。逐個專案套下面三條：</p>
<ul>
<li><strong>停機時沒有人受影響、而且專案裡的資料都能從別處重建</strong>（只跑實驗的專案、個人的沙箱多半是這樣）：多付的費用沒有上限，建停損開關。</li>
<li><strong>停機可以接受、而資料不能遺失</strong>（團隊共用的 staging、有持久資料的實驗專案）：建只停止花錢的資源的那一種。</li>
<li><strong>停機會影響使用者或同事</strong>（正式服務）：全面停機的代價通常高於一段時間的超支，改用預算警示，寄到值班的人手上（見〈預算警示：門檻依據、範圍、抵免與通知對象〉的通知對象），並把〈費用失控的來源與反應時間〉列的即時限制設上，讓帳單資料延遲期間的花費有上限。沒有人能值班時，處理時間就是自己下一次看到通知的時間，配額要依這段時間能承受的花費來設。風險集中的功能可以拆出去，例如 API 金鑰寫在網頁或 App 裡、任何人都拿得到那把金鑰的功能：拆到自己的專案再對那個專案建停損開關。停損開關停掉那個專案時，正式服務裡用這把金鑰的功能也會一起停，正式服務的其餘部分與正式服務的資料要放在另一個專案。</li>
</ul>
<p>接停損開關的預算要另外建、範圍限定在要停用的那一個專案，金額設成這個專案本期能承受的最高花費減掉帳單資料延遲期間多出的費用。延遲費用的上限約是「每小時最多能花的費用 × 延遲的時數」，每小時最多能花多少由〈費用失控的來源與反應時間〉列的配額決定，所以先把配額壓到實際需要的量，才估得出這個上限。一個主題只接一個預算：函式不分辨通知來自哪一個預算，好幾個預算送到同一個主題時，任何一個超支都會讓它停掉同一個專案。</p>
<p>停損開關有三項設定，任何一項設錯，部署指令都回報成功、函式狀態也都顯示 <code>ACTIVE</code>，停損開關卻不會解除帳單連結：服務帳號要另外有讀取帳單狀態的 <code>roles/browser</code>（<code>roles/billing.projectManager</code> 只能建立與刪除帳單連結）、部署要加 <code>--retry</code>（不加時，函式在中途拋出例外而 Cloud Run 回 200，那則超出預算的通知被確認收訊、不再重送）、觸發器的服務帳號要有呼叫函式的 <code>roles/run.invoker</code>（缺它時每一次推送都被 403 擋下，函式一次都不執行）。三項的指令與實測時看到的錯誤，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈建服務帳號並授予兩個角色〉〈部署函式：兩個服務帳號、執行個體上限與重試〉〈授予觸發器呼叫函式的權限〉。</p>
<h2 id="停損開關放在另一個專案">停損開關放在另一個專案</h2>
<p>專案裡跑的工作負載可能被入侵時，攻擊者拿到的身分若有專案的編輯權限，也能刪掉同一個專案裡的函式與主題。主題、函式與服務帳號可以放在一個專門放帳單工具、不受停用影響的專案（Cloud Billing 文件稱為 FinOps administration project，下文稱管理專案；文件建議把帳單相關的主題集中在這種專案），被保護的專案只綁服務帳號的兩個角色。這樣放有三個效果：解除連結之後函式仍在運作，能記錄並通知人「哪個專案被停了」；被保護的專案裡被入侵的身分刪不掉它；驗收時函式不會跟著停，所以兩則測試訊息不必照順序發。代價是管理專案成了所有停損開關的共同依賴：它的帳單停了或權限被收掉，所有被保護的專案一起失去停損開關，所以監看停損開關有沒有持續收到通知的心跳監看要放在管理專案以外。分開放時要改的指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈停損開關放在另一個專案時要改的指令〉。</p>
<h2 id="驗收真實通知確認預算到-pubsub-主題的一段測試訊息確認主題之後的一段">驗收：真實通知確認預算到 Pub/Sub 主題的一段，測試訊息確認主題之後的一段</h2>
<p>停損開關平常不動作，壞掉與「還沒輪到它動作」在任何狀態欄上都長得一樣；〈由預算通知觸發的處置：解除帳單連結與只停止花錢的資源〉最後一段列的三項設定，實測時各自讓它在狀態顯示正常時失效過一次。所以部署成功不算完成，驗收看的是呼叫鏈最後一段的結果：專案的帳單狀態。這是<a href="/blog/report/protection-proves-itself-by-firing/" data-link-title="平常不動作的防護機制的部署驗證：設定被接受、元件狀態正常、執行失敗被吞掉與測試從中途注入這些提早出現的成功訊號，驗證查詢與處置指令的假陰性，以及合成觸發本身有損害時的分段驗證" data-link-desc="部署停損開關、稽核紀錄、告警或自動處置這類平常不動作的防護機制之後，要判斷它算不算部署完成時讀；也用在驗收紀錄寫著「部署成功」「狀態 ACTIVE」「設定已套用」、或驗證查詢回傳零筆就被當成結論的時候">平常不動作的防護機制的部署驗證</a>的一個應用。</p>
<p>驗收分成兩段，因為測試訊息是從主題送進去的：主題之後的每一段（觸發器、呼叫權限、函式、角色、重試）都會被行使到，主題之前的那一段（預算的範圍、金額、抵免設定、發布到主題的權限）不會。主題之前的那一段，用第一則真實的預算通知確認：通知裡的 <code>costAmount</code> 對照帳單報表上這個專案本期的費用，接近 0 而報表不是，就是抵免設定或範圍設錯了。主題之後的那一段，用一則低於預算、一則超出預算的測試訊息確認，兩則走的是函式的兩條分支；只有超出預算那一則會行使解除連結要用到的兩個權限，結果看 <code>billingEnabled</code> 有沒有變成 <code>false</code>。</p>
<p>這次驗收會實際讓專案停擺。專案有停用之後不能遺失的資料時，改用拆開驗證：在可拋棄的專案上用一份換了名稱與主題的複本完整觸發一次，在正式專案上只用 Policy Troubleshooter 確認服務帳號的權限；拆開驗證仍沒有行使正式專案上那一次解除連結，驗收紀錄要寫明缺了這一段。部署之後別人調整了角色或鎖定了帳單連結時，部署的人不會知道要重驗，所以另設一個<a href="/blog/backend/knowledge-cards/dead-mans-switch/" data-link-title="Dead Man&#39;s Switch（心跳監看）" data-link-desc="需要知道監控或排程工作自己停掉了、而它停掉時發不出任何告警時，以「時限內沒收到訊號就通知」反過來判斷">心跳監看（dead man&rsquo;s switch）</a>：函式的紀錄長時間沒有出現就通知，讓函式停止收到通知這件事本身也成為訊號。兩段的訊息、查詢指令、拆開驗證的複本與心跳監看的設法，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈兩段驗收：第一則真實通知，再發兩則測試訊息〉與〈心跳監看：函式停止收到通知時告警〉。</p>
<h2 id="解除連結之後恢復服務的順序">解除連結之後恢復服務的順序</h2>
<p>實際因為超支而停用之後，<code>costAmount</code> 是本期累計值，整個預算期間都不會降回預算以下；只重新連結的話，下一則預算通知（一天會來好幾則）會讓函式再解除一次。恢復的順序依停用的原因而不同：</p>
<ul>
<li><strong>服務自己出錯</strong>：修好程式之後，先調高預算金額或刪掉函式，再重新連結。</li>
<li><strong>資源被別人濫用</strong>：重新連結之前，把預算金額調成目前的 <code>costAmount</code> 加上清理期間能承受的金額，讓停損開關在攻擊者再開資源時仍會動作，又不會在清理途中被已經累計的花費再觸發一次；並先撤銷外洩的 API 金鑰與服務帳號金鑰。重新連結之後列出並刪除不屬於自己的資源，清單要在重新連結之後列：實測停用期間 <code>gcloud compute routers list</code> 回傳零筆，而重新連結之後 Cloud Router 仍在，所以停用期間的空清單不代表資源已經刪除。確認乾淨之後再把預算調回原本的金額。</li>
</ul>
<p>指令與實測時資源在停用前後的狀態，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a> 的〈超支停用之後的恢復〉。</p>
<h2 id="延伸閱讀逐步的實作上限的理由費用的歸因防護機制的驗收與服務帳號的兩邊權限">延伸閱讀：逐步的實作、上限的理由、費用的歸因、防護機制的驗收與服務帳號的兩邊權限</h2>
<ul>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/budget-killswitch-walkthrough/" data-link-title="GCP 預算警示與停損開關的實測紀錄：建預算、Pub/Sub 主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與停用後的恢復" data-link-desc="要在 GCP 專案上建預算警示，或建一個花費超過預算就解除帳單連結的停損開關，想照一次實跑過的順序做、看每一步漏掉或換一種做法的結果時讀；也用在函式部署成功、狀態 ACTIVE 卻從沒解除過帳單連結，或專案因超支被停用之後要恢復的時候">GCP 預算警示與停損開關的實測紀錄</a>：建預算、主題、服務帳號、函式與觸發器、兩段驗收、心跳監看與恢復的逐步指令、實跑結果，以及每一步漏掉或換一種做法時的結果</li>
<li>→ <a href="/blog/devops/operations/08-cost-management/dev-environment-cost/" data-link-title="開發環境成本控制" data-link-desc="壓非 production 環境的隱形浪費時，用排程關機、用完即拆的 ephemeral 環境、CI 跑 spot、以及 per-team 成本上限">開發環境成本控制</a>：〈Per-team 成本上限〉說明給每個團隊與環境設上限的理由，本篇是那個上限在 GCP 上的執行方式</li>
<li>→ <a href="/blog/devops/operations/08-cost-management/cost-monitoring/" data-link-title="成本監控與告警" data-link-desc="要讓雲端帳單不失控時，靠歸因把每筆花費追到擁有者、用異常告警抓突增、以及選 showback 或 chargeback 讓成本有人負責">成本監控與告警</a>：收到預算警示之後，靠資源標記定位是哪個團隊的哪類資源在漲；停損開關解除連結之前，要依歸因的結果選定是哪一個專案</li>
<li>→ <a href="/blog/report/protection-proves-itself-by-firing/" data-link-title="平常不動作的防護機制的部署驗證：設定被接受、元件狀態正常、執行失敗被吞掉與測試從中途注入這些提早出現的成功訊號，驗證查詢與處置指令的假陰性，以及合成觸發本身有損害時的分段驗證" data-link-desc="部署停損開關、稽核紀錄、告警或自動處置這類平常不動作的防護機制之後，要判斷它算不算部署完成時讀；也用在驗收紀錄寫著「部署成功」「狀態 ACTIVE」「設定已套用」、或驗證查詢回傳零筆就被當成結論的時候">平常不動作的防護機制的部署驗證</a>：停損開關、稽核紀錄與告警共用的驗收方法，以及「部署成功、狀態正常」為什麼不算驗收</li>
<li>→ <a href="/blog/devops/infra/knowledge-cards/service-account/" data-link-title="Service Account（服務帳號）" data-link-desc="程式、排程或雲端服務要以自己的身分呼叫雲端 API、要決定給它哪些角色、遇到「狀態正常卻呼叫失敗」的權限問題、或看到 IAM 綁定裡的 serviceAccount: 成員時，查服務帳號是什麼、它的權限要從「它能做什麼」與「誰能以它的身分行事」兩邊設，以及它和人的帳號在管理上的差別">Service Account（服務帳號）</a>：服務帳號的權限分成「它能做什麼」與「誰能以它的身分行事」兩邊，停損開關的角色與部署者的 <code>serviceAccountUser</code> 各屬一邊</li>
</ul>
]]></content:encoded></item></channel></rss>