SPIFFE 与 SPIRE 深度实战:从工作负载身份 SVID、双向证明到信任域联邦的工程全解
在绝大多数公司的生产环境里,"服务之间怎么证明自己是谁"这件事仍然是靠一堆 shell 脚本和 Vault 里的静态密钥糊出来的。证书签发一次管一年,过期靠人肉巡检;AK/SK 写在 ConfigMap 里,轮转一次要拉三个团队开会;ServiceAccount Token 挂在 Pod 里,谁能读谁就是那个服务。
这套做法在单体时代勉强能用,但在多集群、多云、混合部署的今天已经完全失效。SPIFFE(Secure Production Identity Framework For Everyone)要解决的就是这个根问题:给每个工作负载一个可验证、可自动轮转、与部署位置无关的身份。SPIRE 则是它的生产级参考实现。
一、问题的本质:Secret Zero 与身份的"出生证明"
任何 mTLS 方案都绕不开一个鸡生蛋问题:工作负载要用密钥证明身份,但它必须先安全地拿到密钥。这个"第一个密钥"就是 Secret Zero。
常见的错误解法有三类:
- 静态证书分发:CI 里生成、secrets 里存、Pod 启动时挂载。问题是证书没有与"进程"绑定,任何人 copy 出来就能冒用,而且轮转成本极高。
- Vault AppRole / 一次性 token:解决了分发,但引入了对 Vault 的强依赖与新的 Secret Zero(AppRole 本身怎么安全下发?)。
- K8s ServiceAccount Token:确实由平台自动注入,但它是 Bearer Token——泄漏即冒用,且是 JWT 不是 X.509,无法直接用于 mTLS 握手的客户端证书,跨集群/跨平台的联邦也缺乏标准。
SPIFFE 的思路是:身份不靠"你持有什么",而靠"你是谁"。工作负载不领取任何长期凭证,而是在运行时向本地节点上的 SPIRE Agent 证明自己的运行时属性(Pod 属于哪个 namespace、哪个 ServiceAccount、哪个容器镜像),Agent 验证后现场签发一个极短生命周期的 SVID。整个过程中没有 secret 落盘、没有 secret 传输、没有 secret 需要轮转——因为它根本不长期存在。
二、SPIFFE 标准的四个核心概念
2.1 SPIFFE ID
spiffe://prod.example.org/ns/payment/sa/settlement
└──┬───┘ └────────┬────────┘ └───────────┬───────────┘
scheme 信任域 工作负载路径
信任域是身份的权威边界,通常一个组织/一个环境一个(prod.example.org、staging.example.org 严格隔离)。路径部分由注册策略决定,最佳实践是从平台元数据自动生成,而不是人手写——因为人手写一定会漂移、会重复、会失控。
2.2 SVID(SPIFFE Verifiable Identity Document)
SVID 是身份证明本身的载体,有两种形态:
- X.509-SVID:一张 X.509 叶证书,SPIFFE ID 放在 URI SAN(
uniformResourceIdentifier)里。这是 mTLS 的主力,可直接喂给 Envoy、gRPC、Gocrypto/tls。 - JWT-SVID:一个 JWT,
sub是 SPIFFE ID,aud是受众。用于无法建立双向 TLS 握手的场景(如 HTTP API 调用、跨信任域传递、Serverless 环境没有本地 Agent)。
二者的关键区别:X.509-SVID 是双向的(双方都要出示证书),JWT-SVID 是单向的(持有者只需出示 token,验证方从 bundle 取公钥)。因此 JWT-SVID 的 aud 必须严格校验,否则会被重放用于其他服务——这是生产事故的高发点。
2.3 Trust Bundle
信任域的"根证书集合",SPIFFE 把它序列化成 JWKS 风格的 JSON:
{
"keys": [
{ "kty": "EC", "crv": "P-256", "kid": "C6...", "use": "x509-svid", "x5c": ["MIID..."] },
{ "kty": "EC", "crv": "P-256", "kid": "K1...", "use": "jwt-svid", "x5c": ["MIID..."] }
]
}
use 字段把 X.509 和 JWT 的信任锚分开,这是很多自研 PKI 忽略的设计:签名证书的生命周期、密钥用途、轮转策略都不一样,混在一套根里迟早出事。
2.4 Bundle Endpoint 与联邦
Bundle 通过 HTTPS 端点对外发布(SPIFFE Bundle Endpoint Profile),支持 ETag 缓存与 spiffe_refresh_hint。两个信任域互相配置对端端点即可完成联邦——无需共享根 CA,无需中间人。这是 SPIFFE 相比传统 PKI 交叉签名的最大工程优势:跨境并购、跨组织数据交换时,双方各自保留 CA 主权。
三、SPIRE 架构:Server、Agent 与两阶段证明
SPIRE 由两部分组成:
- SPIRE Server:信任域的 CA 与策略中心。持有 CA 密钥、存储注册条目(Registration Entry)、签发 SVID。生产环境必须 HA 部署:共享 PostgreSQL + 外部 Upstream CA(Vault / AWS PCA / cert-manager),否则单点的 Server 挂了新身份就签发不出来。
- SPIRE Agent:每节点一个(DaemonSet)。它自己是 Server 的一个"工作负载",通过节点证明拿到自己的 Agent SVID,然后为节点上的工作负载做工作负载证明并代理签发。
核心是两阶段证明(Two-Phase Attestation):
第一阶段:节点证明(Node Attestation)——Agent 向 Server 证明"我是集群里一台合法的机器"。云上靠平台背书的身份文档:
plugins {
NodeAttestor "k8s_psat" {
plugin_data {
clusters = {
"prod-cluster" = {
service_account_allow_list = ["spire:spire-server"]
}
}
}
}
# AWS 上则使用 aws_iid,校验 IAM Instance Identity Document 与签名
NodeAttestor "aws_iid" {
plugin_data { assume_role_arns = ["arn:aws:iam::123456789012:role/spire-node"] }
}
}
物理机/VM 没有平台背书时,退化成一个一次性 join_token——这时节点证明的强度取决于 token 的传输安全,属于最弱一环,应尽量用 TPM 或云厂商 IID 替代。
第二阶段:工作负载证明(Workload Attestation)——Agent 用平台可验证的事实识别调用者,生成 selector 集合:
spire-server entry create \
-spiffeID spiffe://prod.example.org/ns/payment/sa/settlement \
-parentID spiffe://prod.example.org/spire/agent/k8s_psat/prod-cluster/abc123 \
-selector k8s:ns:payment \
-selector k8s:sa:settlement \
-ttl 3600 \
-federatesWith spiffe://partner.example.com
Selector 的强度差异极大,这是安全设计中最容易被轻视的地方:
| Selector | 强度 | 风险 |
|---|---|---|
k8s:ns: / k8s:sa: | 高 | 由 kubelet PodResources / CRI 提供,进程无法伪造 |
unix:uid: / unix:gid: | 中 | 同 UID 的另一个容器可获得同一身份 |
unix:path: | 低 | 二进制可被复制到别处执行,线上不要用 |
docker:label: | 中 | 取决于 daemon 可信度 |
在 Kubernetes 里,Agent 通过调用方的 PID → cgroup → container ID → kubelet PodResources 反查 Pod 元数据,并把 Unix Domain Socket 的 SO_PEERCRED 作为 PID 来源。这意味着调用方无法自己声明身份,只能由内核凭据决定。也正因为如此,Workload API 的 socket 绝不能跨信任边界共享——同一 socket 上的 PID 校验会被命名空间边界混淆,spiffe-csi-driver 正是为了在 K8s 里安全地按 Pod 注入 socket 而存在。
四、Workload API:没有 Secret 的密钥分发
Workload API 是一个跑在 Unix Domain Socket 上的 gRPC 服务(默认 /run/spire/sockets/agent.sock,路径通过 SPIFFE_ENDPOINT_SOCKET 环境变量传给业务容器)。它只有几个方法,但设计极其克制:
service SpiffeWorkloadAPI {
rpc FetchX509SVID(X509SVIDRequest) returns (stream X509SVIDResponse);
rpc FetchX509Bundles(X509BundlesRequest) returns (stream X509BundlesResponse);
rpc FetchJWTSVID(JWTSVIDRequest) returns (JWTSVIDResponse);
rpc FetchJWTBundles(JWTBundlesRequest) returns (JWTBundlesResponse);
rpc ValidateJWTSVID(ValidateJWTSVIDRequest) returns (ValidateJWTSVIDResponse);
}
注意 FetchX509SVID 是 server streaming——Agent 会在证书接近过期时主动推送新证书,客户端无需轮询。这是整个方案"零运维"的关键:轮转是推送的,不是拉取的,因此不存在"忘了轮转"这种失效模式。
可以用 grpcurl 直接探查(排障时非常有用):
# 列出可用服务
grpcurl -unix -plaintext /run/spire/sockets/agent.sock list
# 用 spire-agent CLI 观察当前节点可获取的身份
spire-agent api watch
spire-agent api fetch jwt \
-audience https://api.partner.example.com \
-spiffeID spiffe://prod.example.org/ns/payment/sa/settlement
五、代码落地:Go 服务的 mTLS 全链路
用官方 go-spiffe/v2 写一个双向 mTLS 的 HTTP 服务,包含自动轮转与对端 ID 授权,完整逻辑不到 40 行:
package main
import (
"context"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/svid/x509svid"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
// X509Source 同时实现了 CertGetter 与 BundleSource,
// 内部持有到 Workload API 的长连接,证书更新由 Agent 推送驱动。
source, err := workloadapi.NewX509Source(ctx,
workloadapi.WithClientOptions(workloadapi.WithAddrFromEnv()))
if err != nil {
log.Fatalf("无法连接 Workload API: %v", err)
}
defer source.Close()
// 授权策略:只接受信任域内特定工作负载,而不是"只要有合法证书就行"
caller := spiffeid.RequireFromString("spiffe://prod.example.org/ns/gateway/sa/ingress")
srv := &http.Server{
Addr: ":8443",
TLSConfig: tlsconfig.MTLSServerConfig(
source, // 自己的证书(自动轮转)
source, // 信任 bundle(自动更新)
tlsconfig.AuthorizeID(caller), // 授权对端 SPIFFE ID
),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.TLS.PeerCertificates) > 0 {
id, err := x509svid.IDFromCert(r.TLS.PeerCertificates[0])
if err == nil {
log.Printf("caller=%s", id)
}
}
w.Write([]byte("ok"))
}),
}
log.Fatal(srv.ListenAndServeTLS("", ""))
}
要点有三个,每一个都是生产里踩过的坑:
ListenAndServeTLS("", "")传空字符串——证书由GetCertificate回调动态提供,不要传文件路径,否则轮转后进程仍用旧证书。- 必须做 ID 授权,不能只做证书校验。只校验签名等于"信任域内任意服务都能调你",等同于没有授权层。
tlsconfig.AuthorizeID/AuthorizeOneOf/AuthorizeMemberOf是最小可用策略。 - Source 必须常驻,不要在每次请求里
NewX509Source。每次新建都会建立新的 gRPC 流,Agent 侧连接数会爆炸。
客户端侧对称:
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsconfig.MTLSClientConfig(
source, source,
tlsconfig.AuthorizeID(
spiffeid.RequireFromString("spiffe://prod.example.org/ns/payment/sa/settlement"),
),
),
},
}
六、Envoy / 服务网格集成:不改业务代码的路径
存量服务改造不动代码时,让 Envoy 通过 SDS 向 SPIRE Agent 取证书是主流方案:
# Envoy 静态资源:定义到 SPIRE Agent SDS 的 upstream
clusters:
- name: spire_agent_sds
type: STATIC
http2_protocol_options: {}
load_assignment:
cluster_name: spire_agent_sds
endpoints:
- lb_endpoints:
- endpoint:
address:
pipe: { path: /run/spire/sockets/agent.sock }
# 下游监听:使用 SDS 获取服务端证书 + 校验客户端
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_certificate_sds_secret_configs:
- name: spiffe://prod.example.org/ns/payment/sa/settlement
sds_config:
resource_api_version: V3
api_config_source:
api_type: GRPC
transport_api_version: V3
grpc_services:
envoy_grpc: { cluster_name: spire_agent_sds }
validation_context_sds_secret_config:
name: spiffe://prod.example.org # 信任域 bundle
sds_config: { ... 同上 ... }
Istio 用户则通常用 cert-manager-istio-csr 或直接让 Istiod 通过 SPIRE 下发——本质上都是把 SPIFFE ID 作为证书 SAN,从而在网格内实现"身份即策略"。
七、生产实战的六个硬经验
1. TTL 能短就短。 默认 1 小时是可用性妥协下的默认值。金融/支付类场景我建议压到 5~15 分钟:SPIFFE 的轮转是推送式的,缩短 TTL 几乎不增加运维成本,但把证书泄漏的爆炸半径压缩了一个数量级。代价是 Agent 推送频率上升,需要按节点 workload 数量评估 Agent 的内存与 CPU。
2. Server 必须 HA,且 CA 必须外接。 单实例 Server + KeyManager "disk" 只能用于 PoC。生产至少是:PostgreSQL(或 MySQL)共享 datastore + Vault/AWS PCA 作为 UpstreamAuthority,Server 本身多副本无状态。否则一次节点故障就会阻断全集群的新身份签发——而老证书还在过期,这是典型的雪崩起点。
3. 注册条目用 CRD 管理,不要人肉 entry create。 在 K8s 上用 spire-controller-manager 的 ClusterSPIFFEID,让 SPIFFE ID 从 Pod 元数据模板化生成:
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: payment-workloads
spec:
spiffeIDTemplate: "spiffe://prod.example.org/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
podSelector:
matchExpressions:
- key: spiffe.io/spire-managed-identity
operator: In
values: ["true"]
workloadSelectorTemplates:
- "k8s:ns:{{ .PodMeta.Namespace }}"
- "k8s:sa:{{ .PodSpec.ServiceAccountName }}"
federatesWith: ["partner.example.com"]
ttl: 15m
这样身份与部署声明同源,新增服务无需在 SPIRE 侧额外操作,也不会出现"两个服务抢同一个 SPIFFE ID"这类幽灵问题。
4. 联邦优先用 Bundle Endpoint,不要做交叉签名。 收购/合作场景下,双方各自维护 CA。federatesWith 配置后,Agent 会按 refresh_hint 定期拉取对端 bundle 并本地缓存——对端短暂不可达不会导致联邦失败,这是设计时有意留的容错。
5. JWT-SVID 的 aud 必须强校验。 我再强调一次:JWT-SVID 是 bearer token,谁拿到都能用。ValidateJWTSVID 的语义是"验证这个 JWT 是发给我的",服务端一定要调用它(或自己校验 aud),不能只验签名。
6. 明确 Agent 是可信计算基(TCB)的一部分。 节点被攻破 ⇒ 该节点上所有工作负载身份沦陷,这是架构层面的既定事实,不是 bug。缓解手段只有一个方向:缩小爆炸半径——短 TTL、节点内按租户隔离、关键负载跑在独立节点池。
八、我的判断
SPIFFE 真正有价值的地方不在于"又一个 PKI",而在于它把身份从配置变成了基础设施原语:可自动生成、可自动轮转、可编程授权、可跨组织联邦。
它与周边技术的边界其实很清楚:Sigstore 解决的是制品的身份(这个镜像是谁构建的),SPIFFE 解决的是运行时工作负载的身份(这个进程是谁)——二者是同一信任链条的上下游,而非竞争关系。零信任架构(ZTA)是策略框架,SPIFFE 则是它能落地所依赖的身份底座之一。
落地的真正难点从来不是技术,而是存量改造覆盖率:只要有 10% 的服务还在用静态证书,你的授权策略就收敛不到统一模型上,审计时就会出现两套真相。我的建议是用 Envoy SDS / sidecar 先把这 10% 兜住,保证身份平面的完整性,再逐步推进 SDK 化改造——先保证"所有流量都有身份",再追求"所有代码都原生"。
身份是安全的地基。地基没打稳之前,在上面堆再多 WAF、策略引擎和审计日志,都只是在沙子上盖楼。

发表评论 取消回复