昨天叢集開好了,今天要往裡面放東西。
我們要建立一個壞掉的服務,但是不會壞的太明顯。
沒有一個故障是「服務掛掉」。
服務掛掉其實是最好查的,Pod 會變紅、kubectl get pod 一眼就看到,根本用不到可觀測性。真實世界裡難查的全部都是另外一種:還活著,但出問題了。這三個故障就是照這個原則設計的。
完整的原始碼放在 GitHub 上,這篇只貼關鍵的片段。
三個小程式,加上一支假裝成客人的機器人:
loadgen ──▶ gateway ──▶ catalog ──▶ pricing
(機器人) (櫃台) (倉庫) (收銀)
/checkout 請求這四個名字都是我自己取的,不是 Kubernetes 內建的東西。前面三個是用 Python 的 FastAPI 寫成的網頁服務,每一支大概 80 行;loadgen 不是網頁服務,就是一支開著不停送請求的程式。
為什麼要拆成三個? 因為 trace 解決的問題是「一個請求跨了好幾個程式,到底慢在哪一個」。只有一支程式的話就沒有「跨」這件事,也就示範不出 trace 的價值。
為什麼需要 loadgen? 因為可觀測性是在看資料,沒有人使用我們的服務就沒有資料,所有圖表都會是一條平的線。所以要有個機器人一直在買東西,它每秒送 10 筆請求,購物車大小是隨機的。這個隨機等一下會很重要。
每個服務都要建立兩個資源:
為什麼要有 Service 這一層?因為 Pod 是會死的,重啟一次 IP 就變了。如果我在 catalog 裡面寫死 pricing 的 IP,對方一重啟就找不到人了。有了 Service,程式只要呼叫 http://pricing,Kubernetes 會自己去找當下的 Pod 在哪裡。
這一層之後裝監控的時候特別重要,因為監控是掛在 Service 上、不是掛在 Pod 上的,所以之後把服務擴成三份,監控會自己跟上。
情境:部分商品的價格沒有套用折扣。HTTP 狀態碼 200、速度正常、沒有任何錯誤訊息。
怎麼埋的:pricing 去查折扣規則的時候,某些商品 ID 查不到會丟出例外。我把它接住,記一行警告,然後回傳「沒有折扣」繼續跑。
try:
discount = RULES[product_id]
except KeyError:
# ↓↓↓ 故障一:接住例外、記一行 WARN、回傳 0 折扣繼續跑 ↓↓↓
log.warning("discount rule not found", product_id=product_id, category=category)
discount_missing_total.labels(category).inc()
discount = 0
return {"product_id": product_id, "category": category,
"price": round(base * (1 - discount), 2), "discount": discount}
我覺得這幾行是整個系列最重要的片段,因為它完全沒有 bug 的樣子,例外被接住了、有記 log、程式繼續跑,看起來甚至還很穩健。
問題出在「查不到規則」跟「這個商品沒有折扣」是兩件不同的事,但是在資料上長得一模一樣,都是 0。折扣是 0 是正常狀態,規則不見了是異常狀態,而這行程式碼把後者當成前者處理掉了。使用者付了原價,系統覺得一切正常。
而且會這樣寫其實是有道理的。如果不接住這個例外,一筆折扣規則沒建好就會讓整個結帳掛掉,這在 code review 一定會被要求改,「不要因為查不到折扣就讓使用者結不了帳」。所以這個寫法在可用性上是對的,在正確性上是錯的,而通常不會有人替後者寫測試。
我在50 個商品裡面我讓 2 個查不到規則,以商品計是 4%。但是購物車平均有 4.2 件,所以算下來大概有 16% 的訂單會受影響。
情境:/checkout 的 P95 延遲惡化,但是 P50 完全不動,錯誤率是 0。
怎麼埋的:catalog 在購物車商品數超過 5 件的時候,會走一條沒有批次查詢的路,對每一件商品各發一次請求給 pricing。十二件商品就是十二次網路往返。
if BUG_N_PLUS_ONE and n > N_PLUS_ONE_THRESHOLD:
# ↓↓↓ 故障二:N+1,每件商品各發一次請求 ↓↓↓
priced = []
for pid in cart.items:
r = await client.get(f"{PRICING_URL}/price", params={"product_id": pid})
priced.append(r.json())
else:
r = await client.post(f"{PRICING_URL}/prices", json={"product_ids": cart.items})
priced = r.json()["items"]
這個模式有個名字叫 N+1 查詢,是效能問題裡面最常見的一種:本來一次就能拿完的資料,變成在迴圈裡打 N 次。
這裡有一個我實作的時候才想通的地方。
N+1 在真實世界之所以痛,不是因為多打了幾次網路,而是因為每一次呼叫背後都有成本,通常是一次資料庫查詢。批次查詢只付一次這個成本,逐一查詢要付十二次。
但是在筆電上,三個服務都跑在同一台機器裡面,呼叫一次快到幾乎不用錢,十二次也只比一次慢個幾毫秒。故障是有的,只是量太小,P95 跟 P50 會擠在同一個桶裡,圖表上什麼都看不到。
所以我在 pricing 每次查折扣規則的地方加了 60 毫秒的延遲,用 DB_LATENCY_MS 這個環境變數控制,去模擬那一次資料庫查詢。這樣一台十二件商品的購物車,走 N+1 的路就是十二次乘以 60 毫秒、大概 700 毫秒,走批次只要付一次 60 毫秒。差距拉開了,長尾才看得出來。
還記得剛剛說 loadgen 的購物車大小是隨機的嗎?這裡要補一個細節,它不是均勻隨機,而是偏斜的:
size = random.randint(1, 5) if random.random() < 0.8 else random.randint(6, MAX_CART)
八成是 1 到 5 件的小車,兩成才是 6 到 12 件的大車。這是刻意的,也是真實電商的形狀。如果購物車大小平均分布,那一半的請求都會變慢,那就不叫長尾了,而是全面性的效能問題。長尾的定義本來就是「少數請求受影響」,所以流量的形狀必須對。
於是我們在圖表上看到的不會是全面變慢,而是一小撮特別慢的請求把 P95 拉高,平均值卻沒什麼變化。這剛好就是前面說「平均值會騙人」的實例。
情境:pricing 的記憶體用量持續上升,超過上限之後被 Kubernetes 強制終止,這件事叫做 OOMKilled(Out Of Memory Killed),然後 Pod 自動重啟,一切正常,過一段時間再來一次。
怎麼埋的:pricing 用一個字典當快取,但是從來不清掉舊資料。
_cache: dict = {} # 從不淘汰
_seq = itertools.count() # 保證每次 key 都不同
def _maybe_leak():
if LEAK_KB_PER_REQUEST > 0:
_cache[next(_seq)] = "x" * (LEAK_KB_PER_REQUEST * 1024)
每處理一件商品就往字典裡塞一塊資料,而且 key 保證不重複,所以這個字典只會愈長愈大,這就是記憶體洩漏。
這裡我要老實講一件事:真實的記憶體洩漏是慢慢累積的,可能要跑上一整天才會爆一次,但是我沒辦法在寫文章那天等一整天。所以洩漏的速度我做成參數 LEAK_KB_PER_REQUEST,示範的時候調快,讓它幾十分鐘就爆一次。
這種故障難在哪裡?難在它會自己「好」。Pod 一重啟記憶體就歸零,圖表看起來完全正常,等你下次注意到又是好幾個小時之後了。一個會自己復原的問題,通常沒有人會認真追。
| 故障 | 使用者感受 | Pod 狀態 | 錯誤率 |
|---|---|---|---|
| 一:算錯價 | 多付錢,但是不會發現 | 正常 | 0 |
| 二:N+1 | 偶爾很慢 | 正常 | 0 |
| 三:記憶體洩漏 | 幾乎無感 | 偶爾重啟 | 短暫升高 |
三個裡面最「明顯」的第三個,用 kubectl get pod 也只看得到一個 RESTARTS 數字在漲,看不出為什麼會漲。
三個故障都做成可以開關的,用環境變數控制:
env:
- name: BUG_SILENT_DISCOUNT # 故障一
value: "false"
- name: BUG_N_PLUS_ONE # 故障二
value: "false"
- name: LEAK_KB_PER_REQUEST # 故障三,0 表示關閉
value: "0"
不做開關的話,後面幾天在講指標的時候系統會一直是壞的,圖表很亂、不好講解。之後每一篇開頭我都會寫明那天開了哪幾個。
三個服務共用一份 Dockerfile,因為它們的結構一樣,只有要啟動哪一支不同:
FROM python:3.12-slim
WORKDIR /app
ARG SERVICE # build 的時候用 --build-arg 傳進來
ENV SERVICE_NAME=${SERVICE}
# 先裝套件再複製程式碼,這樣改程式碼的時候不用重裝套件,build 會快很多
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY common.py .
COPY ${SERVICE}/ ./${SERVICE}/
EXPOSE 8000
CMD ["sh", "-c", "python -m uvicorn ${SERVICE_NAME}.main:app --host 0.0.0.0 --port 8000"]
要注意最後那兩行 COPY 的順序。common.py 是三個服務共用的,裡面放 log 跟指標的設定,所以 build 的位置必須是專案根目錄,不是各服務的子資料夾。我一開始寫成 docker build ./gateway,結果 common.py 不在建置範圍裡面,容器一啟動就 ModuleNotFoundError。
loadgen 不是網頁服務,是一支一直跑的程式,啟動指令不一樣,所以另外給一份 Dockerfile.loadgen。
# 1. build 四個映像檔(注意最後那個「.」,build 的位置是專案根目錄)
docker build --build-arg SERVICE=pricing -t pricing:0.1 .
docker build --build-arg SERVICE=catalog -t catalog:0.1 .
docker build --build-arg SERVICE=gateway -t gateway:0.1 .
docker build -f Dockerfile.loadgen -t loadgen:0.1 .
docker images | grep 0.1 # 確認四個都在
# 2. 載進 kind 叢集
kind load docker-image pricing:0.1 catalog:0.1 gateway:0.1 loadgen:0.1 --name obs
docker exec obs-worker crictl images | grep 0.1 # 確認節點裡真的有了
# 3. 部署
kubectl apply -f k8s/
kubectl get pods
第二步是 kind 特有的,也是最容易漏掉的一步。我第一次就是直接跳到第三步,結果長這樣:

