我用3周时间学会了Go。昨天,我的代码被合并进了k9s。

从零Go经验到最受欢迎的Kubernetes终端UI的合并PR——我是如何误读HTTP/1.1升级、破坏自己的测试套件,并意识到当Kubernetes 1.31改变底层规则时,createget并非同一动词。

背景

一切始于一个我未编写的函数。

我向k9s贡献已有一周,正努力理解这个拥有58,000星项目的Go代码结构。我深入internal/dao/port_forwarder.go,盯着检查用户是否有权限为Pod开启端口转发的代码块。检查很简单:此服务账户是否有pods/portforwardcreate权限?

我读过Kubernetes文档。端口转发需要create。所有人都这么说。代码正常工作。我继续前进。

随后有人提交了issue:“在k8s 1.31受限RBAC下端口转发失败。”

Kubernetes 1.31引入了PortForwardWebsockets特性——一条通过WebSocket而非SPDY转发端口的新代码路径。与SPDY不同,WebSocket只需pods/portforward子资源的get动词。

问题就在这里。代码检查的是create。新WebSocket路径需要get。仅拥有get权限的用户无法在k9s中端口转发,尽管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"]

Enter fullscreen mode Exit fullscreen mode

可在Kubernetes 1.31+(WebSocket路径)上成功运行kubectl port-forward,但k9s会静默置灰端口转发选项,认定他们缺乏权限。

该用户真实存在。issue包含重现步骤。真实集群、真实角色、真实工作流被我若非阅读client-go源码永远不会注意到的动词不匹配破坏。

k9s架构

┌─────────────────────────────────────────────────────────────┐
│                         k9s TUI                              │
│                    (tview / terminal UI)                     │
│  User presses Shift-F on a pod                              │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│              internal/dao/port_forwarder.go                  │
│                                                              │
│  1. Check pod is Running ( readiness gate )                 │
│  2. Authorize: can user create pods/portforward?            │
│         ▲                                                    │
│         │ THIS WAS THE BUG                                   │
│  3. If yes → open port-forward session                      │
│     If no  → grey out option / show error                   │
└──────────────────────┬──────────────────────────────────────┘
                       │
              ┌────────┴────────┐
              │                 │
              ▼                 ▼
    ┌─────────────┐     ┌─────────────┐
    │ SPDY path   │     │ WebSocket   │
    │ (legacy)    │     │ (k8s 1.31+) │
    │ needs       │     │ needs       │
    │ CREATE verb │     │ GET verb    │
    └─────────────┘     └─────────────┘
              │                 │
              └───────┬─────────┘
                      ▼
            ┌─────────────────┐
            │  K8s API Server │
            │  /api/v1/.../   │
            │  portforward    │
            └─────────────────┘

Enter fullscreen mode Exit fullscreen mode

我错过的决策

路径 协议 所需动词 k9s是否检查?
旧版SPDY SPDY/HTTP/1.1升级 pods/portforward上的create ✅ 是
WebSocket WebSocket (RFC 6455) pods/portforward上的get ❌ 否

k9s仅检查create。它不知道WebSocket路径的get要求。因此即使get仅限用户被授权使用其集群实际使用的代码路径,也会被阻止。

失败(及每一次的教训)

失败1:我改了一个词就以为完事

我的首次修复天真得尴尬。

我在port_forwarder.go中找到该函数。它用动词"create"构建SelfSubjectAccessReview并发送给API服务器。我将其改为"get"。它编译通过。我提交了草稿PR。

一小时内,维护者评论道:“这破坏了仍在SPDY路径的集群的向后兼容性。”

我用另一个硬编码动词替换了一个。我没有检查SPDY。没有检查两者。甚至没有考虑运行Kubernetes <1.31或WebSocket特性门禁禁用的集群。我假设“新=正确”。

根本原因:我将协议协商问题视为字符串替换问题。

修复:代码需要独立检查两个动词。若create被授权,用户可通过SPDY端口转发。若get被授权,则可通过WebSocket。若任一被授权,k9s应允许操作。API服务器和client-go在连接时处理实际使用的协议。

// What I wrote first (WRONG — only checks get)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
    return errors.New("insufficient permission")
}

// What merged (RIGHT — checks create AND get independently)
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")
    }
}

Enter fullscreen mode Exit fullscreen mode

实际上,最终合并的代码更简洁——它添加了新的CheckPodPortFwdGet辅助函数并从端口转发检查中同时调用两者。关键洞见:createget并非相互替代的选项。它们是独立协议路径的独立能力。两者可共存。两者均不可省略。

教训:当平台支持多种协议时,新协议的修复不能删除对旧协议的支持。测试共存而非替换。

失败2:因不理解t.Parallel()而破坏测试套件

