地方活動臨時異動,服務暫停之後,鄉親剛確認的單據還在嗎?今天分清模型呼叫、服務執行與部署權限,整理 Secret Manager 的憑證邊界,再以本機停止開關驗證:新操作被攔下,原單仍可查,恢復服務也不重建第二張。真人通知另外用兩個 LINE 視窗驗收。
Stopping an agent should not erase a promise already recorded. This chapter separates model access, runtime permissions, and deployment authority, then implements a small SQLite stop gate. A control epoch rejects work admitted before a pause, while committed requests remain readable and idempotent. Secret Manager configuration and real LINE handoff require separate verification. The deliverable is a reproducible local example, a scoped permission design, and an explicit checklist for completing the human handoff.
現場一句話:活動臨時異動,新詢問先暫停,鄉親剛收到的單號怎麼辦?
只准後端決定的規則:開關擋住新副作用,既有單據保留;讀回仍查身分,恢復仍查冪等鍵。
Google AI 用到/刻意不用:用 Cloud Run 執行身分與 Secret Manager 管理憑證,不讓模型決定誰有權停止服務。
五分鐘入口:python3 -m examples.day22.circuit_breaker --demo --out out/day22/stop-demo。
這篇不能證明:本機停止測試不是雲端 IAM 驗收,也不是 LINE 真實送達或真人受理。
| 元件 | 本篇決策 | 驗證範圍 |
|---|---|---|
| Google AI Studio/Vertex AI | 比較 API Key 與服務帳號呼叫路徑 | 設計比較,未切換正式模型 |
| Cloud Run Service Account | 執行與部署分工 | 權限規劃,雲端政策另驗 |
| Secret Manager | 秘密按資源授權、按版本引用 | 設定方法,不附任何憑證值 |
| 停止開關 | 人工暫停新操作,保留查單 | 新增 SQLite 離線範例 |
模型可以建議下一步,但停止、恢復與單據是否成立,要由後端決定。
在維運彰化蔬食節與彰化旅行+這類 LINE 服務時,我會先想到拿著手機的鄉親:剛按完確認,畫面忽然沒有回應,是沒送出,還是要再按一次?
假設戶外活動因天候需要暫停受理,最省事的做法似乎是把行程停掉。但行程消失只表示程式不再工作,先前寫入可能早已完成。下次服務恢復若把重送當新單,鄉親就得面對兩張單號。
正如《爌肉之城》有白色方塊工作室、旅庫彰化等在地夥伴梳理地方脈絡,到了服務這一端,同樣要尊重既有事實:沒有預約工具就不代店家接單,已確認的是服務詢問,非訂餐成功。本篇沒有金流,驗證的是重複建單與通知,不包裝成防重複扣款的實測。
Day 21 已把直接 SDK 模型回應與依契約重建的下游事件分開。今天接著問:就算找到了可疑回合,停止之後哪些事情還應繼續存在?Day 19 的收件匣也要接回來,而不是另外畫一張看起來有人接手的圖。[1]
模型可以換、行程可以死、容器可以沒了;使用者已經確認過的那張地方服務單,必須還在,而且不能被重開成第二張。
我先採保守政策:暫停新增建單、認領、結案與新派送,保留經授權的查單。已拿到派送許可的在途工作另記結果;停止開關不是把外部世界倒帶。
本篇附上新增的 circuit_breaker.py 與 test_security.py。將範例置於專案的 examples/day22/,從專案根目錄執行;只需 Python 標準函式庫,不必先準備金鑰。
python3 -m unittest examples.day22.test_security -v
python3 -m examples.day22.circuit_breaker --demo \
--out out/day22/stop-demo
示例建立獨立的 security.sqlite,初始狀態是 PAUSED。測試維運者開放服務後,可信確認接點建立一筆合成詢問;接著暫停,阻擋第二筆,重開資料庫,再恢復服務。既有輸出目錄會被拒絕覆寫,方便保留每次紀錄。
打開 REPORT.json,本次本機執行得到下列核對結果;單號由程式生成,沒有放進這段固定輸出:
{
"mode": "SQLITE_OFFLINE_STOP_GATE",
"external_calls": 0,
"new_request_blocked": true,
"committed_rows_unchanged": true,
"same_request_id": true,
"paused_after_reopen": true,
"stale_epoch_blocked": true,
"request_count": 1
}
這裡的「沒變」是逐欄比較 requests 資料,不是要求整個 SQLite 檔案位元組相同。控制表與操作稽核本來就會更新。範例另測通知帳務與業務單據分離;已接受的通知回條不增加 claim_version,才不會讓剛產生的認領卡立刻過期。
Day 19 的 register_confirmed() 是已確認單據的收件匣接點;今天的資料庫則是獨立的停止契約示例,不取代原單庫。整合時不能先讀另一份旗標,再呼叫舊接點,就宣稱建單已受原子保護。控制檢查、原單確認與通知意圖,必須落在能共同提交或回復的交易邊界。
角色表使用明示的合成身分。真正接回 LINE 時,身分須由驗證過的事件建立;把 HTTP 參數寫成 operator,不等於取得管理權。本機驗證的是角色分工,不是 Google IAM 或 LINE 驗簽已經完成。
服務帳號(Service Account,SA)是程式呼叫 Google Cloud 的身分。IAM 則決定這個身分能對哪些資源做什麼。這和 LINE 使用者、志工的業務授權是不同層,不能混成一張「管理員」名牌。[2]
| 責任 | 憑證與權限規劃 | 能做的事 | 不授予的能力 |
|---|---|---|---|
| 模型呼叫 | Developer API 使用受限 API Key;Vertex AI 可用 ADC 取得服務身分 | 呼叫核准的模型介面 | 修改 IAM、決定志工資格 |
| 執行身分 | 專屬 SA;指定秘密的 roles/secretmanager.secretAccessor、所需 Firestore 的 roles/datastore.user |
讀秘密、執行已授權資料操作 | Owner/Editor、任意部署 |
| 部署身分 | WIF 目標規劃;指定服務的發布權、指定 SA 的 actAs |
發布核准映像與修訂版 | 直接授予正式資料庫讀寫角色 |
第一次建立服務與授予 IAM 可由受控流程完成,日常部署不需要專案管理權。這份執行角色清單以 Developer API 為前提,改走 Vertex AI 時需核對模型端權限,不能只換端點就假設授權相同。
表中的 local-agent-runner 為專屬執行帳號命名示例。模型呼叫是一種責任,不代表模型拿著 Google 憑證;金鑰留在伺服器端的 SDK 接點,不進 Prompt 或工具回覆。[3]
AI Studio 的 API Key 適合既有 Developer API 路線;將來改用 Vertex AI 可用服務帳號與應用程式預設憑證(ADC)。有組織防護需求再評估 VPC Service Controls,本篇沒有啟用該周界。[4]
在 Day 10 曾介紹過 GitHub Actions CI(持續整合) 作為自動化測試守門員,每次提交就自動在獨立乾淨的環境中驗證回歸測試,確保今天的改動沒有破壞昨天的承諾。而在今天將它延伸至部署身分時,最容易被忽視的資安死角,就是將 Google Cloud 長效服務帳號金鑰(JSON 檔案)直接存入 GitHub Repository Secrets。一旦金鑰外洩,攻擊者便能持鑰存取雲端資源。
因此,正式部署規劃採用 Workload Identity Federation(WIF),讓 GitHub Actions 在執行管線時透過 OIDC 向 Google Cloud 換取短效 Google Cloud 憑證,免於保存長效 Service Account JSON 金鑰。信任條件應鎖定組織、Repo 數字 ID 與允許分支;部署工作再經環境核准。更關鍵的是權限隔離:GitHub Actions 規劃僅授予建置映像與發布 Cloud Run 修訂版的權限,亦不直接授予正式資料庫的讀寫角色,落實「能發布程式、但不能窺探或竄改鄉親資料」的最小權限原則。[5]
能換程式的人,仍在資料的信任邊界裡。 部署者即使沒有資料庫角色,只要能更新服務並以 Runtime SA 執行程式,仍可能間接讀取該 SA 能讀的資料。因此我把程式審閱、受保護部署入口與映像審核一併列入,而不是宣稱兩個帳號分開就完全隔離。[2][5]
roles/datastore.user 也不是逐張單據的存取規則。伺服器端 Firestore 存取依 IAM 管理,誰能看哪張單、誰能認領,仍由應用程式核對。若連執行程式遭入侵都要隔離停止政策,就要另設控制服務或資料邊界,不能只在同一行程多寫一個 if。[6]

