昨天做圖的時候直接貼了兩行查詢沒解釋,今天補上。
PromQL 是 Prometheus 的查詢語言。它跟 SQL 完全不像,因為它處理的東西不一樣:SQL 查的是一張表,PromQL 查的是一堆隨時間變化的曲線。
這篇要從資料型別開始講,那是理解 rate 的前提。
kubectl set env deploy/pricing BUG_SILENT_DISCOUNT=false LEAK_KB_PER_REQUEST=0
kubectl set env deploy/catalog BUG_N_PLUS_ONE=true
最後一節要看長尾的形狀,需要故障二把它製造出來。
今天的查詢我都貼在 Prometheus 自己的網頁,不是 Grafana——介面很陽春,但是試語法最快,改一改按 Execute 就看得到結果。
kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090
這個視窗要一直開著,另外開一個終端機放它。瀏覽器打開 localhost:9090,點導覽列的 Query。
Prometheus 的指標有好幾種型別,先認識三種就夠用:
| 型別 | 特性 | 例子 | 怎麼看它 |
|---|---|---|---|
| Counter(計數器) | 只會增加,重啟時歸零 | 累計請求數、累計錯誤數 | 幾乎永遠要包一層 rate |
| Gauge(量測值) | 可上可下 | 記憶體用量、佇列長度 | 直接看數值 |
| Histogram(直方圖) | 把數值分桶統計 | 請求耗時的分布 | 用 histogram_quantile |
型別搞錯,寫出來的查詢就是錯的,而且 Prometheus 不會報錯,它會安安靜靜給你一個沒有意義的數字。
假設 checkout_total 現在顯示 1048576。
這個數字沒有任何意義。 它是從服務啟動到現在的累計值,我不知道它是一天累積的還是一個月累積的,也不知道現在是變快還是變慢。
有意義的是它增加得多快:
rate(checkout_total[5m])
意思是「過去五分鐘內,這個計數器平均每秒增加多少」,得到的就是「每秒幾筆請求」。
rate 還順便處理了一件麻煩事:counter 會歸零。Pod 一重啟計數器就從 0 開始,如果我自己拿現在的值減去五分鐘前的值,會得到一個巨大的負數。rate 認得出這種重置並且會自動修正。
這件事在故障三特別重要。pricing 每四分鐘就 OOM 重啟一次,計數器跟著歸零,用 rate 的話圖表不會出現假的斷崖。
rate(x[5m]) → 每秒的平均增加量increase(x[5m]) → 這五分鐘總共增加了多少increase 大約等於 rate 乘上秒數。畫圖幾乎都用 rate,因為單位固定,時間視窗改了圖形也不會跳動;想回答「今天總共發生幾次」這種問題才用 increase。
[5m] 是回看視窗。有一條實用的規則:
視窗至少要是抓取間隔的四倍。
我的 ServiceMonitor 設 interval: 15s,所以視窗至少要 1m,實務上填 5m 比較穩。
為什麼?因為視窗太短的話,裡面可能只有一兩個資料點,遇到一次抓取失敗就整段變空白,圖上出現破洞。這個坑很常見,而且症狀是「圖時有時無」,第一次遇到會以為是服務出問題。
昨天那行完整長這樣:
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket{job="gateway", path="/checkout"}[5m])) by (le)
)
由內往外拆成四層。
第一層,..._bucket。 histogram 型別的指標會自動產生一組以 _bucket 結尾的序列,每一條記錄「耗時小於等於某個值的請求有幾筆」,那個「某個值」放在標籤 le 裡面(le 就是 less than or equal)。所以原始資料長得像:小於 0.1 秒的有 950 筆、小於 0.5 秒的有 990 筆、小於 1 秒的有 998 筆⋯⋯
第二層,rate(...[5m])。 bucket 也是 counter,所以一樣要包 rate。
第三層,sum(...) by (le)。 把所有 Pod 的資料加總,但是要保留 le 這個標籤。保留是必要的,因為下一步要靠不同的 le 值去推算分布,le 被加掉就沒有分布可言了。忘記寫 by (le) 是最常見的錯誤。
第四層,histogram_quantile(0.95, ...)。 從這個分布推算出 95% 的位置。
這裡要注意一件事:這個結果是估算值,不是精確值。 它假設每個桶裡面的資料是平均分布的,實際上不見得。如果桶的邊界設得不好,例如全部請求都落在同一個桶裡,算出來的 P95 會離譜。桶要怎麼設是後面幾天的題目。
有了語法,接下來的問題是該查什麼。Google 的 SRE 書提出四個指標,叫做四個黃金訊號(The Four Golden Signals):
| 訊號 | 中文 | 回答的問題 |
|---|---|---|
| Latency | 延遲 | 回應要多久? |
| Traffic | 流量 | 有多少需求打進來? |
| Errors | 錯誤 | 失敗的比例是多少? |
| Saturation | 飽和度 | 系統離塞爆還有多遠? |
前三個用我們手上的指標就查得出來:
# Traffic:每秒請求數
sum(rate(checkout_total[5m]))
# Errors:錯誤率
sum(rate(checkout_total{status="error"}[5m])) / sum(rate(checkout_total[5m]))
# Latency:P95
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket{job="gateway", path="/checkout"}[5m])) by (le))
Saturation 是四個裡面最難的,因為每種資源的飽和指標都不一樣:記憶體看用量比例、CPU 看等待時間、佇列看長度、連線池看等待中的請求數。以記憶體為例:
sum(container_memory_working_set_bytes{pod=~"pricing.*", container="pricing"})
/ sum(container_spec_memory_limit_bytes{pod=~"pricing.*", container="pricing"})
這兩個指標不是我埋的,是 kubelet 自己回報的,kube-prometheus-stack 裝好就有。故障三那個記憶體洩漏就是一個 saturation 的問題,後面設告警的時候會回來用這條查詢。
黃金訊號在講延遲的時候有一個很重要的提醒:成功和失敗的請求要分開算。
理由是一個「很快失敗」的請求,例如 50 毫秒就回 500,會把平均延遲拉低,讓你誤以為系統變快了。實際上它只是變得更快地失敗。
但是我現在做不到這件事。因為我的 histogram 是這樣定義的:
request_duration = Histogram(
"http_request_duration_seconds", "HTTP 請求耗時", ["service", "path"],
)
只有 service 和 path 兩個標籤,沒有 status。 所以我沒辦法只算成功的請求。
要做到的話,得把 status 加進標籤裡。但是這件事不是免費的,而且代價比想像中大。
因為一個 histogram 不是一條序列,是一整組。 我的桶是這樣設的:
buckets=[0.01, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 5.0]
9 個桶,加上一個 Prometheus 自動補的 +Inf,再加上 _sum 和 _count 兩條,一組標籤組合就是 12 條序列。相比之下,checkout_total 那種 counter,一組標籤組合只有 1 條。
而且標籤之間是相乘不是相加。現在 gateway 有 /checkout 和 /healthz 兩個路徑,就是 2 × 12 = 24 條。加上 status 之後,假設會出現 200、422、500 三種,變成 2 × 3 × 12 = 72 條,一口氣翻了三倍。
72 條當然沒什麼,我的叢集跑在筆電上也撐得住。真正的問題是這個乘法在真實系統裡會失控:如果哪天有人把使用者 ID 加進標籤,一百萬個使用者就是一百萬乘以 12,一千兩百萬條序列。Prometheus 的記憶體大致跟「活躍序列數」成正比,這就是 Day 3 提過的 cardinality 爆炸。
所以「要不要多加一個標籤」永遠是一個取捨:多一個切面,乘上去的成本是整組桶。這個題目後面講指標設計的時候會專門談。
一、對 counter 直接取值
checkout_total # ✗ 一個沒意義的累計值
rate(checkout_total[5m]) # ✓
二、對 gauge 用 rate
rate(container_memory_working_set_bytes[5m]) # ✗
container_memory_working_set_bytes 是「這個容器現在用了多少記憶體」,用完會釋放,所以這個數字會上會下——這種就是 gauge。跟 counter 的差別在於,counter 只進不出(累計請求數不可能變少),gauge 是當下的狀態(記憶體、佇列長度、連線數)。
那怎麼知道一個指標是哪種? 看 /metrics 就好,Day 8 那張截圖裡每個指標上面都有一行 # TYPE:
# TYPE python_gc_objects_collected_total counter
# TYPE python_info gauge
還有一個慣例可以參考:counter 的名字通常以 _total 結尾,gauge 沒有這個習慣。
用錯的後果是這樣:rate 假設輸入只會增加,所以遇到數字下降時,它會判斷成「服務重啟、計數器歸零了」,然後把那段當成重置處理。記憶體本來就會一直上上下下,於是它會不斷誤判,算出一個完全沒有意義的數字。而且不會報錯,你會得到一條看起來很正常的線。
三、忘記 by (le)
histogram_quantile(0.95, sum(rate(x_bucket[5m]))) # ✗
histogram_quantile(0.95, sum(rate(x_bucket[5m])) by (le)) # ✓
錯的那行會發生什麼事?sum(...) 沒有 by (le) 的話,會把所有桶加成一個數字。原本「小於 0.1 秒的有 950 筆、小於 0.5 秒的有 990 筆⋯⋯」這個分布被壓成一個總和,分布就不存在了。
而 histogram_quantile 需要靠 le 標籤才能推算百分位數,拿到一個沒有 le 的東西,它算不出來,結果是空的。
注意:不是紅字錯誤,是圖上什麼都沒有。第一次遇到我以為是資料還沒進來,等了很久才發現是查詢寫錯。這三個錯的共同點也在這裡——沒有一個會告訴你哪裡錯了。
故障二已經打開了,現在跑這三行:
histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job="gateway", path="/checkout"}[5m])) by (le))
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="gateway", path="/checkout"}[5m])) by (le))
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job="gateway", path="/checkout"}[5m])) by (le))
我是在 16:00 左右打開故障的,三張圖都可以看到那個轉折點。
P50:

