iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Kubernetes

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

Day 24:告警疲勞:分級、去重,以及一次真實誤報的檢討

  • 分享至 

  • xImage
  •  

告警區塊最後一天。前三天講原則、寫設定、算門檻,今天講這些東西上線之後會發生什麼事。

先講結論:它們不會照你想的跑。 我昨天那則記憶體預測告警,今天實際跑一次,三個地方跟我寫的時候想的不一樣。

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

kubectl set env deploy/pricing BUG_SILENT_DISCOUNT=false LEAK_KB_PER_REQUEST=8
kubectl set env deploy/catalog BUG_N_PLUS_ONE=false

LEAK_KB_PER_REQUEST=8 是算出來的。pricing 每秒收 5.6 個請求,每個請求平均 4.2 件商品,每件洩漏兩次,所以是每秒 47 次洩漏。乘上 8 KB 就是每分鐘 22 MB,從 60 MiB 爬到 512 MiB 的上限大約 20 分鐘。

告警疲勞是怎麼長出來的

Day 21 我第一次打開 Alertmanager,15 則誤報從裝好那天就在響,我十三天沒看過一次。那些不是我加的,是 chart 的預設值,但結果一樣:我對那個畫面已經免疫了。

真實團隊的版本只是換一個加的人。出事、檢討、加一則告警,重複幾次變成十幾則;其中幾則偶爾誤報,沒人有空修就先忍著;再過一陣子,「先看一下、通常沒事」變成預設反應。這個過程只有加,沒有減。

所以除了「怎麼設好告警」,還要有辦法知道哪一則該拿掉。而那要等它實際跑過一次。

把故障三打開

10:44 打開洩漏,然後每分鐘記一次記憶體和告警狀態:

時間 記憶體 告警狀態
10:44 60 MiB inactive
10:49 161 MiB pending
10:54 275 MiB pending
11:00 406 MiB firing
11:05 506 MiB firing
11:06 94 MiB firing

11:06 那一行就是 OOM。確認一下:

kubectl get pods -l app=pricing -o custom-columns=\
'POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,\
LAST:.status.containerStatuses[0].lastState.terminated.reason,\
EXIT:.status.containerStatuses[0].lastState.terminated.exitCode'

https://ithelp.ithome.com.tw/upload/images/20260924/20180570QYJL0TquLF.png

exit code 137 是 128 + 9,也就是被 SIGKILL 砍掉——不是程式自己結束的,是核心的 OOM killer 動的手。這個畫面是我讓它跑了三個多小時之後截的,RESTARTS 已經 14 次,每二十分鐘一輪。

把同一段時間畫成圖會更清楚。先開 Grafana(另開一個終端機,這行會一直佔著):

kubectl port-forward -n monitoring svc/kps-grafana 3000:80

到 http://localhost:3000 → 左邊選單 Explore → 資料源選 Prometheus(不是 loki 或 tempo),查詢輸入:

container_memory_working_set_bytes{namespace="default", container="pricing"}

右上角時間選 Last 3 hours。我第一次畫出來長這樣:

https://ithelp.ithome.com.tw/upload/images/20260924/201805708U4hlDnrUd.png

不是一條鋸齒,是四條各自往上爬的線,圖例是四串看不懂的 cri-containerd-xxxx。

那四條不是四個 Pod。這裡有個容易搞混的地方:OOMKilled 殺掉的是容器,不是 Pod。 Pod 這個殼一直在,名字和 UID 都沒變,只是裡面的容器被換掉十幾次——上面那個 RESTARTS: 14 就是在數這個。所以四條線是同一個 Pod 裡的四代容器,pod 標籤完全一樣,差別在 cAdvisor 給每個容器實例的 id 和 name。

這個現象等一下會變成今天的第三個發現,先講怎麼畫得好看。max by (pod) 的意思是「把 pod 以外的標籤全部丟掉,剩下的按 pod 分組、每個時間點取最大值」——那四條的 pod 一樣,所以合成一條。順便把 Options 裡的 Legend 填成 {{pod}},圖例就會變成 Pod 名字:

max by (pod) (container_memory_working_set_bytes{namespace="default", container="pricing"})

https://ithelp.ithome.com.tw/upload/images/20260924/20180570Yi6HmN13ng.png

三小時九輪,每一輪都是同一個形狀:斜著爬二十分鐘到 5 億 bytes 左右(memory limit 是 512 MiB),然後垂直掉到底,再從頭來。圖例只有一行,因為從頭到尾就只有一個 Pod。

