iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 17 篇

Day 13(上)|SPOF、Redundancy、Failover:備援不是多開一台就結束

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

**一句話先講完:**只要一個元件失效就讓使用者工作無法完成,它就是單點故障(SPOF);redundancy 是資源存在,failover 是故障時真的判斷並切換,兩者不是同一件事,這一篇先把「怎麼找出假備援」講清楚。

兩個 API instance 後面接同一個資料庫、同一組憑證、同一個 region 的 provider,架構圖看起來有兩份,失敗時仍可能只倒一次就全倒。

① 先找「唯一」在哪裡

沿 Day 9 的 critical path 問四次:唯一的 instance?唯一的資料?唯一的設定/secret?唯一的外部 provider?最後兩項常被忘記。LLM application 還要問:唯一的 model、唯一的 vector index、唯一的 tool authorization service 是不是讓工作完全停擺?

不是每個 SPOF 都必須消除。成本、資料一致性與產品風險決定優先順序;請把可接受的單點,以及失效時的使用者結果寫下來。

隱藏 SPOF 的真實案例

2021 年 10 月 Meta 的全球停機事件是「隱藏 SPOF」的經典案例。架構裡有多個資料中心、多組 DNS 伺服器與跨區域部署;一次骨幹網路維護的錯誤指令,仍讓全球 backbone 離線。DNS 伺服器本身仍在運作,卻無法與資料中心通訊,因而撤回 BGP 廣告並變成「在線但不可達」。事件約持續六小時,最後需由人員到資料中心現場操作才能恢復——內部診斷工具同樣依賴 DNS,工程師連遠端 VPN 都無法登入,恢復路徑與故障路徑纏在一起(⑥ 節會再從「控制面」角度回顧這個案例)。這個案例要檢查的是依賴是否獨立,而非單純計算機器數量。

SPOF 不是「一台機器」,是「一條路徑」

很多人對 SPOF 的第一直覺是「只有一台伺服器在跑」。這個直覺沒有錯,但只講對了最表層的一種。SPOF 真正的定義比機器數量抽象一層:

只要拿掉某一個元件,使用者原本能完成的工作就變成完不成,這個元件就是 SPOF,不管它背後有幾台機器在撐。

這個定義刻意不提「機器」兩個字,是因為 SPOF 可以是一條網路路徑、一份設定檔、一個 DNS record、一組 credential、一個負責簽發憑證的內部服務。用「路徑」而非「機器」來想,才不會把「加一台機器」誤當成「消除單點」。

看起來有備援:
API pod #1  ─┐
             ├─→ 同一個 database endpoint(唯一)
API pod #2  ─┘

實際的 SPOF:
database endpoint

兩個 pod 分擔了計算負載,但只要那個 database endpoint 掛掉,兩個 pod 都會同時失敗。這不是「備援沒做好」,而是「備援做在了不會壞的那一層,沒做在真正會壞的那一層」,這句話值得抄在任何架構審查的開頭。

三個常見誤解

誤解一:多雲/多區域自動代表沒有 SPOF。 Meta 的例子已經示範過:多資料中心、多 DNS 伺服器,仍可能共用一條 backbone。region 數量是必要條件,不是充分條件。

誤解二:SPOF 只存在於基礎設施層。 一份手動維護的 Excel 白名單、一位知道怎麼重啟服務的工程師、一組只有一人知道密碼的管理帳號,都是 SPOF,只是它們不會出現在架構圖裡。Google SRE 把這類人為依賴稱為「bus factor」問題:如果某個人被公車撞了(比喻,不是詛咒),系統還能不能運作。AI 專案常見的版本是:只有一位工程師搞懂 prompt template 為什麼要那樣寫,或只有一人知道 vector index 的 re-embedding pipeline 怎麼跑。

誤解三:備援元件之間互相獨立就夠了。 還要問備援元件依賴的「第三方」是否獨立。兩個 API pod 各自獨立沒錯,但如果兩者都讀同一組 feature flag 服務、同一個 secrets manager、同一個 rate limiter,那組共用服務才是真正決定可用性上限的那個環節。

AI 系統額外多出來的四種「唯一」

傳統 SRE 盤點 SPOF 時問的是 instance、data、configuration、external provider。AI 系統的請求路徑比傳統 CRUD 服務多繞了幾層,因此多出四種容易被忽略的「唯一」:

唯一的 model version
  → 換一個 checkpoint,輸出風格、安全過濾、tool-calling 格式可能全部不同

唯一的 vector index / embedding pipeline
  → index 版本落後、embedding model 換了但沒重新索引,等於資料「看起來在」實際上查不到

唯一的 prompt / system instruction 來源
  → 存在單一 config service 裡,該服務掛掉時所有 workflow 同時失去「怎麼問」的能力

唯一的 tool authorization / policy 服務
  → agent 能不能呼叫工具,取決於這個服務是否可達;它掛了不代表 model 掛了,
    但使用者體驗到的是同一種「無法完成任務」

這四種「唯一」都不會讓 /healthz 變紅,卻能讓整個 AI workflow 從「回答正確」變成「回答不了」或「回答錯」。Day 12 談過 technical success 與 semantic success 的分層,這裡是同一個分層在 SPOF 盤點上的延伸:傳統 SPOF 盤點問的是「還能不能回應」,AI SPOF 盤點還要多問一句「還能不能回應得對」。

不是每個 SPOF 都必須消除,這句話在 AI 系統裡同樣成立,甚至更重要,因為消除「唯一 model version」的代價,可能是要同時維護兩套 prompt、兩套評估集、兩套安全審查,這筆帳要打在成本與風險對照表上,而不是憑直覺決定「當然要備援」。

五分鐘可以做完的自我測驗

在往下讀之前,花五分鐘對照你手邊正在維護(或正在設計)的一個 AI 服務,回答下面八個問題,不需要精確答案,只需要誠實回答「知道」或「不知道」:

1. 這個服務有幾個 model provider?是哪幾家?
2. 如果只有一家,換一家的成本(工程與資料治理)大概是多少?
3. vector index 有沒有複本?複本跟正本共用同一個 ingestion pipeline 嗎?
4. prompt / system instruction 存在哪裡?那個地方掛掉,服務還能不能運作?
5. tool authorization(誰可以呼叫哪個工具)由哪個服務決定?
6. 上一次「這裡故障會怎樣」的討論,是什麼時候?誰在場?
7. 如果 model provider 的 status page 顯示 outage,你的 on-call 流程是什麼?
8. 這個服務的架構圖,是三個月前畫的,還是上禮拜畫的?

如果八題裡有超過三題回答「不知道」,不代表這個服務做得不好,而是代表它現在正處於「redundancy 幻覺」最容易發生的狀態,架構圖看起來合理、日常運作也沒出過大問題,但沒有人真正驗證過故障發生時系統會怎麼反應。這份小測驗刻意設計成不需要任何工具、不需要存取權限就能做,目的是讓它成為讀完這一節後,可以立刻、免費做的第一個動作。

② redundancy 和 failover 是兩件事

redundancy 是有替代資源;failover 是故障時真的改走替代資源。後者需要 health 判斷、路由、資料一致性與回切策略。更糟的是,錯誤 health check 可能把健康流量切到壞節點,於是備援系統變成事故放大器。

這句話值得拆開來看,因為它是本篇最容易被略讀過去、卻最常在事故現場被驗證成真的一句:

redundancy = 資源存在
            (有第二台機器、第二個 provider、第二份資料複本)

failover   = 決策 + 執行
            (偵測 → 判斷 → 路由 → 確認一致性 → 之後還要回切)

redundancy ≠ failover
redundancy 是 failover 的必要條件,不是充分條件

redundancy 是一個「靜態事實」:你可以用架構圖或資源清單直接證明它存在,「這裡有兩台」「這裡有兩個 provider key」。failover 卻是一個「動態行為」:只有在故障真的發生、系統真的做出正確判斷並成功切換之後,你才能證明它存在。這也是為什麼很多團隊會說「我們有備援」卻答不出「上一次備援真的接手是什麼時候」,因為他們驗證的一直是前者,從沒驗證過後者。

對 AI 而言,fallback model 也不是自動安全:模型能力、輸出格式、資料地域、tool-calling 支援可能不同。切換前要決定哪些任務可以降級、哪些必須直接拒絕。

health check 判斷錯,備援反而放大事故

「錯誤 health check 可能把健康流量切到壞節點」這句話聽起來抽象,具體發生的方式通常是下面三種之一:

情境一:假陽性(false positive)
  健康的節點被誤判為不健康
  → 流量被切走,剩下節點承受雙倍負載
  → 剩下節點也開始變慢,觸發第二輪誤判
  → 「級聯式健康檢查誤判」

情境二:假陰性(false negative)
  不健康的節點被誤判為健康
  → 流量繼續(或被切)進去,使用者拿到錯誤或逾時回應
  → health check 本身看的訊號(如 TCP port 有開)
    和使用者真正在意的訊號(如能不能完成請求)不是同一件事

情境三:健康檢查本身變成負載來源
  → 高頻的深度健康檢查(例如每秒呼叫一次真實 LLM)
  → 在依賴已經吃緊時,健康檢查請求本身變成壓垮駱駝的最後一根稻草

情境一是「備援系統變成事故放大器」最常見的具體樣貌:health check 的敏感度設定得太激進(例如單次逾時就判定不健康),會讓系統在流量本來就有波動的正常時段裡,不斷把流量在節點之間甩來甩去,這種行為有個常見的名字叫 flapping,本篇 ⑩ 反例會用組合式健康訊號來處理,這裡先留一個印象:health check 的設計本身,就是一個需要被測試、被告警、被觀察歷史紀錄的子系統,不是接上一支 /healthz 就算完工。

多 provider 並不自動等於可靠。實作時最常掉進三個陷阱:把 429 與 500 視為同一種錯誤而來回切換、沒有重試上限而把等待時間與成本一起放大、忽略 fallback model 的輸出格式或 tool-calling 能力不同。正確設計要分清楚「可以重試」「可以切換」和「必須失敗」,再為每一種決定 bounded attempt、latency budget、使用者結果與可觀測證據。

把三個陷阱拆開看,會發現它們其實對應三種不同性質的錯誤:

429 Too Many Requests   → 我方(或對方)已達配額上限,重試只會讓情況更糟
500 Internal Server Error → 對方發生非預期例外,短時間內重試「可能」有效
逾時(timeout)           → 不確定對方有沒有處理完,重試前要考慮冪等性

把 429 當 500 處理,最常見的後果是:系統以為「再試一次就好」,於是立刻重試,卻正好撞上對方的 rate limit 視窗還沒重置,於是又拿到一次 429,形成一個自己製造出來的重試迴圈,這正是 Day 11 談過的 retry storm 在 provider 層級的翻版,只是這次不是應用程式的 bug,而是路由邏輯本身把兩種語意完全不同的錯誤,塞進了同一個 except Exception 分支。

用程式碼對照會更清楚兩種處理方式的差異:

# 反例:429 與 500 用同一套邏輯處理
def call_with_naive_retry(client, prompt):
    for attempt in range(3):
        try:
            return client.generate(prompt)
        except ProviderError:
            continue  # 不管是 429 還是 500,都立刻重試
    return client_fallback.generate(prompt)


# 對照:依語意分開處理
def call_with_classified_retry(client, fallback_client, prompt):
    for attempt in range(2):
        try:
            return client.generate(prompt)
        except RateLimited as error:
            # 429:尊重對方給的 retry-after,不是立刻重試
            time.sleep(error.retry_after_seconds)
        except ServerError:
            # 500:短暫 backoff 後再試一次,仍在合理範圍內
            time.sleep(0.5 * (attempt + 1))
    # 兩種情況都用盡重試後,才考慮是否符合 fallback 資格
    return fallback_client.generate(prompt)

