跳转到内容
← 返回系统剖析
系统剖析当代36 分钟阅读

Envoy 与服务网格:把网络逻辑从应用里搬出来

Envoy and the Service Mesh: Moving Network Logic Out of the Application

2015 年前后的 Lyft 正处在一段很典型的迁移里:一个 PHP 单体被拆成几十个 Python 和 Go 服务。拆完以后出现了一类新的故障——请求失败了,但没有任何一个服务认为自己出了错。 超时是在哪一层触发的?重试是谁发的、发了几次?某个实例已经病了,为什么流量还在往它身上打?这些问题的答案分散在七八种语言的客…

Envoy服务网格sidecar 代理xDS 协议mTLS

2015 年前后的 Lyft 正处在一段很典型的迁移里:一个 PHP 单体被拆成几十个 Python 和 Go 服务。拆完以后出现了一类新的故障——请求失败了,但没有任何一个服务认为自己出了错。

超时是在哪一层触发的?重试是谁发的、发了几次?某个实例已经病了,为什么流量还在往它身上打?这些问题的答案分散在七八种语言的客户端库里,每种实现的行为都不完全一样,而且没有一处能统一观测。

Matt Klein 和 Lyft 的团队为此写了一个 C++ 代理,2016 年 9 月开源,名字叫 Envoy。它 2017 年 9 月进入 CNCF,2018 年 11 月毕业,是继 Kubernetes 和 Prometheus 之后第三个从 CNCF 毕业的项目。

Envoy 的主张只有一句话:重试、超时、熔断、双向 TLS、指标与追踪,这些逻辑和业务无关,不该由每种语言各实现一遍。把它们搬到一个进程外的代理里,让每个服务的所有进出流量都从这个代理穿过——这就是 sidecar 模式,也是"服务网格"这个词后来指的东西。

这篇文章要讲清楚三件事:Envoy 内部到底怎么跑(线程模型与过滤器链)、控制面怎么在不重启的情况下改变数据面行为(xDS),以及这套架构买来了什么、又付出了什么。

破除误解

误解一:服务网格就是 Istio,Envoy 是 Istio 的一个组件。

反了。Envoy 是一个独立的 L4/L7 代理,可以完全脱离 Kubernetes 和 Istio 单独跑——它同样被用作边缘网关、API 网关(Envoy Gateway 子项目)、甚至编译进移动端应用(Envoy Mobile)。Istio 是控制面,Envoy 是数据面,两者由 xDS 协议连接。

这个区分是 Klein 在 2017 年那篇《Service mesh data plane vs. control plane》里刻意强调的,因为它决定了责任边界:数据面负责真正搬运字节并按配置执行策略,控制面只负责把配置算出来推下去。数据面挂了流量立刻断,控制面挂了流量还在跑——这两种故障的紧急程度完全不同,把它们混成一个东西会让人在事故里做出错误判断。

误解二:Envoy 的"熔断"就是 Hystrix 那种跳闸开关。

不是。Netflix Hystrix 那类熔断器是一个状态机:错误率超过阈值就打开电路,之后半开试探。Envoy 的 circuit_breakers 完全不是这个东西,它是每个上游集群的并发上限——max_connections(最大连接数)、max_pending_requests(最大排队请求数)、max_requests(最大活动请求数)、max_retries(最大并发重试数)。超过上限的请求当场被拒绝,返回 503,访问日志里打上 UO 标记。它防的是"雪崩式排队",不是"持续失败"。

真正把坏实例踢出去的是另一个机制:异常点检测(outlier detection),也就是被动健康检查。它统计每个上游主机的连续 5xx、连续网关错误或成功率,达标就把它从负载均衡池里临时摘除,默认基础驱逐时长 30 秒,重复被驱逐时间翻倍,且默认最多驱逐 10% 的主机——这个上限是为了避免"全员被判定不健康导致整个集群空转"。

把这两者混为一谈的后果非常具体:你想要的是"别再往病人身上打流量",却去调了 max_requests,结果健康的实例也开始被拒。

误解三:装上服务网格就自动拥有了分布式追踪。

网格能给你的是每一跳的独立观测:每个代理都知道自己这一跳的耗时、状态码、上下游是谁。它也会为没有请求 ID 的入站请求生成一个 x-request-id

