iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

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

Day 10(下)|Fault、Error、Failure:別把警報名稱當成根因

  • 分享至 

  • xImage
  •  

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

結論先說:因果鏈只有寫在文章裡不算數,要能在 telemetry 裡查得到、在程式碼裡跑得出來、在事後複盤時寫得下來,才真正管用。

承接上文:(上)建立了 Fault、Error、Failure 的因果模型,拆解一個 503 背後可能藏的五種根因,談了事故處理「先減少 Failure、再追 Error、最後移除 Fault」的優先順序,並用 Transloadit、Clerk、Knight Capital 三個真實事故驗證這套模型。這篇(下)把因果鏈接上 telemetry 設計、一份可動手做的 DIY 練習,以及事故複盤的實作細節。

⑥ Metrics、Logs、Traces 各自回答什麼

Metrics:失敗是否變多?延遲是否跨過門檻?降級比例是否增加?
Logs:某次請求發生哪個 error class?採取了什麼處置?
Traces:request 在哪個 dependency span 停住?花了多久?

Day 2 已經把三根柱子搭進 SRE Lab(Prometheus、Loki、Tempo)。這裡補上它們在因果模型裡各自對應哪一層,差異來自三種資料本身:

Metrics 是聚合後的數字
  → 天生適合回答「Failure 的規模」:多少比例、多快變化
  → 天生不適合回答「這一次為什麼」:它已經把單次請求的細節丟掉了

Logs 是單次事件的紀錄
  → 天生適合回答「Error 的內容」:這次拋出什麼、處理成什麼
  → 天生不適合回答「全域趨勢」:要看趨勢得先聚合,就退化成 metric

Traces 是跨服務的因果鏈
  → 天生適合回答「Error 發生在哪個 span」:時間花在哪一段
  → 天生不適合回答「這是不是 fault」:trace 只能showcase 現象,
     不會告訴你程式碼哪一行寫錯

把這張對照表反過來看也成立:如果值班工程師打開 dashboard 想知道「這一次請求到底卡在哪個 dependency」,那是在問 trace 的問題,metric 給不出答案;如果想知道「這個月 error budget 燒了多少」,那是在問 metric 的問題,翻 log 翻到天亮也翻不出一個趨勢數字。選錯工具不會讓你得到錯誤答案,但會讓你花十倍時間才拿到本來一眼就能看到的答案。

問題 優先證據 原因
有多少使用者受影響? outcome metric 與成功分母 單看 error log 無法算失敗比例。
哪個 dependency 慢? dependency span 與 duration 平均 API latency 看不出時間花在哪裡。
這次 request 被怎麼處理? structured log 可保留 error class、mitigation、request ID。
fault 是設定還是程式路徑? deployment diff、設定、程式碼審查 telemetry 只能縮小範圍,不能自動證明根因。

可用的 log event 不必很長:

{
  "event": "policy_lookup_failed",
  "request_id": "req-42",
  "service": "policy-api",
  "dependency": "policy-store",
  "error_class": "dependency_timeout",
  "user_outcome": "temporary_unavailable",
  "mitigation": "explicit_503"
}

request_id 適合在 log 與 trace 關聯單次請求;不適合成為 Prometheus label。每筆 request 都有一個新的 label value,會造成高 cardinality。完整政策內容、使用者識別資料與 prompt 也不該因為方便 debug 就寫進 metric。

log event 的欄位選擇本身也是一種 contract 的延伸:它決定了「事後回頭看這筆紀錄的人,能不能只靠這一行 JSON 就重建當時發生了什麼」。反過來看一個常見的反面示範,會更容易理解為什麼上面這份 schema 刻意排除了某些欄位:

{
  "event": "policy_lookup_failed",
  "request_id": "req-42",
  "user_email": "justin.lee@example.com",
  "policy_content": "Remote work requires manager approval for...",
  "raw_exception": "TimeoutError: connection to 10.2.3.4:5432 timed out after 3000ms while executing SELECT * FROM policies WHERE ..."
}

這份紀錄看起來資訊量更大,實際上引入了三個問題:user_email 讓這筆 log 變成個資;policy_content 把本該受限存取的業務資料複製了一份到權限通常更寬鬆的 observability 系統裡;raw_exception 裡的完整連線字串(IP、port、SQL)外洩會直接洩漏內部網路拓樸。這三個問題的共同點是:它們不是「這筆 log 有沒有幫助 debug」的問題,而是「這筆 log 的存在本身有沒有製造新的風險」。一份設計不當的 logging schema,本身就是一種安靜潛伏的 fault——可能好幾年都相安無事,直到某次資料外洩事件才真正變成 failure。

為什麼高 cardinality 不只是「浪費空間」

高 cardinality 常被簡化成一句「儲存成本會爆炸」,這只說對了一半。Prometheus 這類 time series 資料庫,內部把每一組不同的 label 組合都當成一條獨立的 time series 儲存與索引;request_id 這種每次都不同的欄位一旦被拿去當 label,等於每一筆 request 都在憑空創造一條只會被寫入一次、永遠不會再被聚合查詢的全新 time series。這帶來兩個後果:記憶體與索引用量隨請求量超線性成長,且任何跨 label 的聚合查詢(例如 sum(rate(...)))都要掃過這些從未打算被聚合的 series,嚴重時甚至把整個監控後端拖垮——所有仰賴同一個 Prometheus 的告警都可能因查詢逾時而延遲或失敗。一個看似「只是想方便 debug」的 label 設計,本身就可能是一個未來的 fault。

