Skip to content

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: trueagent 与工作负载共享宿主 PID 命名空间——工作负载认证插件才能凭 Workload API 调用方的 PID 去宿主 /proc/<pid> 读 cgroup、定位容器spec.hostPID: true
共享 socket 目录(hostPath)agent 的 Workload API Unix socket 落在 hostPath 卷(如 /run/spire/sockets),同节点工作负载 Pod 挂载同一目录即可连上本机 agenthostPath: { path: /run/spire/sockets }
投影 ServiceAccount Tokenprojected.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)字段出处
clusteragent 所在集群名(必填,server 侧要据此选集群配置)psat.go:53;缺失报错 :65
token_path投影 token 路径,默认 /var/run/secrets/tokens/spire-agentpsat.go:22:74

核心是 AidAttestation(psat.go:87):

go
// 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
audienceTokenReview 校验的受众,默认 ["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)是全部校验逻辑所在,顺序如下:

  1. 解包并选集群:反序列化 PSATAttestationData(:176),校验 cluster/token 非空(:181:185),按 cluster 名取集群配置,未配置该集群直接拒(:189)。

  2. 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),确保校验是"受众感知"的。

  3. 服务账户白名单:从 TokenReview 结果解析 namespace/serviceAccountName(GetNamesFromTokenStatus,:203,解析 system:serviceaccount:<ns>:<sa> 形式的 username),拼成 ns:sa 后对白名单查表,不在白名单即拒(:209)。

  4. 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/GetNodeapiserver/client.go:47:71。)

  5. 派生 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 UIDpsat.go:243
k8s_psat:agent_node_ip:<ip>pod.Status.HostIPpsat.go:244
k8s_psat:agent_node_name:<n>pod.Spec.NodeNamepsat.go:245
k8s_psat:agent_node_uid:<uid>node.UIDpsat.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 的意义):

go
// 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:

go
// 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 = trueInsecureSkipVerify,校验 kubelet 服务端证书(便于自签环境,牺牲安全)k8s.go:130:1406

未显式配端口时默认走安全端口 10250(:313defaultSecureKubeletPort :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.ServiceAccountNamek8s.go:1714
k8s:ns:<ns>pod.Namespacek8s.go:1715
k8s:node-name:<n>pod.Spec.NodeNamek8s.go:1716
k8s:pod-uid:<uid>pod.UIDk8s.go:1717
k8s:pod-name:<n>pod.Namek8s.go:1718
k8s:pod-image-count:<n>容器数k8s.go:1719
k8s:pod-init-image-count:<n>init 容器数k8s.go:1720
k8s:pod-image:<img>每个容器镜像(同时产出 ImageImageID/摘要两条)k8s.go:1724:1689
k8s:pod-init-image:<img>每个 init 容器镜像k8s.go:1727
k8s:pod-label:<k>:<v>每个 Pod labelk8s.go:1731
k8s:pod-owner:<Kind>:<Name>每个 ownerReference(如 ReplicaSet:demo-6b...)k8s.go:1734
k8s:pod-owner-uid:<Kind>:<UID>每个 ownerReference 的 UIDk8s.go:1735

容器级(getSelectorValuesFromWorkloadContainerStatus,k8s.go:1741;可用 disable_container_selectors 关闭,:170):

Selector来源出处
k8s:container-name:<n>containerStatus.Namek8s.go:1742
k8s:container-image:<img>该容器镜像(ImageImageID 各一条)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":

组件 / 能力通用 agentK8S agent是否相同出处
二进制spire-agentspire-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🔁 替换本页 §工作负载认证
KeyManagerdisk(典型)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 详解


深入

  • Agent 详解 —— 通用 agent 的中枢(manager / rotator / client / Workload API),本页略过的部分全在这里。
  • Server 详解 —— k8s_psat 服务端所处的 NodeAttestor 子系统与签发链上下文。
  • SVID 颁发流程 —— 节点认证(链路一)与工作负载下发(链路二)的端到端代码级链路。
  • 三节点部署与复现 —— 本页所有 manifest / selector / 对照实验的真实可复现环境。
  • 实战板块 —— 从抓取的真实 SVID 反推"身份的形式与实际决定因素"。