告别Sidecar?Istio 1.23 Ambient模式与Gateway API选型实战

去年初我们团队接手了一个运行了三年的跨境电商平台,当时服务网格用的是Istio 1.18的Sidecar模式。大促前做容量评估时,光Envoy Sidecar占用的内存就快把节点资源吃光了,一个Pod里业务容器才用200MB,Sidecar倒占了350MB。那时候我就开始盯着Ambient Mesh的进展,等到Istio 1.23.0(2024年8月13日发布的版本)出来,发现ztunnel已经稳定到能上生产了,就决定在新集群里彻底换掉Sidecar。

先说说为什么我这次坚决不选Sidecar。我们那个订单系统的Java服务,每个Pod启动时要等Sidecar注入完成才能就绪,平均启动时间多了12秒。有次做灰度发布,新版本Pod因为Sidecar初始化慢,被 readiness 探针连续三次打失败,回滚了两次才找到原因。Ambient模式把代理层拆成了节点级的ztunnel和可选的waypoint proxy,业务Pod不用再挂Sidecar容器,启动速度直接回到原生Pod的水平。

不过选Ambient还是得看场景。我们这次新集群主要跑的是Go和Python写的轻量服务,没有太多需要L7高级策略的场景,所以直接用ztunnel做L4治理就够了。但如果你需要像我们老集群那样做基于HTTP Header的路由,或者需要Wasm插件做流量改写,那还是得加waypoint proxy。这里有个细节,Istio 1.23里ztunnel默认是开启的,但waypoint需要手动给命名空间打标签istio.io/dataplane-mode: ambient,不然不会生效。

再聊聊Gateway API的选型。我们之前用的是Istio的VirtualService加Gateway,后来K8s Gateway API成了主流,Istio 1.23对它的支持已经很完善了。我对比过两者的配置量,同样做一个按地域路由的规则,用VirtualService要写30多行,用Gateway API的HTTPRoute只要18行。而且Gateway API是K8s社区标准,以后换别的网格实现也不用改配置。

这里给个我们实际用的Gateway API配置,这是我们新集群的入口网关,用的Istio 1.23自带的istio-gateway类:

apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: ecommerce-gateway namespace: istio-system spec: gatewayClassName: istio-gateway listeners: - name: http port: 80 protocol: HTTP hostname: "api.example.com" allowedRoutes: namespaces: from: All --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: order-service-route namespace: default spec: parentRefs: - name: ecommerce-gateway namespace: istio-system hostnames: ["api.example.com"] rules: - matches: - path: type: PathPrefix value: /api/orders backendRefs: - name: order-service port: 8080 weight: 90 - name: order-service-canary port: 8080 weight: 10

这个配置里我们把订单服务的流量90%打给稳定版,10%给金丝雀版。之前用VirtualService的时候,还要单独配DestinationRule做子集定义,现在HTTPRoute直接把权重和后端绑定,省了一步。

有个实际遇到的问题得提一下。我们刚开始部署Ambient模式时,ztunnel的日志里一直报connection refused到旧集群的Sidecar服务。排查了半天才发现,旧集群的服务还在用mTLS的STRICT模式,而新集群的ztunnel默认是PERMISSIVE模式,两边认证对不上。后来在旧集群的PeerAuthentication里加了新集群的服务CIDR白名单,才把跨集群调用的认证问题解决了。这也说明迁移的时候不能一刀切,mTLS的策略得跟着环境走。

现在Ambient模式在我们的新集群跑了三个月,节点内存占用比之前Sidecar模式少了40%,一个32G内存的节点能多跑15个Pod。不过我也没完全抛弃Sidecar,老集群里那些需要复杂L7策略的服务还是继续用,毕竟迁移成本太高。Istio 1.23里两种模式是可以共存的,这也是我觉得最实用的地方。

某电商订单系统落地实录:从Spring Cloud迁移至Istio的架构权衡