RateLimited 分支裡的 retry_after_seconds 是關鍵:大多數 provider 在回傳 429 時,會在 header 附上「你該等多久再試」的明確數字,直接尊重這個數字,比自己猜一個 backoff 時間更準確,也更不容易在下一個 rate limit 視窗又撞上去。這一段程式碼的重試次數刻意寫得很保守(range(2)),對照 ⑤ 節的時間預算公式,重試次數與每次的等待時間,永遠要回頭檢查 sum(每次嘗試的時間) ≤ request deadline 這條不等式。

active/passive 與 active/active 不要混用

active/passive 只有 primary 接流量,secondary 等待接手。它的切換條件、資料同步延遲與回切規則要寫清楚;切換期間若無法保證資料一致,使用者應拿到明確的暫時不可用狀態,不該默默重送寫入。

active/passive 的資料流

正常時:
Client → Router → Primary(接受讀寫)
                       │
                       ▼(非同步或同步複製)
                    Secondary(待命,不接流量)

切換後:
Client → Router → Secondary(接手讀寫)
                       │
                       ▼
                    Primary(修復中,稍後重新同步)

這張圖裡最容易被忽略的字是「非同步或同步」,這五個字直接決定了切換當下會不會遺失資料。同步複製保證 secondary 永遠跟 primary 一致,但代價是每次寫入都要等兩邊都確認才算完成,延遲會升高;非同步複製延遲低,但代表 secondary 手上的資料可能落後 primary 幾秒到幾分鐘,一旦在這個落差期間切換,落後的那幾秒寫入就會憑空消失。這不是「選錯了」,而是每一種選擇都要有人明確簽字承認這個取捨,而不是被預設值悄悄決定。

active/active 讓多個節點同時接流量,沒有「等著被叫醒」的 secondary,卻要處理跨節點路由、衝突、資料一致性與部分區域失敗。某個節點不健康時,流量只應移出該節點;禁止把寫入任意重送到另一端,除非操作具備 idempotency 與一致性策略。

active/active 的資料流

正常時:
Client A → Router A → Node A(接受讀寫)─┐
Client B → Router B → Node B(接受讀寫)─┼─ 雙向同步 / 衝突解決
                                          │
節點 A 不健康時:
Client A → Router A → (移出 Node A,改路由到 Node B 或直接降級)
Client B → Router B → Node B(持續服務,不受影響)

active/active 看起來比 active/passive「更進步」,沒有閒置資源、沒有切換延遲、理論上可用率更高。但它把 active/passive 原本集中在「切換那一刻」的複雜度,攤開成「每一刻都在發生」的複雜度:只要兩個節點同時可寫,衝突就有可能發生,不需要等到故障才出現。這也是為什麼 active/active 不是「active/passive 的進階版」,而是一種需要額外一層資料一致性設計的完全不同架構,選它不該只是因為「聽起來比較高可用」。

「處理衝突」不是一句口號,而是要先選一種具體策略:常見的有 last-write-wins(用時間戳決勝負,簡單但可能悄悄蓋掉較新的寫入)、把每個 key 固定分配給某一節點負責寫入(其他節點只能轉發,等於把 active/active 局部退化成 active/passive)、或用 CRDT 之類的資料結構讓合併結果天生無衝突。三種都要付出代價:前兩種要接受某種資料遺失或延遲,第三種要接受資料模型被限制。沒有選定策略之前,「兩個節點都能寫」只是一個尚未回答完的問題,不是已經解決的架構。

換成 AI workflow 的語言,這三種策略對應到的具體場景大概是這樣:

last-write-wins
  → 適合:使用者偏好設定、對話 session 的最後一則訊息
  → 不適合:agent 執行中的 tool call 記錄(後蓋前會遺失審計軌跡)

固定分片(每個 key 只由一個節點負責寫)
  → 適合:每個 conversation id 固定路由到同一個節點處理
  → 代價:那個節點掛掉時,該 conversation 仍然無法寫入,
    只是換了一種方式重新出現「單點」

CRDT / 天生無衝突的資料結構
  → 適合:計數器型資料(如 token 用量累加)、集合型資料(如已完成的工具呼叫清單)
  → 不適合:需要嚴格因果順序、且順序本身帶有商業意義的資料
    (例如一連串會互相影響結果的 agent 決策步驟)

這張對照表的重點不是要你選哪一種,而是提醒你:conflict resolution 策略要對照到具體資料型別去選,不能整個系統只套用一種策略了事。一個 AI 服務裡,對話紀錄、工具呼叫審計、使用者設定、用量計數,很可能需要四種不同的策略,把它們混在同一組 replication 規則裡,通常代表根本沒有人真正想過這個問題。

自動化 failover 也可能「各自正確、合起來錯」

GitHub 在 2018 年 10 月的一次事故,示範了 failover 機制邏輯完全正確、結果卻依然是災難的情況。東岸資料中心與美東網路樞紐之間發生一次僅 43 秒的網路分區;GitHub 用來管理 MySQL topology 的 Orchestrator(基於 Raft 共識)在分區期間依規則進行 leader 重選,西岸與東岸 public cloud 的節點湊足 quorum,判定應把寫入端切到西岸資料中心。這個決策本身完全符合 Orchestrator 的設計,quorum 判斷、leader election,每一步都做對了。問題是應用層的假設從未被寫進這套 failover 機制:東岸的應用伺服器仍大量呼叫已經切到西岸的 MySQL 叢集,每次資料庫呼叫都多一趟跨美國大陸的往返延遲。43 秒的網路分區最終演變成 24 小時 11 分鐘的服務降級,且兩地叢集一度逼近 split-brain 邊緣。GitHub 選擇犧牲部分可用性保資料一致,暫停 webhook 派送與 GitHub Pages 建置,而不是讓兩地繼續各自接受寫入。這正是本節反覆強調的重點:failover 機制與應用層的延遲、一致性假設必須是同一份合約的兩面,不能分開設計。GitHub 的事後分析完整記錄了這次事故。