實務上一個簡單的自我檢查:如果某個欄位的可能值數量會隨著流量成長(request id、user id、完整 URL 含 query string、prompt 內容),它就不該進 label;如果可能值數量是有限、可枚舉的(method、route、status_code、error_class、outcome),才適合。error_class="dependency_timeout" 是好 label,因為 error class 的種類是有限的;error_message="connection to 10.2.3.4:5432 timed out after 3000ms" 是壞 label,因為它幾乎每次都不一樣,而且已經把 request_id 想解決的問題用另一種方式重新引入。

把告警改成可行動的句子

Database errors high 沒有說明誰受影響、哪一段 path 壞掉,也沒有當前處置。

較接近 incident 語言的寫法:

警示:過去 10 分鐘,policy-api 的 policy-store dependency_timeout
占所有 policy lookup 的 8%,其中 6% 回傳 temporary_unavailable。
目前已暫停剛完成的 rollout;請比對 deployment version、dependency span
與 connection-pool wait time。

數字只有在你真的有成功與總量分母時才能寫。沒有分母時,誠實寫「timeout count 增加」,不要把 error count 偽裝成 failure rate。

一個常見反例:告警文字寫得很長,但仍然沒說出因果

把告警寫長不等於把告警寫得可行動。以下這條告警字數不少,卻仍然只停留在 error 這一層,沒有碰到 failure 或 fault:

反例(長,但沒有指出因果):
過去 10 分鐘偵測到 policy-api 出現 47 次 dependency_timeout
錯誤,錯誤發生在 policy-store 呼叫,請 on-call 檢查系統狀態
並視情況採取行動。

這條告警的問題不在資訊太少,而是混在一起、沒說明因果順序:47 次相對於什麼分母?是否已觸發降級?「視情況採取行動」該先看什麼?這些判斷全被丟回接收告警的人。好的告警要講清楚「哪一層」:

好例子(分層清楚):
[Failure] policy-api 過去 10 分鐘有 8% 請求進入 temporary_unavailable
[Error]   全數來自 policy-store 的 dependency_timeout
[已執行]  剛完成的 rollout(09:55)已自動暫停
[待你做]  比對 rollback 前後的 pool wait time 與 deployment diff,
          確認是否為情境 A(設定)或情境 B(資源)

分層寫法逼著設計 alert rule 的工程師在設計階段就想清楚:這個 alert 對應哪一層、已經自動處理了什麼、還剩下什麼要人來判斷。這正是把(上)第 ④ 節「先減少 Failure,再追 Error,最後移除 Fault」的順序,提前寫進系統設計裡。

同一個問題,用三種 telemetry 各問一次會得到不同答案

Day 2 建立的 SRE Lab 同時有 Prometheus、Loki、Tempo 三種工具,很容易讓人誤以為它們是「同一份資料的三種呈現方式」,可以互相替代。實際上它們是三個不同粒度的問題各自的最佳工具,同一個 502 事故,用三種方式問,會得到三種互補、但沒有一種單獨足夠的答案。

PromQL(Metrics):「有多少」「多常發生」「什麼時候開始」
  histogram_quantile(0.95, rate(policy_store_pool_wait_seconds_bucket[5m]))
  → 回答:過去五分鐘 P95 等待時間是多少,開始上升的時間點在哪

LogQL(Logs):「這一筆具體發生了什麼」
  {app="policy-api"} |= "policy_lookup_failed" | json | error_class="dependency_timeout"
  → 回答:哪些 request_id 受影響、每一筆的 mitigation 欄位寫了什麼

TraceQL(Traces):「這次請求的時間都花在哪一段」
  { span.name = "policy_store.fetch" && duration > 3s }
  → 回答:3 秒的延遲是卡在 DB 查詢本身,還是卡在等待連線池釋放連線

三個問題彼此不能互相取代:只看 metrics,知道「P95 變差了」,卻不知道哪一筆 request 受影響;只看 logs,能找到單一一筆失敗的細節,卻沒辦法快速判斷這是不是全站性趨勢;只看 traces,能看到單次請求的時間分佈,但沒有先用 metrics 篩出「哪個時間區間值得看」,就不知道該挑哪一次 trace 來看。這也是為什麼第 ⑨ 節的 postmortem 模板要求同時填「已觀察 Error」的證據來源——監控系統有三層,異常也應該分層描述。

當一個 alert 響起,該用哪個工具「先問」有一個大致固定的順序:先用 metrics 確認影響範圍與時間點,再用 traces 縮小到某一段 call path,最後用 logs 核對具體錯誤訊息。跳過第一步直接翻 log,是半夜被 page 醒後最容易犯的錯——沒有先用 metrics 框出範圍,很容易在幾千行 log 裡迷路。

⑦ 今日 DIY:用假的 timeout 走一次因果鏈

以下是讀者自行執行的隔離練習。它不需要真實 DB,也不應在 production 調低 timeout。本文沒有建立 Day10/DIY、安裝 dependencies、啟動 FastAPI、送出 request 或執行測試。

此練習只驗證 application contract:已知 dependency timeout 發生時,handler 是否留下可關聯訊號,並給 client 明確結果。它不模擬真實網路、DB、TLS、connection pool 或多服務排隊,不能證明 production root cause。

這個練習具體驗證了前面哪一句話

在動手寫程式之前,先講清楚這個練習的定位,避免讀完覺得「就是寫一個 try/except,有什麼好講的」。這個練習要驗證的,是(上)第 ③ 節情境 D 提出的那個問題:當 dependency 拋出一個可理解、甚至可能自行恢復的 error(TimeoutError),handler 到底是把它變成一個對 client 誠實、可行動的 failure(明確的 503、固定 error code、Retry-After),還是把它壓扁成一個沒有任何線索的黑盒子 500?

這是會直接影響 client 行為的設計決策。拿到裸的 500,client 合理的反應只有「放棄,通知使用者服務異常」;拿到 503 加 Retry-After: 5,client 可以安排五秒後重試,使用者甚至可能不會注意到曾經逾時。同一個底層 error,兩種 handler 寫法會造成不同等級的 failure。這把(上)第 ① 節的觀念落到實作:failure 不只發生在 dependency 掛掉時,也可能發生在系統沒有把失敗說清楚時。