前年我们那个电商订单系统还是Spring Cloud全家桶,Eureka做注册中心,Zuul做网关,Hystrix做熔断。去年双11大促时出了个事:有个库存服务的接口响应慢,Hystrix的熔断阈值设得太高,结果把订单服务的线程池全占满了,整个下单链路挂了20分钟。事后复盘,我们觉得Spring Cloud的治理组件太散,每个服务都要引不同的依赖,配置也不统一,就决定迁移到Istio 1.23的服务网格。

先说为什么选Istio而不是继续升级Spring Cloud。我们当时有12个语言栈的服务,Java占60%,剩下的有Go、Python、Node.js写的。Spring Cloud的治理只能覆盖Java服务,其他语言得自己实现类似的逻辑,比如Go服务要接Hystrix得用第三方库,配置格式还和Java的不一样。Istio的Sidecar(后来新集群用Ambient)对所有语言一视同仁,只要跑在K8s里就能统一治理,这点是Spring Cloud比不了的。

迁移的时候我们没敢直接全量切,先拿用户服务做试点。用户服务是Go写的,QPS峰值能到8000,之前用Spring Cloud的Feign调用时,超时设置是全局的5秒,不管什么接口都这个阈值。迁移到Istio后,我们用DestinationRule给不同的接口设了不同的超时:

apiVersion: networking.istio.io/v1beta1 kind: DestinationRule metadata: name: user-service-destination namespace: default spec: host: user-service.default.svc.cluster.local trafficPolicy: connectionPool: tcp: maxConnections: 200 http: http1MaxPendingRequests: 100 maxRequestsPerConnection: 10 outlierDetection: consecutiveErrors: 5 interval: 30s baseEjectionTime: 30s maxEjectionPercent: 50 subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2

这个配置里我们把连接池的最大连接数设为200,比之前Spring Cloud的默认值50高了不少,因为用户服务的连接数峰值确实能到180。然后熔断策略是连续5次错误就踢出实例30秒,这个阈值比之前Hystrix的10次低,因为用户服务的错误大多是临时网络抖动,早点熔断反而能避免雪崩。

迁移过程中最头疼的是服务发现的问题。Spring Cloud用的是Eureka,服务名是user-service,但K8s里的服务名是user-service.default.svc.cluster.local。我们刚开始直接改了代码里的服务名,但后来发现有个老服务是用IP直连的,改起来太麻烦。最后用Istio的ServiceEntry把Eureka里的服务都映射成了K8s服务名,才解决了兼容问题:

apiVersion: networking.istio.io/v1beta1 kind: ServiceEntry metadata: name: legacy-eureka-service namespace: default spec: hosts: - legacy-user-service.eureka.svc addresses: - 10.96.0.101 ports: - number: 8080 name: http protocol: HTTP location: MESH_EXTERNAL resolution: STATIC endpoints: - address: 10.1.2.34 ports: http: 8080 - address: 10.1.2.35 ports: http: 8080

这个ServiceEntry把我们Eureka里的一个老用户服务(还没迁移到K8s的VM实例)纳入了网格,这样新集群的服务调用legacy-user-service.eureka.svc就能路由到VM上的实例,不用改代码。

有个实际踩过的问题:迁移完用户服务后,调用延迟反而涨了30ms。我们查了Envoy的访问日志,发现是mTLS握手的时间。之前Spring Cloud没加密,现在Istio默认开了PERMISSIVE模式的mTLS,第一次调用要握手。后来我们给内部服务都开了STRICT模式,并且把证书缓存时间从1小时调到了24小时,延迟就降下来了,稳定在80ms左右,比之前的110ms还低。

现在订单系统已经全量迁移到Istio 1.23了,大促时的熔断响应速度快了至少3倍。之前Hystrix熔断要等线程池满了才触发,现在Istio的outlierDetection能在5次错误后就踢掉实例,上次库存服务出问题,只影响了不到1%的请求,就自动恢复了。不过我们也留了后路,Spring Cloud的依赖没完全删掉,万一Istio出问题还能快速切回去,毕竟迁移不是一蹴而就的事。