但它没办法替你把追踪上下文从入站请求传到出站请求。因为代理看到的是两条独立的连接:一条进来、一条出去,它无法知道哪个出站请求是由哪个入站请求触发的——那个因果关系只存在于你的应用代码内部。所以除非应用自己把 traceparent 或 b3 系列请求头从入站请求复制到它发起的每一个出站请求上,你拿到的就是一堆孤立的跨度(span),拼不成一棵调用树。

这是服务网格宣传与现实之间最大的一处落差:它能免费给你指标,但追踪需要改代码

一、被重复实现了七遍的那些逻辑

在网格出现之前,业界的答案是客户端库。Twitter 的 Finagle(Scala,2011 年开源)、Netflix 的 Hystrix 与 Ribbon(2012 年前后)都是这条路上的代表作,它们把服务发现、负载均衡、重试、熔断打包进一个库,业务代码 import 一下就有了。

这条路在单一语言栈里效果很好。它垮在两个地方。

第一,语言是乘法。 你有 5 种语言,就要维护 5 套实现,而且它们的行为永远不会完全一致——某一套的重试默认开着退避,另一套没有;某一套把 502 算作可重试,另一套不算。故障演练在一种语言上验证过,不能推广到其他语言。

第二,升级需要重新部署所有服务。 想把全网的默认超时从 30 秒改成 3 秒,或者给重试加上预算限制,你得改库、发版本、然后说服 200 个服务的团队升级依赖并重新部署。这个过程在大公司里以季度计,而且永远有一批服务停在两年前的版本上。

sidecar 模式的做法是把这些逻辑挪到进程外:应用只管发一个最朴素的 HTTP 或 gRPC 请求到本地回环地址,剩下的事由同一个 Pod 里的代理完成。升级代理不需要重新编译应用;换语言不需要重写这套逻辑。

代价也很直白——多了一次进程间的跳转

方案网络逻辑放在哪升级方式多语言成本每次调用的代理跳数隔离粒度
客户端库(Finagle/Hystrix)应用进程内改依赖 + 重新部署应用每种语言一套0进程内,无额外隔离
sidecar 代理(Envoy/Istio)每 Pod 一个独立进程重启 sidecar,不动应用一套2(出站 + 入站)每 Pod 一个失败域与信任域
每节点代理(Cilium/ztunnel)每节点一个共享进程重启 DaemonSet一套1~2节点级,同节点工作负载共享
proxyless(gRPC + xDS)应用进程内的 xDS 客户端改依赖 + 重新部署应用每种语言一套(但只需 gRPC)0进程内

这张表已经把后面所有争论的战场画出来了:跳数、隔离粒度、升级节奏,三者不可能同时最优。

二、线程模型:为什么 Envoy 内部几乎没有锁

Envoy 用 C++ 写成,事件循环基于 libevent,整体是"每核一个非阻塞事件循环"的结构。跑起来的进程里有三类线程:

  • 主线程(main thread):处理 xDS 连接、管理端点(Istio 里是 15000 端口的 admin 接口)、统计刷新、全局定时器。它不处理任何数据面流量
  • 工作线程(worker threads):数量默认等于 CPU 核数(--concurrency 可覆盖)。每个 worker 跑一份独立的事件循环,独立地 accept 连接、跑过滤器链、转发字节。
  • 文件刷新线程(file flusher):把访问日志异步落盘,避免磁盘 I/O 卡住事件循环。

关键设计是:一条连接从建立到关闭都钉在同一个 worker 上,不会被迁移。这样连接相关的所有状态——HTTP 解析器、过滤器状态机、缓冲区——都只被一个线程碰,不需要加锁。

那么共享状态怎么办?路由表、集群成员、TLS 上下文这些东西是全局的,而且会被 xDS 随时改。Envoy 的答案是一套叫 ThreadLocal 的机制:主线程收到新配置后,在自己这边算出一个不可变的新版本对象,然后把"换指针"这个动作作为一个任务投递到每个 worker 的事件循环队列里,由 worker 在自己的循环中执行。