垂直掉下來那一刀之所以乾淨、沒有重疊,是因為容器死掉之後 cAdvisor 就不再回報它的序列,Prometheus 會把它標記成失效——所以那個時間點只剩新容器一條有值,max 自然取到它。

Y 軸那個「500 Mil」是原始的位元組數,Explore 裡沒有換算單位的選項——那是儀表板面板才有的 Standard options。真的想看 MiB 就在查詢後面除一下:... ) / 1048576。

alert-sink 也收到了:

PodMemoryWillExhaustLimit  ticket  pricing 照目前趨勢,4 小時內會撞到記憶體上限

告警有響、分級是 ticket、summary 讀得懂。看起來一切正常。但有三個地方不對。

沒料到的第一件事:預警只剩五分鐘

我在 annotation 裡寫的是「4 小時內會撞到記憶體上限」。實際上,從收到通知到 OOM 只有五分鐘。

把時間攤開來看:

時間 發生什麼
10:44 打開洩漏
10:46 predict_linear 算出「會撞上限」,告警進 pending
11:01 撐滿 for: 15m,轉成 firing,通知送出
11:06 OOMKilled

條件在第 2 分鐘就成立了,但通知第 17 分鐘才送出。 中間那 15 分鐘是 for 要求的等待。

for 本身沒有錯,它存在的理由是避免瞬間抖動變成通知(Day 21 講過)。錯的是我沒有把它跟這個問題的發展速度放在一起看:我設的洩漏速度是每分鐘 22 MB,二十分鐘就撞上限,而我要它等十五分鐘。

判準是:for 要遠小於問題從發生到造成傷害的時間。 真實的記憶體洩漏是以小時或天為單位的,for: 15m 放在那種速度下完全不影響。我這個每分鐘 22 MB 是為了在一篇文章裡演完硬調出來的,配 15 分鐘就太久。

沒料到的第二件事:重啟之後告警不會自己解除

11:06 容器被殺掉重啟,記憶體從 506 MiB 掉回 94 MiB。我以為告警會跟著解除,結果它繼續 firing。

原因是 predict_linear 看的是過去一小時的資料。那一小時裡有五十幾分鐘是陡峭的上升,只有最後那一下是垂直下墜。一條直線穿過這一小時,斜率還是往上,所以它還是預測「會撞上限」。要等下墜之後的新資料填滿整個視窗,斜率才會轉負。

這個行為是對的。 重啟不代表洩漏修好了,告警不該因為記憶體歸零就消失——那正是昨天說固定門檻不能用的理由。

但我昨天給的理由是錯的。我寫的是「趨勢不會被重啟洗掉」,聽起來像 predict_linear 天生免疫。實際上它只是因為視窗夠長,那一下下墜佔的比例太小。視窗改成五分鐘的話,一樣會被洗掉。

沒料到的第三件事:規則在報錯,但畫面上看不出來

這個最嚴重。重啟之後我去看規則狀態:

state = firing
lastError = multiple matches for labels: many-to-one matching must be explicit (group_left/group_right)

它同時在 firing,也同時在報錯。

原因就是上面那張四條線的圖:容器每重啟一次,cAdvisor 就對同一個 (namespace, pod, container) 多留一條序列,靠 id 和 name 標籤區分。畫圖的時候那只是圖例很醜,但我的規則寫的是:

predict_linear(container_memory_working_set_bytes{...}[1h], 4*3600)
  > on(namespace, pod, container)
kube_pod_container_resource_limits{...}

左邊變成兩條、右邊一條,on(...) 就成了多對一,Prometheus 拒絕計算。修法跟剛才畫圖那招一樣,兩邊都先用 max by 收斂成一條:

max by (namespace, pod, container) (
  predict_linear(container_memory_working_set_bytes{namespace="default", container!=""}[1h], 4 * 3600)
)
  > on(namespace, pod, container)
max by (namespace, pod, container) (
  kube_pod_container_resource_limits{namespace="default", resource="memory"}
)

值得注意的是這個錯誤出現的時機:規則寫好、apply、看到 Inactive 的時候一切正常,因為那時候還沒有任何容器重啟過。 它要等到第一次 OOM 才會出現,而那正好是你最需要這則告警的時候。

