觀測棧壞掉的時候,最惡劣的一點是它壞了不會有人通知你——它就是負責通知你的那個東西。
2026-04-02 那天我起一套自架的 observability compose:Tempo 收 trace、Loki 收 log、OTel Collector 當入口、Prometheus 收 metrics、Grafana 當門面。全部掛 :latest。然後有幾個容器起不來,有幾個起來了但行為不對。
根因不是任何一個工具有 bug。是我 docker pull 之後,config 的 schema 已經跟我寫的那份對不上了。
為什麼 :latest 在觀測棧特別致命
一般服務掛 :latest,你賭的是 API 不要有 breaking change。Grafana 系工具你賭的是另一件事:config 欄位不要被改名、不要整段消失、exporter 不要被下架。
(exporter 是「輸出端元件」——OTel Collector 收到資料之後,要往哪送、用什麼格式送,就是由對應的 exporter 決定。要送 Loki 就用 Loki 的 exporter,要送別家就換一個。)
而這件事在觀測生態發生得非常頻繁,因為整個生態正在往 OpenTelemetry 收斂。收斂的過程就是一連串的「這個專屬寫法不要了,改走 OTLP」——OTLP 是 OpenTelemetry 自己那套傳輸協定,它的用意就是讓所有工具講同一種話,不用每接一家就學一種專屬格式。你跟著 :latest 走,等於每次 pull 都在賭自己那份 config 還活著。
那次一口氣中三個。
Tempo:兩個欄位在新版不存在了
# tempo.yaml
# compactor.compaction.block_retention: 168h ← 這行讓它起不來
# query_frontend.search.max_duration: 0 ← 這行也是
stream_over_http_enabled: true # 換成這個順手也發現 storage 的 wal 跟 blocks 要分開路徑,混在同一個目錄在新版會出事。修法很單純:把 image 從 grafana/tempo:latest 釘成 grafana/tempo:2.6.1,然後照那個版本的官方範例把 config 重寫一次。
OTel Collector:loki exporter 整個被拿掉了
這個最能說明前面講的「收斂」是什麼意思:
# 舊寫法——collector-contrib 已經沒有這個 exporter 了
# loki:
# endpoint: http://loki:3100/loki/api/v1/push
# 新寫法——走 OTLP,打 Loki 的 /otlp endpoint
otlphttp/loki:
endpoint: http://loki:3100/otlppipeline 那邊 logs.exporters 也要從 [loki] 改成 [otlphttp/loki]。
這不是 bug,是方向。 Loki 自己長出了原生 OTLP endpoint,那個專屬 exporter 就沒有存在理由了。硬留舊寫法只會一直壞——這種東西不要修,要跟著搬。
Prometheus:exemplar 從 config 搬到啟動參數
先說 exemplar 是什麼,不然這段沒有意義。metrics 給你的是統計數字——「這五分鐘內有 3% 的請求超過一秒」。但你想知道的往往是「那 3% 到底是哪幾筆」。exemplar 就是掛在 metric 上的樣本指路牌:這個數字裡挑一筆出來,附上它的 trace ID。你在 Grafana 看到曲線突起,點那個點,就能跳到造成它的那一筆真實請求。
沒有 exemplar,metrics 和 trace 就是兩個互不相通的世界,你只能自己憑時間戳去猜。
而它的開法在版本之間搬過家——prometheus.yml 裡那整段 storage.tsdb.exemplar 刪掉,改用啟動參數開:
command:
- '--enable-feature=exemplar-storage'還有一個不會報錯但功能默默失效的,跟上面的 exemplar 是同一個用意、不同方向。
Grafana 裡有個設定叫 derivedFields(衍生欄位):它會拿正規表達式去掃每一行 log,把裡面的 trace ID 抓出來,變成一個可以點的連結。點下去就跳到對應的 trace——這就是「從 log 追到請求全貌」那條路。
而它要能跳,得知道跳去哪個資料來源,靠的是 Grafana 給每個資料來源的那個代號(uid)。所以 Tempo 的 datasource 一定要補上 uid: tempo,Loki 那邊的 derivedFields 才指得到它。
沒補的話,你的「log 點下去看 trace」就是死的——而且完全不會有錯誤訊息,你只會覺得「怎麼點了沒反應」。
修完之後我寫了一條規則
當時的結論寫得很漂亮:
觀測元件一律 pin 明確版本,不用
:latest。升版當成一次任務——讀該版 release note 的 config breaking change,改完驗全部 healthy 再 commit。
驗收點也定了:Grafana 開得起來、三個 datasource 綠燈、Collector 的 debug exporter 看得到 telemetry 進來。
四個月後我回去查,答案分成兩半
今天為了寫這篇,我回頭翻自己手上所有的監控設定。結果比我預期的有意思:
現在真正在跑的那套,規則有落地。 平台後來整個搬上 K8s,k8s/base/ 底下的 monitoring 跟 logging 全部釘死版本——Grafana 11.1.0、Prometheus v2.53.0、Loki 3.1.0、Fluent Bit 3.1。沒有一個 :latest。
但那套舊的 docker-compose 還在,而且沒人管。 Prometheus、Grafana、AlertManager、node-exporter、blackbox-exporter——7 個 image 有 6 個掛 :latest。唯一釘死的是 cadvisor(v0.49.1),而那不是我學乖了,是因為 cadvisor 的 :latest 當初就有問題不得不釘。被註解掉的那些備援元件(Loki、Promtail、postgres-exporter、redis-exporter)也全是 :latest,等著哪天誰解開註解再踩一次。
順手還撈到一個沒發現的洞:K8s 那邊三個 service 的 configmap 都設了 OTEL_EXPORTER_OTLP_ENDPOINT: "http://tempo:4317",但 k8s/base/ 裡根本沒有 Tempo 的 deployment。tracing 的開關開著、資料往一個不存在的地方送——而這正是本文開頭那句話的實例:觀測的東西壞了,不會有人通知你。
規則落地與否,取決於它綁在哪個 stack 上
所以「有沒有自律」不是重點,重點是規則活在哪裡:
- 規則綁在還在動的那套上 → 每次改 manifest 都會看到,自然維持
- 規則寫在事故卡片上 → 那張卡綁著出事的那個 stack,那套一旦被取代,卡片就成孤兒
- 那套被取代但沒有被刪掉的舊 stack → 就是規則的盲區。它不痛,所以不會被想起;它還在,所以哪天有人
docker compose up就會重演一次
真正該做的不是「記得要 pin」,是讓沒 pin 這件事自己會叫:
| 落地方式 | 成本 | 抓得到什麼 |
|---|---|---|
CI 裡 grep :latest 直接 fail | 十分鐘 | 新寫的、被解開註解的、被冷凍的舊 stack,全抓 |
| Renovate / Dependabot 管 image tag | 半小時起 | 幫你 pin,還幫你開升版 PR |
| 寫進事故卡片 / 文件 | 五分鐘 | 只保得住你當下手上那套 |
我自己就是第三列的例子:新平台守住了,舊的那套在旁邊掛了四個月的 :latest 沒人看。差別不在紀律,在有沒有一個東西會替你檢查。
這篇的立場
:latest 的問題不在於「版本不確定」——大家都知道版本不確定。問題在於它把「升版」這個應該是決定的東西,變成了一個沒有人按下的意外。
正常的升版是:我看 release note、我改 config、我驗一次、我 commit。掛 :latest 的升版是:某天某台機器重啟了,然後你的 trace 就沒了。中間沒有任何一個人做過決定。
觀測棧尤其不能這樣,因為它壞掉的時候,你失去的正是那個會告訴你壞掉了的東西。
相關
- metrics 與監控的整體設計 → Metrics 與監控
- log 管理與保存策略 → Log 管理
- 告警串接 → 告警與 ChatOps