iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Kubernetes

從看得到到看得懂:30 天在自架 K8s 上實踐可觀測性與告警系列 第 20

Day 20:三大支柱串起來:exemplar 與 trace_id 關聯,反查第二個故障

  • 分享至 

  • xImage
  •  

Day 3 我給自己立了一個判準:從一張圖上的異常點出發,要幾次點擊才能看到造成它的那筆 log?如果答案是「開三個瀏覽器分頁」,那我有的是三個孤島,不是三大支柱。

今天回答這個問題,順便破第二個案。

今天的故障開關:只開故障二

kubectl set env deploy/pricing BUG_SILENT_DISCOUNT=false LEAK_KB_PER_REQUEST=0
kubectl set env deploy/catalog BUG_N_PLUS_ONE=true
kubectl port-forward -n monitoring svc/kps-grafana 3000:80

缺的那座橋:exemplar

Day 17 停在一個缺口:我看著 P99 的圖,看到一個 1 秒的點,但那個點是聚合值,它不記得自己是哪些請求算出來的。

exemplar 就是補這個缺口的東西。概念很簡單:程式在把一筆請求的耗時丟進 histogram 的時候,順手夾一個這筆請求的 trace_id。Prometheus 抓指標時把它一起收下,於是每個桶旁邊都留著一筆「代表性請求」的編號。那筆請求就叫 exemplar(範例點)。

Grafana 畫圖時會把它們畫成線旁邊的小點,點下去直接跳到那條 trace。工作流程從「看到異常、猜可能是什麼、去別的系統找證據」變成「看到異常、點它、看到證據」。

打開它:三邊都要動

這是今天最容易卡的地方,因為程式、Prometheus、Grafana 三邊各要改一處,漏掉任何一邊的結果都一樣:圖上就是沒有小點,沒有錯誤訊息。

一、程式要用 OpenMetrics 格式,而且要真的夾 trace_id。 exemplar 沒辦法用 Prometheus 傳統的文字格式傳,要用它的後繼者 OpenMetrics。common.py 改兩處,先換輸出格式:

from prometheus_client import REGISTRY, Counter, Histogram
from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST, generate_latest

@app.get("/metrics")
def metrics():
    return Response(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)

這裡有一個小坑:OpenMetrics 版的 generate_latest 沒有預設的 registry,忘了傳 REGISTRY 進去,/metrics 會直接 500。我第一次就是這樣。

然後在中介層記耗時的那行夾上 trace_id:

ctx = trace.get_current_span().get_span_context()
exemplar = {"trace_id": format(ctx.trace_id, "032x")} if ctx.is_valid else None
request_duration.labels(SERVICE, path).observe(elapsed, exemplar=exemplar)

改完 /metrics 的 histogram 桶長這樣,# 後面就是 exemplar:

http_request_duration_seconds_bucket{le="0.1",path="/prices",service="pricing"} 1.0 # {trace_id="8937de20b8dcca5da44f29c91858eb90"} 0.0009 1789404529.02

image 升到 0.3,重 build、kind loadapply,跟昨天一樣。

二、Prometheus 要開功能旗標。 exemplar 儲存在 Prometheus 3.14 還是實驗功能,要用 --enable-feature=exemplar-storage 打開。Day 8 裝的 kube-prometheus-stack 沒帶任何 values,所以補一個檔案,放在 repo 的 helm/kube-prometheus-stack-values.yaml

prometheus:
  prometheusSpec:
    enableFeatures:
      - exemplar-storage

三、Grafana 要知道 trace_id 該往哪裡跳。 這件事正常是在 Prometheus 資料源的設定頁做,但 Day 8 那個資料源是 chart 用 ConfigMap 自動建的,Grafana 標成唯讀,設定頁改不了。要走 values 檔:

grafana:
  sidecar:
    datasources:
      exemplarTraceIdDestinations:
        datasourceUid: tempo
        traceIdLabelName: trace_id

datasourceUid: tempo 這行有一個前提:Tempo 資料源的 uid 要真的叫 tempo。Day 18 用 UI 加的那個 uid 是隨機的,所以我把 Loki 和 Tempo 兩個資料源也一起搬進 values 檔宣告(grafana.additionalDataSources,完整內容在 repo),uid 固定叫 lokitempo

