找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4064

积分

0

好友

530

主题
发表于 3 小时前 | 查看: 3| 回复: 0

许多团队以为自己在用 Kubernetes 身份认证,实际上只是把一个长期有效的 Bearer Token 塞进了 Pod。

一、事故不是从 Token 过期开始的,而是从 Token 永不过期开始的

先看一个很典型的线上场景。

某支付中台在一次集群权限收敛中,突然出现一批 401 Unauthorized。表面现象是订单服务访问 API Server 失败,最初大家怀疑的是:

  • RBAC 规则被改坏了
  • API Server 证书有问题
  • 客户端 SDK 缓存了旧凭据

排查到最后,真正的问题反而更基础:

  • 这个服务一直挂载的是传统 Secret 形式的 ServiceAccount Token
  • Token 很早以前被人复制到调试脚本和 Git 仓库里
  • 安全团队发现泄漏后,临时删除并重建了相关 Secret
  • 应用侧、脚本侧、运维侧对“Token 是否会失效、何时失效、谁负责轮换”理解并不一致

这类问题危险的地方不在于一次 401,而在于它通常同时暴露出三件事:

  1. 集群里仍然存在长期有效的静态凭据。
  2. 很多工作负载实际上并不需要访问 Kubernetes API,却默认拿到了 API 凭据。
  3. 团队把“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 时,服务端大致会做这些事:

  1. 解析 token,识别其是否为 ServiceAccount 凭据。
  2. 校验签名是否合法。
  3. 检查 nbfiatexp 等时间约束。
  4. 检查 audience 是否被 API Server 接受。
  5. 检查 token 里绑定的对象引用当前是否仍然有效。
  6. sub 映射为用户身份,例如:
User: system:serviceaccount:payment:order-service
Groups:
- system:serviceaccounts
- system:serviceaccounts:payment
  1. 再进入 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. 401403 要分开治理

  • 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 配置是否一致
  • 是否有对 401403TokenReview 延迟、认证失败率的监控

工作负载侧

  • 哪些 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 当成启动常量缓存

那风险并不在未来,而是在现在。

真正值得做的不是背下几个版本号,而是完成这四件事:

  1. 先收回不必要的默认凭据。
  2. 再为需要身份的工作负载显式投射 token。
  3. 用 audience 和 RBAC 把边界拆开。
  4. 让应用按“可轮换凭据”方式消费 token。

做到这里,ServiceAccount 才算从“能用”进入“可治理”。对于追求极致安全的团队,还可以进一步结合 GitOps 的声明式管理理念,将 SA 和 RBAC 的变更完全纳入代码审查流程,杜绝手动操作带来的配置漂移。

参考资料

  • Kubernetes 官方文档:Service Accounts
  • Kubernetes 官方文档:Configure Service Accounts for Pods
  • Kubernetes 官方文档:Managing Service Accounts
  • Kubernetes 官方文档:Projected Volumes
  • Kubernetes 官方文档:TokenRequest API



上一篇:一代神卡GTX 1060十周年:从霸榜Steam到RTX 3080的11风扇散热折腾记录
下一篇:刷到外企外包30k和甲方19k纠结贴,顺便写个环形链表
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-24 08:14 , Processed in 1.209183 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表