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、Go crypto/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("", ""))
}

要点有三个,每一个都是生产里踩过的坑:

  1. ListenAndServeTLS("", "") 传空字符串——证书由 GetCertificate 回调动态提供,不要传文件路径,否则轮转后进程仍用旧证书。
  2. 必须做 ID 授权,不能只做证书校验。只校验签名等于"信任域内任意服务都能调你",等同于没有授权层。tlsconfig.AuthorizeID / AuthorizeOneOf / AuthorizeMemberOf 是最小可用策略。
  3. 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、策略引擎和审计日志,都只是在沙子上盖楼。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部