③ 練習先從手動切換開始

自動 failover 很吸引人,但先能安全手動切換更重要。你至少要知道:誰有權切、怎麼確認 primary 真的失敗、切後如何避免雙寫、如何回到 primary。這些答案不在 Kubernetes 或雲端服務的按鈕裡。

這個順序常常被跳過,因為「自動化」聽起來就是比較先進的做法。但自動 failover 本質上是把「手動切換的判斷邏輯」寫成程式碼,如果連人都還不確定切換的判斷條件、切換後的驗證步驟、回切的責任歸屬,把這些寫成自動化程式碼,只是把「不確定」用更快的速度執行出來而已。手動先行,不是因循守舊,而是先讓人類的判斷邏輯被講清楚、被寫下來、被演練過,自動化才有東西可抄。

Netflix 的教訓:架構圖畫完不等於備援做完

Netflix 在 2012 年一次 AWS us-east-1 大規模停機後,改採 active/active 多區域架構,但沒有停在「架構圖畫完」這一步,他們接著做 Chaos Monkey(隨機關一台機器)、Chaos Gorilla(關一整個 AWS availability zone)、Chaos Kong(模擬整個 region 消失),持續在生產環境驗證切換是否真的按預期發生。

這三個工具的名字排列本身就是一份很好的教材,因為它們對應的是三種不同粒度的「唯一」:

Chaos Monkey  → 驗證:單一 instance 消失,服務還能不能撐住
Chaos Gorilla → 驗證:整個 availability zone 消失,跨 AZ 的備援還可不可靠
Chaos Kong    → 驗證:整個 region 消失,跨 region 的 failover 是否真能接手

如果只做過 Chaos Monkey 等級的演練,你驗證到的其實只是「多開了幾台機器」這件事有沒有用;region 等級的假設,DNS 切換、跨區資料一致性、控制面在 region 消失時是否還能運作,完全沒有被驗證過。Netflix 選擇把這三個等級都做成常態化、隨時可能在生產環境觸發的演練,正是因為他們意識到:沒被驗證過的 failover,跟不存在的 failover 沒有差別;這也是為什麼手動演練要先於自動化。

值得強調的是,Netflix 並不是先把「自動 failover」做完美才開始做 Chaos 工具,而是反過來,先確保架構在被隨機破壞時「不會整個垮掉」,再逐步把手動應變的動作自動化。這個順序跟本節標題呼應:先能安全手動撐住,再談自動化接手。

手動切換演練至少要留下四份紀錄

不管是不是 Netflix 等級的常態化混沌工程,一次手動 failover 演練值得留下的紀錄至少包含:

1. 觸發前狀態
   誰在什麼時間、對哪個元件、用什麼方式模擬故障

2. 判斷依據
   當下是根據哪些訊號(不是憑印象)判斷「該切」

3. 執行過程
   誰執行了切換、花了多久、中間是否卡在等待別人授權

4. 回切與清理
   什麼時候確認 primary 恢復、什麼條件下才把流量切回去

這四份紀錄本身就是 runbook 的雛形。很多團隊會先寫 runbook 再做演練,但順序反過來通常更誠實:先做一次演練,把演練中真實發生的猶豫、卡關、忘記通知誰的環節都記下來,runbook 才會反映真實世界會發生的狀況,而不是工程師憑想像寫出的理想流程。

把演練寫成可以重複執行的定義

手動演練做過一兩次、四份紀錄的格式也穩定下來之後,值得把它從「一份 Markdown 筆記」升級成「一份結構化的定義檔」,不是為了自動化執行,而是為了讓演練本身變得可重複、可比較。下面是一個最小示意,格式借用了 chaos engineering 社群常見的實驗定義寫法:

experiment: manual-failover-primary-llm
target: primary-model-provider
hypothesis: >
  當 primary provider 的 timeout ratio 超過門檻時,
  policy_question 與 document_summary 類型的請求會在 5 秒內
  切換到 fallback provider,並在回應中標記 degraded=true。
method:
  - 使用 fake provider(見本篇 ⑦ 節)模擬 primary 持續 timeout
  - 手動確認 router 是否依 ④ 節決策表做出對應動作
  - 記錄實際切換耗時、fallback 回應內容、telemetry 是否完整
abort_conditions:
  - 任何 permission_change 類任務被自動切換到 fallback
  - fallback 回應未標記 degraded
rollback: 停止模擬故障,確認 primary 恢復健康訊號後手動回切
owner: (填入負責演練的人)
last_run: not yet performed

把演練寫成這種格式,好處不在於格式本身多漂亮,而在於它逼著你把 ④ 節那張「待量測」的表格,跟一次「真的執行過的實驗」綁在一起,hypothesis 欄位對應決策表裡的門檻與預期行為,abort_conditions 欄位直接對應決策表「不做什麼」那一欄。這份定義檔不需要一開始就自動化執行;先讓它成為一份「下次演練時照著做」的清單,就已經比純敘述式的筆記更容易被檢查、被重複、被交接給下一個接手的人。Day 30 談到正式的 chaos experiment 時,會延伸這個結構,加上自動觸發與自動驗證的機制。

④ 今日 DIY:做一份 failover decision table

紙上的決策表比程式碼更早該存在。原因很直接:程式碼描述「系統會怎麼做」,決策表描述「系統應該怎麼做」,如果兩者對不上,代表程式碼裡藏著一個沒有人明確做過的決定。與其在 code review 時逐行猜測某個 except 分支的意圖,不如先把意圖攤開寫成表格,再對照程式碼是否真的照表操作。

表格的五個欄位不是隨便選的,各自對應一個容易被省略的問題:

情境       → 這是什麼樣的失敗?(不是籠統的「壞了」)
偵測條件   → 用什麼訊號判斷「真的發生了」?(不是憑印象)
選擇       → 系統該做什麼?(重試/切換/拒絕,三選一)
使用者結果 → 使用者具體會看到什麼?(不是「有錯誤處理」這種空話)
不做什麼   → 這個情境下絕對禁止的動作是什麼?

最後一欄「不做什麼」常被省略,卻往往是最重要的一欄。「選擇」欄回答的是「該做什麼」,容易寫得四平八穩;「不做什麼」逼你面對真正的風險,例如「不重送非冪等 tool action」這句話,直接點出如果沒有這條禁令,系統在切換 fallback 時很可能會把一個已經執行過的付款動作再執行一次。

為一個 dependency 寫下這張表。

情境 偵測條件 選擇 使用者結果 不做什麼
primary LLM timeout 升高 timeout ratio 超過你定義的門檻 對可降級查詢切到 fallback 標示較慢或能力受限 不重送非冪等 tool action
vector index 不可用 health check 與 query failure 都成立 回 insufficient_context 不提供未引用答案 不假裝有查到資料
active/passive primary 不健康 primary failure 已被獨立 health signal 確認 切到已同步的 passive 短暫拒絕或受控降級 未確認前雙寫
active/active 節點失敗 單一節點 error 與 routing health 同時成立 將流量移出該節點 其餘節點持續服務 把衝突寫入直接重送
控制面/服務發現(如 DNS、config service)異常 多個看似獨立的下游同時開始失敗,且都指向同一個解析或設定來源 切到已知良好的靜態設定或備援解析路徑(若有),否則明確標示服務降級 清楚的「服務暫時降級」訊息,而非各自為政的個別錯誤 讓每個下游各自重試,掩蓋根因其實在控制面

最後一列刻意加進 ⑥ 節談過的控制面情境,是因為它跟前四列的性質不太一樣:前四列的偵測條件都聚焦在單一元件(某個 provider、某個 index、某個節點),控制面異常的偵測條件卻是「多個看似獨立的下游同時開始失敗」,這正是 AWS DNS 事故與 Google Cloud Service Control 事故共同的癥狀:一開始看起來像是好幾個服務各自出了問題,實際上是同一個根因透過共用的控制面同時發作。把這一列明確寫進決策表,能提醒值班人員在看到「好幾個地方同時紅燈」時,第一個該問的問題不是「哪個服務先修」,而是「這些服務是不是共用了同一個我們還沒盤點過的元件」。

門檻尚未量測就寫「待量測」,不要借用範例數字。接著畫出切換前後的資料流,確認 authorization、observability 與回切也有路徑。

為什麼「待量測」比抄一個數字更誠實

寫決策表時最大的誘惑是去網路上找一個看起來權威的數字填進「偵測條件」,例如看到某篇文章寫「timeout ratio 超過 5% 就該切換」,就原封不動抄進自己的表格。這個數字可能對那篇文章的系統成立,對你的系統完全不成立:你的使用者忍耐度、你的下游 timeout 設定、你的 provider SLA,全部跟那篇文章的作者不一樣。

把「待量測」原封不動留在表格裡,會逼著這件事在下一步被排進待辦清單,而不是被一個看似合理、實則來路不明的數字悄悄蓋過去。這張表格會被後面的路由程式、告警規則直接引用,一旦引用的是憑空想像的門檻,之後的每一層都會繼承同一個錯誤,而且因為「表格看起來很完整」,這個錯誤反而更難被發現。

在隔離環境可用兩個 fake provider 完成手動 failover 演練:primary 一律丟出 timeout,fallback 回固定、明確標示為測試的結果。記錄切換前後的 provider identifier、outcome 與 trace;再將 primary 恢復後確認回切規則。不要用真實 provider 故障當測試開關。

驗收

  • 已列出主要 path 的 instance、data、configuration 與 provider 單點,對照 ①「五分鐘自我測驗」與 ⑥「完整走一遍的例子」,這裡列出的不該只是憑印象想到的兩三項。
  • 每個允許 failover 的情境都有使用者結果與禁止動作,「不做什麼」欄不能空著,見本節開頭對這一欄重要性的說明。
  • active/passive 與 active/active 的流量、資料與切換條件可分別說明,能不能講清楚兩者的資料流差異,是檢驗自己是否真的理解、而非只是記住兩個名詞的簡單方式。
  • fallback 的能力與安全限制有被寫下,對照 ⑤ 節政策問答與 agent tool-calling 兩個例子,能力限制不只是「模型比較弱」,還包括工具呼叫、資料地域等面向。
  • 這是設計練習,不代表 failover 已在任何環境演練成功。
  • 若自行執行 fake-provider 演練,primary 故障、fallback 結果與回切行為都有獨立證據。

本文未執行這項演練,也沒有驗證任何 provider failover。

這份決策表寫完之後,值得問自己最後一個問題:如果把這張表拿給一個完全沒參與過這個系統設計的工程師看,他能不能單靠這張表,正確判斷某個新情境該怎麼處理?如果答案是「還需要問我才知道」,代表表格裡還有隱含的假設沒有被寫下來。這個測試不需要真的找人來測,自己重讀一遍、假裝自己是第一次看到這個系統的人,通常就能抓出漏洞。

⑤ 先定義 failure contract,才談自動切換

一套 failover 機制的第一個輸出不該是程式碼,而是 failure contract。它回答的是:某依賴失效時,系統承諾什麼、明確不承諾什麼。這能阻止團隊在 incident 當下用「再試一次」代替判斷。

為什麼要叫它「合約」而不是「策略」

用「合約」這個詞是刻意的。策略(strategy)暗示這是工程團隊內部的技術選擇,可以隨時因為新想法而調整;合約(contract)暗示這是對使用者、對其他團隊、對法務或風控的承諾,改動之前要走過審查,而不是某個工程師在 pull request 裡順手調整一個 retry 次數。

