K8S Agent 详解
分析对象:SPIRE v1.15.2,
路径:行号相对spire-src,均已用一手代码核对。 配套文档:Agent 详解 · Server 详解 · SVID 颁发流程 · 三节点部署与复现
"K8S agent" 不是另一个二进制,而就是 spire-agent 换上两个 Kubernetes 专属插件。 具体说,它 = spire-agent(同一个可执行文件)+ 节点认证插件 k8s_psat(入网时向 server 证明"我是某集群某节点上的 agent")+ 工作负载认证插件 k8s(下发 SVID 时把调用进程解析成 Pod/容器)。除这两个插件外,agent 的一切——manager(LRU SVID 缓存与轮换)、svid.Rotator(agent 自身 SVID 轮换)、client(到 server 的 mTLS gRPC)、endpoints(Workload API / SDS)——与 Agent 详解 描述的完全一致,与运行在 VM 上、用 join_token + unix 的 agent 没有任何区别。
因此本页只详解 k8s 特有的三处:部署形态、节点认证 k8s_psat、工作负载认证 k8s;其余通用机制不再重复。
部署形态
K8S agent 以 DaemonSet 运行——每个节点恰好一个 agent Pod,与"每节点一个守护进程"的 agent 模型天然对应。为了让 agent 能认证同节点上任意工作负载,并把 SVID 下发给它们,部署上有四个 k8s 专属要点:
| 要点 | 作用 | 真实 manifest 出处 |
|---|---|---|
| DaemonSet | 每节点一个 agent,随节点扩缩自动铺开 | kind: DaemonSet(deploy.md §3) |
hostPID: true | agent 与工作负载共享宿主 PID 命名空间——工作负载认证插件才能凭 Workload API 调用方的 PID 去宿主 /proc/<pid> 读 cgroup、定位容器 | spec.hostPID: true |
| 共享 socket 目录(hostPath) | agent 的 Workload API Unix socket 落在 hostPath 卷(如 /run/spire/sockets),同节点工作负载 Pod 挂载同一目录即可连上本机 agent | hostPath: { path: /run/spire/sockets } |
| 投影 ServiceAccount Token | 用 projected.serviceAccountToken 签发一枚 audience=spire-server、短期(如 7200s)的 token 给 k8s_psat 节点认证使用 | serviceAccountToken: { audience: spire-server } |
注意:这里的投影 token(
audience=spire-server,挂到/var/run/secrets/tokens/)是给节点认证k8s_psat用的,和 Pod 默认那枚/var/run/secrets/kubernetes.io/serviceaccount/token(给工作负载认证k8s访问 kubelet 用)是两枚不同用途的 token,详见后两节。真实的命名空间 / RBAC / ConfigMap / DaemonSet / demo 工作负载 manifest 见 三节点部署与复现 §3。
部署拓扑
一个关键的不对称已经能从图上看出:节点认证走 API Server,工作负载认证走 kubelet。前者是集群级、由 server 侧发起的可信校验;后者是节点级、由 agent 侧发起的本地解析。下面分别展开。
节点认证 · k8s_psat 详解
k8s_psat = Projected Service Account Token 节点认证器,分 agent、server 两侧;二者用同一份 k8s.PSATAttestationData{Cluster, Token} 载荷对接(pkg/common/plugin/k8s/utils.go:89)。
agent 侧:读投影 token 并发出
文件 pkg/agent/plugin/nodeattestor/k8spsat/psat.go。逻辑极薄——从配置的 token_path 读投影 token,连同 cluster 名打包成 payload 发给 server:
| 配置项(HCL) | 字段 | 出处 |
|---|---|---|
cluster | agent 所在集群名(必填,server 侧要据此选集群配置) | psat.go:53;缺失报错 :65 |
token_path | 投影 token 路径,默认 /var/run/secrets/tokens/spire-agent | psat.go:22、:74 |
核心是 AidAttestation(psat.go:87):
// pkg/agent/plugin/nodeattestor/k8spsat/psat.go:93
token, err := loadTokenFromFile(config.tokenPath) // 读投影 token(空文件即报错,:150)
// ...
payload, err := json.Marshal(k8s.PSATAttestationData{ // :98
Cluster: config.cluster,
Token: token,
})
// ...
return stream.Send(&nodeattestorv1.PayloadOrChallengeResponse{ // :106 一次性发出,无挑战
Data: &nodeattestorv1.PayloadOrChallengeResponse_Payload{Payload: payload},
})agent 侧不做任何校验,只负责"取 token → 发出去"。所有信任判定都在 server 侧,因为只有 server 能拿集群 kubeconfig 去问 API Server。
server 侧:TokenReview 校验 + 派生 agent SPIFFE ID
文件 pkg/server/plugin/nodeattestor/k8spsat/psat.go。配置以集群为单位(clusters map,psat.go:45):
| 集群配置项(HCL) | 作用 | 出处 |
|---|---|---|
service_account_allow_list | 白名单:只允许来自这些 ns:sa 的 token(必填,至少一项) | psat.go:52;校验 :100 |
audience | TokenReview 校验的受众,默认 ["spire-server"] | psat.go:57、:110;默认值 :29 |
kube_config_file | 集群 kubeconfig 路径;空则用 in-cluster 配置 | psat.go:61;apiserver.New :129 |
allowed_node_label_keys | 允许作为 selector 暴露的节点 label 键 | psat.go:64 |
allowed_pod_label_keys | 允许作为 selector 暴露的 Pod label 键 | psat.go:67 |
Attest(psat.go:160)是全部校验逻辑所在,顺序如下:
解包并选集群:反序列化
PSATAttestationData(:176),校验cluster/token非空(:181、:185),按cluster名取集群配置,未配置该集群直接拒(:189)。TokenReview 校验 token(核心):用该集群的 k8s client 调 TokenReview API——
go// pkg/server/plugin/nodeattestor/k8spsat/psat.go:194 tokenStatus, err := cluster.client.ValidateToken(stream.Context(), attestationData.Token, cluster.audience) // ... if !tokenStatus.Authenticated { // :199 未通过认证即 PermissionDenied return status.Errorf(codes.PermissionDenied, "token not authenticated ... for cluster %q", ...) }ValidateToken(pkg/common/plugin/k8s/apiserver/client.go:92)构造authv1.TokenReview{Spec:{Token, Audiences}},调clientset.AuthenticationV1().TokenReviews().Create(...)(:108),并核对返回的受众与请求受众至少有一个吻合(:122),确保校验是"受众感知"的。服务账户白名单:从 TokenReview 结果解析
namespace/serviceAccountName(GetNamesFromTokenStatus,:203,解析system:serviceaccount:<ns>:<sa>形式的 username),拼成ns:sa后对白名单查表,不在白名单即拒(:209)。token → Pod → Node:从 TokenReview 的
Extra里取 Pod 名与 Pod UID(:213、:218;键为authentication.kubernetes.io/pod-name/pod-uid,utils.go:15),然后用 k8s API Server 查实体——go// pkg/server/plugin/nodeattestor/k8spsat/psat.go:223 pod, err := cluster.client.GetPod(stream.Context(), namespace, podName) // CoreV1().Pods(ns).Get // ... node, err := cluster.client.GetNode(stream.Context(), pod.Spec.NodeName) // :228 CoreV1().Nodes().Get nodeUID := string(node.UID) // :233 作为 agent 身份主键(
GetPod/GetNode见apiserver/client.go:47、:71。)派生 selectors 与 agent SPIFFE ID:装配一组
k8s_psat:selector(:238),并用 节点 UID 作为 agent 身份的唯一标识:go// pkg/server/plugin/nodeattestor/k8spsat/psat.go:261 return stream.Send(&nodeattestorv1.AttestResponse{ Response: &nodeattestorv1.AttestResponse_AgentAttributes{ AgentAttributes: &nodeattestorv1.AgentAttributes{ CanReattest: true, // ← reattestable:token 过期后可重新认证,无需人工重签 SpiffeId: k8s.AgentID(pluginName, config.trustDomain, attestationData.Cluster, nodeUID), SelectorValues: selectorValues, }, }, })AgentID(utils.go:94)拼出的形如:spiffe://<trust_domain>/spire/agent/k8s_psat/<cluster>/<node-uid>CanReattest: true意味着 agent 可在投影 token 轮换后重新认证(reattestable),这正是投影 token 短期有效仍能长期运行的前提。
派生的节点 selectors(selector 类型前缀 k8s_psat 由 server 侧 v1 包装器统一加上,pkg/server/plugin/nodeattestor/v1.go:113 Type: v1.Name();值由 k8s.MakeSelectorValue 拼成 kind:value,utils.go:103):
| Selector | 来源 | 出处 |
|---|---|---|
k8s_psat:cluster:<c> | attestation 里的集群名 | psat.go:239 |
k8s_psat:agent_ns:<ns> | TokenReview 解析出的命名空间 | psat.go:240 |
k8s_psat:agent_sa:<sa> | TokenReview 解析出的服务账户 | psat.go:241 |
k8s_psat:agent_pod_name:<n> | TokenReview Extra 中的 Pod 名 | psat.go:242 |
k8s_psat:agent_pod_uid:<uid> | TokenReview Extra 中的 Pod UID | psat.go:243 |
k8s_psat:agent_node_ip:<ip> | pod.Status.HostIP | psat.go:244 |
k8s_psat:agent_node_name:<n> | pod.Spec.NodeName | psat.go:245 |
k8s_psat:agent_node_uid:<uid> | node.UID | psat.go:246 |
k8s_psat:agent_node_label:<k>:<v> | 节点 label,仅 allowed_node_label_keys 内的键 | psat.go:249 |
k8s_psat:agent_pod_label:<k>:<v> | Pod label,仅 allowed_pod_label_keys 内的键 | psat.go:255 |
这些 selector 会随 attested node 记入 server 数据库,之后可用于把注册项挂到"具备某标签的节点"上(node-based registration)。
一句话:
k8s_psat的信任根是 Kubernetes API Server 的 TokenReview——SPIRE server 不自己验签 token,而是问 API Server"这枚 token 有效吗、属于谁、在哪个 Pod",再把 Pod 溯源到 Node,以 Node UID 作为 agent 的稳定身份。
工作负载认证 · k8s 详解
文件 pkg/agent/plugin/workloadattestor/k8s/k8s.go,插件名 k8s(:69)。当工作负载通过 Workload API 向本机 agent 请求 SVID 时,agent 已凭 peertracker 拿到调用进程的 PID;k8s 插件的职责就是把这个 PID 解析成 Pod / 容器,产出 k8s: selectors。
主链路:PID → 容器 → kubelet → Pod
Attest(:523)把 PID 包成引用后转入 attestByPIDReference(:616),两步走:
第一步 · PID → (Pod UID, 容器 ID),靠读宿主 /proc(这正是 hostPID: true 的意义):
// pkg/agent/plugin/workloadattestor/k8s/k8s.go:622
podUID, containerID, err := containerHelper.GetPodUIDAndContainerID(pid, p.log)
// ...
if containerID == "" { // :629 不属于任何容器 → 不是 k8s 工作负载,返回空 selector
return &attestReferenceResult{Response: &workloadattestorv1.AttestReferenceResponse{}}, nil
}GetPodUIDAndContainerID(POSIX 实现 k8s_posix.go:56)默认用新版 container locator(containerinfo.Extractor,:65),从进程的 cgroup 路径里用正则抠出 Pod UID 与 64 位十六进制容器 ID(k8s_posix.go:99-:129、:155)。
第二步 · 容器 ID → Pod 对象,靠轮询 kubelet 的只读 /pods:
// pkg/agent/plugin/workloadattestor/k8s/k8s.go:644
podList, err := p.getPodList(ctx, config.Client, config.PollRetryInterval/2)
// ... 遍历 podList,对每个 Pod:
containerStatus, containerFound := lookUpContainerInPod(containerID, pod.Status, log) // :669
if containerFound {
selectorValues = append(selectorValues, getSelectorValuesFromPodInfo(pod)...) // :675 Pod 级 selector
if !config.DisableContainerSelectors {
selectorValues = append(selectorValues,
getSelectorValuesFromWorkloadContainerStatus(containerStatus)...) // :677 容器级 selector
}
}getPodList(:1556)用 singleflight + 短 TTL 缓存合并并发请求,底层就是对 kubelet GET /pods 的一次 HTTP 调用(kubeletClient.GetPodList,:1610,url.Path = "/pods" :1615)。若首轮没找到容器(Pod 可能刚创建、容器未就绪),会按 PollRetryInterval 重试,直到 MaxPollAttempts(默认 60 次 / 每次 500ms,:71、:72)。
关键:工作负载认证只连本机 kubelet,不连 API Server。 这与 k8s_psat 相反,好处是节点级、低时延、不给 API Server 加压。
连 kubelet 的方式与 skip_kubelet_verification
reloadKubeletClient(:1386)按端口选择传输:
| 场景 | 行为 | 出处 |
|---|---|---|
kubelet_read_only_port(如 10255) | 明文 http://127.0.0.1:<port>,无需认证 | k8s.go:1388 |
kubelet_secure_port(默认 10250) | https,默认带 bearer token(默认路径 /var/run/secrets/kubernetes.io/serviceaccount/token,:75、:1467),用 kubelet CA(默认 .../serviceaccount/ca.crt,:74)校验 kubelet 证书 | k8s.go:1478 |
skip_kubelet_verification = true | InsecureSkipVerify,不校验 kubelet 服务端证书(便于自签环境,牺牲安全) | k8s.go:130、:1406 |
未显式配端口时默认走安全端口 10250(:313、defaultSecureKubeletPort :73)。此外 use_anonymous_authentication(:149)、客户端证书 certificate_path/private_key_path(:139、:143)可替代 token 认证。
产出的 selectors
selector 类型前缀 k8s 由 agent 侧 v1 包装器统一加上(pkg/agent/plugin/workloadattestor/v1.go:103 Type: v1.Name())。
Pod 级(getSelectorValuesFromPodInfo,k8s.go:1712):
| Selector | 来源 | 出处 |
|---|---|---|
k8s:sa:<sa> | pod.Spec.ServiceAccountName | k8s.go:1714 |
k8s:ns:<ns> | pod.Namespace | k8s.go:1715 |
k8s:node-name:<n> | pod.Spec.NodeName | k8s.go:1716 |
k8s:pod-uid:<uid> | pod.UID | k8s.go:1717 |
k8s:pod-name:<n> | pod.Name | k8s.go:1718 |
k8s:pod-image-count:<n> | 容器数 | k8s.go:1719 |
k8s:pod-init-image-count:<n> | init 容器数 | k8s.go:1720 |
k8s:pod-image:<img> | 每个容器镜像(同时产出 Image 与 ImageID/摘要两条) | k8s.go:1724、:1689 |
k8s:pod-init-image:<img> | 每个 init 容器镜像 | k8s.go:1727 |
k8s:pod-label:<k>:<v> | 每个 Pod label | k8s.go:1731 |
k8s:pod-owner:<Kind>:<Name> | 每个 ownerReference(如 ReplicaSet:demo-6b...) | k8s.go:1734 |
k8s:pod-owner-uid:<Kind>:<UID> | 每个 ownerReference 的 UID | k8s.go:1735 |
容器级(getSelectorValuesFromWorkloadContainerStatus,k8s.go:1741;可用 disable_container_selectors 关闭,:170):
| Selector | 来源 | 出处 |
|---|---|---|
k8s:container-name:<n> | containerStatus.Name | k8s.go:1742 |
k8s:container-image:<img> | 该容器镜像(Image 与 ImageID 各一条) | k8s.go:1744、:1689 |
selector 示例(一个 demo 命名空间、服务账户 svc、镜像 alpine 的工作负载,约等于 deploy.md 的对照实验):
k8s:ns:demo
k8s:sa:svc
k8s:node-name:k3s-node-1
k8s:pod-name:demo-6b8c9f7d5-abcde
k8s:pod-uid:1f2e...-....-....
k8s:pod-label:app:demo
k8s:pod-owner:ReplicaSet:demo-6b8c9f7d5
k8s:container-name:demo
k8s:container-image:docker.io/library/alpine:3.20
k8s:container-image:docker.io/library/alpine@sha256:<digest>注册项因此可写成 k8s:ns:demo AND k8s:sa:svc(两个 selector 同时满足才发身份),这正是 deploy.md §1 里 spiffe://<td>/ns/demo/sa/svc entry 的匹配条件。
补充:
k8s.go里还有一条实验性 SPIFFE Broker / KubernetesObjectReference 路径(attestByKubernetesObjectReference:733起),它会绕过 kubelet、改用 API Server 解析任意 Kubernetes 对象(Pod 之外也支持 Deployment/CRD 等)并产出对象级 selector。这是可选实验特性,标准的 PID→Pod 工作负载认证不经过它。
与通用 Agent 的关系
K8S agent 复用通用 agent 的全部中枢,只替换两个插件挂载点。下表对比"通用 agent(如 VM 上的 join_token/unix)"与"K8S agent":
| 组件 / 能力 | 通用 agent | K8S agent | 是否相同 | 出处 |
|---|---|---|---|---|
| 二进制 | spire-agent | spire-agent(同一个) | ✅ 相同 | — |
| 顶层装配 / 引导循环 | agent.Run | 同 | ✅ 相同 | Agent 详解 |
manager(SVID LRU 缓存 + 周期同步 + 轮换) | ✅ | ✅ | ✅ 相同 | pkg/agent/manager/manager.go |
svid.Rotator(agent 自身 SVID 轮换) | ✅ | ✅ | ✅ 相同 | pkg/agent/svid/rotator.go |
client(到 server 的 mTLS gRPC) | ✅ | ✅ | ✅ 相同 | pkg/agent/client/client.go |
endpoints(Workload API / Envoy SDS) | ✅ | ✅ | ✅ 相同 | pkg/agent/endpoints/endpoints.go |
storage(状态落盘) | ✅ | ✅ | ✅ 相同 | pkg/agent/storage/storage.go |
| NodeAttestor 插件 | join_token(或 aws_iid/gcp_iit…) | k8s_psat | 🔁 替换 | 本页 §节点认证 |
| WorkloadAttestor 插件 | unix(或 docker/systemd…) | k8s | 🔁 替换 | 本页 §工作负载认证 |
| KeyManager | disk(典型) | memory(典型,Pod 无状态) | ⚙️ 配置差异 | deploy.md §3 |
| 部署形态 | systemd 服务 / 裸进程 | DaemonSet + hostPID + hostPath socket | 🔁 不同 | 本页 §部署形态 |
结论:K8S agent 不是一套独立实现,而是"通用 agent + k8s_psat + k8s"的组态。 SPIRE 的插件化 catalog 让节点认证与工作负载认证成为可插拔挂载点(见 整体架构 §4),换插件即换平台——同一个 agent 二进制,在 VM 上用 join_token/unix,在 K8S 上用 k8s_psat/k8s,中枢代码一行不改。通用部分的细节请直接读 Agent 详解。