AI时代的Kubernetes教程36招-第25招-ServiceAccount、RBAC和Admission Control怎么建立最小权限?
建立身份授权链:Pod身份、RBAC与准入控制的工程落地
一个 default ServiceAccount,能把集群权限悄悄借给每个 Pod。
[读者] 正在把业务搬上 Kubernetes 的后端与平台工程师
[痛点] 所有负载共用 default SA,权限靠“能跑就行”凑
[现在读] 一次越权读取和一次 CI 误删,逼出身份链重构
[读完] 能为每类负载设计 SA、RBAC、准入三段式最小权限
[旧方案] 全部工作负载共用 default SA,再挂一个 cluster-admin
|
v
[新需求] RAG 只读配置、Operator 管特定 CRD、CI 只碰目标 Namespace
|
v
[冲突] 一个身份表达不了三种边界,权限只能取并集
|
v
[后果] 越权面被放大,审计说不清是谁干的,改权限要全体重发
我是老李,做过几年后端,现在在一家做数字人的团队里管平台。系统规模不大:一个检索服务(RAG)从 ConfigMap 与自定义资源里读向量库地址和提示词模板;一个自研 Operator 盯着 digitalhuman.example.com 这组 CRD,把数字人实例同步成 Deployment 与 Service;一条 CI 流水线把镜像推到目标命名空间再更新镜像版本。三块负载跑在同一个集群里,最初共用 default ServiceAccount,理由很简单——能跑。
变化来自一次安全评审。评审结论不复杂:RAG 只该读配置,Operator 只该管自己那几个 CRD,CI 只该动目标命名空间里的工作负载。三条边界写在文档上很清爽,落到集群里却缩成同一个身份。任务是把这个身份拆成三条,并且让拆出来的边界可验证、可审计。
拆分的第一步就撞上问题:权限不是“配一次就够”的东西。ServiceAccount 只回答“我是谁”,它本身不带任何权限;真正决定能做什么的是 Role/ClusterRole 与 Binding;而 Binding 是集群级对象,任何一个早期为了救火加的 ClusterRoleBinding 都会一直生效,直到有人把它删掉。
于是授权链变成三段:认证确认身份,授权判断动作,准入在写入 etcd 之前做最后一次拦截。三段里任何一段写宽了,前面所有的收窄都会白费。
为什么“给 Pod 一个 ServiceAccount”这件小事,最后会变成集群权限的失控入口?
是继续用 default SA 加一个集群级绑定换省事,还是把每类负载的身份边界逐个拆开、再让准入控制兜底?
如果身份边界定错,最先出问题的到底是安全,还是你的发布与排障速度?
01、故事
第一次出事不在生产,在预发。RAG 服务升级后启动报错,日志里是一行 403:
Error from server (Forbidden): configmaps "rag-prompt" is forbidden:
User "system:serviceaccount:digitalhuman:default" cannot get resource
"configmaps" in API group "" in the namespace "digitalhuman"
任务是让 RAG 读到一个 ConfigMap,结果它连自己是谁都没说对。Deployment 里没有写 serviceAccountName,Pod 就回落到命名空间的 default SA;而当时我们只给新建的 rag-reader 绑了 Role,default 上什么都没有。报错信息其实已经把答案写全了,只是没人习惯去读 User "system:serviceaccount:..." 这一段。
第二次更难看。CI 流水线误把一次发布的目标命名空间写错,脚本照常执行,另一个团队的 Deployment 被更新了。事后翻审计日志,只能看到“某个 SA 做了这个动作”,但那台 SA 同时也被十几个工作负载使用,根本定位不到是谁。
两次事故拼起来,问题很清楚:我们把“身份”当成了配置细节,而不是架构的一部分。SA 是集群里唯一能代表 Pod 的东西,它一旦被共用,Pod 之间就再也没有权限边界可言。
一栈三负载
一权共一身
一事起波澜
一链自此生
02、问题
旧方案失效得很彻底。共用 default SA 时,权限只能取所有负载的并集:RAG 需要的读权限、Operator 需要的 CRD 写权限、CI 需要的 Deployment 更新权限,全部叠在同一个身份上。任何一个 Pod 被攻破,等于同时拿到三份权限。
业务影响有两层。浅层是排障成本:403 一出,先要确认是哪个 SA,再确认这个 SA 上挂了哪些绑定,而绑定可能散落在多个 RoleBinding 与 ClusterRoleBinding 里。深层是审计能力:审计日志按身份记录,身份共用等于审计失效,出了事只能靠时间线猜。
技术表现可以归纳成三条:一是 Pod 实际使用的 SA 与设计意图不一致,二是存在命名空间之外的越权路径,三是权限变更没有幂等入口,改一次就有人手工改一次。
把这三条翻成可验证的完成标准:
- 每个工作负载显式声明
serviceAccountName,集群里不存在依赖 default SA 的业务 Pod; - RAG 身份对目标资源只有 get/list/watch,Operator 身份只对指定 apiGroup 的指定资源有权限且带
/status,CI 身份只在目标命名空间内对 Deployment 有写权限; - 用
kubectl auth can-i可以正向验证“能做什么”,也可以反向验证“不能做什么”; - 准入层能拒绝未声明 SA 或显式使用 default SA 的 Pod。
旧约已失效
越权无人知
标准须可验
边界要写清
03、原理
Kubernetes 里一次 API 请求要走完三段:认证(Authentication)确认请求者是谁,授权(Authorization)判断这个身份能不能做这个动作,准入(Admission)在对象被持久化之前做最后一次校验与变更。三段顺序固定,缺一不可,而且准入是唯一能在写入 etcd 前说“不”的地方——RBAC 只能按动词和资源放行,它看不懂对象内部的内容。
身份的载体是 ServiceAccount。Pod 通过投射的 token 向 API Server 证明自己,API Server 把它映射成 system:serviceaccount:<namespace>:<name> 这个用户名,并把 SA 所属的 group 一并带上。需要留意的是,1.24 之后 Kubernetes 不再自动为每个 SA 创建长期有效的 Secret token,凭据更多依靠 TokenRequest 签发的短时令牌,这意味着“把 token 当长期凭据分发”的做法要重新评估,具体行为需按当前官方文档核验。
授权的载体是 RBAC 的四类对象:Role 与 ClusterRole 描述“允许什么”,RoleBinding 与 ClusterRoleBinding 描述“授予谁”。RoleBinding 可以引用 ClusterRole,这是最容易被忽略的一条:它让集群级规则在命名空间内复用,却不会把权限扩散到命名空间之外。
反直觉判断:RBAC 授权的是身份,不是工作负载。 同一个 SA 被两个 Pod 使用,RBAC 无法区分它们。这意味着“给 Pod 一个 SA”不是配置细节,而是在为这个 Pod 划定权限上限——共享 SA 就等于共享权限上限。
第二个反直觉判断:最常见的越权不是某条规则写得太宽,而是 list 权限本身。 对 ConfigMap、Secret 或自定义资源授予 list,等价于允许读取该类型在命名空间内的全部内容,即使你只想要“看到名字”。
认证先立身
授权再定权
准入终落闸
次序不可乱
04、架构
[输入] Pod 内的进程发起 API 请求(携带投射 token)
|
v
[模块] ServiceAccount + 投射 token
|
v
[数据/状态] 认证得到身份 system:serviceaccount:<ns>:<name>
|
v
[处理] RBAC 判定动词/资源/命名空间是否放行
|
v
[输出] 准入控制器校验后写入 etcd,或直接 403 拒绝
边界在这里:SA 只定义身份,不定义权限;RBAC 只判动作,不看对象内容;准入只处理写请求,且只在对象落盘前执行一次。
收益是边界清晰:三类负载各有一个身份,越权被限制在各自的资源范围内,审计日志能直接定位到身份。代价是对象数量增加,每个命名空间至少多出三个 SA 与若干 Role/RoleBinding;同时排障路径变长,403 出现时要先确认身份再确认绑定。
适用条件是:集群里存在多类负载、且安全评审要求明确边界。如果只是单应用单命名空间,SA 拆分仍然值得做,但准入策略可以后置。
三段一条链
各有各的界
得在窄中稳
失在宽中险
05、实战一次
环境:Kubernetes 集群(1.24 及以上,避免依赖自动生成的 SA token Secret),kubectl 与集群版本偏差在一个 minor 内。依赖:只有 kubectl,不需要额外组件。
先建命名空间与三个身份:
apiVersion: v1
kind: Namespace
metadata:
name: digitalhuman
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: rag-reader
namespace: digitalhuman
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dh-operator
namespace: digitalhuman
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deploy
namespace: digitalhuman
RAG 只需要读配置:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: rag-reader
namespace: digitalhuman
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["rag-prompt", "rag-endpoint"]
verbs: ["get"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rag-reader
namespace: digitalhuman
subjects:
- kind: ServiceAccount
name: rag-reader
namespace: digitalhuman
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: rag-reader
Operator 需要的是一个集群级规则,但只针对自己的 CRD,且必须包含 /status:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dh-operator
rules:
- apiGroups: ["digitalhuman.example.com"]
resources: ["digitalhumans", "digitalhumans/status", "digitalhumans/finalizers"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dh-operator
subjects:
- kind: ServiceAccount
name: dh-operator
namespace: digitalhuman
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: dh-operator
CI 只在目标命名空间里动 Deployment:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deploy
namespace: digitalhuman
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deploy
namespace: digitalhuman
subjects:
- kind: ServiceAccount
name: ci-deploy
namespace: digitalhuman
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ci-deploy
应用并验证(未在当前环境实测,以下为预期结果):
kubectl apply -f deploy/security/rbac/
kubectl auth can-i get configmaps/rag-prompt \
--as=system:serviceaccount:digitalhuman:rag-reader -n digitalhuman
kubectl auth can-i delete deployments \
--as=system:serviceaccount:digitalhuman:ci-deploy -n digitalhuman
示例输出:
yes
no
正向能读、反向不能删,这一步就把 V1 跑通了。工作负载侧只需要显式声明身份:
spec:
template:
spec:
serviceAccountName: rag-reader
automountServiceAccountToken: true
到这里 V1 可用,但还留着三个问题:default SA 仍可能被新写的 YAML 悄悄用上;CI 的令牌仍是投射的长期凭据;准入层还没有任何兜底。这三个问题留给后面两章。
先跑通最小
再谈收与放
一验知真假
一证见分权
06、排查
诊断一:RAG 拿到 403。
现象:RAG Pod 启动后读 rag-prompt 报 Forbidden。怀疑:RoleBinding 没生效。检查:
kubectl auth whoami --as=system:serviceaccount:digitalhuman:rag-reader
kubectl get rolebinding rag-reader -n digitalhuman -o yaml
证据:whoami 返回 system:serviceaccount:digitalhuman:default,说明 Pod 用的不是 rag-reader;RoleBinding 本身正确。根因:Deployment 未声明 serviceAccountName,Pod 回落到 default SA。修复:在 Pod 模板里补上 serviceAccountName: rag-reader 并重新发布。
错误尝试: 第一反应是“那就给 default SA 也绑一个只读 Role”。这是错的,而且错得很典型:它把一次配置遗漏改成了架构决策——default SA 从此有了读权限,此后所有忘记声明的 Pod 都会静默继承它;同时 403 的报错信息被掩盖,下一次同样的遗漏不会再暴露出来。
诊断二:CI 账号能删别的命名空间。
现象:演练时用 ci-deploy 身份删掉了另一个命名空间的 Deployment。怀疑:Role 写宽了。检查:
kubectl auth can-i delete deployments \
--as=system:serviceaccount:digitalhuman:ci-deploy -n other-team
kubectl get clusterrolebinding -o wide | grep -i ci
证据:can-i 返回 yes,而 Role 只定义了 patch/update,不含 delete;clusterrolebinding 列表里存在一条早期救火时创建的绑定,把 ci-deploy 绑到了一个集群级角色。根因:新增 Role 时从未撤销旧的 ClusterRoleBinding,RBAC 是加法的,旧绑定一直有效。修复:删除该 ClusterRoleBinding,重跑 can-i 做反向验证。
诊断三:Operator 更新状态失败。
现象:Operator 能创建 CR,但更新 .status 报 Forbidden。怀疑:apiGroup 写错。检查:
kubectl auth can-i update digitalhumans/status \
--as=system:serviceaccount:digitalhuman:dh-operator
证据:对 digitalhumans 返回 yes,对 digitalhumans/status 返回 no。根因:RBAC 中 status 是子资源,必须单独列出 digitalhumans/status,主资源的权限不会自动覆盖它。修复:在 ClusterRole 里补上子资源条目。
现象非根因
猜测要落地
一令问集群
证据自开口
07、优化
三条证据指向三类修改。
根因一:身份靠约定,不靠声明。修改:所有工作负载显式声明 serviceAccountName,同时在 Pod 模板层默认关闭自动挂载,只有确实要访问 API 的容器才打开:
spec:
template:
spec:
automountServiceAccountToken: false
serviceAccountName: rag-reader
原因:默认关闭后,“忘记声明的 Pod 拿到一个可用 token”这条路径被物理切断。新行为是 Pod 里没有 token 文件,调用 API 时会在客户端就失败,而不是在服务端返回 403——错误更早、也更接近根因。
根因二:旧绑定没有被回收。修改:RBAC 变更统一用声明式文件加 kubectl auth reconcile 应用,并把“集群级绑定清单”列为评审项。
kubectl auth reconcile -f deploy/security/rbac/
kubectl get clusterrolebinding -o custom-columns=NAME:.metadata.name,ROLE:.roleRef.name
原因:reconcile 会按文件期望状态补齐缺失的规则,同时让被文件遗漏的绑定暴露出来。新行为是权限变更可回放、可对比。
根因三:子资源权限与准入兜底缺失。修改:ClusterRole 补 digitalhumans/status,并加一条准入校验,拒绝未声明 SA 或显式使用 default 的 Pod。较新版本可用 ValidatingAdmissionPolicy 直接声明 CEL 规则(该 API 自 1.30 起 GA,具体字段以当前官方文档为准);旧版本则用 ValidatingAdmissionWebhook。
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-explicit-sa
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: object.spec.serviceAccountName != 'default'
message: Pod 必须显式声明非 default 的 serviceAccountName
验证方式仍然是 can-i 的正反两面,再加上一次故意提交 default SA 的 Pod,确认它被准入拒绝。修改后的行为:RAG 只能读两个指定 ConfigMap,Operator 只能管自己的 CRD 与 Deployment,CI 只能改目标命名空间的 Deployment,越界路径在准入层被拦下。
需要说明的是,准入校验只覆盖 Pod 这一层,对直接操作 API 的身份(比如 CI)不起作用,那部分只能靠 RBAC 本身的收窄。
证据定改法
改法须可逆
收窄不加戏
验证再收工
08、演进
[同一输入] 三类负载请求 API
|
+--[V1] 共用 default SA + 手工绑定 / 成本:越权面大、审计失效
|
+--[V2] 独立 SA + 收窄 RBAC + 准入兜底 / 成本:对象变多、排障变长
|
[Trade-off]
得到什么 / 失去什么 / 适用边界
正确性上,V1 只要绑定齐全也能跑通,只是正确性依赖人的记忆;V2 把正确性交给声明与准入,默认状态就是安全的。稳定性上,V1 的故障是静默的(多一份权限没人知道),V2 的故障是显式的(少一份权限立刻 403)。复杂度上,V1 对象少但心智负担高,V2 对象多但每个对象只回答一个问题。成本上,V2 需要维护更多 YAML,换来的是审计可定位。适用范围上,多类负载、多团队共用集群时 V2 几乎是必须的;单应用单命名空间可以只做 SA 与 Role 拆分,准入策略延后。遗留问题是权限漂移:绑定会随救火不断增加,需要一个定期巡检来发现“文件里没有、集群里存在”的绑定。
同入不同路
一宽一窄间
得简失于险
得稳失于繁
09、洞见
9.1 身份是权限的锚点
反直觉判断:把权限配得再细,也不如先把身份分对。 前文的 403 与越权删除,根因都不在权限规则本身,而在身份被共用或被回落到 default。RBAC 规则可以事后补,身份一旦共用,历史流量与审计记录就无法回溯。
9.2 default SA 是集群里的最大公约数
反直觉判断:default SA 的危险不来自它被授予了什么,而来自它什么都没被授予时仍然可用。 它是一个永远存在、永远可绑定的身份,任何一次“先让它跑起来”的临时绑定都会长期生效。真正有效的做法是让 default 在准入层直接不可用,而不是靠人记住不要用它。
9.3 准入控制是最后一公里
认证与授权回答“能不能做这个动作”,只有准入能回答“这个对象本身合不合格”。serviceAccountName 是否声明、是否显式使用 default、是否关闭自动挂载,这些都是对象内容层面的判断,RBAC 看不见。需要提醒的是,准入只拦写请求且只执行一次,它补充 RBAC,不能替代 RBAC。
9.4 最小权限的代价要提前认领
收窄权限的直接后果是排障路径变长:403 之后要先确认身份、再确认绑定、最后确认资源与子资源粒度。这个成本无法消除,只能通过把身份写进 Pod 模板、把绑定纳入声明式文件、把 can-i 纳入发布检查来降低。如果团队没准备好承担这部分成本,最小权限最后一定会被一次救火推翻。
身份非负载
通用即无界
准入是末闸
最小需成本
10、系统落地
原来有的是一个 default SA 和几条救火时加的绑定,权限边界停留在文档里。本篇新增的是三段式授权链:每类负载独立 SA、按需收窄的 Role/ClusterRole 与 Binding、以及在 Pod 落盘前校验身份的准入策略。
现在能做的事情很具体:RAG 身份只能读指定的两个 ConfigMap;Operator 身份只能管 digitalhuman.example.com 下的 CRD(含 status 与 finalizers 子资源)以及 Deployment;CI 身份只能在目标命名空间更新 Deployment;任何未显式声明 SA 的 Pod 在准入层被拒绝。
还缺两件事:一是权限漂移巡检,需要定期对比集群里的绑定与 deploy/security/rbac/ 下的声明;二是令牌生命周期的治理,CI 这类非 Pod 身份更适合用 TokenRequest 签发短时令牌,而不是分发长期凭据,具体实现需按当前官方文档核验。
下一步的演进方向是把巡检做成流水线的一环:准入负责“挡住不合规的新对象”,巡检负责“清理历史遗留的旧绑定”,两者合起来才算闭环。
旧有靠约定
新添三道闸
能跑更能证
尚缺自动巡
11、小结
Q1 → 因为 RBAC 授权的是身份而不是工作负载,共用 SA 等于共用权限上限
Q2 → 拆开身份边界并让准入兜底,代价是对象变多、排障变长,但审计可定位
Q3 → 两者都会出问题:安全上越权面被放大,发布上 403 从“能跑就行”变成必修课
状态 → 三类负载拥有独立 SA、收窄的 RBAC 与准入兜底,且能用 can-i 正反验证
三问皆可答
一链已成形
能交最小权
能证不能为
12、作业
12.1 理解题:为什么给 default SA 绑一个只读 Role 是错误修复
参考答案:这会把一次配置遗漏固化成架构决策。default SA 是所有未声明 serviceAccountName 的 Pod 的默认身份,给它任何权限都等于给这些 Pod 集体授权;同时它掩盖了 403 暴露出的真实根因(Pod 没有声明身份),后续同类遗漏不会再被错误信息提示出来。
12.2 实战题:为一个只需要读取自身命名空间某个 Secret 的组件写出最小 RBAC
参考答案:创建独立 ServiceAccount;用 Role 授予 apiGroups: [""]、resources: ["secrets"]、resourceNames: ["<具体名字>"]、verbs: ["get"];再用 RoleBinding 绑定到该 SA。不要授予 list,因为 list 等价于读取该命名空间内该类型的全部内容;同时把 default 从准入层挡住,避免忘记声明时静默继承。
12.3 排障题:Operator 能创建 CR,但更新 status 报 Forbidden,如何定位
参考答案:先用 kubectl auth can-i update <resource>/status --as=system:serviceaccount:<ns>:<sa> 确认子资源权限,再检查 ClusterRole 是否只写了主资源。RBAC 中 status、finalizers 等子资源需要单独列出,主资源的规则不会自动覆盖;修复是在 rules 中补上 <resource>/status。
12.4 架构判断题:如果安全评审要求禁止任何 Pod 使用 default SA,应该在哪一层实现
参考答案:在准入层实现,而不是在 RBAC 层。RBAC 只能判断身份能不能做某个动作,无法判断 Pod 对象是否声明了 serviceAccountName;准入控制可以在对象持久化前校验并在不合规时拒绝。较新版本可用 ValidatingAdmissionPolicy 写 CEL 表达式,旧版本用 ValidatingAdmissionWebhook,同时配合 automountServiceAccountToken: false 降低默认暴露面。
懂了要能写
写了要能查
查了要能判
判了要能改
13、思考
回到最开始那条冲突:一个身份表达不了三种边界。解法不是把权限规则写得更细,而是先把身份分开,再让 RBAC 和准入各管一段——RBAC 管动作,准入管对象。这条链的价值不在于拦住了多少请求,而在于它把“权限是否正确”从人的记忆变成了可验证的事实。
可复用的判断有三条。第一,身份是权限的锚点,先分身份再谈权限;第二,默认身份必须被物理禁用,而不是靠约定避开;第三,任何收窄都要配一条反向验证命令,能证明“不能做什么”,才算真的收窄了。
身份先分明
权限再收窄
准入终兜底
一链守一域

浙公网安备 33010602011771号