四個 Pod 全部卡在 ErrImagePull,等一下會變成 ImagePullBackOff(重試失敗之後會愈等愈久才重試,最久到五分鐘)。但是 get pods 只給我們一個狀態字串,看不出原因,要用 describe 才挖得到:
kubectl describe pod -l app=pricing | tail -20
Events 最後幾行才是重點:
Failed to pull image "pricing:0.1": failed to resolve reference
"docker.io/library/pricing:0.1": pull access denied, repository does not exist
看 docker.io/library/ 這一段。我寫的明明只是 pricing:0.1,但是 Kubernetes 自己在前面補上了 Docker Hub 的位址,然後跑去那裡找一個根本不存在的東西。因為 kind 的節點跟我們的本機是兩個分開的世界,我們 build 出來的映像檔只存在本機,節點裡面沒有,kind load 就是把它搬進去的那一步。
補上 kind load 之後,直接 kubectl delete pod --all 讓它們重新來過就好,不用等它自己重試。
配套是 Deployment 裡面要寫 imagePullPolicy: IfNotPresent,意思是本機有就別出去找。這個欄位的預設值在 tag 是 latest 的時候會變成 Always,那就又跑出去了,所以永遠不要用 latest 當 tag。
上面那張圖裡,kubectl apply 除了建立資源之外,還噴了三次這個:
no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"
ensure CRDs are installed first
這個是正常的,現在不用理它。ServiceMonitor 不是 Kubernetes 內建的資源類型,它是 Prometheus Operator 自己發明的,這種「原本沒有、由 Operator 帶進來」的類型叫做 CRD(Custom Resource Definition,自訂資源定義)。我們還沒裝 Operator,所以 Kubernetes 根本不認得這個字,只能跟我們說先把 CRD 裝好。我把 servicemonitors.yaml 先放在 k8s/ 裡面,是因為它跟服務本身是一組的,等裝完 Operator 那天再套用一次就會生效。
另外這裡還看得到一件事:kubectl apply 不是全有全無的。七個資源建起來了、三個失敗,它照樣一路做完,只是把失敗的印出來。所以下完 apply 不能只看有沒有跳錯,要真的去 get 一次確認結果。
port-forward 會佔住終端機,所以要開兩個視窗。第一個:
kubectl port-forward svc/gateway 8080:80
第二個:
curl -s localhost:8080/checkout \
-H 'content-type: application/json' \
-d '{"items":["P001","P002","P003"]}'
那個 -H 'content-type: application/json' 不能省,少了它 FastAPI 會回 422,而且訊息不太好懂。

四個 Pod 都是 Running、RESTARTS 是 0,然後 /checkout 回了一筆完整的帳單。
要看的是每一筆的 discount。三筆都是 0.1,代表折扣規則有正常套用,也就是三個故障現在都還關著,這是我們的基準狀態。之後每次打開故障,都是拿這張輸出來對照。
而這一筆請求其實跨了三個 Pod:gateway 收到之後轉給 catalog,catalog 再去問 pricing 要價格,而這三個 Pod 可能被排在不同的節點上。從外面看只是一次 curl,中間發生什麼事完全看不到——這就是後面要用 trace 解決的問題。
今天叢集裡有了一個會壞的系統,以及三個埋好的故障:
明天我會把故障全部打開,然後只用 kubectl 去查,看看能查到哪裡為止。先預告結論:查不出來。
完整原始碼在這裡:https://github.com/WIse4466/k8s-observability-demo