这意味着一个必须接受的事实:在配置切换的那一瞬间,不同 worker 看到的配置版本可能不一样。Envoy 明确选择了最终一致,而不是用锁去换强一致——因为后者会把数据面最热的路径变成竞争点。

这个模型换来了很高的吞吐,但它有三处硬性代价。

代价一:worker 之间可能严重不均。 负载均衡的单位是连接,不是请求。HTTP/2 和 gRPC 把成百上千个流复用在一条长连接上,而这条连接只属于一个 worker。于是很容易出现一个 worker 打满 100% CPU、其余 15 个几乎空闲的情况。缓解手段有两个:用 SO_REUSEPORT 让内核在多个 socket 之间分连接,或者启用精确连接均衡器(exact balance)——但后者需要在 accept 路径上加锁,等于把省下来的锁又还回去一部分。

代价二:任何阻塞调用都是灾难。 一个 worker 上跑着几千条连接,只要有一个过滤器在这个线程里做了同步阻塞的事——同步 DNS 解析、读文件、等一个外部 HTTP 响应——这几千条连接全都会停住。所以 Envoy 里所有外部交互都必须写成异步回调,包括外部授权(ext_authz)这种本质上要等一次网络往返的操作。这也是自定义扩展最容易写错的地方。

代价三:内存按 worker 乘以集群数计算。 每个 worker 都持有一份自己的连接池、每上游主机的状态、每集群的统计计数器。集群数量大时,内存开销是乘出来的,而不是加出来的——这一点在后面讲配置规模时会再次咬人。

三、过滤器链:字节怎么变成"可以做决定的东西"

Envoy 处理流量的结构是三层过滤器,越往后知道的信息越多、能做的决定越具体。

第一层,监听器过滤器(listener filter)。 此时连接刚 accept,还没解析任何应用协议,能看的只有原始字节和套接字元数据:

  • tls_inspector 偷看 TLS ClientHello 里的 SNI 和 ALPN,判断这是不是 TLS、要走哪条链;
  • http_inspector 猜这是 HTTP/1.1 还是 HTTP/2;
  • original_dst 通过 SO_ORIGINAL_DST 从内核取回被 iptables 改写前的原始目的地址——这一条是 sidecar 透明拦截的命门。Istio 用 iptables 把 Pod 的出站流量全部 REDIRECT 到本地 15001 端口、入站流量 REDIRECT 到 15006,应用完全不知情;代理要想知道"这个请求本来是发给谁的",只能靠这个机制把原始目的地捞回来。

第二层,网络过滤器(L4)。 典型的有 tcp_proxy(纯四层转发)、redis_proxythrift_proxy,以及最重要的 HTTP 连接管理器(HCM)。HCM 负责把字节流解析成 HTTP 流:处理 HTTP/1.1 与 HTTP/2 的帧、管理流的生命周期、执行路由匹配,并驱动第三层。

第三层,HTTP 过滤器(L7)。 这一层是双向的:请求方向走解码链,响应方向走编码链,同一个过滤器可以在两个方向各挂一段逻辑。

yaml
# 一条入站过滤器链的骨架(省略了 typed_config 细节)
filter_chains:
  - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          rds: { route_config_name: inbound_15006 }   # 路由表由 RDS 动态下发
          http_filters:
            - name: envoy.filters.http.jwt_authn      # 验 JWT 签名,失败直接 401
            - name: envoy.filters.http.ext_authz      # 异步调外部授权服务
            - name: envoy.filters.http.local_ratelimit
            - name: envoy.filters.http.router         # 必须放最后,真正发起转发