性能压测对比:Ambient Mesh vs Sidecar模式资源开销与延迟实测

上个月我们做集群扩容规划,特意对Istio 1.23的Ambient模式和Sidecar模式做了一次完整的压测。测试环境是3台阿里云ECS,配置是8核16G,K8s版本1.29,Istio 1.23.0。测试服务是我们订单系统的简化版,Go写的,单Pod能扛3000 QPS,接口逻辑是查一次Redis再返回,平均响应时间50ms左右。

先讲测试场景。我们分了两组:一组是Sidecar模式,每个Pod注入Envoy Sidecar;另一组是Ambient模式,只开ztunnel,不开waypoint proxy。每组都部署10个Pod,用JMeter从集群外压测,逐步加QPS从1000到10000,每次持续5分钟,记录P99延迟、Pod内存占用、节点CPU使用率。

先看资源开销的数据。Sidecar模式下,每个Envoy Sidecar的内存稳定在320MB左右,10个Pod就是3.2G,加上业务Pod的1.5G(每个150MB),总内存占用4.7G。Ambient模式下,ztunnel在每个节点上跑一个实例,内存占用是180MB,10个Pod的业务容器总内存还是1.5G,节点总内存只用了1.68G,比Sidecar省了64%的内存。CPU方面,Sidecar模式下Envoy占用了节点15%的CPU,ztunnel只占了5%,差了三倍。

延迟数据更有意思。QPS在3000以下时,两种模式的P99延迟差不多,都是60ms左右。但QPS到6000时,Sidecar模式的P99延迟涨到了120ms,Ambient还是70ms。我们查了Envoy的指标,发现Sidecar的envoy_cluster_upstream_cx_overflow计数器在6000 QPS时开始增长,说明连接池满了。而ztunnel的连接池是节点级的,10个Pod共享,最大连接数能到2000,所以没出现溢出。

这里给个我们压测时用到的VirtualService配置,用来模拟不同的流量负载,给Sidecar和Ambient做对比:

apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: order-service-load-test namespace: default spec: hosts: - order-service.default.svc.cluster.local http: - match: - headers: test-load: exact: "high" route: - destination: host: order-service.default.svc.cluster.local subset: v1 timeout: 2s retries: attempts: 3 perTryTimeout: 1s - route: - destination: host: order-service.default.svc.cluster.local subset: v1 timeout: 5s

这个配置里我们给带test-load: high头的请求设了2秒超时,重试3次,用来模拟高负载下的超时场景。压测时我们发了10%的这类请求,Sidecar模式下的超时失败率是0.8%,Ambient模式下是0.3%,因为ztunnel的重试逻辑更高效,不用经过Sidecar的额外转发。

有个意外的发现:Ambient模式在QPS到9000时,P99延迟突然涨到了150ms。我们查了ztunnel的日志,发现是节点上的ztunnel实例CPU满了。后来给ztunnel加了CPU限制,从默认的500m调到1000m,延迟就稳定在90ms了。这也说明Ambient模式虽然省资源,但ztunnel的资源配置还是得根据流量来调,不是配了就不用管了。

再说说网络吞吐。我们用iperf3测了Pod间的TCP吞吐,Sidecar模式下是8Gbps,Ambient模式是9.2Gbps,高了15%。因为Sidecar要做两次网络栈的穿越(业务容器->Sidecar->节点网络),而ztunnel是在节点网络层处理,少了一次拷贝。不过如果是需要L7处理的场景,比如HTTP路由,Ambient加了waypoint proxy后,吞吐会降到8.5Gbps,还是比Sidecar高一点。

最后给个实际的选择建议。如果你们的Pod密度高,比如一个节点跑20个以上Pod,选Ambient模式能省不少资源。我们算过,一个节点跑30个Pod的话,Sidecar要占9.6G内存,Ambient只要1.8G,能多跑15个Pod。但如果你们需要复杂的L7策略,比如Wasm插件、复杂的授权规则,那还是得用Sidecar或者加waypoint proxy,毕竟ztunnel只做L4处理。我们现在的策略是新服务默认用Ambient,老服务需要L7的继续用Sidecar,两种模式在Istio 1.23里跑得很和谐。

