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

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

背景

這一切始於一個我沒寫的函式。

我加入 k9s 已經一週了,一直在試著理解這個擁有 58,000 顆星的專案是如何組織它的 Go 程式碼。我深入研究 internal/dao/port_forwarder.go,盯著一段檢查使用者是否有權在 Pod 上開啟 port-forward 的程式碼。檢查很簡單:這個服務帳號是否對 pods/portforwardcreate 權限?

我看過 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?

我在工作上會用 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 的架構

┌─────────────────────────────────────────────────────────────┐
│                         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/portforwardcreate ✅ 有
WebSocket WebSocket(RFC 6455) pods/portforwardget ❌ 沒有

k9s 只檢查 create。它不知道 WebSocket 路徑需要 get。所以只有 get 權限的使用者被阻擋了,儘管他們對叢集實際使用的那條程式路徑是有授權的。

失敗過程(以及每一次失敗教會我的事)

失敗 1:我改了一個字就以為搞定了

我的第一個修正蠢得可笑。

我找到了 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 檢查時同時呼叫兩者。關鍵洞見是:createget 並不是互相取代的替代選項,它們是針對不同協定路徑的獨立能力。兩者可以共存,誰也不能刪掉。

教訓: 當一個平台支援多種協定時,針對新協定的修正不能拿掉舊協定的支援。要測試共存,不是替換。

失敗 2:我把測試套件弄炸了,因為我不懂 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 行 —— 幾乎全都是針對各種權限組合的測試案例:

  • Pod 沒有在執行中
  • 沒有 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 會成為履歷污點。

失敗 3:我不知道什麼是 HTTP/1.1 upgrade

真正的 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 的時候完全不知道這些。我花了一個晚上讀:

  • RFC 6455(WebSocket 協定)
  • Kubernetes client-goStreamWithContext 的原始碼
  • 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 之前,先理解它所建立在其上的協定。症狀是缺少動詞,原因是協定升級。如果修正不理解協定,就會破壞舊路徑。

PR

統計專案 數值
變更檔案數 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

它乾淨地合併了。沒有後續修正。

我從參與大型專案學到的事

1. 從 issue 開始,不要從程式碼開始

在我動任何檔案之前,我把原始 issue(#4144)看了三遍。回報者提供了 Kubernetes 版本、RBAC 角色,以及完整錯誤訊息。沒有那份重現資訊,我絕對不會理解 WebSocket 遷移的脈絡。

2. 先讀測試檔,再讀原始碼

port_forwarder_test.go 教會我的 k9s 授權機制,比 port_forwarder.go 還多。測試是不能說謊的文件。

3. go test -race 不是可選項

race detector 在維護者看到之前就抓到我的 bug。它把一個尷尬的 review 留言,變成我自己的 CI 失敗。在本機先跑它。永遠都要。

4. 兩行修正需要兩百行測試

正式環境的變更大約 20 行。測試有 223 行。這就是 Go 的生產現實。如果你的修正沒有一個在修正前會失敗的測試,那你的修正還沒完成。

5. 向下相容是約束,不是建議

我的第一版只在 Kubernetes 1.31 上可用。它會讓仍在 1.30 或更舊版本上的一半使用者無法使用 k9s。真正的修正必須同時支援兩條路徑。這就是「在我機器上可以」和「已經合併進 k9s」的差別。

為什麼這件事重要,以及為什麼它其實也不重要

它重要,因為:

  • k9s 有約 58,000 個 GitHub 星標與數千名每日使用者。我的修正影響的是有真實叢集的真實使用者。
  • 我是透過讀 client-go 原始碼與撰寫 table-driven tests 來學 Go,而不是做 TodoMVC。
  • 我現在對 Kubernetes 授權、HTTP 升級,以及協定協商的理解,比以前更深入。

它也不重要,因為:

  • 這只是個 20 行的修正。Google 的資深工程師在喝咖啡前就能寫出這種 diff。
  • 我沒有設計新子系統。我沒有重構整個程式碼庫。我只是修正了一個動詞檢查。
  • 價值不在程式碼本身。價值在於證明我能讀懂 issue、理解脈絡、寫出正確修正、通過 review,並把它送出去。

這才是我正在建立的訊號。

接下來呢?

我會繼續為 k9s 貢獻。這個程式碼庫夠複雜,還能持續教我很多事 —— 像是 tview 如何渲染終端機 UI、k9s 的 watcher 如何避免輪詢 API server、以及 dao 層如何抽象 client-go 操作。

我今天也為 Checkov 做了貢獻 —— 補上 Cloud SQL 與 GKE 叢集缺少的 GCP 可標記資源。小 PR。兩行。20 分鐘就合併。這就是建立肌肉後會發生的事:第二次貢獻會比第一次更快。

TL;DR

我三週前開始學 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


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   ❤️4
310
🥈
我愛JS
📝1   💬4   ❤️1
58
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登