```

顺序不是排版问题,而是语义。每个过滤器对每条流都是一个状态机,处理完头部后返回 Continue(交给下一个)或 StopIteration(暂停整条链,等自己的异步操作回来再恢复)。router 必须是终端过滤器,因为它是唯一真正把请求发出去的那个;放在它后面的过滤器永远不会被执行。

这里有一个容易被忽略的性质:流式是默认,缓冲是例外。Envoy 默认边收边转,不等 body 完整。但只要链上有一个过滤器需要看到完整请求体——比如开了 with_request_bodyext_authz,或者一个要检查 payload 的 WASM 扩展——整条链就退化成"先攒够再走"。延迟从流式变成批式,内存占用随并发请求数和 body 大小相乘。一个看起来只是"加了个鉴权"的改动,可能把代理的内存曲线整个抬起来。

过滤器链的取舍是:可组合性极强,但顺序敏感且难以静态验证。 最典型的事故形态是路径规范化不一致ext_authz 看到的 URL 路径和 router 用来匹配路由的路径,如果经过了不同程度的规范化处理(%2F 是否解码、连续斜杠是否合并、大小写是否统一),攻击者就能构造一个"鉴权时看起来是 A、路由时变成 B"的路径,从而绕过授权。Envoy 历史上为这类问题发布过多次安全公告,并因此增加了 normalize_pathmerge_slashespath_with_escaped_slashes_action 等开关,也调整过它们的默认值。这不是实现 bug 那么简单——它是"把一个决定拆成多个独立组件"这种架构的固有开口。

四、xDS:把配置变成一条订阅流

早期的代理配置是静态文件:改一行,重启进程,正在进行的连接全断。在一个几百服务、Pod 每天滚动几十次的集群里,这条路根本不成立——端点列表每分钟都在变

Envoy 的做法是把配置全部变成可订阅的资源,通过一族叫 xDS(x Discovery Service)的协议从控制面拉取。它可以走 gRPC 双向流(主流),也可以走 REST 轮询。

协议下发什么典型变更频率
LDS Listener监听哪些端口、每个端口的过滤器链长什么样低(改网格拓扑时)
RDS Route路由表:哪个 host/path 走哪个集群、超时与重试策略、流量切分权重中(发灰度、改权重)
CDS Cluster上游集群的定义:负载均衡算法、连接池、熔断上限、健康检查
EDS Endpoint每个集群里具体有哪些 IP、端口、权重、可用区极高(Pod 每次增删都变)
SDS SecretTLS 证书与私钥、CA 信任根中(按证书寿命轮换)
RTDS / ECDS运行时开关;单个过滤器的配置

顺序问题是这套协议里最不直观的部分。 假设控制面先推了一份引用集群 X 的路由表,但定义 X 的 CDS 还没到——这段时间里命中该路由的请求全部 503,访问日志里是 NR(no route)或找不到集群。所以 xDS 规定了一个"先建后拆"(make-before-break)的推送顺序:CDS → EDS → LDS → RDS,永远先让被引用的东西存在,再让引用它的东西上线。

问题是:如果 LDS、CDS、RDS 各自跑在独立的 gRPC 流上,就没有任何机制能保证它们之间的到达顺序。这正是 ADS(Aggregated Discovery Service)存在的理由——把所有资源类型合并到同一条流、同一个管理服务器上,用流内的天然顺序保证依赖顺序。生产环境里几乎所有网格都用 ADS。

第二个关键设计是确认机制。xDS 不是"推下去就算数",而是每次推送都要被 Envoy 显式确认:

Envoy  → 控制面   DiscoveryRequest{ type_url: ".../Cluster",
                                   version_info: "7", response_nonce: "n-42" }   # ACK 第 7 版
控制面 → Envoy    DiscoveryResponse{ version_info: "8", nonce: "n-43",
                                   resources: [ ...全部 Cluster... ] }
Envoy  → 控制面   DiscoveryRequest{ version_info: "7", response_nonce: "n-43",
                                   error_detail: { message: "..." } }            # NACK:版本停在 7
```

ACK 与 NACK 的唯一区别,是 version_info 有没有向前走、以及带不带 error_detailNACK 的时候 Envoy 会保留旧配置继续服务——拒绝一份坏配置显然比加载它更好,但它也制造了服务网格里最常见的困惑:控制面显示"已下发",数据面的行为却纹丝不动。所以排查网格问题的第一动作永远是去看代理实际持有的配置(istioctl proxy-config 或 admin 端点的 /config_dump),而不是看控制面的意图。

第三个是全量与增量之争。原始的 xDS 是"世界状态"(State of the World)语义:任何一个端点变化,控制面都要把该类型的全部资源重发一遍。一个 5000 端点的集群里挂掉一个 Pod,推送的是完整的 5000 条列表。规模上去以后,控制面的 CPU 和出网带宽都被这件事吃掉。