4. 生产环境排障:Istio mTLS证书轮换导致的5xx错误深度排查

去年双十一大促前一周,我们那个核心的订单支付系统突然在预发环境出现了间歇性的 503 错误。当时我正盯着监控大盘喝咖啡,看到错误率曲线突然抖了一下,从 0.01% 跳到了 0.5%。虽然看起来不多,但对于我们当时峰值 QPS 在 8000 左右的支付链路来说,这意味着每分钟有几十笔交易可能失败,这绝对是个定时炸弹。

我们的环境是 Kubernetes 1.28 配合 Istio 1.23.0(就是 2024 年 8 月 13 号刚发布的那个版本)。出问题的是 order-service 调用 payment-service

现象与初步排查

起初我以为是 Pod 重启或者负载均衡的问题。我查了 payment-service 的 Pod 状态,很正常,重启次数都是 0,CPU 和内存水位也都在 40% 以下。但是 order-service 的日志里清一色报错:upstream connect error or disconnect/reset before headers. reset reason: connection termination

这就很奇怪了,连接被重置了。我第一反应是去抓包,但在容器里抓 Envoy 的包太麻烦,我直接去看了 Envoy 的访问日志。

我给 order-service 的 Sidecar 开启了 debug 日志,命令大概是这样:

kubectl exec -it deploy/order-service -c istio-proxy -- curl -X POST http://localhost:15000/logging?level=debug

在日志里翻了半天,发现了一个关键线索:TLS error: 268435581:SSL routines:OPENSSL_internal:CERTIFICATE_VERIFY_FAILED

证书验证失败了?

深入根因:SDS 的同步延迟

当时我就纳闷了,Istio 不是自动管理证书吗?后来我查了 Istio 1.23 的文档,结合当时的监控,发现了一个细节。Istio 的证书轮换机制(SDS, Secret Discovery Service)是这样的:Citadel(现在集成在 Istiod 里)会签发证书,默认有效期是 24 小时,Sidecar 会在过期前主动去 Istiod 刷新。

问题出在大促前的扩容。我们为了应对流量,把 payment-service 的副本数从 10 个瞬间扩到了 50 个。

这 40 个新 Pod 启动后,Envoy 需要向 Istiod 申请新的证书。Istiod 在那一瞬间的 CPU 使用率飙到了 90% 以上(我们给 Istiod 的 limit 设得有点保守,只给了 2 核)。导致部分新启动的 Envoy 虽然拿到了证书,但在进行 mTLS 握手时,由于 Istiod 负载过高,根证书的推送或者中间状态的同步出现了微小的延迟。

具体来说,老 Pod 的 Envoy 还在用旧的根证书去验证新 Pod 的证书,而新 Pod 的证书虽然合法,但如果是由于 Istiod 压力导致 Root CA 的推送时序问题,就会出现验证失败。

解决方案与配置

我当时的做法分两步走:

我修改了 DestinationRule,增加了连接池和熔断的配置,虽然这不能直接解决证书问题,但能防止因为证书刷新瞬间的失败导致整个连接池雪崩:

apiVersion: networking.istio.io/v1beta1 kind: DestinationRule metadata: name: payment-service-dr spec: host: payment-service.prod.svc.cluster.local trafficPolicy: tls: mode: ISTIO_MUTUAL connectionPool: tcp: maxConnections: 50 connectTimeout: 2s keepAlive: time: 60s http: http1MaxPendingRequests: 100 maxRequestsPerConnection: 10 # 这里是为了防止因为证书刷新导致的瞬时错误导致服务完全不可用 outlierDetection: consecutive5xxErrors: 5 interval: 30s baseEjectionTime: 30s maxEjectionPercent: 50