練習刻意選擇 FastAPI 的 dependency injection 而不是直接 monkeypatch 或改全域變數:把可能失敗的依賴做成可替換的介面,production 程式碼不用為了測試長出額外分支,測試只需要在邊界上換掉一個實作。這呼應 Day 9 談 service boundary 的立場:boundary 劃得好,測試與故障注入才有地方下手。

Step 1:先寫實驗的使用者結果

在你的筆記寫下:

當使用者讀取遠端工作政策,而 policy store 在 deadline 內沒有回應時,
API 不回傳舊資料或模糊的 500;它回傳 503、固定錯誤碼與 Retry-After,
讓 client 可以決定稍後重試。

「不回傳舊資料」是本範例的產品選擇,不是普遍規則。若產品允許 stale read,contract 必須標示資料版本與新鮮度,並由產品、風險與資料 owner 決定何時可以使用。

把 contract 寫成一段可以被直接拿來對照 assert 的文字,還有一個容易被忽略的好處:它讓「測試通過」與「contract 被滿足」之間的距離變得很短。第 Step 3 的測試斷言(response.status_code == 503、response.headers["retry-after"] == "5")幾乎是這段 contract 的逐句翻譯。如果 Step 1 跳過不寫,直接開始寫程式碼,很容易在不知不覺中讓程式碼的行為定義了 contract,而不是反過來——這正是(上)「先寫 contract,不是先挑 SLI」在測試層級的翻版。

把 Step 1 那段 contract 拆成子句,對照到 Step 3 實際會寫的 assert,會看到幾乎是一句一行的對應關係——如果某個子句找不到對應的 assert,通常代表這句話還太抽象:

Contract 子句 對應的測試斷言
「deadline 內沒有回應」 用 TimeoutPolicyStore 替換依賴,模擬這個條件成立
「不回傳舊資料或模糊的 500」 assert response.status_code == 503(而不是 200 或裸的 500)
「固定錯誤碼」 assert response.json()["detail"]["error"] == "temporary_unavailable"
「Retry-After」 assert response.headers["retry-after"] == "5"
「讓 client 可以關聯這次請求」(隱含在「可辨識」裡) assert "request_id" in response.json()["detail"]

Step 2:在隔離專案建立最小 handler

讀者可在獨立 uv 專案採用 app/main.py 平面布局,安裝 fastapi、uvicorn、pytest 與 httpx 後自行執行。不要把範例接到真實 policy store。

這一步的程式碼刻意保持精簡:只有一個 route、一個 Protocol 介面、兩個實作。三個值得留意的設計決策:PolicyStore 用 Protocol(structural typing)而非 ABC,讓兩個實作互不知道對方;get_policy_store() 是一個工廠函式而非全域變數,Depends() 認的是函式本身,測試用 app.dependency_overrides 替換的是這把 key,而非改全域狀態;handler 只 catch TimeoutError,不是前面示範過的反例 except Exception——讀者可以自己把 TimeoutPolicyStore 改成拋出 ValueError,親眼看一次「只 catch 你設計要處理的例外」與「catch 全部」的行為差異。

# app/main.py
from __future__ import annotations

import json
import logging
from typing import Annotated, Protocol
from uuid import uuid4

from fastapi import Depends, FastAPI, HTTPException


logger = logging.getLogger("policy-api")
app = FastAPI()


class PolicyStore(Protocol):
    def fetch_policy(self, policy_id: str) -> dict[str, str]:
        """Return one policy record or raise a dependency exception."""


class InMemoryPolicyStore:
    def fetch_policy(self, policy_id: str) -> dict[str, str]:
        return {
            "policy_id": policy_id,
            "summary": "Remote work requires manager approval.",
        }


class TimeoutPolicyStore:
    def fetch_policy(self, policy_id: str) -> dict[str, str]:
        raise TimeoutError("simulated policy-store timeout")


def get_policy_store() -> PolicyStore:
    return InMemoryPolicyStore()


def get_timeout_store() -> PolicyStore:
    return TimeoutPolicyStore()


@app.get("/policy/{policy_id}")
def read_policy(
    policy_id: str,
    store: Annotated[PolicyStore, Depends(get_policy_store)],
) -> dict[str, str]:
    request_id = str(uuid4())

    try:
        policy = store.fetch_policy(policy_id)
    except TimeoutError:
        logger.warning(
            json.dumps(
                {
                    "event": "policy_lookup_failed",
                    "request_id": request_id,
                    "dependency": "policy-store",
                    "error_class": "dependency_timeout",
                    "user_outcome": "temporary_unavailable",
                    "mitigation": "explicit_503",
                }
            )
        )
        raise HTTPException(
            status_code=503,
            detail={
                "error": "temporary_unavailable",
                "request_id": request_id,
            },
            headers={"Retry-After": "5"},
        ) from None

    logger.info(
        json.dumps(
            {
                "event": "policy_lookup_succeeded",
                "request_id": request_id,
                "dependency": "policy-store",
                "user_outcome": "policy_returned",
            }
        )
    )
    return policy

範例使用 FastAPI dependency injection,route 預設取得 InMemoryPolicyStore,測試時再換成 TimeoutPolicyStore。FastAPI 官方文件以 app.dependency_overrides 支援測試替換 dependency;HTTPException 可以中止 request processing,回傳指定 status、detail 與 headers。FastAPI:Testing Dependencies FastAPI:HTTPException

程式中的 json.dumps 只是產生 key-value JSON event。production 還需要 logging formatter、redaction、retention 與 access control;看起來像 JSON 不代表資料治理已完成。

