我用三週學會 Go。昨天,我的程式碼被合併進 k9s。
從零 Go 經驗到最受歡迎的 Kubernetes 終端機 UI 合併 PR — 我如何誤讀 HTTP/1.1 升級、破壞自己的測試套件,以及學習到當 Kubernetes 1.31 在你腳下改變規則時,create 和 get 並非同一個動詞。
背景設定
一切始於一個我並未撰寫的函式。
我已為 k9s 貢獻一週,試圖理解這個擁有 58,000 顆星的專案如何組織其 Go 程式碼。我深入 internal/dao/port_forwarder.go,盯著一段檢查使用者是否有權開啟 pod 端口轉發的程式碼區塊。檢查很簡單:此服務帳戶是否對 pods/portforward 擁有 create 權限?
我已閱讀 Kubernetes 文件。端口轉發需要 create。大家都這麼說。程式碼正常運作。我繼續前進。
然後有人開了一個 issue:「在 k8s 1.31 與受限 RBAC 下,端口轉發失敗。」
Kubernetes 1.31 引入 PortForwardWebsockets 功能 — 一條新的程式碼路徑,透過 WebSocket 而非 SPDY 來轉發端口。而 WebSocket 與 SPDY 不同,只需要 pods/portforward 子資源的 get 動詞。
那就是錯誤所在。程式碼檢查的是 create。新的 WebSocket 路徑需要 get。只有 get 存取權的使用者無法在 k9s 中進行端口轉發,儘管 kubectl 對他們來說運作正常。
這是我試圖修復它、第一次失敗,並在三天內學到比三週教學更多的 Go、HTTP 升級和 Kubernetes 授權的過程。
這不是教學。這是一個耗時四十八小時的兩行修復的解剖。
為什麼不只使用 kubectl?
我專業地使用 kubectl port-forward。它運作正常。它透明地處理 WebSocket 遷移。沒有錯誤報告。沒有邊緣案例。沒有問題。
但 k9s 不是 kubectl。k9s 是一個包裝 client-go 並為 pod 互動呈現統一介面的終端機 UI。當 k9s 顯示一個 pod 並讓你按 Shift-F 來轉發端口時,它首先會檢查你是否被允許這麼做。而那個檢查是錯誤的。
這個問題並非學術性的。一個擁有此 RBAC 角色的使用者:
rules:
- apiGroups: [""]
resources: ["pods/portforward"]
verbs: ["get"]
進入全螢幕模式 退出全螢幕模式
可以在 Kubernetes 1.31+ 上成功執行 kubectl port-forward(WebSocket 路徑),但 k9s 會靜默地將端口轉發選項變灰,認為他們缺少權限。
那個使用者是真實存在的。這個 issue 有重現步驟。一個真實的叢集、一個真實的角色、一個因我若不閱讀 client-go 原始碼就永遠不會注意到的動詞不匹配而中斷的真實工作流程。
k9s 架構
┌─────────────────────────────────────────────────────────────┐
│ k9s TUI │
│ (tview / terminal UI) │
│ 使用者按下 pod 上的 Shift-F │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ internal/dao/port_forwarder.go │
│ │
│ 1. 檢查 pod 是否正在執行(準備就緒閘道) │
│ 2. 授權:使用者可以建立 pods/portforward 嗎? │
│ ▲ │
│ │ 這就是錯誤所在 │
│ 3. 如果是 → 開啟端口轉發工作階段 │
│ 如果不是 → 將選項變灰 / 顯示錯誤 │
└──────────────────────┬──────────────────────────────────────┘
│
┌────────┴────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ SPDY 路徑 │ │ WebSocket │
│ (舊版) │ │ (k8s 1.31+) │
│ 需要 │ │ 需要 │
│ CREATE 動詞 │ │ GET 動詞 │
└─────────────┘ └─────────────┘
│ │
└───────┬─────────┘
▼
┌─────────────────┐
│ K8s API 伺服器 │
│ /api/v1/.../ │
│ portforward │
└─────────────────┘
進入全螢幕模式 退出全螢幕模式
我錯過的決定
| 路徑 | 協定 | 所需動詞 | k9s 已檢查? |
|---|---|---|---|
| 舊版 SPDY | SPDY/HTTP/1.1 升級 |
create on pods/portforward
|
✅ 是 |
| WebSocket | WebSocket (RFC 6455) |
get on pods/portforward
|
❌ 否 |
k9s 只檢查 create。它不知道 WebSocket 路徑的 get 需求。因此,即使 get-only 使用者被授權使用其叢集實際使用的程式碼路徑,也會被阻擋。
失敗(以及每個失敗教會我的事)
失敗 1:我更改了一個字就收工了
我的第一次修復令人難堪地天真。
我在 port_forwarder.go 中找到該函式。它使用動詞 "create" 建立一個 SelfSubjectAccessReview 並將其傳送至 API 伺服器。我將其更改為 "get"。它編譯通過。我開了一個草稿 PR。
一小時內,一位維護者評論道:「這破壞了仍在 SPDY 路徑上的叢集的向後相容性。」
我已用另一個硬編碼動詞取代一個。我沒有檢查 SPDY。我沒有檢查兩者。我甚至沒有考慮執行 Kubernetes <1.31 的叢集,或 WebSocket 功能閘道已停用的叢集。我假設「新的 = 正確的」。
根本原因:我將協定協商問題視為字串替換問題。
修復:程式碼需要獨立檢查兩個動詞。如果 create 被授權,使用者可以透過 SPDY 進行端口轉發。如果 get 被授權,他們可以透過 WebSocket 進行端口轉發。如果任一被授權,k9s 應允許該操作。API 伺服器和 client-go 會在連線時處理實際使用哪種協定。
// 我第一次寫的 (錯誤 — 只檢查 get)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
return errors.New("insufficient permission")
}
// 合併的版本 (正確 — 獨立檢查 create AND 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 輔助函式,並從端口轉發檢查中呼叫兩者。關鍵洞察:create 和 get 並非互相取代的替代方案。它們是獨立協定路徑的獨立功能。兩者可以共存。兩者都不能被捨棄。
教訓:當一個平台支援多種協定時,為新協定提供的修復不能刪除對舊協定的支援。測試共存,而不是取代。
失敗 2:因為我不了解 t.Parallel(),我搞砸了測試套件
k9s 有一個真正的測試套件。不是玩具測試 — 是帶有約 50 個測試案例的表驅動測試,涵蓋多種情境。我新增了一個涵蓋新 WebSocket 路徑的測試。它在本地通過。
然後 CI 失敗了。
錯誤是模擬用戶端的資料競爭。我在測試迴圈外實例化一個共享的模擬 RestClient 以節省設定程式碼。有些測試以 t.Parallel() 執行。兩個平行測試同時變更了同一個模擬的回應狀態。
競爭偵測器 (go test -race) 捕捉到它並導致建置失敗。
根本原因:我認為透過共享模擬設定來提高效率。實際上,我在一個我並未完全理解的測試檔案中製造了並行風險。
修復:我重構每個測試案例,使其在測試閉包內建立自己的 RestClient 模擬。沒有共享狀態。我新增的測試檔案最終有 223 行 — 幾乎都是針對每種權限組合的測試案例:
- Pod 未執行
- 無
get權限,無create權限 → 阻擋 -
create-only 權限 → 允許(舊版 SPDY) -
get-only 權限 → 允許(WebSocket 路徑,這是修復) - 兩種權限皆有 → 允許
// 我最終寫的 223 行測試的一部分
{
name: "get-only-portforward-allowed",
pod: runningPod,
authorized: map[string]bool{
"selfsubjectaccessreviews": true,
"pods": true,
"portforwardget": true,
},
want: true,
},
進入全螢幕模式 退出全螢幕模式
教訓:平行測試並非免費。如果你不擁有測試基礎設施,假設共享狀態是被禁止的,直到證明相反為止。測試中的 data race 是一種履歷汙點。
失敗 3:我不知道什麼是 HTTP/1.1 升級
真正的錯誤比動詞字串更深。我需要理解為什麼 get 對 WebSocket 足夠,但對 SPDY 不夠。
SPDY(舊版協定)是由對端口轉發端點的 HTTP POST 請求啟動的。HTTP POST 在 Kubernetes 授權中映射到 create 動詞。因此 SPDY 端口轉發需要 create。
WebSocket 的啟動方式不同。它們以包含 Upgrade: websocket 標頭的 HTTP GET 請求開始。伺服器以 101 Switching Protocols 回應,連線隨即變成 WebSocket。
因為初始請求是 GET,Kubernetes 授權將其映射到 pods/portforward 子資源上的 get 動詞。
當我開啟 issue 時,我對這些一無所知。我花了一個晚上閱讀:
- RFC 6455(WebSocket 協定)
- Kubernetes
client-go中StreamWithContext的原始碼 PortForwardWebsockets的 KEP (KEP-4006)-
k9s自己處理client-go棄用的 issue 歷史記錄
根本原因:我認為端口轉發是「一個 API 呼叫」。實際上,這是 client-go、API 伺服器和 kubelet 之間的協定協商舞蹈。授權模型取決於協定。
修復:我沒有改變修復。一旦我檢查了兩個動詞,程式碼就是正確的。但我確實更新了錯誤訊息以指示兩個可接受的動詞,以便被阻擋的使用者能確切知道他們需要哪些權限:
return fmt.Errorf("user is not authorized to create or get portforward %q", path)
進入全螢幕模式 退出全螢幕模式
教訓:在修復分散式系統中的錯誤之前,請了解其建構的協定。症狀是缺少動詞。原因是協定升級。不了解協定的修復會破壞舊版路徑。
PR
| 統計 | 值 |
|---|---|
| 變更的檔案 | 2 |
| 新增的行數 | 240 |
| 刪除的行數 | 3 |
| 觸及的檔案 |
internal/dao/port_forwarder.go, internal/dao/port_forwarder_test.go
|
| 新增的測試 | 6 個涵蓋每種權限組合的測試案例 |
| 審查回合 | 2 |
| 從初稿到合併的時間 | 48 小時 |
差異很小。邏輯很簡單。努力在於理解為什麼邏輯需要存在 — 並透過測試證明它,這些測試會捕捉到其他人犯我初稿的錯誤。
PR 標題: fix(dao): allow port-forward with 'get' verb on pods/portforward for K8s 1.31+ WebSocket path
它乾淨地合併了。不需要後續修復。
我從貢獻大型專案中學到的
1. 從 issue 開始,而不是從程式碼開始
我在觸碰任何檔案之前,將原始 issue (#4144) 讀了三遍。報告者包含了他們的 Kubernetes 版本、RBAC 角色以及確切的錯誤訊息。如果沒有那個重現,我永遠不會理解 WebSocket 遷移的背景。
2. 在閱讀原始碼檔案之前先閱讀測試檔案
port_forwarder_test.go 比 port_forwarder.go 教會我更多關於 k9s 如何處理授權的知識。測試是不會說謊的文件。
3. go test -race 不是可選的
競爭偵測器在維護者發現之前就捕捉到我的錯誤。它將一個令人難堪的審查評論轉變為私下的 CI 失敗。在本地執行它。永遠如此。
4. 兩行修復需要兩百行測試
產品變更是約 20 行。測試是 223 行。這就是產品 Go 中的比例。如果你的修復沒有一個在修復前就會失敗的測試,你的修復就還沒完成。
5. 向後相容性是一個限制,而不是建議
我的初稿在 Kubernetes 1.31 上運作。它會破壞 k9s 對仍在使用 1.30 或更舊版本的一半使用者群。真正的修復會同時支援兩條路徑。這就是區分「在我的機器上運作」和「被合併進 k9s」的關鍵。
為什麼這很重要(以及為什麼不重要)
它很重要,因為:
- k9s 有約 58,000 個 GitHub 星號和數千名每日使用者。我的修復影響了擁有真實叢集的真人。
- 我是透過閱讀
client-go原始碼和撰寫表驅動測試來學習 Go 的,而不是透過建立 TodoMVC。 - 我現在對 Kubernetes 授權、HTTP 升級和協定協商的理解達到了我之前沒有的程度。
它不重要,因為:
- 這是一個 20 行的修復。Google 的資深工程師在喝咖啡前就能寫出這樣的差異。
- 我沒有架構新的子系統。我沒有重構程式碼庫。我修復了一個動詞檢查。
- 價值不在於程式碼。價值在於證明我可以閱讀 issue、理解背景、撰寫正確的修復、通過審查並交付。
這就是我正在建立的訊號。
下一步是什麼?
我繼續為 k9s 貢獻。程式碼庫複雜到足以繼續教會我東西 — tview 如何渲染終端機 UI、k9s 監看器如何避免輪詢 API 伺服器、dao 層如何抽象 client-go 操作。
我今天也為 Checkov 做出了貢獻 — 為 Cloud SQL 和 GKE 叢集新增了遺漏的 GCP 可標記資源。小型 PR。兩行。20 分鐘內合併。這就是當你建立肌肉時會發生的事:第二次貢獻比第一次更快。
摘要
我三週前開始學習 Go。我在 k9s 中發現一個錯誤,其中端口轉發授權只檢查 create 動詞,遺漏了 Kubernetes 1.31 新 WebSocket 路徑所需的 get 動詞。
我的第一次修復是錯誤的 — 我用 get 取代 create,並幾乎破壞了舊版 SPDY 叢集的向後相容性。最終的修復會獨立檢查兩個動詞。
我撰寫了 223 行的表驅動測試,以涵蓋每種權限組合。PR 在 48 小時內合併。我的測試中的一個 data race 教會我平行測試不共享模擬狀態。
如果你正在學習一種語言,不要建立教學專案。找到一個真實專案中的真實錯誤並修復它。測試套件會比任何課程教你更多。
你的第一個開源貢獻故事是什麼?在評論中分享 — 尤其是如果你也破壞了測試套件並活著講述它。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.