为什么这么做? 如果不调整连接池,一个 Pod 证书验证失败,可能会导致调用方的连接池被快速耗尽,进而报错扩散到整个集群。通过 outlierDetection(异常点检测),我们可以把那些证书有问题的 Pod 暂时剔除出负载均衡列表,给 Istiod 一点时间去同步证书,而不是让所有流量都去撞墙。

后来我复盘的时候发现,Istio 1.23 其实在 SDS 的推送效率上已经优化了不少,但在这种瞬间大规模扩容的场景下,控制平面的资源预留还是得留足余量。

---

5. 进阶配置:利用Wasm插件实现多集群环境下的自定义流量鉴权

我们公司现在的架构是典型的多集群部署,华东和华北两个 K8s 集群,都接入了 Istio 1.23 的网格。最近有个需求挺头疼:我们需要在服务间调用时,不仅要做 mTLS,还要校验一个自定义的 X-Tenant-Id 请求头,确保 A 租户的请求不能跑到 B 租户的服务实例里去。

以前这种逻辑我们都是写在 Java 代码里的拦截器,或者 Spring Cloud Gateway 里。但现在跨集群了,服务直接通过 Istio 的 East-West Gateway 互通,总不能在每个服务里都写一遍鉴权逻辑吧?维护成本太高了。

为什么选 Wasm?

我研究了一下,本来想用 AuthorizationPolicy,但那个主要基于 JWT 或者源 IP,对于这种业务相关的自定义 Header 校验,配置起来太死板。后来我盯上了 WebAssembly (Wasm) 插件。Istio 1.23 对 Wasm 的支持已经很稳了,性能损耗我测过,大概在 2-3 毫秒左右,对于我们的业务来说完全可以接受。

我决定写一个 Wasm 插件,直接挂载到 Envoy 上,在流量进入 Pod 之前就把不合法的请求拦在门外。

编写 Wasm 插件(C++ 示例)

我用了 Envoy 的 C++ SDK 来写,虽然 Rust 也行,但我们团队对 C++ 更熟一点。这个插件的作用很简单:检查 Header,如果不包含特定的 X-Tenant-Id 或者值不对,直接返回 403。

下面是核心代码片段(编译后是一个 .wasm 文件):

// tenant_auth.cc #include <string> #include <unordered_set> #include "proxy_wasm_intrinsics.h" class TenantAuthRootContext : public RootContext { public: explicit TenantAuthRootContext(uint32_t id, StringView root_id) : RootContext(id, root_id) {} bool onConfigure(size_t /* configuration_size */) override { // 这里可以从配置里读取合法的租户列表,为了简单,我硬编码了 return true; } }; class TenantAuthContext : public Context { public: explicit TenantAuthContext(uint32_t id, RootContext* root) : Context(id, root) {} FilterHeadersStatus onRequestHeaders(uint32_t /* headers */, bool /* end_of_stream */) override { auto headers = getRequestHeaders(); auto tenant_id = headers->get("X-Tenant-Id"); // 如果没有这个 Header,直接拒绝 if (tenant_id.empty()) { sendLocalResponse(403, "Missing X-Tenant-Id", "Forbidden", {}); return FilterHeadersStatus::StopIteration; } // 假设我们只允许 "tenant-a" 和 "tenant-b" std::string value(tenant_id->view()); if (value != "tenant-a" && value != "tenant-b") { sendLocalResponse(403, "Invalid Tenant", "Forbidden", {}); return FilterHeadersStatus::StopIteration; } // 校验通过,继续转发 return FilterHeadersStatus::Continue; } }; // 注册插件 static RegisterContextFactory register_TenantAuthContext( CONTEXT_FACTORY(TenantAuthContext), ROOT_FACTORY(TenantAuthRootContext));

部署与配置

写完代码后,我把它编译成了 tenant_auth.wasm,然后丢到了一个 OCI 镜像仓库里,或者你也可以放在 HTTP 服务器上。Istio 1.23 支持直接从 oci:// 或者 http:// 拉取 Wasm 镜像。