如果讀者真的動手跑這一步,這裡是幾個常見的坑:

  • logging 沒輸出:logging.warning() 預設不會自動輸出到終端機,除非事先呼叫過 logging.basicConfig()。第一次跑覺得「明明寫了 log 卻沒印出來」,先確認 root logger 的 level 與輸出目的地,不要急著改 handler 邏輯——這也呼應第 ⑧ 節的誤判:logging path 沒設好,看起來就和沒有錯誤發生一樣。
  • --reload 偶爾失靈:中途手動編輯程式碼做實驗時,reload 機制偶爾因模組快取沒完全重建而讓行為看起來「改了但沒生效」;穩妥的作法是每次改完手動重啟整個 process。
  • 一直只拿到 200:get_policy_store() 預設回傳 InMemoryPolicyStore,直接用 curl 打 /policy/remote-work 會一直拿到 200,這是預期行為——要看到 503,必須照 Step 3 用 dependency_overrides 替換依賴,或臨時把 route 改成 Depends(get_timeout_store)。
  • 為什麼用 Protocol 不用 ABC:Protocol(PEP 544 結構化型別)不要求明確繼承,只要方法簽章相符就算數;ABC 屬於名義型別,必須明確繼承。日後想換一個 fake store 實作,Protocol 完全不用動既有程式碼,ABC 則多一層耦合——這是「理解為什麼這樣設計」的坑,程式依然能跑,但改成 ABC 會悄悄失去這個設計原本想保留的彈性。

Step 3:注入 fake Fault 並檢查 response

測試不是把真實 DB 搞慢,而是將 get_policy_store 替換為 fake dependency:

# tests/test_policy_timeout.py
from fastapi.testclient import TestClient

from app.main import app, get_policy_store, get_timeout_store


client = TestClient(app)


def test_timeout_becomes_explicit_temporary_unavailable() -> None:
    app.dependency_overrides[get_policy_store] = get_timeout_store

    try:
        response = client.get("/policy/remote-work")
    finally:
        app.dependency_overrides.clear()

    assert response.status_code == 503
    assert response.headers["retry-after"] == "5"
    assert response.json()["detail"]["error"] == "temporary_unavailable"
    assert "request_id" in response.json()["detail"]

留意 try/finally 這個結構,它是這個測試能不能安全和其他測試共存的關鍵。app.dependency_overrides 是掛在 app 這個全域物件上的字典,一旦某個測試替換了它卻忘記清除,這個替換會一路延續到同一個 pytest session 裡接下來的每一個測試,讓後面原本該測 200 的測試也一起變成 503,而且不會提示「這是因為前一個測試沒清乾淨」。這是典型的「測試汙染」(test pollution)陷阱,也是第 ⑩ 節驗收清單特別把它列成獨立項目的原因。

預期 response:

HTTP/1.1 503 Service Unavailable
retry-after: 5
content-type: application/json

{
  "detail": {
    "error": "temporary_unavailable",
    "request_id": "<generated UUID>"
  }
}

讀者可自行用 uv run pytest -q 執行測試,或以 uv run uvicorn app.main:app --reload 啟動本機 server 後發 request。本文沒有執行上述命令。不要將 timeout store、test override 或 debug logging 帶進正式環境。

Step 4:將證據對回三層

層次 隔離實驗的內容 可觀察證據 不能證明什麼
Fault 將 dependency override 指向 TimeoutPolicyStore 測試設定與程式碼 production DB、網路或 pool 的 root cause。
Error fetch_policy() 拋出 TimeoutError handler 捕捉的 exception、error event、dependency span 真實 timeout 的頻率與範圍。
Failure / 受控降級 client 得到 503、固定 code、Retry-After HTTP response 與 log event 使用者能否接受五秒後重試,需由產品 contract 決定。

保留「不能證明什麼」這一欄很重要。測試通過只代表這條程式路徑符合寫下的 contract;它不是 production availability 證明,也不是 incident postmortem。

把這張表格拿去和(上)第 ③ 節的情境 D 對照,會發現這個 DIY 練習只覆蓋了五個情境裡的其中一個——它證明了「當 timeout 發生,handler 有沒有誠實處理」,卻完全沒有碰觸情境 A、B、C、E。這不是練習設計得不夠完整,而是刻意的取捨:五個情境需要的驗證方式差異很大,硬要塞進一個 FastAPI 最小練習裡,只會讓練習變得又長又混亂。Day 3 的故障注入實驗已經示範過「用 Docker Compose 真的把依賴打掛」的做法,兩種練習互補,不是互相取代。

Step 5:若已有 telemetry,先加最少欄位

Metric dimensions(低 cardinality)
- service="policy-api"
- dependency="policy-store"
- outcome="success" | "temporary_unavailable"
- error_class="dependency_timeout"

Trace attributes
- request_id
- dependency.name="policy-store"
- error.class="TimeoutError"
- mitigation="explicit_503"

Structured log fields
- request_id
- error_class
- user_outcome
- service_version

這是 schema 草案,不是要求每種 telemetry 都塞所有欄位。request_id、user identifier、完整 query 與 prompt 不該進 metric label;完整 policy 也不該在未經資料分類時進 error event。

這三組欄位並非隨意分配,而是配合第 ⑥ 節「三種 telemetry 各自回答什麼」設計的:request_id 出現在 trace 與 log,卻刻意不進 metric dimensions,因為 trace 與 log 用來「回頭查單一一筆請求」,metric 用來「彙總所有請求看趨勢」;同一個欄位放進不同用途的 telemetry,該不該收,答案可以完全相反。error_class 則同時出現在三種裡,因為它是有限、可枚舉的值,三種用途都用得上。設計 schema 時,與其問「這個欄位有沒有用」,更準確的問法是「在哪一種 telemetry 裡有用、在哪一種裡只是徒增風險」。

