我用3周时间学会了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转发端口的新代码路径。与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辅助函数并从端口转发检查中同时调用两者。关键洞见:create和get并非相互替代的选项。它们是独立协议路径的独立能力。两者可共存。两者均不可省略。
教训:当平台支持多种协议时,新协议的修复不能删除对旧协议的支持。测试共存而非替换。
失败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-go中StreamWithContext的源码 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.go比port_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并修复。测试套件将教你比任何课程更多。
你的首次开源贡献故事是什么?在评论中分享——尤其如果你也破坏过测试套件并活着讲述。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.