結論先說:因果鏈只有寫在文章裡不算數,要能在 telemetry 裡查得到、在程式碼裡跑得出來、在事後複盤時寫得下來,才真正管用。
承接上文:(上)建立了 Fault、Error、Failure 的因果模型,拆解一個 503 背後可能藏的五種根因,談了事故處理「先減少 Failure、再追 Error、最後移除 Fault」的優先順序,並用 Transloadit、Clerk、Knight Capital 三個真實事故驗證這套模型。這篇(下)把因果鏈接上 telemetry 設計、一份可動手做的 DIY 練習,以及事故複盤的實作細節。
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 常被簡化成一句「儲存成本會爆炸」,這只說對了一半。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」的順序,提前寫進系統設計裡。
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 裡迷路。
以下是讀者自行執行的隔離練習。它不需要真實 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 劃得好,測試與故障注入才有地方下手。
在你的筆記寫下:
當使用者讀取遠端工作政策,而 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"] |
讀者可在獨立 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.warning() 預設不會自動輸出到終端機,除非事先呼叫過 logging.basicConfig()。第一次跑覺得「明明寫了 log 卻沒印出來」,先確認 root logger 的 level 與輸出目的地,不要急著改 handler 邏輯——這也呼應第 ⑧ 節的誤判:logging path 沒設好,看起來就和沒有錯誤發生一樣。--reload 偶爾失靈:中途手動編輯程式碼做實驗時,reload 機制偶爾因模組快取沒完全重建而讓行為看起來「改了但沒生效」;穩妥的作法是每次改完手動重啟整個 process。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 會悄悄失去這個設計原本想保留的彈性。測試不是把真實 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 帶進正式環境。
| 層次 | 隔離實驗的內容 | 可觀察證據 | 不能證明什麼 |
|---|---|---|---|
| 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 真的把依賴打掛」的做法,兩種練習互補,不是互相取代。
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 裡有用、在哪一種裡只是徒增風險」。
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 有機會重演。
重啟可能清掉耗盡連線、釋放 memory 或暫時降低流量。這是有價值的 mitigation,不是 root-cause 證明。保留重啟前後 connection count、request rate、deployment version 與 error timeline。
Clerk 的案例是這個誤判最好的反面教材:團隊 9 月 17 日凌晨看到負載尖峰完全消失,一小時後尖峰規律地重現,才發現原本的判斷錯得多快。重啟能清除的是 error 層的異常狀態,不會動到造成它的 fault——如果 fault 的觸發條件會自然重現,symptom 消失只是暫時買到了一段沒有觸發條件的空窗期。想確認重啟止血在哪一層,最直接的方法是問:「如果現在故意重現剛才的觸發條件,症狀會不會馬上回來?」答案是會,代表 fault 還在原地。
更長 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,但要有 deadline、上限、退避與總 retry budget。非 idempotent 寫入盲目重試,可能造成重複扣款或重複建立資料。本文的 GET 範例沒有自動 retry,只讓 client 知道服務暫時不可用。
「三次」這個數字本身沒有意義,殺傷力要放進系統規模才看得出來:如果一次使用者操作背後有五層服務呼叫,每層各自獨立重試三次,理論上限是 3⁵ = 243 次後端呼叫由單一次使用者請求觸發;只要上游的重試恰好疊在下游剛開始復原的那幾秒,額外湧入的流量很容易讓一個原本只是「暫時變慢」的 dependency,被多層 retry 疊加成一場自己造成的流量洪峰——這正是(上)第 ② 節提過的「retry 沒有上限」這條 latent fault 換個場景重演。要讓 retry 真正安全,至少要具備三個條件:只對已知可重試的 error class 重試、有總 retry budget 上限、以及帶 jitter 的指數退避。
logging path 可能壞了、sampling 改了,或錯誤被吞掉後回傳品質更差的結果。使用者 outcome 仍要由成功分母、延遲、quality signal 或產品 evidence 判斷。
這個誤判在 AI workflow 裡格外危險,因為它疊加了(上)第 ② 節的「無聲 failure」——RAG 系統把錯誤答案包裝成通順的文字,本來就不會拋出任何 exception,也不會留下 error log。如果團隊的健康判斷完全建立在「error log 是不是乾淨」,等於把一整類最需要被看見的 failure 排除在偵測範圍之外。這類 failure 需要 evaluation dataset 或人工抽樣才看得見,因為根本沒有東西可以記錄,問題不在 logging,在於沒有人去檢查答案本身對不對。
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,對其餘四個毫無置喙餘地。
前面幾節反覆強調「不要用裸的 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」。把責任歸屬和改善空間混為一談,會讓團隊錯失握在自己手上的修復機會,只因為它們被貼上了「不是我們的錯」的標籤。
事件中可使用這份短模板,避免假設在聊天訊息裡悄悄變成事實:
## 使用者影響(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 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;下列項目由讀者自行在隔離環境完成與驗證。
Fault、runtime Error 與 user-visible Failure。request_id 關聯單次請求,但沒有把它當 metric label。UNKNOWN 或只報 error count。Retry-After。app.dependency_overrides,不讓 state 汙染其他 case。Retry-After 或 request_id)。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|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.