圖 1:身分分工、秘密管理與受控停止開關架構。部署身分、執行身分與模型呼叫三權分立(目標雲端架構規劃),秘密規劃由 Secret Manager 注入,受控停止閘門確保服務維護時原單妥善保存。
專案 ID、逾時門檻是設定;Gemini API Key、LINE Channel Secret 與 Access Token 是秘密。.env 只是載入格式,不是保險箱。提交範例只留名稱,真值留在 Secret Manager;已洩漏的憑證要撤銷或輪替,刪掉最新檔案不能消除歷史副本。[7]
Cloud Run 原生支援秘密環境變數與檔案掛載。環境變數在實例啟動時解析,適合固定引用版本;引用 latest 也不會讓已啟動的行程自行更新。掛載檔案在讀取時取得對應版本,輪替時還要考慮應用程式是否快取。[8]
| 設定方法 | 本篇建議 | 必須另留的紀錄 |
|---|---|---|
| 一般環境變數 | 放非敏感設定 | 名稱、值與修訂版 |
| 秘密環境變數 | 引用秘密名稱及固定版本 | Runtime SA、版本、讀取結果 |
| 秘密檔案 | 放在專用路徑,按需求讀取 | 掛載路徑、版本與輪替策略 |
在 Cloud Console 設定服務時,我會從「變數與秘密」引用已存在的 Secret,而不是把真值貼成一般環境變數。先核對 Runtime SA,再檢查每個秘密自己的 IAM;這些是部署操作指引,不是本次已完成的雲端設定。[8]
Secret Manager 管理秘密的保存位置與取用授權,不代表應用程式永遠碰不到秘密真值。 程式使用憑證時仍能接觸真值,除錯輸出、例外內容或整包環境變數傾印都可能洩漏。通用最佳實務偏好直接 API 存取;Cloud Run 原生注入是受支援的整合方式,仍須管理應用端風險。[7][8]
權限驗收不只測「讀得到」:指定秘密能讀取,無關秘密應被拒絕;測試身分不能列出正式單據。用 Runtime SA 與 Deployer SA 各測一次,才能確認授權未放太寬。本機綠燈不能代替拒絕紀錄。
查驗時只留秘密資源名稱、版本、身分與成功/拒絕結果。截圖、日誌與模型上下文都不放秘密內容。Channel Secret 用來驗 Webhook,Access Token 用來呼叫 Messaging API,兩者也不能互換。[9]
檔名雖叫 circuit_breaker.py,本篇實作的是人工停止閘門,不是依失敗率自動切換的完整斷路器。它只有 SERVING 與 PAUSED,另存控制版本 epoch。每次狀態切換加一,暫停後再恢復,也能辨認先前放行的舊工作。
epoch 和 Day 19 的 claim_version 也各有責任:前者代表受理政策變更,後者代表單據接手狀態變更。暫停不能把每張單的認領版本一起加一,否則恢復後志工手上的原卡全都作廢。兩個欄位分開,才知道該請使用者重新確認,還是請志工重新讀取待辦。
只在模型呼叫前看一次開關不夠。假設請求取得版本一後開始等待模型,期間維運者暫停再恢復,旗標看來又是開放,舊結果卻不該直接獲准建單。提交時要在同一交易再讀版本,把這段競爭條件攔住。
下面節錄 confirm() 的交易段;函式前段另查角色與輸入格式。subject 和操作內容必須先經可信確認接點驗證,epoch 不是密碼,也不替代使用者同意:
fingerprint = hashlib.sha256(category.encode()).hexdigest()
with self.transaction() as db:
old = db.execute(
'SELECT * FROM requests WHERE owner=? AND operation_key=?',
(subject, operation_key)).fetchone()
if old:
if old['fingerprint'] != fingerprint:
raise ValueError('SAME_KEY_DIFFERENT_CONTENT')
return dict(old)
self.gate(db, expected_epoch)
rid = str(uuid.uuid4())
db.execute('INSERT INTO requests VALUES(?,?,?,?,?,?,1,NULL)',
(rid, subject, operation_key, fingerprint,
category, 'request_created'))
payload = json.dumps({
'request_id': rid, 'category': category,
'claim_version': 1, 'label': '我來處理'},
ensure_ascii=False, sort_keys=True)
db.execute('INSERT INTO outbox VALUES(?,?,?,?,0,NULL,NULL)',
(rid, str(uuid.uuid4()), payload, 'pending'))
return dict(db.execute(
'SELECT * FROM requests WHERE request_id=?', (rid,)).fetchone())
BEGIN IMMEDIATE 把本機控制更新與建單寫入排成明確順序。先提交的單留下;停止提交之後,尚未提交的新單就被擋下。已存在的同鍵同內容先讀回,所以維護期間重按確認也能得到原單,且不新增通知意圖。[11]
row = db.execute(
'SELECT mode,epoch FROM control WHERE id=1').fetchone()
if row is None or row['mode'] != 'SERVING':
raise Paused('SERVICE_PAUSED')
if expected_epoch is not None and (
type(expected_epoch) is not int or row['epoch'] != expected_epoch):
raise Paused('CONTROL_EPOCH_CHANGED')
return row['epoch']
停止旗標放在同一份持久資料中,重開連線不會自動解鎖。搬到 Cloud Run 時,實例各自的 SQLite 或環境變數不能代表全體狀態;需把控制版本接進共享儲存與提交交易。快取只能早點拒絕,最後寫入仍查權威狀態。
通知則跨出資料庫交易。已取得許可的派送可能在暫停後才回傳;本篇保存晚到的接受紀錄,只改 outbox,不改單據或認領版本。尚未取得許可的新派送被擋住。這個分界說的是「哪次派送獲准」,不是停止後網路封包立刻消失。
恢復只改控制狀態,不自動掃描重送。已接受的通知直接查原回條;不確定者沿原 retry key 與原內容對帳,超過 LINE 的二十四小時重試窗口就轉人工處理,恢復開關不能重設那個期限。[10]
Day 19 建立、Day 21 預告的雙視窗實機證據,收件匣尚未接入 Cloud Run 上的 LINE 服務,我也尚未完成雲端部署與雙帳號實測,因此明確移至 Day 26 進行整合驗收。屆時兩個視窗的截圖、LINE 回傳的請求識別碼與資料庫狀態轉換,必須對回同一個單號。以下先訂定驗收的四個階段與核對清單,讓後續實機結果有固定的對照標準。
| 階段 | 使用者視窗 | 授權志工視窗 | 後端核對 |
|---|---|---|---|
| 開放並確認 | 取得服務詢問單號 | 看見同號待辦 | 原單與通知意圖各一筆 |
| 啟動停止 | 新單收到暫停說明;原單可查 | 原待辦仍可看,認領被攔 | 服務單與認領版本不變 |
| 解除停止 | 查回同一單號 | 以原卡認領,再測第二人競爭 | 僅一個合法認領成功 |
| 再次查回 | 看見已接手狀態 | 顯示認領結果 | 同號、同版、同一組事件 |
維護回覆也有兩種不同情況:拒絕新的業務操作,可以顯示暫停說明;無法取得資料庫或授權資料時,則不能保證仍查得到原單。後者要回報暫時無法查詢,不能為了安撫鄉親而猜出一個進度。停止開關保護狀態,不承諾所有依賴都永遠可用。
志工資格要從驗簽後的 source.userId 查白名單,不能信任 Postback 自填的志工身分。停止開關也放在獨立管理入口;使用者打「恢復服務」只是一句話。[9]
| 證據 | 審閱版狀態 | 支持到哪裡 |
|---|---|---|
| 新增本機測試 | 二十八項通過 | 停止、舊版本、授權、雙連線競爭與通知重試 |
| 本機示範 | 已執行,外部呼叫零次 | 原單保存、原號查回、恢復不重建 |
| Day 22 Commit/CI | 4b9f142/Run 37444398905 |
Day 22 專用工作流執行 28 項安全測試與停止示範,遠端綠燈通過 |
| Cloud Run/IAM 驗收 | 未執行 | 離線角色表不能替雲端授權作證 |
| 雙 LINE 視窗 | 未執行;移至 Day 26 | 驗收流程見上表 |
表中的 Run ID(37444398905) 是 GitHub Actions 每次執行工作流程時產生的唯一編號。讀者只要在 GitHub 程式庫切換至 Actions 分頁,或點選 Commit 旁的綠色勾勾,就能依這串編號查核遠端獨立乾淨環境下的完整執行日誌與測試報告。
停止說明應讓鄉親知道兩件事:今天暫停新增,先前單號仍可查。志工也要知道待辦還在,只是認領暫緩。資安規則若最後只留下「錯誤」,操作的人就只好靠猜。
這次交付的是本機停止契約與雲端權限設計。真正部署時,我會把身分政策、秘密版本、程式版本與雙視窗結果放在同一份驗收紀錄裡,既有服務整合也另驗。
下一篇是 Day 23|為什麼是 AI Studio、Antigravity、Firebase:選型判斷表。回頭看工具地圖,每項選擇都要能回答:它替哪一位鄉親少掉一次重填,又讓哪一位接手者少猜一件事?
能停下來,也能把原單交還到人手上,才是我希望地方服務留下的安心。
新增本機範例:examples/day22/circuit_breaker.py、test_security.py;未接入既有 LINE handler。以下官方資料於 2026-10-06 核對。
[1] Day 21:追蹤契約與實測邊界。
[2] Cloud Run:服務身分與部署所需角色。
[3] Gemini:API Key。
[4] Vertex AI:驗證方式與VPC Service Controls 範圍。
[5] WIF 與部署管線及部署身分安全。
[6] Firestore:伺服器端 IAM。
[7] Secret Manager 最佳實務與憑證外洩處理。
[8] Cloud Run:引用秘密。
[9] LINE:驗證 Webhook 簽章與Messaging API。
[10] LINE:重試請求與 retry key。
[11] SQLite:交易與 BEGIN IMMEDIATE。