接下来是关键步骤,我需要把这个插件配置到网格里。我使用了 WasmPlugin CRD,把它应用到华东集群的 payment-service 上。

apiVersion: extensions.istio.io/v1alpha1 kind: WasmPlugin metadata: name: tenant-auth-filter namespace: prod spec: selector: matchLabels: app: payment-service # 指定插件作用于哪个阶段,AUTHN 阶段是在认证之后,路由之前 phase: AUTHN priority: 10 url: oci://registry.example.com/wasm/tenant_auth:1.0.0 # 如果插件需要配置,可以在这里传,对应 C++ 里的 onConfigure pluginConfig: valid_tenants: - "tenant-a" - "tenant-b"

为什么这么做? 我把这个插件挂在了 AUTHN 阶段。这意味着,只要请求到了 payment-service 的 Envoy,还没进业务容器,Wasm 插件就先干活了。如果不合法,直接 403,连业务容器都不用惊动。

有一次线上排查问题,我发现华北集群的一个老服务忘了加 X-Tenant-Id 头,结果请求到了华东集群被 Wasm 插件拦截了。当时监控里看到 payment-service 的入站流量里 403 飙升,我一看 Envoy 日志,全是 Missing X-Tenant-Id,立马就知道是华北那边发版漏了配置。

这种排查效率比看业务日志高多了,因为 Envoy 的 Access Log 里会带上 Wasm 插件的拒绝状态,一眼就能定位是流量鉴权层面的问题,而不是业务逻辑 bug。

现在这种多集群、多租户的场景下,Wasm 插件确实比改代码或者改 Ingress 配置要灵活得多。特别是 Istio 1.23 之后,Wasm 的运维和分发机制更成熟了,我打算把更多的通用鉴权逻辑都往这边迁移。

站长实战手记

一次差点让我背P0故障的证书轮换

去年双十一前,我负责给一个日订单量 30 万左右的电商系统做 Istio 迁移。当时为了省事,直接上了 Sidecar 模式,集群规模不大,二十来个 Pod。

就在大促压测那天凌晨,监控突然报警,订单服务的成功率从 99.9% 掉到了 85%,全是 5xx。我当时脑子一片空白,Kiali 上看流量都是红的。我第一反应是服务挂了,赶紧去查 Pod 日志,结果应用层一点报错都没有。

折腾了两个小时,我才把目光锁死在 Istio 的 mTLS 证书上。我查了下 istio-pilot 的日志,发现证书刚好在那个时间点进行了自动轮换。问题出在哪儿呢?我们当时的应用用的是 Java 11,JVM 的证书信任库(Trust Store)是硬编码在镜像里的,Istio 轮换了根证书,但 JVM 根本不认新证书,导致 Envoy 握手全失败。

最后我是怎么解决的?我连夜写了一个初始化脚本,在 Pod 启动的时候动态把 Istio 的 CA 证书注入到 JVM 的信任库里,重新打包上线,这才把成功率拉回来。那次之后,我对 mTLS 的自动化机制有了阴影,现在只要用 Istio,我都会专门写个 Job 去定期校验证书的一致性。

我的真实看法

* 别为了用而用:如果你只是个单体应用或者几个简单的服务,真的没必要上 Istio。光是那些 Sidecar 占用的内存,就够你喝一壶的。

* Ambient 模式是未来:我现在新项目基本都看 Ambient 模式,毕竟不用在每个 Pod 里塞一个 Proxy,运维压力小太多了。

* 迁移要慎重:从 Spring Cloud 迁过来,别指望一键切换。那些 Feign 调用、Ribbon 负载均衡的逻辑,得一点点剥离,不然你会很痛苦。

给读者的话

学 Istio 千万别只看官方文档里的 Bookinfo 例子,那个例子太理想化了。建议你直接拿自己公司的一个非核心业务去试,直接去抓包看 Envoy 的日志,看它到底是怎么处理一个 HTTP 请求的。只有当你亲眼看到 503 是因为证书对不上,而不是代码写错时,你才算是真正入门了。