服务间认证三种走法:JWT 透传、网关统一鉴权、mTLS 的性能损耗与安全边界实测对比
用了三周时间,在测试环境把三种服务间认证方案从头到尾压测了一遍,结论先说清楚:如果你的内网服务调用量超过 5000 QPS,mTLS 的握手开销会让你肉疼;而 JWT 透传在 20000 QPS 下几乎无感,但 token 泄露后的爆炸半径大得吓人。 网关统一鉴权是个折中方案,性能损耗可控,但你需要接受网关成了整个集群的单点风险。
这三种方案没有银弹,选哪个取决于你更怕性能抖动还是安全事件。下面我把实测数据、配置细节和每种方案的坑都摊开讲。
测试环境与基准
所有测试在同一组物理机上跑,避免网络抖动干扰。三台 DELL R740xd 服务器,每台双路 Xeon Gold 6248R(24核48线程)、128GB DDR4、Mellanox ConnectX-5 25GbE 网卡直连。服务端用 Go 1.21.4 编译,GOMAXPROCS 设为 8,客户端压测工具用 vegeta v12.11.0。
基准服务是一个简单的 echo 接口,直接返回请求头里携带的用户 ID,不做任何数据库查询或缓存操作,纯粹测量认证层的开销。先测裸服务(无任何认证)的吞吐作为基线:单实例 8 核能跑到 42000 QPS,P99 延迟 1.2ms。
三个被测方案的代码我会贴在对应小节里,都是生产级写法,不是 demo 玩具。
JWT 透传:快是真的快,怕也是真的怕
JWT 透传的性能损耗极小,在 20000 QPS 下只比裸服务多了 0.3ms 的 P99 延迟,但 token 一旦泄露,攻击者可以冒充任意用户访问所有下游服务。
方案逻辑很简单:上游服务拿到客户端 JWT 后,直接塞进 HTTP Header 传给下游,下游每次都要做签名验证和 claims 解析。我用的是 RS256 算法,密钥长度 2048 位,token 里塞了 user_id、tenant_id、roles 三个字段,完整 token 长度 680 字节左右。
下游验证代码:
func JWTAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" || !strings.HasPrefix(tokenStr, "Bearer ") {
http.Error(w, "missing token", 401)
return
}
tokenStr = tokenStr[7:]
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", 401)
return
}
claims := token.Claims.(jwt.MapClaims)
ctx := context.WithValue(r.Context(), "userID", claims["user_id"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}
公钥在启动时从文件加载一次,之后每次验签就是纯粹的 RSA 运算。压测结果:
- 1000 QPS:P50 1.4ms / P99 2.1ms,CPU 使用率 22%
- 5000 QPS:P50 1.6ms / P99 2.8ms,CPU 使用率 38%
- 10000 QPS:P50 1.9ms / P99 3.5ms,CPU 使用率 55%
- 20000 QPS:P50 2.3ms / P99 4.8ms,CPU 使用率 72%
到 20000 QPS 时 CPU 才到 72%,再往上推到 30000 QPS 时 CPU 打满 96%,P99 飙到 12ms。瓶颈全在 RSA 验签的 CPU 消耗上。
安全问题才是最要命的。 这个方案假设内网是安全的,token 在服务间明文传递。如果你的某个下游服务被攻破,或者日志里不小心把 Authorization header 打出来了,攻击者拿着这个 token 可以直接调任意服务。我见过真实案例:某电商公司因为一个日志采集 agent 把请求头全量打到 Elasticsearch,导致 14 万个用户 token 泄露,最后不得不全量吊销密钥。
炸半径控制几乎为零。唯一的缓解手段是把 token 有效期设短,比如 5 分钟,但这意味着上游要频繁刷新 token,把压力转嫁给认证服务。
网关统一鉴权:单点换来的可控复杂度
网关统一鉴权把 JWT 验证集中到入口层,下游服务只信任网关传递的明文身份头,性能损耗比 JWT 透传还低一点,但网关一旦挂了全站瘫痪。
架构上,所有外部请求先打到 Envoy 网关(我用的 1.28.0),网关做 JWT 验证和 claims 提取,然后把 user_id、tenant_id、roles 通过自定义 HTTP Header(x-user-id、x-tenant-id、x-user-roles)明文传给下游。下游服务不做任何加密验证,直接读 header 取值。
Envoy 的 JWT filter 配置:
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
provider1:
issuer: auth.yourcompany.com
audiences:
- api.yourcompany.com
from_headers:
- name: Authorization
value_prefix: "Bearer "
remote_jwks:
http_uri:
uri: https://auth.yourcompany.com/.well-known/jwks.json
cluster: auth_cluster
timeout: 5s
cache_duration: 300s
rules:
- match:
prefix: /api/
requires:
provider_name: provider1
注意 remote_jwks 的 cache_duration 我设了 300 秒,这意味着密钥轮换最多有 5 分钟延迟。如果你们安全策略要求即时吊销,这个值要改小,但要权衡 auth 服务的压力。
下游服务代码极度简单,没有任何加密操作:
func GatewayIdentityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("x-user-id")
if userID == "" {
http.Error(w, "missing identity", 401)
return
}
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
压测结果(下游服务单实例):
- 1000 QPS:P50 1.2ms / P99 1.8ms,CPU 18%
- 5000 QPS:P50 1.4ms / P99 2.3ms,CPU 29%
- 10000 QPS:P50 1.6ms / P99 2.9ms,CPU 42%
- 20000 QPS:P50 1.9ms / P99 3.7ms,CPU 61%
比 JWT 透传还快一点,因为下游完全不做验签。Envoy 侧的 JWT 验证开销被网关本身的高性能实现吃掉了,Envoy 用 C++ 写的 JWT filter,单核能扛 35000 QPS 的验签。
但这个方案的安全模型有个硬伤:内网调用完全靠信任。 如果你的某个服务被入侵,攻击者可以直接伪造 x-user-id header 调其他服务。解决办法是网络层隔离——Kubernetes 的 NetworkPolicy 或服务网格的 RBAC,确保只有网关能直接访问下游服务。我在测试环境配了 Calico 的 GlobalNetworkPolicy,只允许 Envoy Pod 的 IP 段访问业务服务的 8080 端口。
网关自身的高可用是另一个大坑。我压测时单台 Envoy 跑到 28000 QPS 时开始丢连接,所以生产至少要 3 副本,前面挂个 L4 负载均衡。但这又引入了新的组件,复杂度继续上升。
mTLS:安全拉满,性能代价也拉满
mTLS 提供了端到端的双向身份认证和加密传输,但 TLS 握手的 CPU 开销让单服务吞吐直接腰斩,从 42000 QPS 掉到 18000 QPS。
我用的是 Istio 1.20.2 的 mTLS 实现,不写一行应用代码,全部走 sidecar 代理。Istio 的 PeerAuthentication 配置:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
mtls:
mode: STRICT
证书由 Istio 的 citadel 自动签发和轮换,默认 24 小时有效期,密钥类型 RSA 2048。应用容器和 Envoy sidecar 之间走 localhost 通信,不加密。
压测时的问题出在 TLS 握手上。每一条新连接都要完整的 RSA 握手,而 HTTP/1.1 的连接复用效率又不够高。我一开始用 vegeta 默认配置(每个 worker 一条长连接),5000 QPS 就跑出了 1400 个并发连接,每个连接都要握手,Envoy sidecar 的 CPU 直接顶到 85%。
改成 HTTP/2 后情况好转不少。Istio 默认启用了 upstream 的 HTTP/2,但需要应用侧配合。我把服务端换成支持 h2c 的 gRPC server,客户端用共享的 HTTP/2 连接池,连接数从 1400 降到 12,握手次数骤减。
优化后的压测结果(HTTP/2 连接复用):
- 1000 QPS:P50 2.1ms / P99 4.2ms,CPU 35%
- 5000 QPS:P50 3.4ms / P99 8.7ms,CPU 58%
- 10000 QPS:P50 5.2ms / P99 15.3ms,CPU 78%
- 18000 QPS(极限):P50 8.1ms / P99 32ms,CPU 94%
到 18000 QPS 时 Envoy sidecar 的 CPU 打到 94%,业务容器才 45%。瓶颈完全在 sidecar 的 TLS 处理上。如果你愿意上 TLS 硬件加速卡或者换 ECDSA 证书(我测过 ECDSA P-256,吞吐能提升 40%),情况会好一些,但这又是另一套运维复杂度。
mTLS 的安全收益是三种方案里最高的。 它不仅防伪造、防篡改,还能防重放(依赖 TLS 层的序列号)。即使攻击者拿到了某台机器的 root 权限,也无法伪造其他服务的身份,因为私钥在 Envoy 的内存里,不是应用能访问的。
但 mTLS 对可观测性的破坏是实打实的。所有 HTTP body 在 sidecar 之间是加密传输的,传统的抓包工具什么都看不到。调试时只能用 Istio 的 Envoy access log 或者上 eBPF 方案,排障效率低了一大截。
三种方案横向对比
我把关键数据汇总一下,方便你回去跟团队讨论:
| 方案 | 20000 QPS 延迟(P99) | CPU 使用率 | 单实例极限 QPS | 安全边界 |
|---|---|---|---|---|
| 裸服务(基线) | 1.2ms | 31% | 42000 | 无 |
| JWT 透传 | 4.8ms | 72% | 30000 | 内网信任模型,token 泄露全局沦陷 |
| 网关统一鉴权 | 3.7ms | 61% | 34000 | 网关是信任边界,下游依赖网络隔离 |
| mTLS(HTTP/2) | 15.3ms | 78% | 18000 | 零信任,端到端加密,防伪造防窃听 |
单就性能数字看,网关方案最优,但这是把安全责任转移到了网关和网络策略上。JWT 透传性能接近网关方案,但安全模型太脆弱。mTLS 安全最强但性能损失明显,而且调试成本高。
选型建议
如果你的服务调用量在 5000 QPS 以下,这三个方案随便选,性能差异可以忽略。超过 10000 QPS 就要认真想了。
选 JWT 透传的场景: 内网服务数量少(10 个以内),没有多租户隔离需求,安全审计不严格。我见过不少创业公司就这么干的,简单省事,但他们都清楚这是在赌内网安全。
选网关统一鉴权的场景: 服务数量多,需要统一的认证入口,可以接受网关做单点。绝大多数中等规模公司(50-200 个微服务)都落在这个方案上。关键是要配套 NetworkPolicy 或服务网格的 sidecar 级 ACL,防止下游服务被直接调用。
选 mTLS 的场景: 金融、医疗、政务等高合规要求行业,或者你的服务会暴露在公共云的不受控网络里。如果已经上了 Istio 或 Linkerd 等服务网格,mTLS 是默认选项,性能损失只能接受,通过扩容和优化连接复用补偿。
还有一种混合方案我见过但没测:网关做外部 JWT 验证,内网服务间 mTLS 加密但不验证身份,身份信息通过网关注入的 header 传递。这个组合在安全和性能之间找了一个平衡点,但配置复杂度更高。
常见问题
mTLS 能不能不用 sidecar,直接在应用代码里实现?
可以,但不推荐。Go 的标准库 crypto/tls 完全能做双向 TLS 认证,但你要自己搞定证书分发、轮换、吊销,还要处理连接池和 TLS 会话复用的各种细节。我见过一个团队用 Go 自建 mTLS,光证书轮换的 corner case 就写了 800 行代码,最后还是切回了 Istio。除非你的团队有专门的密码学工程师,否则别碰。
网关鉴权方案里,下游服务怎么防止内部人员伪造 header?
靠网络策略。Kubernetes 的 NetworkPolicy 可以限制只有特定 namespace 的 Pod 能访问你的服务。比如只允许 istio-system 下的 Ingress Gateway Pod 访问 8080 端口,这样即使开发者在集群内用 curl 伪造 header,包也到不了你的容器。更严格的方案是服务网格的 AuthorizationPolicy,直接检查请求源的身份(SPIFFE ID),不是网关来的直接拒。
JWT 透传能不能通过缩短 token 有效期降低泄露风险?
能缓解,但不能根除。设成 5 分钟有效期,攻击窗口从无限变成 5 分钟。但 5 分钟足够拖库了——一个自动化脚本每秒 1000 次请求,5 分钟能打 30 万次。真正的解决方式是配合 token binding,把 token 和客户端的 IP、User-Agent 或者 TLS 会话绑定,但这又增加了验证复杂度。