许多团队以为自己在用 Kubernetes 身份认证,实际上只是把一个长期有效的 Bearer Token 塞进了 Pod。
一、事故不是从 Token 过期开始的,而是从 Token 永不过期开始的
先看一个很典型的线上场景。
某支付中台在一次集群权限收敛中,突然出现一批 401 Unauthorized。表面现象是订单服务访问 API Server 失败,最初大家怀疑的是:
- RBAC 规则被改坏了
- API Server 证书有问题
- 客户端 SDK 缓存了旧凭据
排查到最后,真正的问题反而更基础:
- 这个服务一直挂载的是传统
Secret 形式的 ServiceAccount Token
- Token 很早以前被人复制到调试脚本和 Git 仓库里
- 安全团队发现泄漏后,临时删除并重建了相关 Secret
- 应用侧、脚本侧、运维侧对“Token 是否会失效、何时失效、谁负责轮换”理解并不一致
这类问题危险的地方不在于一次 401,而在于它通常同时暴露出三件事:
- 集群里仍然存在长期有效的静态凭据。
- 很多工作负载实际上并不需要访问 Kubernetes API,却默认拿到了 API 凭据。
- 团队把“Pod 有身份”误解成了“身份管理已经安全”。
ServiceAccount Token 真正的风险,从来不是它会不会过期,而是你可能根本不知道它什么时候才会失效、被谁使用、是否还在被复制。
二、先把问题说清楚:ServiceAccount、Token 和“默认挂载”不是一回事
很多文章会把这三个概念混在一起,实际落地时最容易出错的也正是这里。
1. ServiceAccount 是身份主体
它回答的是“这个 Pod 以谁的身份出现”。
在 Kubernetes 里,典型主体长这样:
system:serviceaccount:payment:order-service
其中:
payment 是命名空间
order-service 是 ServiceAccount 名称
RBAC 授权最终匹配的就是这个主体和它所属的组。
2. Token 是身份凭据
它回答的是“Pod 如何证明自己就是这个主体”。
这个凭据通常是 JWT,但它有两条完全不同的实现路径:
| 方式 |
典型时期 |
凭据载体 |
是否过期 |
是否自动轮换 |
是否推荐 |
| Secret-based token |
v1.24 之前大量存在 |
Secret 挂载 |
否 |
否 |
不推荐 |
| TokenRequest / projected token |
当前主流 |
projected volume |
是 |
是 |
推荐 |
3. 默认挂载是一个 Admission 行为
它回答的是“这个 Pod 会不会自动获得 API 凭据”。
只要没有显式关闭 automountServiceAccountToken,ServiceAccount Admission Controller 就会给 Pod 注入一个 API 访问凭据卷。当前 Kubernetes 默认注入的是投射式 token,而不是早年的 Secret 卷。
这意味着一个常被忽略的事实:
你即便没有手写任何 volume,也可能已经给 Pod 发了访问 API Server 的凭据。
三、为什么老方案是“定时炸弹”
如果你的集群历史比较久,或者平台层曾经为了兼容旧业务手工创建过 kubernetes.io/service-account-token 类型的 Secret,那么你很可能还背着传统模式的包袱。
1. 静态 Token 的问题不是“老”,而是生命周期不可控
传统 ServiceAccount Token 的核心问题有四个:
- 没有自然过期时间
- 不会由 kubelet 自动轮换
- 很容易被脚本、环境变量、日志、调试容器复制出去
- 很多团队把它当成“集群内长期通用凭据”使用
在 Kubernetes v1.24 之前,系统会为 ServiceAccount 自动生成这类长期 token。v1.24 之后,这种自动生成行为被关闭;但“不会自动生成”不等于“历史风险自动消失”,因为:
- 老集群升级后,历史遗留 Secret 可能依然存在
- 团队仍然可以手工创建长期 token Secret
- 外部系统集成时,很多人仍然图省事走静态 token
2. 清理机制存在,但不能当成安全兜底
Kubernetes 后续为自动生成的 legacy token 增加了追踪和清理机制。较新的版本会对长期未使用、且满足特定条件的自动生成旧 token 做失效标记甚至清理。
但它解决不了三个核心问题:
- 手工创建的长期 token 不在“自动清理”这个保护范围里
- 已经泄漏出去的 token,不会因为你“很少使用它”就自动安全
- 清理机制的目标是减少遗留风险,不是替代凭据治理
所以工程上不能把“新版 Kubernetes 会帮我处理”当作结论。你仍然要主动盘点、迁移和收敛。
四、新方案到底改变了什么:不是换个挂载姿势,而是把身份变成短周期凭据
当前推荐方案的核心是 TokenRequest API 加 projected serviceAccountToken volume。
它和旧方案的差别不只是“动态生成”,而是身份模型变了。
1. Token 变成了有边界的临时凭据
投射式 ServiceAccount Token 有几个关键特征:
- 有过期时间
- 可以指定
audience
- 由 kubelet 在到期前自动轮换
- 可以和 Pod 对象生命周期绑定
默认情况下,这类 token 的有效期通常是 1 小时,最短请求值是 10 分钟。kubelet 会在 token 生存期达到约 80% 时,或者 token 年龄超过 24 小时时,主动尝试轮换。
2. Token 不只是“这个 SA 签发的”,还是“为这个对象签发的”
这点非常重要。
当 token 通过 TokenRequest 为 Pod 使用场景签发时,JWT 里除了 sub 之外,还会包含 Kubernetes 私有声明,例如:
{
"aud": [
"https://kubernetes.default.svc"
],
"iss": "https://kubernetes.default.svc",
"sub": "system:serviceaccount:payment:order-service",
"kubernetes.io": {
"namespace": "payment",
"pod": {
"name": "order-service-7d5f4b6c9-abcde",
"uid": "62e8f..."
},
"serviceaccount": {
"name": "order-service",
"uid": "adc8b5e4-..."
}
}
}
这带来两个工程意义:
- 这个 token 不再只是“某个账号的令牌”,而是“某个对象在某个时间窗口内使用的令牌”
- API Server 在认证时会校验这些绑定关系是否仍然有效
如果 Pod 被删除,绑定到它的 token 不应该再被继续信任。
3. 但要区分“离线验签”和“向 API Server 验证”
这是很多系统接入外部服务时最容易忽略的边界。
如果外部系统只是根据 OIDC Discovery 拿到公钥,然后本地离线校验 JWT 签名,它能确认:
- 签名是真的
- token 没过期
- audience 匹配
但它不能实时确认:
- 这个 Pod 现在是否还存在
- 这个 ServiceAccount 是否已被删除
- 这个绑定对象是否已经失效
如果你的业务要求“对象失效后凭据立刻失效”,那就不能只做离线验签,而应该走 TokenReview。
这不是实现细节,而是安全边界。
五、一次请求到底怎么被认证
理解认证链路,很多排障才不会跑偏。
当 Pod 用 Authorization: Bearer <token> 访问 API Server 时,服务端大致会做这些事:
- 解析 token,识别其是否为 ServiceAccount 凭据。
- 校验签名是否合法。
- 检查
nbf、iat、exp 等时间约束。
- 检查 audience 是否被 API Server 接受。
- 检查 token 里绑定的对象引用当前是否仍然有效。
- 将
sub 映射为用户身份,例如:
User: system:serviceaccount:payment:order-service
Groups:
- system:serviceaccounts
- system:serviceaccounts:payment
- 再进入 RBAC 授权阶段。
所以当你看到 401 Unauthorized 时,它通常表示问题发生在“身份认证”之前几步;而看到 403 Forbidden 时,往往说明“身份是真的,但权限不够”。
这两个错误在生产里必须分开看,否则你会把认证问题误判成 RBAC 配置问题,或者反过来。
六、生产落地时,先做减法,再做精细化授权
很多团队一上来就写 Role、RoleBinding,结果越写越多,却没有先回答一个更关键的问题:
这个 Pod 真的需要 Kubernetes API 凭据吗?
实践里,建议先把工作负载分成三类。
| 工作负载类型 |
是否需要 API Token |
建议做法 |
| 完全不访问 Kubernetes API |
不需要 |
automountServiceAccountToken: false |
| 只访问 API Server |
需要 |
使用默认 API audience 的投射 token |
| 同时访问 API Server 和外部信任系统 |
需要 |
分别投射不同 audience 的 token |
这个判断比“先给每个服务建一个 SA”更重要。因为最小权限的第一步不是把权限写细,而是不给不需要的 Pod 发凭据。
七、一套更稳妥的生产配置
下面用一个支付服务的场景来落地。
假设 order-service 有三个需求:
- 读取命名空间内一个固定名称的
ConfigMap
- 参与 Leader Election,读写
Lease
- 访问 Vault 获取数据库动态凭据
这时不应该使用 default ServiceAccount,而是拆出专用身份,并且把 API 凭据和 Vault 凭据分开。
1. 先定义最小权限的 ServiceAccount 和 RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-service
namespace: payment
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-service-role
namespace: payment
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["order-service-config"]
verbs: ["get", "watch"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-service-rb
namespace: payment
subjects:
- kind: ServiceAccount
name: order-service
namespace: payment
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: order-service-role
这里有两个关键点:
- 在 ServiceAccount 上把
automountServiceAccountToken 关掉,避免“默认给 API token”
- RBAC 只授予确实需要的对象和动词,不直接给
list secrets 这种高风险权限
2. 再显式投射需要的 token
apiVersion: v1
kind: Pod
metadata:
name: order-service
namespace: payment
spec:
serviceAccountName: order-service
automountServiceAccountToken: false
containers:
- name: app
image: example.com/order-service:1.0.0
volumeMounts:
- name: identity-tokens
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: identity-tokens
projected:
sources:
- serviceAccountToken:
path: kube-api-token
audience: https://kubernetes.default.svc
expirationSeconds: 3600
- serviceAccountToken:
path: vault-token
audience: vault
expirationSeconds: 3600
这套配置比“让默认 token 同时拿去访问 API Server 和 Vault”更稳妥,因为:
- 泄漏
vault-token 不等于直接泄漏 API Server 凭据
- 外部系统可以明确校验自己的 audience
- 后续可以单独调整某一种 token 的 TTL 和消费方式
八、应用代码最容易犯的错,不是不会读 token,而是把 token 当成常量
生产事故里,最常见的问题不是 YAML 写错,而是应用侧根本没按照“可轮换凭据”去使用 token。
错误做法
- 启动时读取一次 token,放进全局变量
- 用环境变量传 token 给应用
- 通过
subPath 把 token 单文件挂进容器
- 自己写一个长生命周期 HTTP Client,把 Bearer Token 固定死
这些做法本质上都在把“短期凭据”重新用成“静态凭据”。
正确做法
- 优先使用官方 SDK,让它从文件读取并感知轮换
- 如果必须自己接入,按请求或按短周期从文件重读
- 不要使用
subPath 挂载 token 文件,因为 projected volume 的更新不会反映到 subPath 挂载
Go 示例:通过 BearerTokenFile 使用可轮换 token
package main
import (
"context"
"log"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func main() {
cfg, err := rest.InClusterConfig()
if err != nil {
log.Fatalf("build in-cluster config failed: %v", err)
}
clientset, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Fatalf("create client failed: %v", err)
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
_, err := clientset.CoreV1().
ConfigMaps("payment").
Get(context.Background(), "order-service-config", metav1.GetOptions{})
if err != nil {
log.Printf("k8s api call failed: %v", err)
continue
}
log.Println("configmap read succeeded")
}
}
这段代码的关键不是“能访问 API”,而是 InClusterConfig() 默认走文件型 token,而不是把字符串硬编码进进程内存。
Java 示例:每次访问外部系统前从文件读取 token
@Configuration
public class VaultConfig {
@Value("${vault.token-file:/var/run/secrets/tokens/vault-token}")
private String tokenFile;
@Bean
VaultTemplate vaultTemplate() {
VaultEndpoint endpoint = VaultEndpoint.create("vault.example.com", 443);
endpoint.setScheme("https");
ClientAuthentication authentication = () -> {
try {
String token = Files.readString(Path.of(tokenFile)).trim();
return LoginToken.of(token);
} catch (IOException ex) {
throw new IllegalStateException("read vault token failed", ex);
}
};
return new VaultTemplate(endpoint, authentication);
}
}
如果你接的不是 Vault,而是自研服务,原则也一样:不要让 token 成为启动期常量。
九、迁移时真正会踩的坑,不在官方示例里
1. 关掉自动挂载后,很多框架会直接报错
一些控制器、Operator、Leader Election 组件、服务网格扩展、Admission Webhook 自己就依赖集群内身份。如果你把 automountServiceAccountToken 一刀切关掉,启动阶段就可能报:
- 无法加载 in-cluster config
- 无法创建
Lease
- 无法调用 API Server 拉配置
所以迁移不能从“统一改配置”开始,而要从“盘点谁真的依赖 API”开始。
2. 401 和 403 要分开治理
401 常见于 audience 不匹配、token 过期、对象绑定失效、issuer 配置不一致
403 常见于 RBAC 缺权限
如果监控里只把两者都归成“调用 Kubernetes API 失败”,故障定位会非常慢。
3. 外部系统只做 JWT 离线验签,不代表接入是安全的
这是 Vault、自研鉴权服务、Service Mesh 外围扩展里都容易出现的误区。
离线验签适合这些场景:
- 你更关心吞吐和低延迟
- 你能接受 token 在 TTL 窗口内继续有效
- 你只需要确认“它确实由这个集群签发”
如果你要求“Pod 删除后立刻失效”,那就应该使用 TokenReview,不要用“验签通过”替代“身份当前仍有效”。
4. 多 audience 设计不当,会把权限边界重新打平
常见偷懒方式是:
- 用 API Server audience 的 token 去访问外部系统
- 外部系统根本不校验 audience
这样一来,外部系统一旦泄漏 token,攻击者拿到的就是能直接打 API Server 的凭据。
这类问题不是“接入细节没做好”,而是身份边界设计失败。
十、从单集群到多系统信任,应该怎么选
当 ServiceAccount Token 不只服务于 Kubernetes API,而要扩展到 Vault、网关扩展、跨集群访问或零信任体系时,建议这样判断。
| 场景 |
推荐方式 |
原因 |
| 只在集群内访问 API Server |
默认投射 token |
简单、原生、足够 |
| 集群内服务访问外部信任系统 |
自定义 audience 的 projected token |
减少 API 凭据外溢 |
| 外部系统需要严格确认对象仍有效 |
TokenReview |
能校验绑定对象当前状态 |
| 外部系统只需要确认签发者和基础声明 |
OIDC Discovery + JWT 验签 |
延迟低、易集成 |
| 工作负载间双向身份与链路加密 |
Service Mesh / SPIFFE |
解决的是工作负载身份和 mTLS,不是 API 调用凭据本身 |
这里需要区分两个问题:
- ServiceAccount Token 解决的是“工作负载如何证明自己”
- SPIFFE / Mesh 更多解决的是“服务之间如何建立持续身份与安全通道”
两者有关联,但不能互相替代。
十一、上线前建议做一次显式检查
如果你准备治理 ServiceAccount Token,建议至少检查下面这些项。
集群侧
- 是否仍然存在手工创建的
kubernetes.io/service-account-token Secret
- 是否还有业务依赖历史遗留的长期 token
- API Server 的
service-account-issuer、签名密钥和 audience 配置是否一致
- 是否有对
401、403、TokenReview 延迟、认证失败率的监控
工作负载侧
- 哪些 Pod 根本不需要访问 Kubernetes API
- 哪些 Pod 同时依赖 API Server 和外部信任系统
- 是否仍有代码在启动时一次性读取 token
- 是否存在通过
subPath 挂 token 的写法
权限侧
- 是否仍在使用
default ServiceAccount 承载正式业务
- 是否存在宽泛的
ClusterRoleBinding
- 是否误授予
list/watch secrets
接入侧
- 外部系统是否校验
audience
- 需要实时失效语义的场景是否使用了
TokenReview
- 是否把“JWT 可验签”误当成“身份一定当前有效”
十二、一个更实际的结论
ServiceAccount Token 的演进,本质上不是 Kubernetes 把旧凭据换成新凭据,而是把“长期共享密钥”改造成“和对象生命周期绑定的短期身份凭据”。
如果你的集群今天还存在这些情况:
- 默认给所有 Pod 发 API 凭据
- 业务仍依赖长期有效的 token Secret
- 外部系统不校验 audience
- 应用把 token 当成启动常量缓存
那风险并不在未来,而是在现在。
真正值得做的不是背下几个版本号,而是完成这四件事:
- 先收回不必要的默认凭据。
- 再为需要身份的工作负载显式投射 token。
- 用 audience 和 RBAC 把边界拆开。
- 让应用按“可轮换凭据”方式消费 token。
做到这里,ServiceAccount 才算从“能用”进入“可治理”。对于追求极致安全的团队,还可以进一步结合 GitOps 的声明式管理理念,将 SA 和 RBAC 的变更完全纳入代码审查流程,杜绝手动操作带来的配置漂移。
参考资料
- Kubernetes 官方文档:Service Accounts
- Kubernetes 官方文档:Configure Service Accounts for Pods
- Kubernetes 官方文档:Managing Service Accounts
- Kubernetes 官方文档:Projected Volumes
- Kubernetes 官方文档:TokenRequest API