P95:

P99:

把三個數字排在一起看:
| 故障前 | 故障後 | 倍數 | |
|---|---|---|---|
| P50 | 0.075 秒 | 約 0.081 秒 | 1.08 倍 |
| P95 | 0.10 秒 | 約 0.82 秒 | 8 倍 |
| P99 | 0.10 秒 | 約 0.96 秒 | 10 倍 |
這就是長尾的形狀。 一半的使用者幾乎沒感覺,慢了 6 毫秒;但是最慢的那 1% 從 100 毫秒變成快一秒。
如果我只看平均值,會覺得系統「好像稍微變慢了一點」,完全不會警覺。
有一個細節值得說:P50 其實也動了一點,從 75 毫秒變成 81 毫秒。照理說 N+1 只影響超過 5 件的大購物車,也就是兩成的請求,P50 不該受影響才對。
我猜是這樣:那兩成的大購物車現在每筆要打 12 次 pricing,pricing 整體變忙了,所以連走批次路徑的小購物車也跟著慢了幾毫秒。故障會外溢,不會乖乖待在它該待的地方——這件事光看程式碼是想不到的。
另外注意這三張圖的 Y 軸:一張最大 0.083,另外兩張到 1.00。Prometheus 每張圖都自動縮放,所以三張分開看,反而看不出彼此的比例。這就是為什麼昨天要把三條線放進同一張 Grafana 面板——形狀要靠並排才看得出來。
不過看到形狀之後,我還是答不出為什麼——是哪個服務、哪一段慢。那要等 trace。
rate,gauge 絕對不要rate 是每秒速率,increase 是區間總量;時間視窗至少要抓取間隔的四倍histogram_quantile 記得 by (le),而且它算出來的是估算值明天講另外兩套方法論 USE 和 RED,以及它們跟四個黃金訊號的關係。這三套講的東西有一大半重疊,但是適用的對象不一樣。