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 与设计意图不一致,二是存在命名空间之外的越权路径,三是权限变更没有幂等入口,改一次就有人手工改一次。

把这三条翻成可验证的完成标准:

  1. 每个工作负载显式声明 serviceAccountName,集群里不存在依赖 default SA 的业务 Pod;
  2. RAG 身份对目标资源只有 get/list/watch,Operator 身份只对指定 apiGroup 的指定资源有权限且带 /status,CI 身份只在目标命名空间内对 Deployment 有写权限;
  3. 用 kubectl auth can-i 可以正向验证“能做什么”,也可以反向验证“不能做什么”;
  4. 准入层能拒绝未声明 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 管动作,准入管对象。这条链的价值不在于拦住了多少请求,而在于它把“权限是否正确”从人的记忆变成了可验证的事实。

可复用的判断有三条。第一,身份是权限的锚点,先分身份再谈权限;第二,默认身份必须被物理禁用,而不是靠约定避开;第三,任何收窄都要配一条反向验证命令,能证明“不能做什么”,才算真的收窄了。

身份先分明
权限再收窄
准入终兜底
一链守一域

posted @ 2026-10-12 09:51  李福春  阅读(3)  评论(0)    收藏  举报