從零 Go 經驗到在最受歡迎的 Kubernetes 終端機 UI 中合併 PR —— 我如何誤讀 HTTP/1.1 升級、弄壞自己的測試套件,以及學到在 Kubernetes 1.31 改變底層規則時,create 和 get 並不是同一個動詞。
這一切始於一個我沒寫的函式。
我加入 k9s 已經一週了,一直在試著理解這個擁有 58,000 顆星的專案是如何組織它的 Go 程式碼。我深入研究 internal/dao/port_forwarder.go,盯著一段檢查使用者是否有權在 Pod 上開啟 port-forward 的程式碼。檢查很簡單:這個服務帳號是否對 pods/portforward 有 create 權限?
我看過 Kubernetes 文件。port-forward 需要 create。大家都這麼說。程式也能運作。我就往下走了。
然後有人開了一個 issue:「在 k8s 1.31 搭配受限 RBAC 時,port-forward 失敗。」
Kubernetes 1.31 引入了 PortForwardWebsockets 功能 —— 一條使用 WebSockets 而不是 SPDY 來轉發連接埠的新程式路徑。而 WebSockets 不同於 SPDY,它只需要 pods/portforward 子資源上的 get 動詞。
問題就出在這裡。程式在檢查 create。新的 WebSocket 路徑需要 get。只有 get 權限的使用者無法在 k9s 中做 port-forward,儘管他們在 kubectl 上一切正常。
這就是我試著修這個問題、第一次失敗,並在三天內比三週教學還更深入理解 Go、HTTP 升級,以及 Kubernetes 授權的故事。
這不是教學。這是一次花了我四十八小時的兩行修正的事後驗屍。
我在工作上會用 kubectl port-forward。它能正常運作。它能透明處理 WebSocket 遷移。沒有 bug 回報。沒有邊界案例。沒有問題。
但 k9s 不是 kubectl。k9s 是一個包裝 client-go、並提供統一介面來操作 Pod 的終端機 UI。當 k9s 顯示一個 Pod,並讓你按 Shift-F 轉發連接埠時,它會先檢查你是否有權這麼做。而那個檢查是錯的。
這個問題不是學術上的。某個使用者有這樣的 RBAC 規則:
rules:
- apiGroups: [""]
resources: ["pods/portforward"]
verbs: ["get"]
在 Kubernetes 1.31+(WebSocket 路徑)上,他可以成功執行 kubectl port-forward,但 k9s 會悄悄把 port-forward 選項灰掉,以為他沒有權限。
那位使用者是真實存在的。這個 issue 有重現步驟。真實的叢集、真實的角色、真實的工作流程,卻被一個動詞不匹配搞壞了;如果我沒有讀 client-go 原始碼,我根本不會注意到。
┌─────────────────────────────────────────────────────────────┐
│ k9s TUI │
│ (tview / 終端機 UI) │
│ 使用者在 Pod 上按下 Shift-F │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ internal/dao/port_forwarder.go │
│ │
│ 1. 檢查 Pod 是否為 Running(就緒條件) │
│ 2. 授權:使用者是否可以 create pods/portforward? │
│ ▲ │
│ │ 這裡就是 bug │
│ 3. 若可以 → 開啟 port-forward 工作階段 │
│ 若不行 → 選項灰掉 / 顯示錯誤 │
└──────────────────────┬──────────────────────────────────────┘
│
┌────────┴────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ SPDY 路徑 │ │ WebSocket │
│(舊版) │ │(k8s 1.31+)│
│ 需要 │ │ 需要 │
│ CREATE 動詞 │ │ GET 動詞 │
└─────────────┘ └─────────────┘
│ │
└───────┬─────────┘
▼
┌─────────────────┐
│ K8s API Server │
│ /api/v1/.../ │
│ portforward │
└─────────────────┘
| 路徑 | 協定 | 所需動詞 | k9s 有檢查嗎? |
|---|---|---|---|
| 舊版 SPDY | SPDY/HTTP/1.1 升級 | pods/portforward 的 create |
✅ 有 |
| WebSocket | WebSocket(RFC 6455) | pods/portforward 的 get |
❌ 沒有 |
k9s 只檢查 create。它不知道 WebSocket 路徑需要 get。所以只有 get 權限的使用者被阻擋了,儘管他們對叢集實際使用的那條程式路徑是有授權的。
我的第一個修正蠢得可笑。
我找到了 port_forwarder.go 裡的函式。它建立了一個 SelfSubjectAccessReview,動詞是 "create",然後送到 API server。我把它改成 "get"。可以編譯。我開了一個 draft PR。
不到一小時,維護者就留言:「這會破壞仍然走 SPDY 路徑的叢集的相容性。」
我只是把一個硬編碼動詞換成另一個。我沒有檢查 SPDY。我沒有同時檢查兩者。我甚至沒想到 Kubernetes <1.31 的叢集,或是停用 WebSocket feature gate 的叢集。我假設「新的 = 正確」。
根本原因: 我把協定協商問題當成字串替換問題來處理。
修正方式: 程式需要分別檢查兩個動詞。如果 create 有授權,使用者就可以透過 SPDY 做 port-forward。如果 get 有授權,使用者就可以透過 WebSocket 做 port-forward。只要任一者有授權,k9s 就應允許這個操作。API server 和 client-go 會在連線時處理到底使用哪種協定。
// 我第一版寫的(錯誤 —— 只檢查 get)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
return errors.New("insufficient permission")
}
// 最終合併的版本(正確 —— 分別檢查 create 與 get)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
if !utils.CheckPodPortFwdGet(a.factory.Client(), a.factory.Config(), path) {
return errors.New("insufficient permission")
}
}
實際上,最後合併的程式碼更乾淨 —— 它新增了一個 CheckPodPortFwdGet helper,並在 port-forward 檢查時同時呼叫兩者。關鍵洞見是:create 和 get 並不是互相取代的替代選項,它們是針對不同協定路徑的獨立能力。兩者可以共存,誰也不能刪掉。
教訓: 當一個平台支援多種協定時,針對新協定的修正不能拿掉舊協定的支援。要測試共存,不是替換。
t.Parallel()k9s 有完整的測試套件。不是玩具測試 —— 是跨多種情境、約 50 個測試案例的 table-driven tests。我加了一個測試來涵蓋新的 WebSocket 路徑。它在我本機跑過了。
然後 CI 失敗了。
錯誤是 mock client 的 data race。我把共用的 mock RestClient 建在測試迴圈外面,好省去一些設定程式碼。有些測試用了 t.Parallel()。兩個平行測試同時修改了同一個 mock 的回應狀態。
race detector(go test -race)抓到了它,並讓建置失敗。
根本原因: 我以為共用 mock 設定比較有效率。其實我是在一個我不完全理解的測試檔裡製造並行危險。
修正方式: 我把每個測試案例都重構成在測試 closure 內建立自己的 RestClient mock。不共享狀態。我最後新增的測試檔有 223 行 —— 幾乎全都是針對各種權限組合的測試案例:
get 權限,也沒有 create 權限 → 阻擋create 權限 → 允許(舊版 SPDY)get 權限 → 允許(WebSocket 路徑,也就是這次修正)// 我最後寫出的 223 行測試的一部分
{
name: "get-only-portforward-allowed",
pod: runningPod,
authorized: map[string]bool{
"selfsubjectaccessreviews": true,
"pods": true,
"portforwardget": true,
},
want: true,
},
教訓: 平行測試不是免費的。如果你不擁有整個測試基礎架構,就先假設不能共用狀態,直到你證明可以為止。測試中的 data race 會成為履歷污點。
真正的 bug 比動詞字串更深。我需要理解為什麼 get 足以用於 WebSockets,卻不適用於 SPDY。
SPDY(舊協定)是透過對 port-forward endpoint 發出 HTTP POST 請求來啟動的。HTTP POST 在 Kubernetes 授權中對應 create 動詞。所以 SPDY port-forward 需要 create。
WebSockets 的啟動方式不同。它們從一個 HTTP GET 請求開始,並帶有 Upgrade: websocket 標頭。伺服器回應 101 Switching Protocols,連線便成為 WebSocket。
因為初始請求是 GET,Kubernetes 授權就會把它對應到 pods/portforward 子資源上的 get 動詞。
我在開這個 issue 的時候完全不知道這些。我花了一個晚上讀:
client-go 裡 StreamWithContext 的原始碼PortForwardWebsockets 的 KEP(KEP-4006)k9s 自己的 issue 歷史,看它如何處理 client-go 的 deprecation根本原因: 我以為 port-forward 是「一個 API 呼叫」。其實它是一場 client-go、API server 與 kubelet 之間的協定協商舞步。授權模型取決於協定。
修正方式: 我沒有改變修正邏輯。只要同時檢查兩個動詞,程式就正確了。但我確實更新了錯誤訊息,讓使用者知道兩種可接受的動詞,這樣被擋下來的人就能清楚知道自己需要哪些權限:
return fmt.Errorf("user is not authorized to create or get portforward %q", path)
教訓: 在分散式系統裡修 bug 之前,先理解它所建立在其上的協定。症狀是缺少動詞,原因是協定升級。如果修正不理解協定,就會破壞舊路徑。
| 統計專案 | 數值 |
|---|---|
| 變更檔案數 | 2 |
| 新增行數 | 240 |
| 刪除行數 | 3 |
| 修改檔案 | internal/dao/port_forwarder.go, internal/dao/port_forwarder_test.go |
| 新增測試 | 6 個測試案例,涵蓋所有權限組合 |
| Review 輪次 | 2 |
| 從第一版到合併的時間 | 48 小時 |
差異很小。邏輯很簡單。真正費工的部分,是理解為什麼這段邏輯非存在不可,以及用測試證明它,讓別人不會犯下我第一版的錯誤。
PR 標題: fix(dao): allow port-forward with 'get' verb on pods/portforward for K8s 1.31+ WebSocket path
它乾淨地合併了。沒有後續修正。
在我動任何檔案之前,我把原始 issue(#4144)看了三遍。回報者提供了 Kubernetes 版本、RBAC 角色,以及完整錯誤訊息。沒有那份重現資訊,我絕對不會理解 WebSocket 遷移的脈絡。
port_forwarder_test.go 教會我的 k9s 授權機制,比 port_forwarder.go 還多。測試是不能說謊的文件。
go test -race 不是可選項race detector 在維護者看到之前就抓到我的 bug。它把一個尷尬的 review 留言,變成我自己的 CI 失敗。在本機先跑它。永遠都要。
正式環境的變更大約 20 行。測試有 223 行。這就是 Go 的生產現實。如果你的修正沒有一個在修正前會失敗的測試,那你的修正還沒完成。
我的第一版只在 Kubernetes 1.31 上可用。它會讓仍在 1.30 或更舊版本上的一半使用者無法使用 k9s。真正的修正必須同時支援兩條路徑。這就是「在我機器上可以」和「已經合併進 k9s」的差別。
client-go 原始碼與撰寫 table-driven tests 來學 Go,而不是做 TodoMVC。這才是我正在建立的訊號。
我會繼續為 k9s 貢獻。這個程式碼庫夠複雜,還能持續教我很多事 —— 像是 tview 如何渲染終端機 UI、k9s 的 watcher 如何避免輪詢 API server、以及 dao 層如何抽象 client-go 操作。
我今天也為 Checkov 做了貢獻 —— 補上 Cloud SQL 與 GKE 叢集缺少的 GCP 可標記資源。小 PR。兩行。20 分鐘就合併。這就是建立肌肉後會發生的事:第二次貢獻會比第一次更快。
我三週前開始學 Go。我在 k9s 中發現一個 bug:port-forward 授權只檢查 create 動詞,卻漏掉了 Kubernetes 1.31 新 WebSocket 路徑所需的 get 動詞。
我第一版修正是錯的 —— 我把 create 換成 get,差點破壞仍使用舊 SPDY 叢集的向下相容性。最後的修正是分別檢查兩個動詞。
我寫了 223 行 table-driven tests,覆蓋每一種權限組合。PR 在 48 小時內合併。我測試裡的 data race 教會我,平行測試不能共用 mock 狀態。
如果你正在學一門語言,不要只做教學專案。去真實專案裡找一個真實 bug 並修掉它。測試套件會教你比任何課程都多。
你的第一個開源貢獻故事是什麼?留言分享吧——尤其是如果你也曾把測試套件弄壞,還活著把故事講出來。
原文出處:https://dev.to/le_beltagy/i-learned-go-in-3-weeks-yesterday-my-code-merged-into-k9s-2dg4