我用三週學會 Go。昨天,我的程式碼被合併進 k9s。

從零 Go 經驗到最受歡迎的 Kubernetes 終端機 UI 合併 PR — 我如何誤讀 HTTP/1.1 升級、破壞自己的測試套件,以及學習到當 Kubernetes 1.31 在你腳下改變規則時,createget 並非同一個動詞。

背景設定

一切始於一個我並未撰寫的函式。

我已為 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 輔助函式,並從端口轉發檢查中呼叫兩者。關鍵洞察:createget 並非互相取代的替代方案。它們是獨立協定路徑的獨立功能。兩者可以共存。兩者都不能被捨棄。

教訓:當一個平台支援多種協定時,為新協定提供的修復不能刪除對舊協定的支援。測試共存,而不是取代。

失敗 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-goStreamWithContext 的原始碼
  • 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.goport_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 教會我平行測試不共享模擬狀態。

如果你正在學習一種語言,不要建立教學專案。找到一個真實專案中的真實錯誤並修復它。測試套件會比任何課程教你更多。

你的第一個開源貢獻故事是什麼?在評論中分享 — 尤其是如果你也破壞了測試套件並活著講述它。