若想把這份 schema 接上 Day 2 的 SRE Lab

DIY 練習本身沒有接 Prometheus,但這份 schema 草案可以直接對照 Day 2 已經搭好的 SRE Lab 落地——outcome 與 error_class 正是那種「值有限、可枚舉」的低基數維度,適合用 prometheus-client 的 Counter 記錄:

from prometheus_client import Counter

policy_lookup_total = Counter(
    "policy_lookup_requests_total",
    "Policy lookup outcomes by dependency and error class",
    ["service", "dependency", "outcome", "error_class"],
)

# 對應 Step 2 handler 裡的兩個分支:
policy_lookup_total.labels(
    service="policy-api",
    dependency="policy-store",
    outcome="temporary_unavailable",
    error_class="dependency_timeout",
).inc()

policy_lookup_total.labels(
    service="policy-api",
    dependency="policy-store",
    outcome="success",
    error_class="none",
).inc()

把這段程式碼放進 Step 2 的 handler,兩個分支各自 .inc(),就能讓(上)第 ③ 節情境 A 用過的那條 PromQL 直接對這個 DIY 練習的 metric 起作用——這正是 Day 2 開始反覆強調的架構原則:Application 只需要認得 OTLP/Prometheus client 這層介面,底下存儲與查詢方式可以完全不變。這段程式碼不屬於本文的 DIY 驗收範圍(Day10/DIY 沒有加這段),純粹給想更進一步練習的讀者一個銜接點。

⑧ 幾個會讓事故重演的誤判

前五個誤判有一個共同結構:都把「一個動作讓症狀消失」直接翻譯成「這個動作找到並修好了 fault」。fault、error、failure 之間各有一段「不一定會發生」的轉換,讓 failure 停止的動作也不一定發生在 fault 那一層——它可能只是在 error 或 failure 層做了乾淨的止血。分不清楚止血止在哪一層,是這五個誤判共同的病根。後兩個誤判性質稍有不同:一個把「做對了一半」誤認成「做完了」,另一個把「fault 的歸屬」和「修復的責任」混為一談——但殊途同歸,都會讓同一種 failure 有機會重演。

「重啟後好了,所以 instance 壞掉」

重啟可能清掉耗盡連線、釋放 memory 或暫時降低流量。這是有價值的 mitigation,不是 root-cause 證明。保留重啟前後 connection count、request rate、deployment version 與 error timeline。

Clerk 的案例是這個誤判最好的反面教材:團隊 9 月 17 日凌晨看到負載尖峰完全消失,一小時後尖峰規律地重現,才發現原本的判斷錯得多快。重啟能清除的是 error 層的異常狀態,不會動到造成它的 fault——如果 fault 的觸發條件會自然重現,symptom 消失只是暫時買到了一段沒有觸發條件的空窗期。想確認重啟止血在哪一層,最直接的方法是問:「如果現在故意重現剛才的觸發條件,症狀會不會馬上回來?」答案是會,代表 fault 還在原地。

「timeout 加大就更可靠」

更長 timeout 有時只是把快速、明確的 503 變成使用者等 60 秒才失敗;慢 request 還會佔住 worker,形成資源耗盡。timeout 是 contract、capacity 與 dependency behavior 的共同設計,不是越大越好。

這個誤判常見,是因為拉長等待時間看起來比讓 client 提早放棄安全,但 timeout 從來不是孤立的旋鈕。把情境 C 的機制拿回來看:每個 worker 一次只能服務一個 request,timeout 設得越長,卡住的請求佔用 worker 的時間就越長,worker 池被佔滿的速度只會更快。原本 3 秒 timeout 時,dependency 變慢只影響少數請求;拉到 60 秒,同樣的 dependency 變慢卻可能讓 worker 池幾秒內被榨乾。調高 timeout 只是把情境 D(黑盒子式失敗)換成情境 C(放大式失敗)。真正該問的是「這個 dependency 在什麼情況下算合理地慢」,這需要看歷史延遲分佈,不能憑直覺拍一個數字。

「retry 三次就好」

讀取在短暫錯誤下可以 retry,但要有 deadline、上限、退避與總 retry budget。非 idempotent 寫入盲目重試,可能造成重複扣款或重複建立資料。本文的 GET 範例沒有自動 retry,只讓 client 知道服務暫時不可用。

「三次」這個數字本身沒有意義,殺傷力要放進系統規模才看得出來:如果一次使用者操作背後有五層服務呼叫,每層各自獨立重試三次,理論上限是 3⁵ = 243 次後端呼叫由單一次使用者請求觸發;只要上游的重試恰好疊在下游剛開始復原的那幾秒,額外湧入的流量很容易讓一個原本只是「暫時變慢」的 dependency,被多層 retry 疊加成一場自己造成的流量洪峰——這正是(上)第 ② 節提過的「retry 沒有上限」這條 latent fault 換個場景重演。要讓 retry 真正安全,至少要具備三個條件:只對已知可重試的 error class 重試、有總 retry budget 上限、以及帶 jitter 的指數退避。

「error log 歸零,所以使用者沒事」

logging path 可能壞了、sampling 改了,或錯誤被吞掉後回傳品質更差的結果。使用者 outcome 仍要由成功分母、延遲、quality signal 或產品 evidence 判斷。

這個誤判在 AI workflow 裡格外危險,因為它疊加了(上)第 ② 節的「無聲 failure」——RAG 系統把錯誤答案包裝成通順的文字,本來就不會拋出任何 exception,也不會留下 error log。如果團隊的健康判斷完全建立在「error log 是不是乾淨」,等於把一整類最需要被看見的 failure 排除在偵測範圍之外。這類 failure 需要 evaluation dataset 或人工抽樣才看得見,因為根本沒有東西可以記錄,問題不在 logging,在於沒有人去檢查答案本身對不對。