failure contract 通常不是一份獨立文件,而是分散在三個地方:

產品層: 使用者在依賴失效時,介面上會看到什麼文案、什麼狀態
工程層: router、retry、fallback 的實際行為(也就是本篇要落地的程式碼)
維運層: on-call 在 incident 當下,被允許做什麼、不被允許做什麼

這三層如果沒有共同引用同一份 failure contract,常見的落差是:產品文案寫著「系統會自動為您重試」,工程實作卻只重試一次就直接失敗;或是維運手冊寫著「provider 掛了立即切換」,但沒有人告訴前端要不要顯示降級提示。failure contract 存在的目的,就是讓這三層在同一張表格前對齊。

AI 系統的 failure contract 特別關鍵,因為 LLM provider 的停機歷史比傳統 API 更頻繁。2024–2025 年間,主要 provider 都有過停機事件:Anthropic Claude(4 月 1 小時)、OpenAI chat.completions(11 月 4 小時)、Google Gemini(2 月 40 分鐘)。無 fallback 的團隊在 4 小時 outage 中體驗完整服務中斷;使用確定性 failover 的團隊只體驗到 20–60 秒的故障時間窗。差異不在於「運氣好」,而在於故障合約是否明確寫下。

以公司政策問答服務為例,使用者問的是「我可以每週遠端工作三天嗎?」。primary LLM 失效時,換到能力不同的模型後繼續產生沒有來源的肯定答案,風險高過明確顯示暫時無法回答。

工作類型 primary 失效後的可接受行為 不可接受行為 理由
只讀政策問答 切到驗證過的 fallback,或回 insufficient_context 以猜測補足內容 錯誤政策會直接影響使用者決策
摘要既有文件 以相同輸入切換模型,標記降級 悄悄改掉輸出結構 讀者可接受品質降低,但程式要能解析
建立工單草稿 保存草稿並請使用者稍後重試 自動重送建立動作 建立動作可能重複,後果不是一個 500
執行付款、刪除或權限變更 拒絕並要求明確重試 切 provider 後自動執行 安全與 idempotency 優先於可用率

這張表不是合規文件的裝飾品。它是後面路由程式、告警規則、runbook 與驗收條件共同引用的決策來源。若某一格填不出來,代表還不應該自動化。

再換一個場景:agent 的 tool-calling 失敗

政策問答是一個相對單純的例子,因為它是唯讀的。把同一套思考方式套到一個會執行動作的 agent 上,會冒出更多需要拍板的細節。假設一個 agent 接到「幫我把這張發票標記為已付款」的請求,過程是:理解意圖 → 呼叫「查詢發票」工具 → 呼叫「標記已付款」工具 → 回報結果。primary model 在這中間某一步失效時,失敗合約要回答的問題完全不同於政策問答:

情境:primary 在「查詢發票」之後、「標記已付款」之前失效

可接受行為:
  保留已查到的發票資訊,暫停在這一步,
  向使用者回報「已找到發票,因系統問題暫時無法標記,請稍後重試」

不可接受行為:
  切到 fallback model,讓它憑著對話上下文「猜測」該呼叫哪個工具,
  重新觸發一次「標記已付款」,如果 primary 其實已經呼叫成功只是回應遺失,
  這會造成重複標記或重複扣款這類真正的業務事故

理由:
  「標記已付款」不是冪等操作,重試的風險不是使用者體驗變差,
  而是可能製造出財務系統裡真實存在、卻不該存在的紀錄

這個情境凸顯出一個政策問答例子沒有涵蓋的問題:在多步驟 agent workflow 裡,「primary 失效」發生的位置,比「要不要切換」這個決定本身更關鍵。 同樣是 primary 失效,發生在唯讀的「查詢發票」那一步,換一個 model 重新查一次幾乎沒有風險;發生在有副作用的「標記已付款」那一步之後,連 primary 有沒有真的執行成功都無法確定的狀況下,任何形式的自動重試(不管是重試 primary 還是切到 fallback)都可能是在對同一筆帳做第二次操作。這也是為什麼 ④ 節的 decision table 裡,「執行付款、刪除或權限變更」這一列寫的是「拒絕並要求明確重試」,而不是任何一種自動化選項,重試的決定權,在這類操作上要交還給使用者或人工審核,而不是留給路由邏輯自己判斷。

失敗不只分成「有」或「沒有」

同樣是 model call 沒有成功,處置可能完全不同。把例外名稱直接當成 routing 規則,通常撐不過第一個 provider SDK 升版;先以可觀察的行為分類,才比較穩定。

類別 例子 首選動作 為什麼
transient connect timeout、短暫 503 有上限地重試 primary 可能只是瞬時擁塞
overload 明確 rate limit、queue full 依配額等待、排隊或受控切換 立即重試通常只會更糟
permanent request error 400、schema 不合法、未授權 直接失敗並修正請求 換 provider 不會修好壞輸入
semantic incompatibility fallback 不支援 tool schema 拒絕該工作或改走安全降級 技術回應成功仍可能破壞 workflow
unknown 無法分類的 provider error 以低嘗試次數保守失敗 不讓未知錯誤無限循環

這裡的「有上限」必須同時有次數和時間兩個限制。只設定 max_attempts=3 不夠:若每次 timeout 是 20 秒,使用者可能已經等了 60 秒,還沒輪到 fallback。

request deadline: 8s
├─ primary attempt 1: 最多 2s
├─ primary attempt 2: 最多 2s,僅 transient 時使用
├─ fallback attempt: 最多 3s,僅允許的任務類型
└─ reserve: 1s 給 parser、validator 與回應

上面的數字只是流程示意,不是建議門檻。實際預算要根據 Day 16 的 latency 分布、使用者耐心與下游 timeout 倒推。時間預算是服務自己的資料,不能從別人的投影片複製。

