可觀測性與儀器化
概述
無法觀測的程式碼就是無法營運的程式碼。可觀測性是從外部,利用程式碼發出的遙測資料,來回答「系統在做什麼以及為什麼」的能力。儀器化不是上線後的附加功能,它應該與功能本身一起被撰寫,就像測試一樣。如果一個功能上線時沒有遙測,那麼第一個使用者回報的錯誤就會變成一場考古,而非一次查詢。
使用時機
- 建構任何將會在生產環境中運行的功能
- 加入新的服務、端點、背景任務或外部整合
- 生產事故花了太長時間才診斷出來(「我們無法判斷發生了什麼事」)
- 設定或檢視警報規則
- 審查一個涉及 I/O、重試、佇列或跨服務呼叫的 PR
不適用於:
- 診斷正在發生的故障——請使用
debugging-and-error-recovery技能(可觀測性是讓該技能下次更快運作的關鍵) - 針對測量到的緩慢進行效能剖析與最佳化——請使用
performance-optimization技能 - 上線日的監控檢查清單與回滾觸發條件——請參閱
shipping-and-launch技能;本技能涵蓋的是為它們提供資料的儀器化
流程
1. 在儀器化之前先定義「正常運作」
沒有問題意識的遙測就是雜訊。在加入任何儀器化之前,寫下值班工程師會針對此功能提出的 2-4 個問題:
功能:結帳付款重試
值班工程師會問的問題:
1. 付款一次成功與經重試後成功的比例是多少?
2. 當付款永久失敗時,原因是什麼?(供應商錯誤?超時?驗證失敗?)
3. 付款供應商是否比平常慢?
→ 下面的每個信號都必須有助於回答其中一個問題。
如果你說不出這些問題,那你還沒準備好進行儀器化——你會記錄所有東西,卻什麼也學不到。
2. 為每個問題選擇正確的信號
| 信號 | 能夠回答的問題 | 成本輪廓 | 範例 |
|---|---|---|---|
| 結構化日誌 | 「在這個特定案例中發生了什麼事?」 | 每事件計費;隨流量成長 | payment_failed 附帶供應商錯誤碼 |
| 指標 | 「整體而言,頻率 / 速度如何?」 | 固定每個時間序列;查詢成本低 | 供應商呼叫的 p99 延遲 |
| 追蹤 | 「時間在跨服務時花在哪裡?」 | 每個請求計費;通常會取樣 | 一次緩慢的結帳,按跳點分解 |
經驗法則:指標告訴你有問題,追蹤告訴你在哪裡,日誌告訴你為什麼。
3. 結構化日誌
記錄事件,而不是散文。每一行日誌都是一個 JSON 物件,帶有穩定的事件名稱和機器可讀的欄位:
// 不好:字串插值——無法查詢、不一致
logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`);
// 好:穩定的事件名稱 + 結構化欄位
logger.warn({
event: 'payment_failed',
paymentId: id,
provider: 'stripe',
errorCode: err.code,
attempt: n,
}, 'payment failed');
日誌等級——一致地使用它們:
| 等級 | 意義 | 值班行動 |
|---|---|---|
error | 不變量被破壞;可能有人需要採取行動 | 調查 |
warn | 功能降級但已處理(重試成功、使用了備用方案) | 觀察趨勢 |
info | 重要的業務事件(訂單已下、任務已完成) | 無 |
debug | 診斷細節 | 生產環境中預設關閉 |
關聯 ID 是強制性的。 在系統邊界產生(或接受)一個請求 ID,並將其附加到每一行日誌、每個跨度(span)以及每個對外呼叫中。沒有它,你無法從交錯的日誌中重建單一請求:
// Express:每個請求一個子日誌記錄器,ID 向下游傳播
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] ?? crypto.randomUUID();
req.log = logger.child({ requestId: req.id });
res.setHeader('x-request-id', req.id);
next();
});
當多個進入點寫入同一個日誌時,命名該進入點。 關聯 ID 識別一次執行;它沒有說明是哪個程式碼路徑啟動了它。由排程器、重播端點和手動 CLI 執行到達的同一任務,會在同一個日誌接收器中產生無法區分的行。因此,歸因一行日誌只能靠排除法——交叉比對排程器的歷史記錄、行程表、部署日誌——而這個論點只在這些外部記錄仍然存在時才成立。在執行開始處,在關聯 ID 旁邊標註進入點,並以相同的方式傳播兩者:
// 每個進入點一個輔助函式:執行本身的日誌記錄器攜帶兩個欄位。
// 使用 `entryPoint`,而不是 `source`——ECS 保留 `source.*` 給網路欄位。
const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) =>
logger.child({ entryPoint, requestId: runId });
// 排程器 tick -> runLog('scheduler', crypto.randomUUID())
// POST /jobs/:id/replay -> runLog('replay_endpoint', req.id)
// CLI 呼叫 -> runLog('cli', process.env.RUN_ID ?? crypto.randomUUID())
這兩個欄位都必須跨越與關聯 ID 相同的邊界——佇列中繼資料、HTTP 標頭——否則工作人員會重新推斷進入點並進行猜測。一個僅與進入點相關聯的欄位只是一個提示,而不是歸因:任何可以呼叫該任務的事物都可以重現它。
絕不在日誌中記錄祕密、令牌、密碼或完整的個人識別資訊(PII)。 這是來自 security-and-hardening 技能的嚴格規則——遙測管線是經典的資料洩漏途徑。請使用允許清單(allowlist)來記錄欄位;不要記錄整個請求主體。
4. 指標
對於請求驅動的服務,在每個端點和每個外部依賴上對 RED 進行儀器化:Rate(請求/秒)、Errors(失敗率)、Duration(延遲直方圖,非平均值)。對於資源(佇列、池、主機),請使用 USE:Utilization(使用率)、Saturation(飽和度)、Errors(錯誤率)。
與追蹤一樣,與供應商無關的方法是 OpenTelemetry 指標 API(與步驟 5 相同的 SDK 和上下文)。下面的範例使用 Prometheus 的 prom-client——這是一個常見的後端選擇,但不是唯一的一個;無論如何,RED/USE 和基數(cardinality)規則都是相同的。
import { Histogram } from 'prom-client';
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status_class'], // '2xx', 不是 '200'
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
高基數(High cardinality)是失敗模式。 每一個獨特的標籤組合都是一個單獨的時間序列。標籤必須來自小的、固定的集合(路由模板、狀態類別、供應商名稱)。絕不要使用使用者 ID、原始 URL、錯誤訊息或其他無邊界的值作為標籤——這些屬於日誌和追蹤。
可作為標籤: route="/api/tasks/:id" status_class="5xx" provider="stripe"
絕不能作為標籤: user_id, email, request_id, full URL, error message text
永遠追蹤百分位數,而不是平均值:平均值會隱藏那 1% 體驗極差的使用者。使用直方圖並讀取 p50/p95/p99。
5. 分散式追蹤
使用 OpenTelemetry——它是與供應商無關的標準,其自動儀器化涵蓋 HTTP、gRPC 和常見的資料庫客戶端,且幾乎不需要撰寫程式碼:
// tracing.ts — 必須在任何其他東西之前被匯入
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
serviceName: 'checkout-service',
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
只在有意義的內部工作單元(例如 applyDiscounts、chargeProvider)周圍加入手動跨度,並附加值班人員會用來過濾的屬性。在每個非同步邊界(HTTP 標頭、佇列訊息中繼資料)傳播上下文,否則追蹤會在間隙處中斷。預設以低比率進行基於頭部的取樣;如果你的後端支援尾部取樣,則保留 100% 的錯誤。
6. 警報
針對使用者感受到的症狀發出警報,而不是針對原因:
症狀(值得傳呼): 原因(儀表板,而非傳呼):
錯誤率 > 1% 持續 5 分鐘 CPU 達到 85%
p99 延遲 > 2 秒 一個 Pod 重新啟動
佇列年齡 > 10 分鐘 磁碟達到 70%
基於原因的警報會在沒有問題時觸發,並會遺漏你未預測到的故障。基於症狀的警報則恰好在使用者受到影響時觸發,無論原因是什麼。
為你建立的每個警報制定規則:
- 它必須是可操作的。 如果應對方式是「忽略它,它會自動修復」,請刪除該警報。
- 它必須連結到一份 runbook——即使只有三行:它的含義、要執行的第一個查詢、升級管道。
- 它必須有一個由 SLO 或歷史數據證明的閾值和持續時間,而不是猜測。
- 只使用兩個嚴重性等級:傳呼(影響使用者,立即行動)和工單(功能降級,本週內行動)。第三個層級會變成雜訊,讓人們學會忽略所有東西。
撰寫 Runbook
上述規則 2 要求每個警報都必須連結到一份 runbook。Runbook 的任務是回答三個問題,而不需要讀者思考:發生了什麼事、首先要檢查什麼、以及如果無法解決該找誰。儲存在 docs/runbooks/ 中,以警報名稱命名。
最小可行 runbook(三行):
# Runbook:/api/tasks 高錯誤率
**含義:** 資料庫連線池可能已耗盡,或是一次不好的部署。
**首先檢查:** `SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';`
— 如果計數超過池限制,請參閱步驟 2。(請根據你的資料庫替換等效指令。)
**升級管道:** #db-oncall 或工程值班輪值。
何時需要擴展到三行以上: 僅在單靠第一個檢查不足以做出決定時才加入步驟。一份涵蓋最常見三種原因的、有五個步驟的 runbook,勝過一份涵蓋所有邊緣情況、有二十個步驟、但會被略讀的文件。
保持 runbook 時效性。 在關閉每個使用過它的 incident 時,將更新 runbook 作為結案的一部份——過時的 runbook 會建立虛假信心。如果某個步驟是錯誤的或遺漏的,請在將 incident 標記為已解決之前修正它。
7. 驗證遙測本身
儀器化也是程式碼;它也可能是錯的。在宣布工作完成之前,觸發這些路徑並查看實際輸出:
- 在暫存環境中強制產生一個錯誤 → 透過
requestId在日誌中找到它,確認欄位是結構化的(不是[object Object]) - 發送測試流量 → 確認指標序列出現,且帶有預期的標籤和合理的數值
- 在追蹤 UI 中追蹤一個跨服務的請求 → 沒有中斷的跨度
- 觸發每個新警報一次(暫時調低閾值) → 確認它到達正確的頻道,且 runbook 連結有效
常見的合理化藉口
| 合理化藉口 | 現實 |
|---|---|
| 「等運作正常後我再補日誌」 | 「之後」變成「第一次事故發生後」,這是你發現自己盲目時最昂貴的時刻。邊建構邊儀器化。 |
| 「更多日誌 = 更好的可觀測性」 | 非結構化的雜訊會讓事故處理變慢,而不是變快。三個可查詢的事件勝過三百行散文。 |
| 「先用 console.log 就好」 | 非結構化的輸出無法被過濾、關聯或發出警報。結構化日誌記錄器只需多花五分鐘設定一次。 |
| 「出問題時看儀表板就好」 | 沒有定義問題就建立的儀表板會顯示所有東西,唯獨不顯示答案。從值班問題開始。 |
| 「為所有重要事項設定警報,我們之後再調整」 | 一個充滿雜訊的傳呼機會訓練人們忽略它。調整永遠不會發生;但遺漏真正的傳呼卻會發生。 |
| 「把使用者 ID 當作指標標籤可以讓除錯更容易」 | 這也會讓你的指標後端崩潰。高基數的查詢屬於日誌和追蹤。 |
| 「對我們兩個服務來說,追蹤太小題大作了」 | 兩個服務已經意味著日誌無法回答的跨服務延遲問題。自動儀器化讓其成本微不足道。 |
危險信號
- 一個包含重試、佇列或外部呼叫,但沒有任何新遙測資料的功能 PR
- 通過字串插值而非結構化欄位建構的日誌行
- 沒有關聯/請求 ID——每一行日誌都是一個孤兒
- 一個日誌串流同時由排程器、Webhook 和手動執行提供資料,但沒有欄位指出是哪一個產生了該行
- 使用使用者 ID、原始 URL 或錯誤訊息文字作為標籤的指標(基數炸彈)
- 以平均值而非百分位數追蹤的延遲
- 每天觸發但被確認而無人採取行動的警報
- 針對原因(CPU、記憶體)傳呼人類的警報,而面向使用者的錯誤率卻未被監控
- 日誌中出現祕密、令牌或完整的請求主體
- 「在我的機器上可以執行」作為生產功能健康的唯一證據
驗證
在對一個功能進行儀器化之後,請確認:
- 該功能的值班問題已被寫下,並且每個信號都對應到其中一個問題
- 所有日誌輸出都是結構化的(JSON),每行都帶有穩定的事件名稱和關聯 ID
- 每個由一個以上進入點寫入的日誌接收器,都帶有一個進入點欄位,該欄位在執行開始處設定,並與關聯 ID 一起傳播,而不是在下游推斷
- 任何日誌行中都沒有祕密、令牌或未經脫敏處理的 PII(抽檢實際輸出)
- 每個新端點和每個外部依賴都存在 RED 指標,且標籤集合是有限的
- 延遲是以直方圖呈現;p95/p99 是可查詢的
- 可以在追蹤 UI 中端到端地追蹤一個請求,沒有中斷的跨度
- 每個新警報都是基於症狀的、有 runbook 連結,並且已經過一次測試觸發
- 透過遙測資料(而非閱讀原始碼)在暫存環境中定位了一個誘發的故障
有關此清單的快速參考版本,包括上線前的儀器化門檻,請參閱 ../../references/observability-checklist.md。