增量 xDS(Delta xDS)改成只发变化的资源名与内容,加上一份"这些资源被移除了"的列表。代价是控制面必须为每个连接的客户端记住"它当前已知哪些资源、版本是多少"——控制面从近乎无状态变成有状态,故障恢复逻辑也随之复杂。这是一次典型的"用状态换带宽"。

顺带一提:xDS 早期的 v2 API 在 2020 年被弃用、2021 年从 Envoy 中移除,现在通行的是 v3。这套协议也早已不只属于 Envoy——它被当作一个通用的数据面 API 来标准化,gRPC 自身就实现了 xDS 客户端,可以完全不要代理直接从控制面拿服务发现与负载均衡策略。

最后是配置规模这个隐藏炸弹。 默认情况下,控制面不知道服务 A 会调用谁,于是给每一个 sidecar 推送整个网格所有服务的监听器、集群和路由。1000 个服务 × 每个服务若干 Pod,推送量与每个代理的内存占用都朝着平方级走。Istio 为此提供了 Sidecar 资源和 exportTo 字段来手工收窄可见范围——这是一个必须由人来提供信息的补丁,因为调用关系这件事,系统本身并不知道。

五、mTLS 与身份:这一块最难在库里做对

服务网格最被低估的价值,是它把工作负载身份这件事做成了基础设施。

Envoy 通过 SDS 获取证书:本地代理(Istio 里是 istio-agent)替它向控制面申请证书,然后通过 Unix 域套接字把证书和私钥推给 Envoy。整个过程中私钥不落盘、不进 Kubernetes Secret、不经过应用容器,到期前自动轮换。Istio 的工作负载证书默认寿命 24 小时,在过半时续期。

身份本身用 SPIFFE 格式写在证书的 SAN URI 字段里:

spiffe://cluster.local/ns/default/sa/reviews
```

注意它绑定的是 Kubernetes ServiceAccount,不是 IP 也不是主机名。这是整个设计里最关键的一步:在容器编排环境里,IP 是短命且会被回收复用的,用它做身份等于把授权建立在流沙上。

灰度启用是另一个现实问题。一个跑着的集群不可能在某一秒同时把所有服务切成强制 mTLS。Istio 的 PERMISSIVE 模式让同一个端口同时接受明文和 mTLS——靠的正是前面提到的 tls_inspector 嗅探。这是唯一可行的迁移路径,但代价必须讲清楚:只要 PERMISSIVE 还开着,"我们已经全网加密"就是一句假话,任何能到达该端口的明文连接依然会被接受。迁移的最后一步(切 STRICT)恰恰是最容易被无限期推迟的一步。

为什么这件事库做不好?因为它需要证书签发、轮换、信任链分发、撤销,而每种语言的 TLS 栈行为都不一样——OpenSSL、BoringSSL、Go 的 crypto/tls、JDK 的 JSSE 在证书校验细节上各有脾气。让一个 C++ 代理统一处理,是这套架构里回报率最高的一项。

六、账单:它到底收了多少钱

资源开销是按 Pod 乘出来的。 每个 Pod 多一个进程,常驻内存从几十 MB 起步,并随该代理持有的集群与端点数量增长;CPU 随请求速率走。Istio 官方的性能文档给出的量级是每 1000 rps 每个代理消耗不到一个 vCPU 核心和几十 MB 内存——单看不多,乘以几千个 Pod 就是可观的集群成本,而且这部分成本与业务负载无关,是常驻的。

延迟的增加不在均值,在尾部。 一次服务间调用从 1 跳变成 3 跳:客户端 → 客户端 sidecar → 服务端 sidecar → 服务端。每一跳多一次回环 TCP 传输、一遍 HTTP 解析与重新编码、一整条过滤器链。总量通常在毫秒量级,均值上很多团队感觉不到;但 p99 会明显变差,因为连接池冷启动、配置热更新、worker 排队这些事件都落在尾部。如果你的延迟预算以微秒计(高频交易、存储数据面),这套东西直接出局。

生命周期顺序曾经是最折磨人的运维问题。 应用容器先于 sidecar 就绪时,它发出的请求会失败;应用退出后 sidecar 还活着,导致 Job 和 CronJob 永远不结束。这不是网格能自己修的——它是容器编排层缺少"辅助容器"这个概念造成的。Kubernetes 1.28(2023 年 8 月)以 alpha 引入原生 sidecar 容器(把 restartPolicy: Always 加在 initContainer 上,使它先启动、后终止、并在整个 Pod 生命周期内保持运行),1.29 转 beta,之后正式稳定,这个坑才在正确的层面被填上。

控制面成了新的关键组件,但它的失效不是立刻致命的。 istiod 宕机后,已有的 Envoy 会保留最后一份配置继续转发流量——这叫 fail-static,是刻意的设计。但从那一刻起:新 Pod 拿不到配置起不来,端点变化不再生效(挂掉的实例不会被摘),最要命的是证书无法续签,24 小时后连接开始成批失败。所以正确的描述不是"控制面挂了就全完",而是"控制面挂了,你有一个以证书寿命为长度的倒计时"。

调试链路确实变长了。 一个 503 至少有六个可能的来源:路由没匹配上、上游连不上、熔断上限触发、重试次数用尽、外部授权拒绝、下游自己断了。Envoy 访问日志里的响应标记(response flags)是最有效的分诊工具——NR 没有路由、UF 上游连接失败、UO 触发上游熔断上限、URX 重试次数耗尽、UAEX 外部授权拒绝、DC 下游连接终止。不会读这几个字母,网格就会把"网络问题"变成"没人能查的网络问题"。

可观测性本身也要花钱。 Envoy 为每个集群、每个上游主机维护一组计数器,Istio 默认导出的指标带有源工作负载、目标工作负载、响应码等多个标签。集群规模一大,Prometheus 的时间序列基数会爆炸——不少团队最终发现网格产生的遥测数据量超过了业务数据本身,不得不回头做指标裁剪。

攻击面也变大了。 代理是一个用 C++ 写的、直接处理未认证外部输入的 HTTP 解析器,它跑在每一个 Pod 里。前面说过的路径规范化类漏洞只是其中一类。把安全能力集中到代理上是净收益,但"代理本身成为高价值目标"是这笔交易的附带条款。

七、sidecarless 与 eBPF 想省掉哪一部分

要看懂近几年的路线之争,先得把 sidecar 的开销拆成三块,因为它们省起来的难度完全不同:

  • (a) 每 Pod 一个常驻进程的固定内存与 CPU;
  • (b) 同节点两个进程之间走完整回环 TCP/IP 协议栈的转发开销;
  • (c) L7 解析与过滤器链本身的计算开销。

eBPF 能省的主要是 (b)。 通过 sockmap/sockops 这类程序,内核可以在两个本地 socket 之间直接搬运数据,跳过 TCP/IP 的大部分处理。这是真实且可观的节省。

eBPF 省不掉 (c),而且短期内也不会。内核里的 eBPF 程序受验证器严格约束:循环必须可证明有界、栈空间只有 512 字节、程序复杂度有上限。用它做完整的 HTTP/2 帧解析、TLS 握手、正则路由匹配、外部授权调用,既不安全也不现实。所以所有 eBPF 方案在需要 L7 能力时,最终都回到用户态的 Envoy——差别只在于那个 Envoy 放在哪儿。

于是路线分成了几条:

Cilium 走的是"eBPF 做 L3/L4,每节点一个共享的 Envoy 做 L7"。身份、策略、转发在内核里完成,只有需要 HTTP 级策略的流量才被送进用户态代理。省的是 (a) 的一大部分——N 个 Pod 共用一个代理进程。

Istio Ambient 模式(2022 年 9 月提出,2024 年 11 月随 Istio 1.24 GA)把 sidecar 劈成两层。ztunnel 是每节点一个的 L4 代理,用 Rust 写,只负责身份、mTLS 和四层转发,通过 HBONE(把 mTLS 之上的 HTTP/2 CONNECT 当隧道用,走 15008 端口)在节点之间传输。waypoint 是按命名空间或服务部署的 Envoy,只在你确实需要 L7 策略时才存在。它的主张很清楚:大多数工作负载只需要加密和身份,不需要一整条 L7 过滤器链,那就别为它付钱。

代价同样清楚,而且是结构性的。 每节点共享代理意味着:故障域从"一个 Pod"扩大到"这个节点上的所有 Pod";一个租户的流量高峰会挤占另一个租户的代理资源;最严重的是这个进程同时持有节点上所有工作负载的私钥,一次内存越界读就从单实例问题变成多租户身份泄露。sidecar 模型"每 Pod 一个失败域、一个信任域"的性质不是低效的副产品,它正是那笔开销买到的东西。

Linkerd 的立场是第三种。 它认为问题不在 sidecar,而在 Envoy 太重:Linkerd 用 Rust 自研了一个只做必要功能的微代理(linkerd2-proxy),把单个代理的资源占用压到很低,然后坚持每 Pod 一个。Buoyant 的 William Morgan 公开反对 ambient 那套取舍,理由正是上面的隔离论证。

还有一条釜底抽薪的路:proxyless。 gRPC 直接内置 xDS 客户端,服务发现、负载均衡、重试策略从控制面拿,但全部在应用进程内执行,代理彻底消失,跳数归零。这等于承认:在单一 RPC 框架统一天下的场景里,库方案本来就更优。它没有解决多语言问题,只是把"N 种语言各写一遍"变成了"gRPC 在 N 种语言里各实现一遍"——只不过这次由一个团队统一维护。

把这些路线放在一起看,会发现它们不是"消灭代理",而是在一条谱系上重新选点:一端是进程内库(零跳、强耦合、隔离最弱),另一端是每 Pod 代理(多跳、松耦合、隔离最强),per-node 与 proxyless 是中间的两个折中。没有哪一点是普遍最优的,选点取决于你更怕什么——怕成本,还是怕爆炸半径。

八、什么时候不该上

  • 服务数量在十几个以内。 控制面的运维、代理的资源、团队的认知负担,加起来会超过它管理的东西。
  • 单一语言、单一 RPC 框架。 库方案更省,proxyless 更适合。
  • 延迟预算以微秒计。 两跳代理不可接受,没有配置能救。
  • 团队里没有人愿意读 Envoy 配置。 网格不会消灭网络问题,它把网络问题搬到了一个新的、需要专门学习才能观察的地方。没有这份投入,它带来的净效果是负的。

跨域连接

  • 电信网络:控制面与数据面分离不是云原生的发明,电话网在 1980 年代就用共路信令(SS7)把信令从话路里抽了出去。这段前史提供了服务网格故障模式的完整预演:信令网中断时,正在进行的通话不受影响,但新呼叫一个也建立不起来——与 istiod 宕机后"存量连接照跑、新 Pod 起不来、证书倒计时"的表现是同一种结构。电信业也验证了这笔交易的另一半:把控制逻辑集中出去让网络第一次变得可编程,同时制造了全网级的新故障类别,1990 年 1 月 AT&T 那次因交换机恢复代码缺陷引发的全国长途中断,正是集中信令逻辑在网络中级联传播的结果。服务网格把同一套架构搬进微服务,也就原样继承了这份风险画像——包括"数据面比控制面更该保守"这条从电信业学来的设计纪律。
  • 网络科学:重试风暴不是配置写错了,它是调用图的拓扑性质。每一跳的重试次数在路径上是相乘的:三层深度、每层允许两次重试,最下游最坏情况会收到八倍流量,而这八倍恰好在系统已经因为慢而重试的时刻到达。网络科学对级联失效的刻画正好解释了这类事故的时间点——节点过载后其负载被重分配给邻居,邻居越过阈值继续失效,故障沿着介数最高的节点扩散,所以最先垮的往往是被最多服务共用的那个基础服务。Envoy 的重试预算把重试限制成活动请求的一个百分比,而不是给每个请求单独计次,本质上是给这个动力系统加阻尼,把放大系数压到 1 以下;熔断上限则是在节点层面强行截断负载重分配的路径。
  • 安全工程:sidecar 与每节点代理的差别,用安全工程的语言说就是失效域怎么划。每 Pod 一个代理相当于给每个单元配一套独立防护,共因失效被限制在一个 Pod 内;每节点一个共享代理则是一层共享防护,一次进程崩溃或一处内存越界同时波及节点上的所有工作负载,而它偏偏还持有全部工作负载的私钥——单点故障与单点泄密叠在了同一个进程里。安全工程通常用冗余换可用性,但这里冗余的成本是按 Pod 数量相乘的,所以业界实际选择的是分层:ztunnel 只承担 L4、waypoint 才做 L7,等于承认不同风险等级的功能应该住在不同的失效域里。纵深防御的老问题也在这里复现——防护层越多、层间接口越多,鉴权在接口处被绕过的机会就越多,路径规范化不一致导致的授权绕过正是这种接口缺陷的标准形态。
  • 劳动与组织形态:服务网格真正改变的,是"谁负责网络"这条组织边界。客户端库方案把重试、超时、熔断的责任分散在每个服务团队手里,网格把它收拢到一个平台团队——这是一次分工的重新划界,而不只是技术替换。收益是标准的专业化收益:一群人把这件事做对一次,其余两百个团队不必再各自学一遍。成本是协调成本上升与责任模糊化:故障发生时服务团队说"是网格的问题",平台团队说"是你的应用没透传 trace 头",而双方各自只能看到链路的一半,谁也无法独立证伪。组织研究对集中化职能的结论在这里逐条成立——一致性和单位成本改善,响应速度与局部知识让渡出去。这也解释了为什么网格落地的失败往往不是技术失败:没有一个稳定的平台团队时,网格没有主人。
  • 认知偏差:链路变长以后,真正的风险不是查不到,而是查到了错的东西。一次请求要穿过客户端、两个代理、服务端四段,每段各有日志与指标,而人的归因会被可得性启发牵着走——最近改过的、最容易查的那个组件先被怀疑。网格恰好放大了这一点:它的指标最全、面板最漂亮,于是总是第一个被看到,也总是第一个被指控。确认偏误随后接手,一旦形成"是 sidecar 的问题"这个假设,访问日志里每一条 UO 标记都像证据,而真正的原因(应用自己的慢查询、上游连接池配置过小)根本不会主动出现在网格面板上。有效的做法是把注意力锚定在能区分假设的证据上——响应标记和 config_dump 之所以是排查的正确起点,正因为它们能证伪某个假设,而不只是能为它找到支持。

参考文献

  • Klein, M. Announcing Envoy: C++ L7 proxy and communication bus. Lyft Engineering, 2016 年 9 月.(Envoy 开源时的设计动机与 Lyft 的原始问题场景)
  • Klein, M. Envoy threading model. Envoy Proxy Blog, 2017.(主线程 / 工作线程 / 文件刷新线程与 ThreadLocal 机制的一手描述)
  • Klein, M. Service mesh data plane vs. control plane. Envoy Proxy Blog, 2017.(数据面与控制面的职责边界,本文误解一的出处)
  • Envoy Project Authors. Envoy Proxy Documentation — Architecture Overview: Threading Model, Listeners, HTTP Filters, xDS Protocol, Circuit Breaking, Outlier Detection. CNCF.(过滤器链语义、xDS ACK/NACK 与推送顺序、熔断与异常点检测的默认值口径)
  • Istio Authors. Istio Documentation — Performance and Scalability, Security (Mutual TLS, SDS), Ambient Mode.(资源开销量级、PERMISSIVE 模式、ambient 架构的官方口径)
  • Li, W., Lemieux, Y., Gao, J., Zhao, Z., Han, Y. Service Mesh: Challenges, State of the Art, and Future Research Opportunities. IEEE International Conference on Service-Oriented System Engineering (SOSE), 2019.(学术视角下的服务网格开销与研究缺口)

延伸阅读

  • Posta, C., Maloku, R. Istio in Action. Manning, 2022——从 Envoy 配置一路讲到控制面调优,是把本文第四节落到实操的最佳读物。
  • Calcote, L., Butcher, Z. Istio: Up and Running. O'Reilly, 2019——版本偏早,但对 sidecar 注入、iptables 拦截与身份体系的讲解仍然是最清楚的。
  • Morgan, W. eBPF, sidecars, and the future of the service mesh. Buoyant Blog, 2022——反方立场,读完第七节再读,能看清"隔离到底值多少钱"这个争论的两端各自在守什么。