只設「次數上限」而不設「時間上限」為什麼危險,可以用一個簡單不等式檢查:sum(每次嘗試的 timeout) ≤ request deadline。若 max_attempts=3、每次 timeout 20 秒,總和是 60 秒;但如果 request deadline 是 8 秒,代表第二次嘗試根本不會真的發生,使用者連線早就中斷或上游閘道器先逾時斷開了,重試邏輯自以為還有兩次機會,其實從第一次失敗開始就已經沒有意義。反過來,如果 deadline 抓得足夠寬,max_attempts 又沒有搭配時間上限,重試會一路吃掉使用者的耐心與下游服務的容量,直到某個更上層的 timeout 把整條鏈路砍斷,但那時候使用者已經等了遠超過他願意等的時間。次數與時間必須同時出現在同一張表裡,其中一項空著都不能視為「已定義」。

常見誤解:把例外的「名字」當成分類依據

上面那張錯誤分類表容易被誤讀成「照 exception 類別去 switch-case」。實際上更穩定的分類依據是「可觀察的行為」,而不是「例外叫什麼名字」:

不穩定的分類方式(容易在 SDK 升版後失效):
    if isinstance(error, openai.RateLimitError): ...
    if isinstance(error, anthropic.APITimeoutError): ...

較穩定的分類方式(依可觀察行為,而非特定 SDK 型別):
    status_code in (429,) or "rate limit" in error_signal   → overload
    status_code in (408,) or is_timeout(error_signal)       → transient
    status_code in (400, 401, 403, 422)                     → permanent request error

差別在於:第一種寫法把 router 的正確性綁死在某個第三方 SDK 的例外階層上,SDK 一次不相容升版就可能讓所有分類靜默失效(例如某個例外類別被重新命名或移到不同模組,程式不會報錯,只是分類全部落到 unknown)。第二種寫法依賴的是「狀態碼」「訊息內容」這類跨 SDK 版本相對穩定的訊號,把「這是什麼 SDK 拋出的」和「這代表什麼意思」兩件事分開處理,分類邏輯也才經得起未來的 SDK 升版。

⑥ 用完整依賴圖找出「假備援」

只畫 model provider 往往太樂觀。一次 AI 工作可能還依賴 DNS、egress、secret、prompt registry、vector index、tool policy、輸出 parser 與 audit log。兩個 provider 若共用其中一項,仍可能同時不可用。

「假備援」這個詞刻意選得比較刺,因為它想戳破的正是那種讓人安心的錯覺:架構圖上畫了兩條線,review 會議上大家都點頭說「有備援」,但那兩條線其實在某一層匯流成同一條。找出假備援的方法不是畫更漂亮的圖,而是問一個很煩人、卻沒有捷徑的問題:每一個看起來獨立的方塊,往下追三層,是不是真的獨立?

                    ┌─ Primary model provider
Client → API → Router ┤
                    └─ Fallback model provider
          │
          ├─ Prompt / policy configuration
          ├─ Retrieval index
          ├─ Tool authorization service
          ├─ Secret / credential source
          ├─ DNS and outbound network
          └─ Trace, log and metric pipeline

接著把每個方塊從「有沒有兩份」改問成「是否獨立失效」。這是很不浪漫、卻最有用的審查方式。

元件 看起來的備援 要追問的共同依賴 結論可能是什麼
兩家 LLM provider provider 名稱不同 同一 API gateway、同一 egress、同一 secret rotation 仍有共同故障域
兩個 API pod replica 數量為二 同一 node pool、同一 database、同一 deployment 設定 只能承受單 pod 故障
兩個 vector index index 有複本 同一 ingestion pipeline、同一 object storage 新資料或版本可能一起失效
主備 region region 名稱不同 全域 DNS、身份系統、人工操作路徑 切換控制面可能才是 SPOF
兩組憑證 key 有兩把 同一 secrets manager policy 一次權限誤設就全失效

一個很容易漏掉的例子:控制面

資料面是使用者請求真正經過的路徑;控制面則決定誰能改路由、讀設定、換 secret、執行回切。平時只量資料面,事故時才發現管理介面、DNS 或部署權限失效,工程師就算知道該切換也做不到。

Meta 在 2021 年 10 月的 outage 說明了這種關係:網路骨幹的變更讓 DNS 與資料中心可達性一起受到影響,內部工具也因相依的網路服務而難以使用。閱讀事故報告時,請檢查恢復路徑是否也相依於故障路徑。Meta 的技術說明提供了完整脈絡。

「監控自己的系統」也可能共用故障域:AWS 2025 年 10 月的 DNS 事故

控制面 SPOF 最反直覺的一種樣貌,是「負責告訴你系統壞了的系統」本身和「被監控的系統」共用同一個故障域。2025 年 10 月 20 日,AWS us-east-1 發生一次大規模事故:內部負責監控網路負載平衡的子系統出現異常,導致透過 Route 53 對 DynamoDB 端點的 DNS 解析失敗。任何依賴 DynamoDB 存資料、或依賴 IAM 做身分驗證的無伺服器應用程式,幾乎同時失去服務能力,影響範圍是數千個依賴這條路徑的下游應用程式。

這個案例值得放進「假備援」這一節,是因為它精準示範了控制面 SPOF 最隱蔽的形態:受影響的應用程式本身可能真的有多個運算節點、多個可用區,資料面看起來備援充分,但它們全部要透過同一個 DNS 解析才能找到 DynamoDB 端點。DNS 解析屬於控制面,不是資料面;當控制面失效時,資料面的備援節點再多,也連不到它們原本要服務的資料。

表面上的依賴圖:
API 服務(多個可用區) → DynamoDB(AWS 全代管,理論上高可用)

實際的依賴鏈:
API 服務(多個可用區) → DNS 解析(Route 53) → DynamoDB 端點
                              ↑
                    這一段是這次事故真正故障的地方

