大数跨境

Kubernetes 服务间认证:ServiceAccount Token 在代码里怎么用?

Kubernetes 服务间认证:ServiceAccount Token 在代码里怎么用? 运维开发与AI实战
2026-10-15
0
导读:用订单服务调用库存服务的例子,拆解 Token 挂载、请求携带、TokenReview 校验和业务授权,给出 Python 与 Kubernetes 配置,并说明轮换和双向认证的边界。

服务都部署在 Kubernetes 内部,订单服务调用库存服务时,库存服务如何确认请求来自订单服务?

一种方案是让每个服务使用独立的 ServiceAccount,再用 Kubernetes 签发的 Token 证明身份。配置好 ServiceAccount 后,应用仍然要完成几件事:读取 Token、随请求发送、校验对方的 Token,然后判断对方能否调用当前接口。

我用一个具体例子把这条链路拆开:demo 命名空间里的 orders 调用 inventory,读取 GET /stock。代码使用 Python 标准库,便于看清 HTTP 请求里到底发生了什么。示例展示认证核心,不是完整的生产部署;文末列出集群联调需要验证的项目。

先分清:身份、凭证和权限

ServiceAccount 是工作负载的身份。这里分别创建 orders 和 inventory 两个 ServiceAccount。它们与同名的 Kubernetes Service 是不同资源:Service 负责流量寻址,ServiceAccount 负责身份。

Token 是证明身份的凭证。本文使用投射到 Pod 文件系统中的短期 ServiceAccount Token。它是签名 JWT,包含签发者、主体、有效期和受众等信息。JWT 的内容可以被解码,签名用于防篡改,内容并没有因此被加密。

对库存服务而言,校验成功后得到的调用方名称类似这样:

system:serviceaccount:demo:orders

这能回答“请求持有哪个 ServiceAccount 的凭证”。它不会自动回答“这个身份是否可以扣减库存”。后一个问题需要库存服务自己的授权规则。

本文约定:orders 只能读取 GET /stock。即使另一个服务持有有效 Token,也不能因此获得这个接口的权限。

还有一个范围限制:多个 Pod 共用 orders 时,在这条授权规则看来,它们是同一个身份。它也不代表最终登录用户。如果需要按用户、租户或某张订单授权,还要增加相应的业务身份与权限检查。

一次请求,为什么会涉及两枚 Token?

先看图中的蓝色请求,再看向下延伸的校验路径。库存服务收到订单服务的 Token 后,会向 API Server 发起一次 TokenReview,请 Kubernetes 帮它验证凭证。

业务请求携带订单凭证,校验请求则用库存自己的凭证访问 API Server。

这两次请求的身份不同:

  • 订单 → 库存:Authorization 请求头携带订单服务的 Token,受众是库存服务。

  • 库存 → API Server:Authorization 请求头携带库存服务自己的 Token,受众是 Kubernetes API Server;请求体里的 spec.token 才是待验证的订单 Token。

这个区别是实现中最容易绕晕的地方。库存服务不能直接拿订单 Token 当作自己的凭证去调用 Kubernetes API。

TokenReview 成功只完成身份校验。库存服务随后还要读取返回的身份,检查本地授权规则,才能执行库存查询。

第一步:为调用方挂载专用 Token

先创建身份,以下资源均是示例:

apiVersion: v1
kind: Namespace
metadata:
  name: demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders
  namespace: demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: inventory
  namespace: demo

下面是订单服务的 Pod spec 片段。使用 Deployment 时,把它放在 spec.template.spec 下,并合并进已有容器配置。镜像地址需要替换。

serviceAccountName: orders
automountServiceAccountToken: false
containers:
  - name: orders
    image: your-registry/orders:demo
    volumeMounts:
      - name: inventory-token
        mountPath: /var/run/secrets/inventory
        readOnly: true
volumes:
  - name: inventory-token
    projected:
      sources:
        - serviceAccountToken:
            path: token
            audience: urn:demo:inventory
            expirationSeconds: 3600

automountServiceAccountToken: false 关闭默认 API Token 的自动挂载,不会禁止这里显式声明的投射卷。订单服务只需要调用库存接口,就不必为此取得 Kubernetes 资源的读写权限。投射凭证由 kubelet 为 Pod 获取,应用无需自己调用 TokenRequest。

audience 表示“这枚凭证打算交给谁使用”。这里使用 urn:demo:inventory 作为库存服务的标识,它无需是一个能访问的网址,但签发配置与校验配置必须完全一致。