然後 helm upgrade

helm upgrade kps prometheus-community/kube-prometheus-stack -n monitoring --version 90.0.0 \
  -f helm/kube-prometheus-stack-values.yaml

這一步我把前面十天的東西弄丟了

upgrade 跑完,Grafana 的 Pod 重啟,我重新登入——Day 9 的儀表板、Day 11 的九宮格、Day 12 的節點 USE、Day 15 加的 Loki、Day 18 加的 Tempo,全部不見了

原因是 kube-prometheus-stack 的 Grafana 預設沒有持久化。它把儀表板和資料源存在 Pod 裡的一個 SQLite 檔,Pod 一換,檔案跟著沒了。Grafana 從 Day 8 起連續跑了七天沒重啟,所以我一直沒發現這件事;今天 upgrade 改了它的 ConfigMap,Pod 重建,十天的手工全部歸零。

Prometheus 也一樣。 它的資料目錄預設是 emptyDir,重啟後 Day 8 到今天的指標歷史也沒了。延遲圖上的線從 upgrade 那一刻才開始。

補救方式是同一個 values 檔再加兩段,兩邊都掛 PVC:

prometheus:
  prometheusSpec:
    storageSpec:
      volumeClaimTemplate:
        spec:
          resources:
            requests:
              storage: 5Gi

grafana:
  persistence:
    enabled: true
    size: 2Gi

掉的東西救不回來。資料源已經改成宣告的,不會再掉;儀表板我把 Day 9 那張照文章裡的設定重建成 JSON,用 ConfigMap 掛進去(在 repo 的 grafana/),chart 的 sidecar 會自動載入,之後重啟也不會掉。這才是儀表板該有的存法——後面講 IaC 的時候會回來講。

再 upgrade 一次之後,還遇到一個。

Grafana 13 的 plugin 會自己消失

儀表板回來了,但兩個面板都是紅色三角形、No data。去看 Grafana 的 log:

Plugin registered  pluginId=prometheus
Installing plugins [mysql, loki, prometheus, tempo, ...]
plugin process exited  plugin=.../plugins-bundled/prometheus/...
Failed to install plugin  pluginId=prometheus
  error="unlinkat /usr/share/grafana/data/plugins-bundled/prometheus: read-only file system"

Grafana 13 把 Prometheus、Loki、Tempo 這些資料源做成「預裝 plugin」,啟動後會在背景嘗試從網路更新它們。更新的做法是先停掉正在跑的、刪掉舊目錄、再寫新的——但 chart 給容器的是唯讀根檔案系統,刪不掉,於是舊的已經停了、新的裝不上,plugin 就消失了。Prometheus 資料源還在列表裡,但背後沒有東西在服務它。

之前七天沒事,很可能是那段時間 plugin 沒有新版,安裝器沒動作;今天剛好有。修法是關掉背景預裝,用 image 裡內建的版本:

grafana:
  grafana.ini:
    plugins:
      preinstall_disabled: true

這一段也在同一個 values 檔裡。

所以你要做的是

我是分三次 upgrade 把上面這些踩出來的。repo 裡的 helm/kube-prometheus-stack-values.yaml 已經是最後的版本,五件事都在裡面:exemplar、Prometheus 持久化、Grafana 持久化、資料源宣告、關掉 plugin 背景更新。你跑一次就好。

但有一件事要在 upgrade 之前做:把你手工做的儀表板匯出。 持久化是 upgrade 之後才生效的,這次重啟你 Day 9、11、12 做的儀表板一樣會掉。到每張儀表板右上角 Share → Export → Export as JSON,存下來。之後想放回去,照 repo 裡 grafana/dashboard-checkout-sli.yaml 的樣子包成 ConfigMap 再 apply,就會自動載入而且不會再掉。Day 15 和 Day 18 用 UI 加的 Loki、Tempo 資料源不用管,values 檔會用宣告的版本補回來。

匯出完再跑:

helm upgrade kps prometheus-community/kube-prometheus-stack -n monitoring --version 90.0.0 \
  -f helm/kube-prometheus-stack-values.yaml
kubectl rollout status deploy/kps-grafana -n monitoring
kubectl apply -f grafana/          # Day 9 的儀表板,我已經改成 ConfigMap 了