「fake timeout 重現了,所以 production 一定同一個 Fault」

fake timeout 只驗證 failure handling contract。它不能模擬 DNS、TLS、query plan、connection pool 或多服務排隊。測試叫 simulated_timeout,比叫 db_root_cause_reproduced 誠實得多。

這也是第 ⑦ 節 DIY 練習反覆強調的立場:TimeoutPolicyStore 是用一行 raise TimeoutError(...) 製造的例外,跳過了真實 timeout 背後可能存在的一切——網路封包遺失、TLS handshake 卡住、connection pool 排隊。測試全部綠燈就在 postmortem 裡寫「已復現並驗證 root cause」,等於把「這條程式路徑符合 contract」偷換成「我知道 production 為什麼會 timeout」——這兩件事的距離,正是(上)第 ③ 節五個情境要說明的:同一個 symptom 底下可能藏著完全不同的 fault,fake timeout 練習從設計上就只鎖定了情境 D,對其餘四個毫無置喙餘地。

「回傳 503 了,所以我們已經做對了」

前面幾節反覆強調「不要用裸的 500」,這容易讓人走到另一個極端:以為只要 status code 是 503,這次的 failure handling 就已經合格,可以收工了。503 只是一個必要條件,不是充分條件。以下這個 handler 回傳的也是 503,卻仍然沒有完全兌現(上)第 ① 節寫下的 contract:

except TimeoutError:
    raise HTTPException(status_code=503, detail="Service temporarily unavailable")
    # 沒有 Retry-After header
    # 沒有可追蹤的 request_id
    # 沒有留下任何 log event

這段程式碼技術上符合「回傳可辨識的暫時失敗」這半句 contract,卻沒做到「可重試」——client 拿到 503 卻沒有 Retry-After,不知道該等多久再試;也沒有 request_id,on-call 無法在 log 裡找到對應的那一次請求。這正是為什麼(上)第 ① 節堅持把 contract 寫成可以逐句檢驗的句子:少做一個形容詞背後的實作決定,contract 就沒有被完整滿足,即使 status code 已經是正確的 503。

「這是第三方的問題,不是我們能修的」

最後一個誤判和前面六個性質不太一樣——前面幾個都發生在調查階段,這一個發生在調查已經有結論之後:一旦確認 fault 出在某個外部依賴(LLM provider 限流、第三方 API 逾時、雲端服務商的區域性故障),團隊很容易把「fault 不在我方程式碼」直接推論成「我方無事可做,只能等對方修好」,於是把 postmortem 的「下一步」欄位留白,或者只寫一句「已通報 vendor」。

這個推論漏掉了一個關鍵區分:fault 的位置和「誰能降低 failure 的影響」是兩個不同的問題。即便 fault 確實出在第三方,本方系統的 error handling 與降級策略仍然完全屬於本方可控的範圍——情境 C 談過的 bulkhead、(上)第 ① 節反覆強調的「明確的暫時失敗優於假裝正常」,這些設計決策全部發生在「我方系統如何回應一個外部 fault」。把責任歸屬和改善空間混為一談,會讓團隊錯失握在自己手上的修復機會,只因為它們被貼上了「不是我們的錯」的標籤。

⑨ 讓 incident 與 postmortem 留下可檢查的因果

事件中可使用這份短模板,避免假設在聊天訊息裡悄悄變成事實:

## 使用者影響(Failure)
- 哪個工作無法完成?
- 何時開始?已知範圍與分母是什麼?
- 目前採取哪些降級、切流或溝通?

## 已觀察 Error
- 哪個 service path、dependency、error class?
- 哪些 log、metric、trace 支持此觀察?

## Fault 假設
- 最近有哪些設定、版本、流量或依賴變化?
- 每個假設的支持與反證是什麼?
- 尚未確認的部分標為 UNKNOWN。

## 下一步
- 誰負責 mitigation?誰收集證據?何時重新評估影響?

重大 incident 結束後,postmortem 應留下 impact、mitigation、根因與 contributing factor,以及避免再發生的 action。Google SRE 將 postmortem 視為記錄與學習工具,重點是找 contributing causes,不是找一個人背鍋。Google SRE:Postmortem Culture

「使用者影響」這個欄位看似最好寫,實務上卻是最常被寫得模糊的一格。對照著看,會比較容易抓到分寸:

模糊的寫法 可檢驗的寫法 差在哪裡
「部分使用者受影響」 「約 8% 的 policy 查詢請求收到 503,影響時段 10:02–10:15」 後者有分母、有時間範圍,能被下一個人拿去核對
「服務變慢」 「P95 延遲從 200ms 上升到 4.8 秒,P50 未受明顯影響」 後者指出是尾端延遲惡化,不是全面性變慢,修復方向完全不同
「造成不好的使用者體驗」 「使用者送出查詢後畫面轉圈超過 10 秒,最終顯示無法辨識的錯誤訊息」 後者是可以重現、可以在下次修復後回頭驗證是否解決的具體描述

左欄的句子不是錯的,它們只是「無法被否證」——沒有任何後續觀察能拿來說「這句話其實不準確」。右欄的句子則可以在事後被驗證:如果下次同樣情況重演,8% 變成 15%,這是一個能被指出來的變化,能推動「這次修復是不是真的有效」被認真檢討,而不是憑印象覺得「應該有比較好」。

Blameless 不是免罰金牌,是換一種問問題的方式

「Blameless postmortem」這個詞經常被簡化成「不罵人」,這不算錯,但漏掉了它真正想解決的工程問題。Blameless 文化最初借用自航空與醫療產業,背後的假設是:事故發生當下,每個人都是根據他們當時所擁有的資訊,做出了認為合理的決策。與其問「是誰按錯了那個按鈕」,更有生產力的問法是「為什麼系統的設計,讓按錯按鈕有機會造成這麼大的影響」。