受众限制不能代替调用方授权。 不能因为 Token 的 aud 是库存服务,就认定它来自订单服务。身份仍然要检查。

expirationSeconds: 3600 是请求的有效期,实际签发结果受集群配置约束。kubelet 会轮换投射 Token,应用负责重新读取文件。不要把 Token 复制进镜像、ConfigMap 或长期环境变量,也不要用 subPath 挂载这个需要更新的文件。

第二步:调用前读文件,放入 Bearer 请求头

订单服务的请求代码如下。NoRedirect 明确拒绝重定向,避免凭证被带到意外的地址。

import ssl
from pathlib import Path
from urllib.request import (
    HTTPRedirectHandler, HTTPSHandler, Request, build_opener,
)

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

def get_stock():
    token = Path(
        "/var/run/secrets/inventory/token"
    ).read_text().strip()
    context = ssl.create_default_context(cafile="/trust/ca.crt")
    opener = build_opener(HTTPSHandler(context=context), NoRedirect())
    request = Request(
        "https://inventory.demo.svc:8443/stock",
        headers={"Authorization": f"Bearer {token}"},
    )
    with opener.open(request, timeout=5) as response:
        return response.read().decode()

每次调用都重新打开 Token 文件,是教学示例里最容易理解的轮换处理方式。高吞吐场景可以复用连接和 TLS 配置,并设计短周期刷新机制,但不能只在进程启动时读取一次 Token。

这里有一个明确的部署前提:库存服务已启用 HTTPS,证书覆盖 inventory.demo.svc,订单服务在 /trust/ca.crt 挂载了签发该服务证书的受信 CA。这个 CA 不一定等于 Kubernetes API Server 的 CA。

ServiceAccount Token 是 Bearer 凭证,持有者可以使用它。只因为流量在集群内部,就改用明文 HTTP,会暴露凭证;签名不能防止凭证被截获后重放。

第三步:允许库存服务调用 TokenReview

库存服务需要向 Kubernetes API 发起认证校验,因此它要有 create tokenreviews 权限。以下配置仅授予这一项权限:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: demo-inventory-token-reviewer
rules:
  - apiGroups: ["authentication.k8s.io"]
    resources: ["tokenreviews"]
    verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: demo-inventory-token-reviewer
subjects:
  - kind: ServiceAccount
    name: inventory
    namespace: demo
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: demo-inventory-token-reviewer

TokenReview 是集群作用域 API,因此这里使用 ClusterRole 和 ClusterRoleBinding。create 表示发起校验,不是在 etcd 中保存一个供后续查询的业务对象。

库存服务 Pod 使用以下设置:

serviceAccountName: inventory
automountServiceAccountToken: true

在标准集群配置下,默认卷会提供访问 API Server 所需的 Token 和 CA 文件:

/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

这枚默认 Token 供库存服务访问 Kubernetes API。它与订单服务挂载的 urn:demo:inventory Token 用途不同。

不要为了完成认证给库存服务绑定 cluster-admin。也不需要给订单服务授予 tokenreviews 权限:本例中,订单只发出业务请求,库存才负责校验。

第四步:在服务代码中验证身份

库存服务发给 API Server 的请求,核心结构如下。尖括号内的内容是占位说明,不是真实凭证:

POST /apis/authentication.k8s.io/v1/tokenreviews
Authorization: Bearer <inventory 自己的 API Token>
Content-Type: application/json

{
  "apiVersion": "authentication.k8s.io/v1",
  "kind": "TokenReview",
  "spec": {
    "token": "<orders 发来的 Token>",
    "audiences": ["urn:demo:inventory"]
  }
}

把下面的 Python 代码保存为 auth.py。它使用 HTTPS 校验 API Server 证书,并在每次校验时重读库存服务自己的 Token,避免漏掉轮换。

import json
import ssl
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import (
    HTTPRedirectHandler, HTTPSHandler, Request, build_opener,
)

AUDIENCE = "urn:demo:inventory"
SA_DIR = Path("/var/run/secrets/kubernetes.io/serviceaccount")
REVIEW_URL = (
    "https://kubernetes.default.svc"
    "/apis/authentication.k8s.io/v1/tokenreviews"
)

class AuthFailure(Exception):
    def __init__(self, status):
        self.status = status

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

