可觀測性:三態健康檢查與 tracing 抽象層
三態健康檢查(Healthy / Degraded / Unhealthy)比 up/down 多一個「還能服務但接近極限」的狀態。tracing 則只依賴一個 Tracer 介面,OTel 當 adapter 接上。這篇講介面驅動的可觀測性。
更正(2026-11-02 18:45):拿掉三態「貼近 K8s probe」的比較,改為說明三態怎麼對到 probe 結果由端點決定(修正建議:Claude,Codex(gpt-6-astra)驗證)。補上 check 必須遵守 context 取消,否則會拖住整批與下一輪檢查;基本 Tracer 方法不會產生 OTel span,刪掉「業務碼一行不改」;MemoryHealthCheck 的門檻改為已配置 heap 以整數 MiB 計、超過 80% 才報 Degraded(修正建議:Codex(gpt-6-astra),Claude 驗證)。
前置閱讀:〈interface 與組合〉
三態健康檢查
傳統健康檢查只有 up/down,但真實世界常是「還能跑但快不行了」。gortex 用三態:
const (
HealthStatusHealthy HealthStatus = "healthy"
HealthStatusDegraded HealthStatus = "degraded" // 還能服務,但接近極限
HealthStatusUnhealthy HealthStatus = "unhealthy"
)
聚合採 worst-wins:任一 check 是 Unhealthy,整體就 Unhealthy;否則有 Degraded 就 Degraded;全好才 Healthy。內建的 MemoryHealthCheck 示範得很清楚:它看的是已配置的 heap(runtime.MemStats.Alloc,以整數 MiB 計,不是行程的 RSS),超過上限是 Unhealthy,超過上限的 80% 就先報 Degraded 預警。
Kubernetes probe 的結果只有 Success、Failure、Unknown(Unknown 是診斷沒跑完,稍後再試),沒有降級這一格,見官方說明。gortex 的 health 套件只算出三態,沒有規定怎麼對到 HTTP 狀態碼,Degraded 要讓 readiness 回 200 還是 503,由接 /healthz 端點的人決定,readiness 和 liveness 也可以用不同的判準。
並行檢查與逾時
HealthChecker 背景開一個 goroutine 按 interval 定時跑(runChecks + ticker),結果快取起來,/healthz 那類端點直接讀快取、不每次現跑。
實際跑的時候所有 check 並行,各自帶 timeout,先跑完的 check 先更新快取,不用等慢的那個:
for name, check := range checks {
wg.Add(1)
go func(n string, c HealthCheck) {
defer wg.Done()
checkCtx, cancel := context.WithTimeout(ctx, hc.timeout)
defer cancel()
result := c(checkCtx) // 每個 check 各自的 deadline
// ...存結果
}(name, check)
}
wg.Wait()
sync.WaitGroup 等全部跑完。timeout 只會取消 context,不會強制中止 check:check 要自己聽 ctx.Done()、用支援 context 的 I/O,不理會取消的 check 會讓 wg.Wait() 一直等,Check 遲遲不返回,背景 ticker 的下一輪也跟著延後。這是〈goroutine 與 channel〉的並行加〈記憶體與併發原語〉的同步原語,落在維運面的用法。
Tracer 介面 + OTel adapter
這節是 interface 那篇講的介面驅動設計的活教材。框架只依賴一個小介面:
type Tracer interface {
StartSpan(ctx context.Context, operation string) (context.Context, *Span)
FinishSpan(span *Span)
AddTags(span *Span, tags map[string]string)
SetStatus(span *Span, status SpanStatus)
}
type EnhancedTracer interface { // 介面嵌入
Tracer
StartEnhancedSpan(ctx context.Context, operation string) (context.Context, *EnhancedSpan)
}
附三種實作:NoOpTracer(關掉 tracing)、SimpleTracer(in-memory,開發用)、OTelTracerAdapter(接 OpenTelemetry)。要從 in-memory 換到 OTel,光換實作不夠:OTelTracerAdapter 的基本 StartSpan/FinishSpan 只轉給 gortex 自己的 tracer,不會產生 OTel span(*Span 帶不了 OTel span,開了也沒人能結束它)。要送進 OTel,開 span 的地方得改用 adapter 的 StartSpanWithOptions,拿回 *SpanAdapter 再呼叫 End();用 StartEnhancedSpan 的話,要從 context 取出 SpanAdapter 來結束。adapter 本身仍是 interface 那篇講的那種,只是呼叫端要跟著改。adapter 內部用一張 severityMap,把 gortex 的 8 級嚴重性(DEBUG=10 … EMERGENCY=80)對映到 OTel 的屬性。
Context propagation
span 怎麼跟著請求一路傳下去?用 context,而且用私有的 contextKey 型別避免 key 撞車:
type contextKey string
const spanContextKey contextKey = "span"
func ContextWithSpan(ctx context.Context, span *Span) context.Context {
return context.WithValue(ctx, spanContextKey, span)
}
func SpanFromContext(ctx context.Context) *Span { /* ctx.Value(spanContextKey) */ }
StartSpan 先用 SpanFromContext 取出父 span、建子 span、再把子 span 放回 ctx 往下傳。整條 call chain 就靠 context 串起來,不用全域變數、也不用手動到處傳。
補充筆記
- 三態健康(Healthy / Degraded / Unhealthy)比 up/down 多一個降級狀態;K8s probe 只有 Success/Failure/Unknown,三態怎麼對應由端點決定;聚合 worst-wins。
- check 背景定時跑 + 快取;執行時
WaitGroup並行、各自 timeout,快的先更新快取;check 要遵守 ctx 取消,否則整批與下一輪都要等它。 - tracing 只依賴
Tracer/EnhancedTracer介面(介面嵌入);NoOp / Simple / OTel 三種實作可抽換,正是 interface 那篇的 adapter;要產生 OTel span 得改用StartSpanWithOptions。 - span 用私有
contextKey+context.WithValue沿 call chain 傳遞;8 級 severity 對映 OTel。 - 可觀測性建在介面上;接 OTel 時,開 span 的呼叫要改用 adapter 的方法。
原始碼:yshengliao/gortex。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 環境 Go 1.25(gortex go.mod)· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃,新漆未乾。