這個錯誤在 Alerts 分頁看不到。要去 Status → Rules(網址是 http://localhost:9090/rules),找到 memory-trend 這個群組,規則旁邊會標一個紅色的 err,底下就是那行錯誤訊息:

https://ithelp.ithome.com.tw/upload/images/20260924/20180570VyXZ0x8iUn.png

同一時間回 Alerts 分頁看同一則規則:

https://ithelp.ithome.com.tw/upload/images/20260924/20180570HJcuQRaYEn.png

FIRING (1),Active Since 1 小時 16 分,一切正常。右下角的 Value 還印著 6844925928——也就是 predict_linear 算出四小時後會用到 6.8 GB(上限是 512 MiB)。它一邊報錯一邊算得出數字、一邊照樣送通知。 只有一個地方會告訴你出事了,而那個地方不是你平常會看的那一頁。也可以從 API 撈,欄位叫 health 和 lastError:

curl -s http://localhost:9090/api/v1/rules | python3 -c "
import json,sys
for g in json.load(sys.stdin)['data']['groups']:
    for r in g['rules']:
        if r.get('lastError'): print(r['name'], '→', r['lastError'])"

這是這個系列第九次遇到同一種事:東西看起來在運作,實際上壞了,而且沒有任何地方會告訴你。 前八次都是別人的預設值,這次是我自己寫的。

分級:可不可以打斷別人

從上面三件事回到設計。我用三級:

分級 什麼時候用 送去哪 我的例子
page 使用者正在受影響,需要立刻處理 立即通知 burn rate 快燒
ticket 真的有問題,但可以等 每日彙整 記憶體洩漏預測、折扣算錯
info 值得知道,不需要行動 只進儀表板 部署完成、擴縮容

大部分的告警疲勞來自把 ticket 當成 page。 分級的意義不是標記嚴重程度,是決定它可不可以打斷別人。

一個好用的檢驗:這則告警半夜三點響,你會起床嗎?答案是「應該不用」的話,它就不該是 page。Day 23 我用 burn rate 把故障二從 page 降成 ticket,用的就是這個判準,只是那次有數字撐腰。

定期檢討:刪除比新增難

告警是會腐爛的。服務改了、門檻過時了、當初擔心的問題已經不存在了。我打算每個月問三個問題:

  1. 這則告警上個月響了幾次? 零次的可能是門檻太鬆,或者那個風險已經消失
  2. 響的那幾次,有幾次是真的? 真陽性率低於一半的,要修或要刪
  3. 有沒有哪次事故是沒有告警的? 那才是該補的地方

第三個最重要,因為前兩個都在看現有的告警,只有它會發現缺口。

但刪除告警比新增難得多,因為刪除要承擔「萬一以後出事」的責任,而新增不用。這個不對稱就是告警愈長愈多的根本原因。我的做法是:把「刪掉一則沒用的告警」當成跟「新增一則告警」同等的成果。

第三個故障結案

  • 症狀:pricing 記憶體以每分鐘 22 MB 線性上升,20 分鐘後 OOMKilled 重啟,週而復始
  • 原因:用字典當快取但從不淘汰,每次請求都塞新的 key 進去
  • 為什麼固定門檻不行:太高來不及、太低天天叫
  • 怎麼攔下的:predict_linear 預測四小時後會超過上限,分級 ticket
  • 實際表現:告警有響,但 for: 15m 讓預警時間只剩六分鐘;而且容器重啟後規則會因為序列變兩條而報錯

做完把它關掉:

kubectl set env deploy/pricing LEAK_KB_PER_REQUEST=0

四句天花板全部劃掉

Day 7 那四句:

天花板 被誰解決 哪一天
只有現在,沒有歷史 Prometheus Day 8
只有文字,不能聚合 Loki + LogQL Day 16
只有單點,無法跨服務關聯 OpenTelemetry + exemplar Day 20
只能被動查,不會主動通知 Alertmanager + burn rate 今天

三個故障也全部結案。如果面試官現在再問我一次「服務出問題時你怎麼查」,我答得出來了。

小結

  • 告警疲勞的形成過程只有加沒有減,所以需要刪除的機制
  • for、預測視野、問題發展的速度,這三個數字要一起挑,只想一個就會像我一樣把四小時預警變成六分鐘
  • 規則可以一邊 firing 一邊報錯,而 Alerts 分頁不會告訴你

明天開始最後兩個區塊。先做一隻會讀告警的 agent,然後把它自己也放進這套觀測系統裡。


上一篇
Day 23:從 SLO 反推告警:burn rate 告警怎麼設
下一篇
Day 25:讓一隻 tool-calling agent 讀 Alertmanager,把告警翻成人話
系列文
從看得到到看得懂:30 天在自架 K8s 上實踐可觀測性與告警 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言