這次事故留給本篇最直接的提醒是:畫依賴圖時,「連到哪個服務」和「怎麼找到那個服務」是兩件必須分開畫的事。後者(DNS、服務發現、憑證解析)常常被當成理所當然存在的基礎設施,直到它自己變成事故的根因。

一個共用組件失效,會讓所有「看起來各自獨立」的服務一起倒:Google Cloud 2025 年 6 月的事故

另一種控制面 SPOF 的樣貌,是一個所有服務都要經過的「守門員」元件本身出錯。Google Cloud 的 Service Control 系統,是所有 Google Cloud API 呼叫在真正執行前都要先經過的配額與權限驗證關卡。2025 年 6 月 12 日,一次沒有經過 feature flag 保護就直接上線的配額政策功能,在遇到特定資料表裡帶有未初始化欄位的紀錄時,觸發了 null pointer 例外,進入重複崩潰的迴圈。

因為幾乎所有 Google Cloud API(Compute、Storage、Container 等)都要先問過 Service Control「這個請求可以放行嗎」,Service Control 一旦答不出來,這些原本彼此獨立、分屬不同產品團隊維護的服務,就在同一時間一起開始回傳 5xx,事件持續超過兩小時,波及 Spotify、Discord、Snapchat 等大量下游客戶。

表面上互相獨立的服務:
Compute API   Storage API   Container API   ...(各自的程式碼、各自的團隊)

實際共用的守門員:
Compute API ─┐
Storage API  ─┼─→ Service Control(配額/權限驗證)
Container API─┘
                       ↑
              這一層出錯,上面全部一起倒

這個案例補上了 AWS DNS 案例沒有涵蓋的另一半教訓:控制面 SPOF 不只發生在「找路徑」這種基礎設施層級,也可能發生在「每個請求都要先問過」的業務邏輯層級,任何一個被所有服務共同呼叫、用來做驗證或授權判斷的內部服務,都值得在依賴圖上被單獨標記出來,而不是被畫進某個服務自己的方塊裡,被當成它私有的實作細節。放到 AI 系統的語境,這正好對應到本篇第一節提過的「tool authorization service」,如果一個 agent 平台上所有的 tool call 都要先經過同一個授權服務放行,這個授權服務本身,就是這個平台的 Service Control。

用故障域標記每一條線

你不需要一開始就有 CMDB。先在架構圖旁標註下列欄位即可:

component: fallback-model-b
data-plane: yes
control-plane dependency: prompt-router-config
failure-domain: provider-b / region-2 / shared-egress-1
state: stateless
last failover exercise: not yet performed
owner: (填入團隊或角色)

最後一欄很重要。not yet performed 是資訊,不是丟臉。把未知寫出來,才不會在 outage 時被架構圖的顏色騙過去。

把這份標記表跟前面 AWS、Google Cloud 兩個案例放在一起看,會發現一個共同的規律:這兩起事故裡,受影響的工程團隊事前大概率都能在「component」「data-plane」這兩欄填出漂亮的答案,多可用區、多節點、看起來備援充分。真正讓事故發生的,是「control-plane dependency」這一欄從來沒有人認真填過。標記表存在的意義,正是逼著這一欄不能被跳過。

一個完整走一遍的例子:客服 triage agent

把前面幾節的方法論串起來,用一個具體系統走一遍會比抽象規則更好記。假設你在維護一個客服 triage agent:使用者的問題進來,agent 判斷分類、查詢知識庫、視需要呼叫「建立工單」工具,最後回覆使用者。先畫出完整依賴圖:

使用者訊息
    │
    ▼
API Gateway(雲端代管,多可用區)
    │
    ▼
Triage Agent 服務(兩個 replica)
    ├─→ Primary LLM provider
    ├─→ Fallback LLM provider
    ├─→ 知識庫 vector index(單一 region)
    ├─→ 工單系統 tool(外部 SaaS,單一 API key)
    ├─→ Prompt / policy 設定(單一 config service)
    └─→ Trace/log/metric pipeline(單一 collector)

接著逐一套用 ⑥ 節那句「往下追三層,是不是真的獨立」:

元件 表面備援 追三層之後發現的共同依賴 結論
Triage Agent 兩個 replica 有 同一個 config service 提供 prompt、同一組 API key 連工單系統 計算層有備援,但決策內容與外部授權仍是單點
Primary/Fallback LLM 有 兩者都透過同一個內部 egress proxy 出站 egress proxy 掛掉時兩個 provider 都連不上,備援形同虛設
知識庫 vector index 沒有明說 單一 region、單一 ingestion pipeline 這個 region 的網路問題,會讓 retrieval 直接失效,而不是效能下降
工單系統 tool 沒有明說 單一 API key,且該 key 快到期日期沒人追蹤 憑證過期會讓「建立工單」這個有副作用的操作突然全部失敗
Prompt / policy 設定 沒有明說 是 ⑥ 節談的控制面,兩個 replica 都讀同一份 設定服務掛掉時,即使 replica 都活著,也不知道該怎麼問、能不能用工具

這張表跑完一輪,會發現「兩個 replica」這個原本讓人安心的事實,只解決了表格裡五分之一的風險。egress proxy、vector index 的 region、工單系統的 API key、prompt 設定服務,每一個都值得回到 ④ 節的 failover decision table 裡,明確寫下「這個元件失效時,使用者會看到什麼、系統該做什麼、禁止做什麼」。這正是本篇想強調的順序:先把依賴圖攤開、找出假備援,再回頭去填決策表,而不是反過來,先寫一堆 fallback 程式碼,再指望架構圖自動跟著補齊。

下篇會把這份決策表接回可執行的 fake-provider router,串進 metrics、logs、traces 三種可觀測性訊號,並整理三個常見的 failover 反例與一份設計審查清單。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 12(下)|AI Workflow Failure Modes:模型有回話,不等於工作完成
下一篇
Day 13(下)|SPOF、Redundancy、Failover:備援不是多開一台就結束
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言