port-forward 要重開,Grafana 也要重新登入。進去之後 Dashboards 裡會有「結帳服務 SLI」,Connections → Data sources 裡會有 loki 和 tempo,都是灰的唯讀。

https://ithelp.ithome.com.tw/upload/images/20260920/20180570YxcOgoVmrT.png

Prometheus 資料源的設定頁最下面,Exemplars 區塊四個欄位都填好了:Internal link 開著、Data source 是 tempo、Label name 是 trace_id

三邊都改完,打開延遲圖,線的旁邊應該多出小點。沒有的話,三邊回頭一個一個查:程式那邊 curl 一下 /metrics 看 Content-Type 是不是 openmetrics、桶旁邊有沒有 # {trace_id=...};Prometheus 那邊查 /api/v1/status/flagsenable-feature 有沒有 exemplar-storage;Grafana 那邊看資料源設定頁的 Exemplars 區塊。

第一步:看到形狀

Day 9 的延遲面板,P50 / P95 / P99 三條線:

https://ithelp.ithome.com.tw/upload/images/20260920/20180570ddq7puoYwD.png

長尾。P50 貼著 80 毫秒不動,P95 在 800 毫秒、P99 在 1 秒,跟 Day 10 看到的一樣。但這次線旁邊多了綠色的小菱形——每一個都是一筆真實請求的耗時,散在 100 毫秒到 900 毫秒之間。

第二步:點下去,看火焰圖

滑鼠移到最上面那個小點上:

https://ithelp.ithome.com.tw/upload/images/20260920/20180570YOqdDqjeqK.png

Value 1.04,也就是這筆請求花了 1.04 秒;le=2.0 是它掉進的桶——Day 13 說過桶要圍繞門檻設,1 秒和 2 秒之間沒有刻度,所以它只能記在 2.0 那格。最下面是 trace_id 和一個 View trace 的按鈕。按下去,Tempo 打開了那條 trace,trace_id 跟剛才小視窗裡的一樣:

https://ithelp.ithome.com.tw/upload/images/20260920/2018057065M2NDJdaP.png

1.04 秒、83 個 span、三個服務。上面 Overview 那條縮圖是樓梯狀的,往下看 catalog 的 POST /items 佔了 1.02 秒,它底下是一排 catalog GETpricing GET /price,每一組 64 毫秒左右,一個接一個排成一排,前一個結束下一個才開始。

每一次呼叫只花 60 幾毫秒,單獨看完全正常——這就是為什麼 metrics 查不出來,pricing 自己的延遲指標一切健康。問題不在單次呼叫慢,在於呼叫了十幾次,而且是串著打。

這張圖就是 Day 17 那張 ASCII 的真實版。

第三步:確認條件

昨天在 catalog 加的那個屬性派上用場了。點開 catalog 的 span,看它的屬性:

cart.item_count = 12
pricing.mode    = n_plus_one

再找一條正常的 trace 對照,cart.item_count = 3pricing.mode = batch,pricing 只有一個 span,總耗時 80 幾毫秒。規律出現了:pricing 的呼叫次數等於購物車商品數,而且只在超過 5 件時發生。

第四步:驗證假設

用 TraceQL 撈出所有大購物車的慢請求,門檻用 Day 2 定的 SLO 300 毫秒:

{ span.cart.item_count > 5 && duration > 300ms }

https://ithelp.ithome.com.tw/upload/images/20260920/20180570RCAitlXoQc.png

一整排,390 毫秒到 2 秒都有。再反過來查小購物車有沒有慢的:

{ span.cart.item_count <= 5 && duration > 300ms }

https://ithelp.ithome.com.tw/upload/images/20260920/20180570aO66oEtTB2.png

我原本以為這條會是空的,結果有東西,而且不只一兩筆。先別急著推翻假設,把數量算出來——過去 30 分鐘:

總數 > 300ms 比例
大購物車(> 5) 1,225 1,000 以上 82% 以上
小購物車(≤ 5) 4,792 15 0.3%

82% 對 0.3%,假設成立。這就是 N+1:程式為了拿 N 件商品的價格,發了 N 次請求,而不是一次。