把這個原則套進今天的模型,會得到一組具體的問句替換:

不問:是誰寫出這個漏掉 connection release 的 exception path?
問:  code review 流程裡,什麼機制原本該攔住這種疏漏,
      為什麼沒有攔住?

不問:值班的人為什麼沒有在第一時間發現 pool 快耗盡了?
問:  儀表板上,pool wait time 這個指標存在嗎?
      如果存在,為什麼沒有被設成告警?

不問:為什麼那個 rollout 沒有先做好完整測試?
問:  部署流程裡,有沒有機制可以讓一次 config 變更
      自動被拿去和已知容量上限比對?

這組替換把調查焦點從個人行為移到系統是否能攔截同樣的錯誤。把希望放在下一個值班的人運氣或記性比較好,不是工程解法。Knight Capital 提醒我們:除了「有人忘記更新第八台伺服器」,還要問部署流程為什麼容許版本不一致持續數秒卻沒被自動偵測。

實務上把這個原則具體操作化的簡單工具是「連續追問為什麼」(Five Whys):每問一次都刻意把答案往系統設計的方向推,而不是停在第一個聽起來合理的個人行為上。用 09:55 那次 policy-api 的 rollout 事故當範例:

Q1:為什麼使用者收到 503?
A1:因為 policy-store 連線池被打滿,新請求拿不到連線。

Q2:為什麼連線池會被打滿?
A2:因為新版本的請求併發量比舊版本高,超出了原本設定的 pool 上限。

Q3:為什麼沒有人在部署前發現這個上限不夠?
A3:因為 deployment 流程沒有要求把預期併發量拿去和 pool 設定值比對。

Q4:為什麼 deployment 流程沒有這一步?
A4:因為 pool 容量規劃目前是手動、憑經驗設定的,
     沒有納入標準的部署前檢查清單。

Q5:為什麼容量規劃沒有被納入標準檢查清單?
A5:因為目前沒有任何自動化機制,能在部署前
     把「預期流量」和「下游資源上限」兩份資訊放在一起比對。
     → 這裡才是值得真正投入資源修的系統性問題

留意這條鏈每往下一層,答案就更遠離「這一次事故」,更靠近「這一類事故未來會不會再發生」。前兩個「為什麼」還停留在 Error 與 Fault 層面的技術細節,第三個開始碰到流程,第四、第五個已完全跳脫這次事故本身,變成在問「我們的系統性防護網,有哪一格是空的」。停在 A1 或 A2 就收工的 postmortem,往往只會產生「這次已經調高 pool 上限」的結論——確實修好了這次的 fault,卻沒觸碰到「為什麼這類問題總要等出事才被發現」這個更根本的缺口。「下一步」欄位裡最有價值的項目,通常藏在第四、第五層的答案裡,而不是第一層。

這個分層讓你能同時說三句真話:503 對使用者造成影響;timeout 的根因尚未確定;rollback 已降低範圍。它把已知與未知分開。

把模板套進今天的例子,看它實際長什麼樣

模板本身是抽象的,套進(上)第 ③④ 節那個十分鐘事故的例子,看起來會像這樣——這也是給讀者一個具體參照,避免第一次寫 postmortem 時把「已觀察 Error」和「Fault 假設」寫成同一句話:

## 使用者影響(Failure)
- 09:57–10:15 之間,查詢遠端工作政策的請求中,約 8% 收到
  temporary_unavailable(503),其餘正常完成。
- 影響範圍:僅限 policy-api,未觀察到其他 service 受影響。
- 已執行:10:06 rollback 09:55 的 rollout;10:15 5xx 比例回到基線。

## 已觀察 Error
- policy-api 對 policy-store 的呼叫出現 dependency_timeout,
  時間範圍與 rollback 前後對齊(見 trace ID 附件)。
- pool wait time P95 在同一區間內從 12ms 升到 640ms
  (見 Grafana 面板連結)。

## Fault 假設
- [支持中] 09:55 的 rollout 調整了 connection pool 設定,
  疑似把上限調低;已 rollback,等待下一個高峰驗證。
- [尚未排除] 同時段流量比前一週同時段高 15%,
  不能排除即使沒有這次 rollout,容量也已經接近臨界值。
- [UNKNOWN] 是否有其他服務同時段也在爭用同一個 DB 的連線,
  尚待查核共享資源的使用狀況。

## 下一步
- 負責人 A:本週內把 pool 上限設定加進 deployment diff 的
  自動化檢查,變更需要額外核可。
- 負責人 B:加裝 pool wait time 的告警,在真正打滿之前先示警。
- 重新評估:下一次同等流量高峰(預計下週二)觀察是否重現。

留意這份範例裡「Fault 假設」明確標出 [支持中]、[尚未排除]、[UNKNOWN] 三種狀態,而不是把最先想到的故事直接寫成結論。這是刻意的寫法:postmortem 最常被詬病的問題,不是資訊不夠多,而是把調查過程中第一個「感覺合理」的假設不知不覺寫成「已確認的事實」,讓後面看文件的人失去繼續質疑的空間。

「下一步」欄位最容易變成永遠不會被檢查的承諾

寫 postmortem 最容易的部分是描述已經發生的事,最難的部分是讓「下一步」欄位裡的項目真的被完成。實務上常見的失效模式,是團隊把 postmortem 寫得很完整,卻沒有把「下一步」轉成工作追蹤系統裡的 ticket——這些項目既不在任何人的 sprint 裡,唯一會再看到它們的機會,是下一次同樣的事故重演、有人回頭翻舊 postmortem 才發現「這個我們早就知道了」。避免這個陷阱不需要複雜工具,只需要一個簡單紀律:每一個「下一步」項目,都要在寫完文件的當下同步建立一張有負責人、有到期日的 ticket,並把連結貼回文件裡——讓 postmortem 變成一個索引,而不是一個句點。

