VS Code 跑 mirrord 撞上 WebSocket 403:从 IDE 报错追到 K8s impersonation 的鉴权链路
目录
我前一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》 写完之后,第一次用 VS Code 的 mirrord 扩展真接到那套 demo 集群——结果在 agent 起来之后立刻被一个 403 Forbidden 卡住。这篇是那次排查的复盘:从 IDE 报错一路追到 Kubernetes impersonation 鉴权链路上一个我之前不知道的细节,以及最后那条最小修复怎么落到 demo 里。
相关代码都在
meirongdev/mirrord-demo的feat/rbac-mirrord-governance分支上。修复对应的 commit 是6e44011。
一、出发点:用 VS Code 扩展跑这套 demo#
前一篇 demo 用 bash run-mirrord.sh -- java -jar ... 在命令行里直接跑通。但团队里大部分人是从 VS Code / IntelliJ 直接 launch / debug 进程的,所以我得验证一遍:用 mirrord 的 VS Code 扩展,配合 alice 的 scoped kubeconfig,能不能跑出和命令行一样的效果。
流程很简单:
-
在 VS Code 里装 mirrord 扩展。
-
launch.json里加一个 Java 调试配置,target 是 demo 里的 Spring Boot app(target/mirrord-demo-0.0.1-SNAPSHOT.jar)。 -
用 alice 的 kubeconfig 启动 VS Code:
KUBECONFIG=$(pwd)/rbac/.credentials/alice.kubeconfig code . -
在 VS Code 里点 mirrord 图标,选 target deployment
team-a-dev/app,按 F5 启动调试。
理想路径是:mirrord 扩展替我创建好 agent,调试进程透明地接到集群里。结果第一步就挂了。
二、现象:agent 起得来,但 WS upgrade 直接 403#
VS Code 的 mirrord 输出窗口里跳出来这段:
Failed to connect to the created mirrord-agent:
failed to upgrade to a WebSocket connection:
failed to switch protocol: 403 Forbidden
关键词是「created mirrord-agent」——agent 这一步是过了的。kubectl get pods -n team-a-dev 也能看到 agent pod 已经在 Running,logs 没有异常。403 是出在 mirrord CLI / 扩展和 agent pod 之间握手升级 WebSocket 那一步。
到这里我心里大概有几个候选:
- ClusterRole 里漏了
pods/portforward create? - PodSecurity 拒了 agent 的 capability?
- agent.namespace 配错了,agent job 跑到
default去了? serviceaccounts impersonate出问题了?
按概率排:4 我一开始觉得最不可能——mirrord-developer ClusterRole 里 serviceaccounts: get, impersonate 是写了的,RoleBinding 也按 namespace 绑好了。
三、初步排查:把能直接断言的都断言一遍#
第一轮我直接用 kubectl auth can-i 把 ClusterRole 里每条规则都问了一遍:
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i create pods/portforward -n team-a-dev # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i get pods/log -n team-a-dev # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i create jobs.batch -n team-a-dev # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i impersonate serviceaccounts -n team-a-dev # yes
每条都 yes。RBAC 看起来一切都好。
又顺手看了下 PodSecurity——namespace label 是 pod-security.kubernetes.io/enforce: privileged,agent pod 也确实跑起来了,所以不是这里。agent.namespace 在 .mirrord/mirror.json 里显式写了 team-a-dev,pod 也在那里,也不是这里。
到这一步线索断了。在 mirrord 的 GitHub issue 里翻了一圈,看到一条提到 403 on protocol switch 跟 impersonation 有关,但没具体写要看哪个 verb——我决定回到 auth can-i,但这次把所有跟 impersonation 沾边的姿势都打一遍。
四、auth can-i 给出第一条反直觉信号#
下面这一组 query 的结果其实是把整个排查转过来的关键:
# 这条之前问过:在 team-a-dev 里能不能 impersonate SA
kubectl auth can-i impersonate serviceaccounts --as alice -n team-a-dev
# → yes
# 同样的 verb,不带 -n —— 问的是 cluster-scoped
kubectl auth can-i impersonate serviceaccounts --as alice
# → no
# impersonate users —— 不带 -n
kubectl auth can-i impersonate users --as alice
# → no
# impersonate users —— 带 -n team-a-dev
kubectl auth can-i impersonate users --as alice -n team-a-dev
# → no ← 这是真正反常的一条
最后一条最关键:带了 -n team-a-dev 还是 no。
kubectl auth can-i 的语义是「问当前 user 能不能在指定 scope 下执行这个 verb」。一般加了 -n,K8s 会去 namespace 内查 RBAC;这里加了反而没用,意味着这个检查根本不接受 namespace 作为限定。
这是「users 这个资源根本不是 namespace-scoped 的」的直接证据——再去看 K8s 文档 Authentication > User impersonation 那一节,事情就拼起来了。
五、Kubernetes impersonation 其实查了两次#
API server 收到带 Impersonate-User header 的请求时,会在执行实际请求之前跑一组 authorization。当 header 形如 Impersonate-User: system:serviceaccount:team-a-dev:default(一个 SA 的 user-form 表示),它会同时跑两条独立的检查:
| 检查的资源 | 作用域 | RoleBinding 能不能授权? |
|---|---|---|
users(impersonate 这个 verb) |
cluster-scoped 虚拟资源 | 不能 |
serviceaccounts(impersonate 这个 verb) |
namespaced | 能 |
两条都得过。任何一条挂了,握手就 403。
users 和 groups 不是 K8s 里真的有的对象——它们没有 CRUD,没有 kubectl get users,没有 OpenAPI schema。它们是 RBAC 子系统造出来的虚拟资源,用来表达「impersonate 某个 user / 某个 group 的能力」。它们天生没有 namespace 维度——既不是某个 namespace 里的对象,也不可能被 namespace 限定。
这就解释了为什么 kubectl auth can-i impersonate users --as alice -n team-a-dev 加了 -n 还是 no:namespace 限定对 users 是没意义的,K8s 不会因为 team-a-dev 里有人绑了 impersonate users 就允许 alice 在那里以任意用户身份发请求。
六、为什么 RoleBinding 物理上接不住#
RoleBinding 在 K8s RBAC 里的定义就是:
把
ClusterRole里的规则限制到一个 namespace 内。
但要限制,前提是这些规则本身有 namespace 维度可以限。对 namespace-scoped 资源(pods、jobs、secrets…),这成立;对 cluster-scoped 资源/虚拟资源(nodes、users、groups、clusterroles…),这根本不成立。
所以即使你写出这种 YAML:
kind: ClusterRole
metadata:
name: mirrord-developer
rules:
- apiGroups: [""]
resources: ["users"]
verbs: ["impersonate"]
再用一个 RoleBinding 把它在 team-a-dev 内绑给 alice——kubectl apply 不会报错,YAML 也合法,但authorization 时这条规则会被跳过,因为 K8s 发现 users 没有 namespace 维度,RoleBinding 的限制对它无效,干脆不当作授权来源。这是 K8s RBAC 子系统的硬约束,不是配置写错了。
可以再花一行验证一下这一点:
# 即使写在 RoleBinding 引用的 ClusterRole 里,依然 no
kubectl auth can-i impersonate users --as alice -n team-a-dev
# → no
七、为什么客户端非走 user-form header 不可#
到这一步还剩一个疑问:K8s 文档明明写过——
Impersonating a service account requires the
impersonateverb onserviceaccountsin the apiGroup""for the namespace of the impersonated service account.
也就是说纯 namespace-scoped 的 SA impersonation 是合法的。那为什么 mirrord 实际发的请求会触发 users 这一条检查?
答案在客户端怎么序列化 impersonation header 上。Kubernetes 支持两种形态:
- SA-form:
Impersonate-Account: team-a-dev/default(设了这个 header,就只跑serviceaccounts那一条检查); - User-form:
Impersonate-User: system:serviceaccount:team-a-dev:default(K8s 看到system:serviceaccount:前缀会额外跑serviceaccounts那一条,但users这一条也要跑)。
现实是 kubectl --as=...、client-go 的 rest.ImpersonationConfig.UserName、mirrord 内部基于 client-go 的实现,都是设的 user-form Impersonate-User。除非客户端代码显式去用 SA-form 那个少见的 API,否则触发的就是「users + serviceaccounts 都查」那条路径。
所以这是个工程现实问题:K8s 设计上允许「只用 RoleBinding 就够」,但主流客户端的发包格式让这条路径在实际工程里走不通。改 mirrord 客户端的 header 不在我能控制的范围内,能动的就只有「在 RBAC 这边补一个能匹配的 cluster-scoped 授权」。
八、修复:拆出一个最小的 ClusterRole#
被逼出来的方案是把 impersonation 单独拎出来。新增一个只含一条规则的 ClusterRole:
# rbac/admin/manifests/01b-mirrord-impersonator-clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mirrord-impersonator
rules:
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["get", "impersonate"]
然后给每个开发者一个独立的 ClusterRoleBinding 指向它:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mirrord-impersonator-__USER__
roleRef:
kind: ClusterRole
name: mirrord-impersonator
subjects:
- kind: User
name: __USER__
grant-namespace-access.sh 改成 apply 两份:namespace RoleBinding(绑 mirrord-developer,覆盖 pods / jobs / portforward / log / events 这些大件,作用域限定 namespace)+ cluster-scoped ClusterRoleBinding(绑 mirrord-impersonator,只 1 条规则)。落到 demo 里的完整脚本形态、verify 步骤和模型表整理在另一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》§八 里,本文不再展开。
跑完 grant 之后再回 VS Code 启动 debug,握手秒通,agent 那一侧没再出现 403。
九、为什么不直接给 mirrord-developer 加一个 ClusterRoleBinding#
最简单的写法是:mirrord-developer 已经有 serviceaccounts: impersonate 了,给 alice 加一个指向它的 ClusterRoleBinding,一行解决。
不行。那等于把整组 namespace-scoped verb 一起提升到 cluster 作用域:alice 同时在 kube-system、default、team-b-dev 里都能 list pods、create jobs、pods/portforward、get secrets……前一篇文章整个治理目标(按 namespace 隔离、其它 namespace 默认 deny)就被这一个 binding 推翻了。
所以「最小修改」的解读不是 binding 行数最少,而是让 alice 多出来的 cluster 作用域权限尽可能窄。拆一个只含一条规则的小 ClusterRole 出来,绑出去的是 1 个 verb;放进原来那个 fat ClusterRole 里,绑出去的是十几个 verb。形态上是「多了一个 ClusterRole + 一个 ClusterRoleBinding」,但作用域语义上是更收紧的——这是关键的取舍。
十、落地到 demo 里的两件连带改动#
修复不是孤立地多 apply 一个 ClusterRoleBinding 就完事——撤销和回归测试都得跟着改。这两件事的实现细节在另一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》 的 §八 和 §十里写完整了;这里只记我从这次踩坑里抽出来、希望以后写 RBAC 都记住的两条:
1. ClusterRoleBinding 是 per-user 的,撤销不能跟着 RoleBinding 无脑删。
原来的 revoke-namespace-access.sh 只删一条 RoleBinding,模型干净。现在多了 per-user 的 ClusterRoleBinding:如果 alice 被授权过 team-a-dev 和 team-b-dev 两个 namespace,从 team-a-dev 撤销绝不能把 ClusterRoleBinding 顺手删掉——否则她在 team-b-dev 的 mirrord 接入会跟着坏。规则改成:删 namespace RoleBinding 之后数一下用户剩余的 RoleBinding 条数,只有为 0 时才回收 ClusterRoleBinding。「单点撤销不污染其它 namespace」的承诺这样才仍然成立。
2. cluster-scoped 检查必须进 validate 脚本,不能等真跑 mirrord 才发现。
这次 bug 没被前一篇文章里的 validate-rbac.sh 拦住,本质原因是它只 assert 了「namespace 内 verb 能不能用」,对 cluster-scoped 检查零断言。修复时同步加了 assert_allowed_cluster / assert_denied_cluster 一对 helper,断言 grant 前后 cluster-scoped impersonate serviceaccounts 的允许/拒绝状态。下次有人误删 mirrord-impersonator,或者 grant 脚本退化成只 apply RoleBinding,CI 立刻挂——比「漏 verb 然后跑 mirrord 才发现」反馈快一个量级。
十一、复盘:这次踩坑让我多记下来的几件事#
auth can-i 通过≠ 实际请求通过。mirrord 握手走的是 impersonation 这条非 trivial 鉴权路径,跟普通kubectl get不是同一张图。下次写 RBAC demo,我会专门加一个「以目标 SA 身份直接发请求」的断言,比如kubectl --as=system:serviceaccount:team-a-dev:default get pods -n team-a-dev—— 这条更贴近 mirrord 真实走的鉴权路径。- 最小权限要按「作用域」拆,不要按「verb」拆。把 impersonate 写进
mirrord-developer看着挺干净,但它和其它 verb 的作用域天然不同。如果一开始就按「这条规则是 cluster-scoped 还是 namespaced」分类,第二个 ClusterRole 会更自然地出现,不会等到撞 403 才被迫拆。 - 回归测试要跟着真踩过的错走。
assert_allowed只覆盖 namespace 内 verb 是不够的——cluster-scoped 检查应该也算一等公民断言。修 bug 同时把测试补上,下次才不会被同一条阴沟绊倒。这次的 bug 装回validate-rbac.sh之后,CI 一秒就能告诉我「ClusterRoleBinding 没了」。 - K8s 文档要按客户端 header 一路追下去。单看 RBAC 文档以为「SA impersonation 是 namespace-scoped」就完事了——其实 authentication 文档里写明了同一个 SA 经 user-form 发出去时会触发
users那条检查。下次涉及 impersonation 我会先用kubectl auth can-i ... --as ...各种姿势跑一遍,把鉴权图谱画清楚再写 RBAC。 - IDE 路径和 CLI 路径不是一回事,得分别验证。命令行
bash run-mirrord.sh ...跑得通,不代表 VS Code 扩展也跑得通——前者直接继承 shell env,后者有自己的 process tree 和 env 解析规则。新接入团队前,IDE 这条路径必须单独走一遍,而且要用真实开发者的 scoped kubeconfig,而不是 admin。
相关链接#
- 上一篇:《给 mirrord 开发者按 namespace 签发 kubeconfig》 — 这篇 403 故事发生的 demo 背景。
- 修复对应的 commit:
6e44011 - Kubernetes 官方文档:User impersonation
- Kubernetes 官方文档:RoleBinding and ClusterRoleBinding
- mirrord 文档:Configuration reference