那 0.3% 是什麼?點進去看,形狀完全不一樣。N+1 的慢是 catalog 底下一排 pricing 串著打;這 15 筆的慢是空白——gateway 裡空了 100 毫秒、catalog 收到請求之後空了 900 毫秒才呼叫 pricing、pricing 收到之後又空了 1 秒才開始算。三個服務一起停,那是整台機器卡了一下,筆電上跑 kind 偶爾會這樣,跟購物車大小無關。

這是火焰圖比數字多給的東西:兩種慢,metrics 上都是「超過 300 毫秒」,trace 上一個是階梯、一個是空白。

再跳一次,跳到 log

在 trace 畫面點任何一個 span,它會往下展開細節,右上角有一個 Logs for this trace 的按鈕。原理就是昨天填進 log 的那個 trace_id 欄位。

這個按鈕要先告訴它 log 在哪、怎麼查,設定叫 Trace to logs。我把它寫在同一個 values 檔裡,跟 Tempo 資料源一起宣告:

jsonData:
  tracesToLogsV2:
    datasourceUid: loki
    filterByTraceID: true
    tags:
      - key: service.name      # span 上的服務名
        value: app             # 對到 Loki 的哪個標籤

tags 那組對應是關鍵:span 上服務名的欄位叫 service.name,Loki 的標籤叫 app,不對應的話 Grafana 會拿 service.name 去查 Loki,查不到東西也不會報錯。

點 catalog 的 span,按 Logs for this trace:

https://ithelp.ithome.com.tw/upload/images/20260920/20180570ArGV6GCv7j.png

Grafana 幫你組了一條 LogQL——{app="catalog"} |= "那個 trace_id"——右邊直接是這筆請求在 catalog 印的兩行 log,trace_id 跟左邊一模一樣。

左邊 span 的 Child Count 是 16、Duration 1.25 秒;右邊 log 寫著 item_count: 12mode: n_plus_onecalls: 12。trace 說「這一段底下有很多小孩」,log 說「因為我打了 12 次」——兩邊講的是同一件事,只是一個用結構、一個用文字。

整條路通了。

回答 Day 3 那個判準

從一張圖上的異常點,到造成它的那筆 log:

步驟 動作
1 在延遲圖上點一個 exemplar 小點
2 按 View trace 看火焰圖
3 按 Logs for this trace 看 log

三次點擊,全程沒離開 Grafana,沒有手動輸入過任何一次時間範圍或 ID。 Day 3 的答案是「開三個瀏覽器分頁」,今天是三次點擊。

而且這條路是雙向的:在 Loki 看到一行可疑的 log,它的 trace_id 欄位旁邊也會有一個按鈕跳去 Tempo。

第二個故障結案

  • 症狀:P95 / P99 從 100 毫秒以內惡化到 1 秒左右,P50 不動,錯誤率 0
  • 原因:catalog 在購物車超過 5 件時,對每件商品各發一次請求給 pricing
  • 為什麼 metrics 查不到:每一次呼叫都正常,只有次數不對,而次數是 metrics 看不到的維度
  • 為什麼 log 查不到:12 行 log 之間沒有東西把它們串起來——在有 trace_id 之前
  • 怎麼查到的:從 P99 的 exemplar 跳進 trace,看到 12 個並排的 span,用 cart.item_count 驗證條件

Day 7 那句「只有單點,無法跨服務關聯」,正式劃掉。四句天花板現在只剩最後一句:只能被動查,不會主動通知。

小結

  • 兩座橋:exemplar 從指標跳到 trace,trace_id 從 trace 跳到 log,接起來是三次點擊
  • 三邊都要動:程式換 OpenMetrics、Prometheus 開旗標、Grafana 走 values 檔
  • Grafana 和 Prometheus 預設都沒持久化,重啟全掉;儀表板和資料源要用宣告的

明天開始告警區塊,處理最後一句天花板。Day 6 的第三個故障——那個記憶體洩漏——還在那裡慢慢長大。


上一篇
Day 19:自動與手動 instrumentation:把 trace 埋進示範服務
下一篇
Day 21:好告警的三個條件:可行動、有主人、有上下文
系列文
從看得到到看得懂:30 天在自架 K8s 上實踐可觀測性與告警22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言