⑩ 今日驗收清單

本文沒有建立或執行 DIY;下列項目由讀者自行在隔離環境完成與驗證。

  • [ ] 能用自己的 service contract 說明何者構成 Failure。
  • [ ] 能區分 latent Fault、runtime Error 與 user-visible Failure。
  • [ ] 能為同一個 503 提出至少兩個 fault 假設與需要的證據。
  • [ ] 已為 dependency timeout 決定 timeout、retry、fallback、拒絕或人工處理的產品行為。
  • [ ] 已確認 log 或 trace 能以 request_id 關聯單次請求,但沒有把它當 metric label。
  • [ ] 已確認 failure rate 有成功分母;沒有分母時標為 UNKNOWN 或只報 error count。
  • [ ] 若自行完成 fake-store 練習,timeout 轉成明確 503、固定 error code 與 Retry-After。
  • [ ] 若自行完成測試,會清除 app.dependency_overrides,不讓 state 汙染其他 case。
  • [ ] 已在練習筆記標示:fake timeout 成功不證明 production root cause 或 availability。
  • [ ] 能說明 mitigation 與永久修正各自處理哪一層,不把重啟寫成根因修復。
  • [ ] 能舉出一個 AI workflow(RAG、agent 或 tool call)裡「技術上成功、答案卻錯誤」的無聲 failure 例子。
  • [ ] 能用 Avižienis 等人的 fault-error-failure 因果鏈,解釋為什麼「fault 存在」不等於「一定會變成 failure」。
  • [ ] 寫過一份 postmortem 草稿(哪怕只是練習),且 Fault 假設欄位標示了支持、反證與 UNKNOWN 三種狀態,而不是只寫一個結論。
  • [ ] 能用「系統該有什麼機制攔住這個問題」取代「是誰的疏忽」,重新描述一次自己曾經歷過的一次事故。
  • [ ] 能說明回傳正確 status code 和完整實踐 contract 之間的差距(例如少了 Retry-After 或 request_id)。
  • [ ] 能區分「fault 出在第三方」與「我方無事可做」是兩件不同的事,並舉出至少一個本方仍可改善的降級設計。
  • [ ] 能說出同一次事故裡,Failure、Error、Fault 三層通常分屬哪些不同角色,以及為什麼事故當下不該混著追問。
  • [ ] 能用「連續追問為什麼」把一次事故的根因,從單一技術細節往系統性缺口方向多追問兩層。

⑪ 本文結論

Failure 是使用者沒有得到承諾的結果;Error 是系統執行時進入不正確狀態;Fault 是讓 error 有機會發生或擴大的條件。fault 必須被觸發才會變成 error,error 沒被吸收才會變成 failure。reliability 工程就在兩道門檻之間運作:retry、fallback、circuit breaker、deadline、bulkhead 都在降低轉換成下一層的機率。

值班時先保護使用者,並把證據留下來。事故後再往回追:哪個 error 被觀察到?哪些 fault 假設被支持或否定?這樣告警不再只是「系統壞了」,而是一張把修復、觀測與後續行動接起來的地圖。

Transloadit 的連線洩漏、Clerk 的隱性節流機制消失與 Knight Capital 的死程式碼重新甦醒,跨越十三年、不同產業與技術堆疊,卻可用同一套因果結構描述。事故細節不同,但都能區分:什麼條件潛伏著、什麼被觸發、使用者實際受到什麼影響。這能讓人在混亂中把現象放回因果鏈,而不是急著用一個只解釋症狀的答案結案。這套詞彙同樣適用於 AI workflow;failure 有時不會讓 CPU 尖峰或拋出例外,只會讓使用者依照聽起來很有把握、實際錯誤的答案做決定。

明天的 Day 11 會把這條因果鏈鋪進更多具體的 failure mode——crash、network partition、resource exhaustion——今天建立的詞彙會是接下來三十天不斷被重複使用的共同語言。

把本文出現過的幾個工具收在一起看,會發現它們各自負責因果鏈的不同段落,沒有一個能單獨涵蓋全部:

寫 contract(上,第 ①節)           —— 定義什麼構成 Failure
Fault-Error-Failure 分層(上,第 ②節) —— 定位一次事故落在哪一層
五個情境並排(上,第 ③節)           —— 同一症狀,保留多個 fault 假設
先減少 Failure 再追 Error(上,第 ④節) —— 決定事故當下的行動順序
三根 telemetry 支柱(下,第 ⑥節)    —— 用對工具問對問題
postmortem 模板(下,第 ⑨節)        —— 把調查過程留下可檢查的紀錄

這六樣工具沒有一個要求你在事故當下同時想起全部——on-call 手忙腳亂的那幾分鐘,能記住「先減少 Failure,再追 Error,最後移除 Fault」這一句話已經足夠;其餘的工具,是在事後寫 postmortem、在平時設計 telemetry 與 alert 規則時才會真正派上用場。把這六樣工具當成一張隨時可以拿出來對照的地圖,而不是一份考試前要硬背的清單,會更接近它們原本被設計出來的用途。

Day 11 預告

下一篇:Day 11|Production Failure Modes。把這條因果鏈套進 crash、timeout、network、resource exhaustion、configuration 與 dependency failure,練習在不同 failure mode 下觀察訊號、縮小 blast radius。

延伸閱讀


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


上一篇
Day 11(上)|Production Failure Modes:先替系統想好難看的死法
下一篇
Day 11(下)|Production Failure Modes:先替系統想好難看的死法
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言