def review_token(token):
    try:
        reviewer_token = (SA_DIR / "token").read_text().strip()
        context = ssl.create_default_context(
            cafile=str(SA_DIR / "ca.crt")
        )
        opener = build_opener(
            HTTPSHandler(context=context), NoRedirect()
        )
        body = {
            "apiVersion": "authentication.k8s.io/v1",
            "kind": "TokenReview",
            "spec": {"token": token, "audiences": [AUDIENCE]},
        }
        request = Request(
            REVIEW_URL,
            data=json.dumps(body).encode(),
            headers={
                "Authorization": f"Bearer {reviewer_token}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with opener.open(request, timeout=3) as response:
            result = json.load(response)
    except (OSError, HTTPError, URLError, ValueError):
        raise AuthFailure(503) from None
    if (
        not isinstance(result, dict)
        or not isinstance(result.get("status"), dict)
    ):
        raise AuthFailure(503)
    return result["status"]

这里的 503 表示认证依赖不可用,业务操作不会继续。真实系统应记录超时、上游状态码等经过脱敏的诊断信息,但不要记录 Authorization、完整 Token 或 TokenReview 请求体。

如果 API Server 对这次请求返回 HTTP 403,首先检查库存服务是否有 create tokenreviews 权限。这与“订单服务无权访问库存接口”的业务 403 是两件不同的事。

TokenReview 请求成功,不等于待验证 Token 有效。 必须继续检查响应中的 status.authenticated,并核对 status.audiences 包含预期受众。

第五步:在认证之后执行授权

继续向 auth.py 添加下面的代码:

ALLOWED = {
    ("system:serviceaccount:demo:orders", "GET", "/stock")
}

def authorize(header, method, path, review=review_token):
    parts = header.split()
    if len(parts) != 2 or parts[0].lower() != "bearer":
        raise AuthFailure(401)
    token = parts[1]
    if len(token) > 16384:
        raise AuthFailure(401)

    status = review(token)
    if status.get("error"):
        raise AuthFailure(503)
    if status.get("authenticated") is not True:
        raise AuthFailure(401)
    audiences = status.get("audiences")
    if not isinstance(audiences, list) or AUDIENCE not in audiences:
        raise AuthFailure(401)

    user = status.get("user")
    username = user.get("username") if isinstance(user, dict) else None
    if not isinstance(username, str):
        raise AuthFailure(401)
    if (username, method, path) not in ALLOWED:
        raise AuthFailure(403)
    return username

16384 是示例选择的输入长度上限,不是 Kubernetes 的 Token 长度规范。生产环境还应在 HTTP 入口限制请求头大小,避免无限接收数据后才检查。

这里把非空 status.error 统一映射为 503,是示例的保守处理策略。这个字段也可能包含凭证过期等校验错误,不能仅凭它断定 API Server 故障。因此,过期 Token 在本例中也可能得到 503;安全要求是拒绝执行业务,而不是保证所有无效 Token 都返回 401。不要依赖错误字符串做稳定分类,也不要因此无限重试。

从图中间的认证节点向下看:认证失败走向 401;身份可信之后,还要经过授权节点,无权访问则走向 403。认证依赖异常走向 503,也不会进入业务处理。

身份有效后仍要检查接口权限;认证失败、无权访问与校验不可用分别停止请求。

在 Web 框架中,把 authorize() 放进中间件、依赖函数或路由前置检查即可。处理顺序是:

  1. 只接受一个 Authorization 请求头,拒绝重复头。

  2. 调用 authorize(header, method, path)。

  3. 捕获 AuthFailure,返回其中的状态码;401 响应带上 WWW-Authenticate: Bearer。

  4. 只有函数正常返回后,才执行查询或修改数据。

目录中的 server.py 给出了一个标准库 HTTPS 服务示例,client.py 是调用端。它们与 auth.py 放在同一目录,可用 Python 3.11+ 运行。服务端需要 /tls/tls.crt、/tls/tls.key,并监听 8443;Kubernetes Service 应将流量转发到该端口。证书签发、Secret/CA 挂载和镜像构建需要按实际环境补齐。标准库 HTTP 服务仅用于教学,生产环境应使用具备并发限制、超时和监控能力的服务框架。

示例精确匹配 /stock,带查询参数的 /stock?sku=1 不会被放行。实际框架中应使用已解析的路由或明确的资源动作来授权,同时校验参数;不要通过随意删除路径片段来扩大放行范围。

这里的授权只检查 ServiceAccount 全名。若账号删除后以同名重建,规则仍会认可新账号;需要绑定某一个账号实例时,可再核对 TokenReview 返回的 user.uid。

“互相认证”需要说清楚是哪一种

上面的链路中,库存通过 Token 验证订单身份;订单通过 HTTPS 证书校验目标服务器。它没有让订单读取并验证库存的 ServiceAccount Token。

如果“互相认证”指两个服务会分别向对方发请求,可以对称配置:

  • 订单调用库存时,发送受众为 urn:demo:inventory 的订单 Token。

  • 库存调用订单时,发送受众为 urn:demo:orders 的库存 Token。

  • 两边都实现 TokenReview 校验与自己的业务授权,也都需要相应的 reviewer 权限。

同一个 Pod 可以挂载面向不同目标的多枚 Token,访问 API Server 的凭证继续单独使用。不建议把多个用途不加区分地塞进一枚通用 Token。

如果要求“在同一条连接上,双方都以工作负载身份互相证明”,则应评估 mTLS 等机制。不能把回包里附带一枚 Token 当作等价替代,也不能把普通 Bearer 请求直接称为 mTLS。

不调用 TokenReview,直接验 JWT 可以吗?

可以。另一条路径是从受信集群的发行者发现信息中获取 JWKS,即签名公钥集合,再使用成熟的 JWT 库完成本地验证。

但“把 JWT 解码,读出 sub”远远不够。本地验证要检查签名、固定的可信签发者、允许的算法、受众和时间约束,并处理密钥轮换。不要根据未经验证的 Token 内容,任意访问其声称的发行者地址。

两种路径的主要差异在于依赖与失效语义:TokenReview 增加 API Server 的请求负载,但能检查绑定对象的当前状态;纯离线验签减少请求时的控制面依赖,却不能仅凭签名得知 Pod 或 ServiceAccount 已被删除,可能继续接受 Token 直到过期。

选择在线校验,也不要把删除对象理解成“全系统同一毫秒撤销”。绑定对象的删除宽限处理,以及应用自己的正向缓存,都会影响实际停止接受凭证的时间。

对理解与首次实现而言,我会先采用本文的 TokenReview 路径。需要优化吞吐时,再测量调用量、延迟与控制面容量,决定是否引入缓存或离线验签。缓存必须包含受众这一维度,缓存键可以使用 Token 哈希;有效期应有短上限并受 Token 剩余寿命约束,且要接受相应的撤销延迟。认证依赖故障时不能默认放行。

上线前,验证这些失败路径

本文没有在真实 Kubernetes 集群中执行端到端部署。以下是需要在隔离测试环境完成的验收条件,不是已经得到的运行结果:

  • 正确身份、正确受众访问 GET /stock,应返回 200。

  • 缺少 Token、伪造 Token、过期 Token,或受众错误,应被拒绝。

  • 另一个 ServiceAccount 持有受众正确的有效 Token,仍应返回业务 403。

  • 去掉库存服务的 reviewer 权限,或中断它到 API Server 的连接,业务不能执行;本例返回 503。

  • 持续运行跨过一次 Token 轮换后,新的请求仍应成功。

  • 删除绑定 Pod 后,检查旧 Token 在宽限处理和缓存窗口结束后被拒绝。

  • 使用错误的服务端证书或不可信 CA 时,客户端 TLS 校验必须失败。

还需要保护“谁能使用这些身份”。如果某个人可以在目标命名空间创建使用 orders ServiceAccount 的 Pod,或者有权为它申请 Token,就可能获得订单服务身份。应用层的白名单必须建立在可信的工作负载创建权限之上。

这套方案把凭证签发和轮换交给 Kubernetes,但服务代码仍有明确责任:调用方读取最新凭证,被调用方验证受众与身份,再按接口执行授权。把这几个动作分开实现,ServiceAccount Token 才能成为清楚、可检查的服务间认证机制。

【声明】内容源于网络
0
0
运维开发与AI实战
DevSecOps工程师,分享AI, Web3, Claude code开发的经验与心得。希望能帮大家解决技术难题,提升开发效率!自身从与大家的沟通中获得进步,欢迎留言交流,一起成长!
内容 2550
粉丝 0
运维开发与AI实战 DevSecOps工程师,分享AI, Web3, Claude code开发的经验与心得。希望能帮大家解决技术难题,提升开发效率!自身从与大家的沟通中获得进步,欢迎留言交流,一起成长!
总阅读72.5k
粉丝0
内容2.5k