| 元件 | 版本 |
|---|---|
| Kubernetes | v1.35 |
| Gateway API | v1.5(Experimental channel) |
| Envoy Gateway | v1.8.0 |
| 測試後端 | Day 3 部署的 whoami-v1 / whoami-v2(各 2 副本) |
金絲雀部署在 Ingress 時代是 annotation 的重災區。nginx 要三個 annotation 互相配合(canary、canary-weight、canary-by-header),而且得建立第二個 Ingress 物件去「影子覆蓋」第一個;Traefik 又是另一套 CRD。設定散在兩個資源上、語意靠註解字串表達,改錯了沒有任何東西會告訴你。
Gateway API 把它變成一個 weight 欄位。今天要驗證四件事:
weight: 0 到底代表什麼,以及全部為 0 時會怎樣先把規格看清楚:
kubectl explain httproute.spec.rules.backendRefs.weight
Weight specifies the proportion of requests forwarded to the referenced
backend. This is computed as weight/(sum of all weights in this
BackendRefs list). ... Weight is not a percentage and the sum of
weights does not need to equal 100.
If only one backend is specified and it has a weight greater than 0, 100%
of the traffic is forwarded to that backend. If weight is set to 0, no
traffic should be forwarded for this entry. If unspecified, weight
defaults to 1.
三個要點:
weight / 所有 weight 的總和,不是百分比,總和不必是 100weight: 0 = 這個後端不吃流量
weight 預設是 1——所以兩個後端都不寫權重就是 50/50,這是很多人沒注意到的預設值為了讓「不是百分比」這件事本身可被驗證,實驗刻意不寫 90:10,而寫 45:5:
# day08/route-canary.yaml
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- name: whoami-v1
port: 80
weight: 45
- name: whoami-v2
port: 80
weight: 5
45/(45+5) = 90%。如果實作把 weight 當百分比看,這組設定會得到完全不同的結果。
for i in $(seq 1 200); do
curl -s -H "Host: canary.localhost" 127.0.0.1/ | grep -m1 '^Name:'
done | sort | uniq -c
181 Name: whoami-v1
19 Name: whoami-v2
9.5%。目標 10%、200 次取樣,落在合理抖動內。規格對此也有明文:「For non-zero values, there may be some epsilon from the exact proportion」——不保證精確,只保證比例。
這一點在寫驗證腳本時很重要:斷言必須寫成區間,不能寫成等於。本篇的腳本用的是 10% ±5 個百分點。
weight: 0 的兩種情境kubectl -n demo patch httproute canary --type=json \
-p='[{"op":"replace","path":"/spec/rules/0/backendRefs/1/weight","value":0}]'
打 100 次:
100 Name: whoami-v1
0 Name: whoami-v2
一次都沒有。這是金絲雀「踩煞車」的正確做法——把新版權重歸零,而不是把 backendRefs 刪掉。留著條目的好處是隨時可以再調上去,而且 ResolvedRefs 仍會持續監看那個 Service 存不存在。
規格只寫了「單一條目為 0 不吃流量」,沒有明文規定全部為 0 會怎樣。這是規格的留白,實測才知道:
kubectl -n demo patch httproute canary --type=json \
-p='[{"op":"replace","path":"/spec/rules/0/backendRefs/0/weight","value":0}]'
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: canary.localhost" 127.0.0.1/
500
Envoy Gateway 回 500。這算是合理的處置——沒有任何後端可送,錯誤碼明確——但它是實作的選擇,不是規格保證。換一個實作可能回 503,也可能把「全部為 0」當成「都沒設」而平均分配。
實務上的教訓是:別用「把所有權重歸零」當作下線手段。要下線就把 Route 刪掉或改成 RequestRedirect,行為才是規格定義的。
權重分流是隨機的,同一個使用者這次看新版、下次看舊版。要讓「內部同事一律看新版」,得靠標頭匹配——而這直接吃 Day 6 那條裁決階梯:
# day08/route-ab.yaml
rules:
# 保底規則刻意寫在前面
- matches:
- path: { type: PathPrefix, value: / }
backendRefs: [{ name: whoami-v1, port: 80 }]
- matches:
- path: { type: PathPrefix, value: / }
headers:
- { name: X-Canary, value: "true" }
backendRefs: [{ name: whoami-v2, port: 80 }]
兩條規則的 path 都是 /,第 2 階平手;帶 header 的那條在第 4 階(header 匹配數量)勝出。保底規則寫在前面正是為了證明這一點——如果靠的是列表順序,帶 header 的請求就會被第一條攔走。
curl -s -H "Host: ab.localhost" 127.0.0.1/ | grep '^Name:'
curl -s -H "Host: ab.localhost" -H "X-Canary: true" 127.0.0.1/ | grep '^Name:'
Name: whoami-v1
Name: whoami-v2
一般使用者穩定落在舊版,帶標頭的穩定落在新版。實務上這個標頭通常由前一層(登入閘道、CDN)依使用者身分注入,而不是讓用戶端自己帶——否則誰都能把自己切到新版。
「切換過程不掉封包」這種宣稱只能用數據講話。做法是背景持續打、前景做切換,最後統計非 200 的次數:
# 背景持續打 25 秒,記錄每一個狀態碼
( END=$((SECONDS+25))
while [ $SECONDS -lt $END ]; do
curl -s -o /dev/null -m 5 -w '%{http_code}\n' -H "Host: canary.localhost" 127.0.0.1/
done > /tmp/poll.log ) &
sleep 5
# 切換:舊版歸零、新版接手
kubectl -n demo patch httproute canary --type=json \
-p='[{"op":"replace","path":"/spec/rules/0/backendRefs/0/weight","value":0},
{"op":"replace","path":"/spec/rules/0/backendRefs/1/weight","value":1}]'
wait
wc -l < /tmp/poll.log # 總請求數
grep -vc '^200$' /tmp/poll.log # 非 200 的數量
4408
0
4408 個請求,零個非 200。 切換完成後再取樣 50 次,全部落在新版。
會零中斷是因為這件事從頭到尾沒有動到任何 Pod——沒有滾動更新、沒有 Pod 重啟、沒有連線被切斷。改的只是資料平面的路由權重,Envoy 熱套用新設定,既有連線照常走完。這是 Day 5 結尾那段「watch API → 翻譯 → 熱套用」的直接後果。
對照 Ingress 時代的做法:改 annotation 之後 nginx controller 會重載設定檔,reload 期間的連線處理取決於 controller 版本與設定,這正是當年金絲雀切換要挑離峰時段做的原因。
這是今天最有價值的一段。規格對「部分後端無效」寫得非常具體:
When a HTTPBackendRef is invalid, 500 status codes MUST be returned for
requests that would have otherwise been routed to an invalid backend. If
multiple backends are specified, and some are invalid, the proportion of
requests that would otherwise have been routed to an invalid backend
MUST receive a 500 status code.
For example, if two backends are specified with equal weights, and one is
invalid, 50 percent of traffic must receive a 500.
MUST,而且連比例都指定了。 那就照著擺一組:
# day08/route-broken.yaml
backendRefs:
- name: whoami-v1
port: 80
weight: 1
- name: no-such-service # 不存在
port: 80
weight: 1
for i in $(seq 1 100); do
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: broken.localhost" 127.0.0.1/
done | sort | uniq -c
50 200
50 500
精準命中規格的例子。(重跑一次是 44/56,同樣落在隨機分配的合理範圍。)
同時 Route 的狀態也說清楚了原因:
ResolvedRefs = False reason = BackendNotFound
這個設計值得停下來想一下:壞掉的那一半不會被靜默地導到好的那一半。 如果實作「聰明地」把流量全導到活著的後端,你會看到 100% 的 200,然後在完全不知情的狀況下跑一整週——直到某天發現金絲雀從來沒收到過流量。規格選擇讓錯誤可見,代價是那 50% 的請求真的會失敗。
這與上面是不同的錯誤。把新版縮到 0 副本:
kubectl -n demo scale deploy whoami-v2 --replicas=0
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: canary.localhost" 127.0.0.1/
503
規格對此的用字是 SHOULD:「When a HTTPBackendRef refers to a Service that has no ready endpoints, implementations SHOULD return a 503」。Envoy Gateway 照做了。
差別在狀態欄位上更清楚:
kubectl -n demo get httproute canary -o json \
| jq -r '.status.parents[0].conditions[] | "\(.type)=\(.status) reason=\(.reason)"'
Accepted=True reason=Accepted
BackendsAvailable=False reason=EndpointsNotFound
ResolvedRefs=True reason=ResolvedRefs
ResolvedRefs 仍然是 True——Service 這個參考本身完全正確,錯的是它背後沒有 Pod。這兩件事被分開表達了:
| 狀況 | ResolvedRefs | 狀態碼 | 該去修什麼 |
|---|---|---|---|
| Service 不存在 | False / BackendNotFound |
500 | YAML 打錯字、namespace 錯 |
| Service 在、沒有 Pod | True |
503 | Deployment 沒起來、副本數為 0 |
BackendsAvailable 不在 Gateway API 規格的核心 condition 之列(安裝的 CRD 裡查不到這個字),是 Envoy Gateway 額外提供的資訊。能用就用,但別把跨實作的除錯流程建立在它上面。