k9s有真实的测试套件。非玩具测试——跨多场景的约50个测试用例的表驱动测试。我添加了覆盖新WebSocket路径的测试。它在本地通过。

随后CI失败。

错误是模拟客户端中的数据竞争。我在测试循环外实例化共享模拟RestClient以节省设置代码。某些测试使用t.Parallel()运行。两个并行测试同时修改同一模拟的响应状态。

竞争检测器(go test -race)捕获并构建失败。

根本原因:我以为通过共享模拟设置来提高效率。实际是在未完全理解的测试文件中制造并发风险。

修复:我重构每个测试用例,使其在测试闭包内构建自己的RestClient模拟。无共享状态。我添加的测试文件最终达到223行——几乎全是针对每种权限组合的测试用例:

  • Pod未运行
  • get权限,无create权限 → 阻止
  • create仅限权限 → 允许(旧版SPDY)
  • get仅限权限 → 允许(WebSocket路径,此为修复)
  • 两者权限 → 允许
// Part of the 223 lines of tests I ended up writing
{
    name: "get-only-portforward-allowed",
    pod:  runningPod,
    authorized: map[string]bool{
        "selfsubjectaccessreviews": true,
        "pods":                   true,
        "portforwardget":         true,
    },
    want: true,
},

Enter fullscreen mode Exit fullscreen mode

教训:并行测试并非免费。若不拥有测试基础设施,假设共享状态被禁止,除非另有证明。测试中的data race是简历污点。

失败3:我不知道什么是HTTP/1.1升级

真正的bug比动词字符串更深。我需要理解为什么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弃用问题的历史

根本原因:我以为端口转发是“一次API调用”。实际是client-go、API服务器和kubelet之间的协议协商舞蹈。授权模型依赖于协议。

修复:我未更改修复。代码在检查两个动词后正确。但我更新了错误消息以指示两个可接受动词,以便被阻止的用户确切知道所需权限:

return fmt.Errorf("user is not authorized to create or get portforward %q", path)

Enter fullscreen mode Exit fullscreen mode

教训:在修复分布式系统中的bug前,理解其构建协议。症状是缺失动词。原因是协议升级。不理解协议的修复会破坏旧路径。

PR

统计
变更文件 2
新增行数 240
删除行数 3
触及文件 internal/dao/port_forwarder.go, internal/dao/port_forwarder_test.go
新增测试 6个覆盖每种权限组合的测试用例
评审轮次 2
从初稿到合并时间 48小时

diff很小。逻辑简单。工作量在于理解为何需要此逻辑——并通过能捕获他人犯我初稿错误的测试证明。

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非可选

竞争检测器在我之前捕获了我的bug。它将尴尬的评审评论转为私有CI失败。始终本地运行。

4. 两行修复需两百行测试

生产变更约20行。测试为223行。这是生产Go中的比例。若你的修复没有在修复前会失败的测试,你的修复未完成。

5. 向后兼容是约束而非建议

我的初稿在Kubernetes 1.31上工作。它会破坏仍在1.30或更早版本上的半数用户。真正的修复同时支持两条路径。这区分了“在我机器上工作”与“合并进k9s”。

为何重要(及为何不重要)

重要原因:

  • k9s有约58,000 GitHub星和数千日常用户。我的修复影响真实人群的真实集群。
  • 我通过阅读client-go源码和编写表驱动测试而非构建TodoMVC学会Go。
  • 我现在理解Kubernetes授权、HTTP升级和协议协商,达到前所未有的水平。

不重要原因:

  • 这是20行修复。Google的高级工程师在喝咖啡前写此类diff。
  • 我未架构新子系统。未重构代码库。仅修复了动词检查。
  • 价值不在代码。价值在于证明我能阅读issue、理解上下文、编写正确修复、通过评审并发布。

这是我正在构建的信号。

下一步?

我继续向k9s贡献。代码库足够复杂,能不断教我——tview如何渲染终端UI,k9s观察者如何避免轮询API服务器,dao层如何抽象client-go操作。

我今天也向Checkov贡献——为Cloud SQL和GKE集群添加缺失的GCP可标记资源。小PR。两行。20分钟合并。当你锻炼肌肉时:第二次贡献比第一次快。

TL;DR

我三周前开始学Go。我在k9s中发现bug:端口转发授权仅检查create动词,遗漏Kubernetes 1.31新WebSocket路径所需的get动词。

我的首次修复错误——将create替换为get,差点破坏旧SPDY集群的向后兼容性。最终修复独立检查两个动词。

我编写223行表驱动测试以覆盖每种权限组合。PR在48小时内合并。我的测试中的data race教我并行测试不共享模拟状态。

若你正在学语言,不要构建教程项目。找真实项目中的真实bug并修复。测试套件将教你比任何课程更多。

你的首次开源贡献故事是什么?在评论中分享——尤其如果你也破坏过测试套件并活着讲述。