☰
Envoy ext_proc实战:用gRPC外置服务解耦网关过滤器扩展
2026/10/17 18:04:30 网站建设 项目流程

做网关二次开发的同学,应该都体会过扩展Envoy有多纠结。想给请求改个头、改个包体、接一套内部认证逻辑,摆面前的无非三条路:用C++写原生HTTP Filter,维护成本高到劝退;上Lua脚本,只能处理轻量场景;走Wasm,光是SDK版本、ABI兼容就能耗掉大半工期。而Envoy External Processing(业内一般叫ext_proc)给了一条完全不同的路——把请求处理逻辑直接外置成一个独立的gRPC服务,让代理通过流式协议把请求的各个阶段“丢”给外部处理器,再按返回结果决定怎么放行、怎么改写、怎么拦截。

这套机制解决的是Envoy扩展生态里最痛的一个问题:代理内核和业务逻辑的解耦。你不需要跟Envoy一起编译,不需要被C++绑死,处理器用什么语言写都行。适合的场景也很明确:网关团队要做统一认证、请求改写、风险识别,又不希望把这些逻辑堆进代理进程里。这篇文章我会把ext_proc的协议机制、配置方式、代码实现和踩坑经验完整过一遍,目标是你看完就能自己搭出一套可用的外部处理器。

1. 为什么需要External Processing:扩展模型演进中的必然选择

1.1 传统扩展方式的边界在哪

先说说我为什么对ext_proc这么上心。早年做网关功能,最常碰的是HTTP Filter。它的能力确实强,从请求头到路由、从限流到鉴权都能插一脚,但代价也很实在:你得用C++写,而且必须严格匹配Envoy源码里Filter接口的签名。一旦Envoy升级,接口变了,你的代码就要跟着改、重新编译、重新上线。这套流程在快速迭代的业务场景里非常难受。

后来社区推了Lua。Lua的好处是嵌入式、启动快、写起来简单,但它能操作的对象和访问的范围有限。做个小逻辑够用,比如加个简单响应头,但你要做复杂的异步调用、连外部服务、处理大包体的业务级改造,Lua就力不从心了。Wasm是另一个方向,隔离性和安全模型都不错,但它在Envoy生态里的成熟度、工具链、宿主兼容问题,会让刚上手的人花大量时间在跟构建环境作斗争,而不是写真正的业务逻辑。

这三条路的共同问题是:扩展逻辑要么和代理耦合太深,要么受限于语言和运行时的能力边界。而真实网关场景里,很多改造逻辑本来就该存在于代理之外——团队里可能有人用Go写鉴权服务,有人用Python做风控策略,这些服务已经在生产环境跑得很稳了。为什么不直接让Envoy把请求交给它们处理?ext_proc就是从这个朴素需求里长出来的。

1.2 外置处理器的核心价值:语言无关与进程隔离

ext_proc的思路一句话就能讲清楚:Envoy在请求处理的关键节点上,把请求或响应的头部、包体通过gRPC流式协议发给外部服务,外部服务处理后返回指令,Envoy根据指令继续执行或直接返回错误。整个过程里,外部处理器是一个标准gRPC服务端,Envoy是客户端。

这个设计带来了三个很实际的收益。

第一是语言无关。你想用Go、Rust、Java甚至Python实现处理器都可以,只要会写gRPC服务就行。这意味着团队里哪个语言生态最成熟,就用哪个语言去写网关逻辑,不需要为了扩展Envoy硬学C++。

第二是进程隔离。处理器崩了,最多是这一跳请求失败,不会拖垮整个代理进程。这对稳定性要求高的网关环境非常重要。你可以放心地把处理器做成一个独立的微服务,用常规的服务治理手段去管理它——灰度、监控、扩缩容,全都走自己熟悉的链路。

第三是生态复用。ext_proc处理器本质上就是一个普通后端服务,你已有的鉴权库、风控模型、配置中心客户端,全部可以直接拿来用。这跟把逻辑塞进Envoy内部是两种完全不同的开发体验。

当然,外置也有外置的代价。最明显的是网络开销:原本在代理进程内一步完成的事,现在变成了一次gRPC远程调用。所以合理使用处理模式、控制调用阶段、设计好超时降级,是这门技术能不能落地的关键。后面我会详细讲。

2. 核心机制拆解:双向流协议与处理阶段

2.1 流式协议与消息顺序怎么工作

ext_proc的底层协议是gRPC双向流。过滤器对应的服务定义是envoy.service.ext_proc.v3.ExternalProcessor,核心方法是Process,入参是服务端流式的ProcessingRequest,出参是客户端流式的ProcessingResponse。

这里要理解一个关键点:一个HTTP请求在Envoy内部处理时,会按顺序经过多个阶段——收到请求头、收到请求体、准备响应头、准备响应体。ext_proc把这些阶段定义成了不同的消息类型。外部处理器启动后,通过一个长连接的流,持续接收这些阶段消息,处理完再通过流返回响应。

消息类型主要有四种:

  • RequestHeaders:请求头阶段。外部处理器可以在这里修改请求头、添加新头部,或者直接生成一个响应来阻止请求继续转发。
  • RequestBody:请求体阶段。如果配置了请求体处理,Envoy会把请求体(或分片)发来,处理器可以改写请求体。
  • ResponseHeaders:响应头阶段。通常用来修改上游返回的响应头,或者做统一打点。
  • ResponseBody:响应体阶段。对应的就是对响应体做改写。

用生活化的类比来说,这就像一条流水线的质检节点。每一个工件(请求)经过某个工位(处理阶段)时,工位上的师傅(外部处理器)可以决定:直接放行、改造一下再放行、或者就地打回(直接返回错误响应)。每个工位独立工作,但整条流水线的顺序是固定的。

开发处理器时要注意消息顺序是严格串行的。Envoy在收到上一个阶段的处理结果之前,不会发送下一个阶段的消息。所以你在处理器里维护状态时,可以放心地认为:同一个请求的消息是有序到达的。但不同请求之间是并发处理的,流上会交错出现多个请求的不同阶段消息,需要靠请求消息里带的字段区分上下文。协议里通过请求头里的attributes或动态元数据来做关联。

2.2 processing_mode:不是每个阶段都必须处理

很多人第一次配置ext_proc,以为每个请求都会触发全部四个阶段,结果发现处理器只收到头部的消息,包体消息一直不来,然后开始怀疑是不是过滤器没生效。其实这是正常的,取决于processing_mode配置。

processing_mode里分两块:请求侧和响应侧,每侧又能独立设置头部和包体的处理策略。默认的REQUEST_HEADERS和RESPONSE_HEADERS是开启的,REQUEST_BODY和RESPONSE_BODY默认是关闭的。这其实是Envoy的一个刻意设计:在大部分场景里,你只是想改改头部、加个标记,根本不需要碰包体,默认开启反而增加无谓的开销和延迟。

包体的处理策略又分两种模式:BUFFERED和STREAMED。

  • BUFFERED:把整个包体缓冲完再发给处理器。好处是处理器拿到的是完整内容,可以做整体校验或替换;代价是内存占用高,大文件上传场景会很难受。
  • STREAMED:包体分片发,每片到达就发给处理器。适合流式传输场景,但处理器的逻辑会复杂一些,要处理分片边界。

我自己的经验是能不开包体就不开。很多业务需求看起来要改包体,其实换个思路用头部加标记、或者后端配合改一下,就能省掉一整条复杂链路。实在要改,优先选BUFFERED,并且对包体大小做好限制,防止内存被打爆。

2.3 关键配置项:failure_mode_allow、async_mode、route级覆盖

ext_proc的配置项不多,但每个都很关键。先说failure_mode_allow。

这个字段决定外部处理器不可用时的行为。默认是false,也就是fail closed:外部服务连不上、超时、返回错误,Envoy会直接返回5xx,请求不会继续向后端转发。这在安全敏感的场景里是合理的,比如认证服务挂了,宁可全部失败也不能放开请求。但如果你做的是辅助型改造,比如加个非关键的观测标记,那fail closed会严重影响可用性。这时把failure_mode_allow设为true,外部处理器故障时请求会自动直通,不经过处理继续向后端走。

另一个重要配置是async_mode。这个模式下,Envoy把处理结果异步交给外部服务,自身不阻塞等待,请求照常向后端转发。外部服务返回的结果用于动态更新元数据,供后续过滤器或日志使用。它适合“只读”类改造:比如把请求信息同步到一个外部分析系统。但它不适合需要根据处理结果决定是否拦截请求的场景,因为它本身就是异步的,没法在关键路径上做决策。

然后是route级的覆盖配置。ext_proc既可以在HTTP过滤器层面全局配置,也可以在路由配置里做per-route覆盖。这个能力很实用:你可以在全局配置一个默认处理器,然后在某个特定路由上换成另一个处理器,或者直接关闭处理。典型的用法是做灰度——先让一小部分流量走新的处理逻辑,用路由权重控制比例,验证没问题再把全局配置切过去。

你可以把ext_proc想象成网关里的“业务插件中心”,而route级配置是插件的动态路由开关。理解了这些,后面搭建服务时才不会配置错地方。

3. 实操环节:从零实现一个外部处理器

3.1 环境准备与接口定义

开始写代码之前,先把环境准备好。我的建议是直接用Docker跑一个固定版本的Envoy镜像,避免本地编译的麻烦。处理器这边我以Go为例,因为gRPC生态最成熟,写起来也快。你只需要准备:

  • 一个能跑gRPC服务的语言环境(这里用Go)
  • 从Envoy仓库拿到的proto文件,核心是external_processor.proto和它依赖的processing_mode.proto、header_mutation.proto、body_mutation.proto等
  • protoc或buf工具,用来生成Go的桩代码

如果你用的是buf,一个简单的buf.yaml就能管理依赖。如果没有buf,直接用protoc指定-I指向Envoy仓库的api目录也行,就是把所有依赖的proto路径都配上,稍微啰嗦一点。

生成完桩代码后,你的项目里会有一个ExternalProcessorServer接口,核心方法就一个Process。这个方法是双向流的,要在一个循环里不断接收ProcessingRequest,然后返回ProcessingResponse。这句描述听起来简单,但很多细节决定功能是否正常。下面我把核心逻辑拆开说。

3.2 实现gRPC服务端:读消息、回响应

处理器的主循环大概是这样的结构:

func (s *server) Process(stream ext_proc.ExternalProcessor_ProcessServer) error { for { req, err := stream.Recv() if err == io.EOF { return nil } if err != nil { return err } resp := &ext_proc.ProcessingResponse{} switch v := req.Request.(type) { case *ext_proc.ProcessingRequest_RequestHeaders: resp.Response = handleRequestHeaders(v.RequestHeaders) case *ext_proc.ProcessingRequest_RequestBody: resp.Response = handleRequestBody(v.RequestBody) case *ext_proc.ProcessingRequest_ResponseHeaders: resp.Response = handleResponseHeaders(v.ResponseHeaders) case *ext_proc.ProcessingRequest_ResponseBody: resp.Response = handleResponseBody(v.ResponseBody) } if err := stream.Send(resp); err != nil { return err } } }

这里最容易犯的错是只处理了某种消息、其他消息直接忽略,甚至不发送响应。协议要求每条请求消息都要有对应的响应,如果你switch里漏掉了某个case,那这个流的消息就卡住了,整个请求流程会一直等下去,直到超时。我建议默认分支给一个空响应也要发送回去。

再来看看请求头阶段具体怎么实现。假设我要给所有经过网关的请求添加一个内部标记头,并在校验失败时直接返回401。代码大概这样:

func handleRequestHeaders(headerReq *ext_proc.RequestHeaders) *ext_proc.ProcessingResponse_RequestHeaders { mutation := &ext_proc.HeaderMutation{} mutation.SetHeaders = append(mutation.SetHeaders, &config_core_v3.HeaderValueOption{ Header: &config_core_v3.HeaderValue{ Key: "x-ext-proc-tag", Value: "processed", }, }) // 假设这里做鉴权,发现请求头里缺少必要凭证 if headerReq.Headers["authorization"] == "" { return &ext_proc.ProcessingResponse_RequestHeaders{ RequestHeaders: &ext_proc.HeadersResponse{ Response: &ext_proc.CommonResponse{ Mutation: mutation, ImmediateResponse: &ext_proc.CommonImmediateResponse{ HttpStatus: &envoy_type_v3.HttpStatus{ Code: envoy_type_v3.StatusCode_Unauthorized, }, Body: []byte("missing auth header"), }, }, }, } } return &ext_proc.ProcessingResponse_RequestHeaders{ RequestHeaders: &ext_proc.HeadersResponse{ Response: &ext_proc.CommonResponse{ Mutation: mutation, }, }, } }

ImmediateResponse是外部处理器“直接拍板”的能力:不用再往后端转发,Envoy直接用这个响应返回给客户端。这是做认证拦截时最常用的字段。

还需要说明的是,请求头阶段返回的HeaderMutation如果设置了SetHeaders,Envoy会把这些头加到请求里,再往后端发。这跟响应头阶段返回的SetHeaders效果类似,但作用对象不同,别混了。

3.3 配置Envoy:一个最小可用的示例

代码写好了,接下来把Envoy配置上。假设外部处理器跑在本机的9002端口,用gRPC。Envoy侧首先需要定义一个cluster指向它,然后在HTTP过滤器链里挂上ext_proc过滤器。

一个最小化的静态配置片段如下:

static_resources: clusters: - name: ext_proc_server connect_timeout: 0.25s type: STATIC lb_policy: ROUND_ROBIN load_assignment: cluster_name: ext_proc_server endpoints: - lb_endpoints: - endpoint: address: socket_address: address: 127.0.0.1 port_value: 9002 http2_protocol_options: {} http_filters: - name: envoy.filters.http.ext_proc typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor grpc_service: envoy_grpc: cluster_name: ext_proc_server failure_mode_allow: true processing_mode: request_header_mode: SEND response_header_mode: SEND request_body_mode: NONE response_body_mode: NONE

这里要注意http2_protocol_options不能少,gRPC是基于HTTP/2的,如果没有显式启用HTTP/2,Envoy到后端的连接会退化成HTTP/1.1,gRPC调用直接失败。

failure_mode_allow: true在这个最小示例里是为了安全:如果处理器没启动,Envoy也不会把整个网关弄挂。你先这样配起来调通,之后再按业务需要决定要不要改成fail closed。

还有一处容易忽略:过滤器链里的顺序。envoy.filters.http.ext_proc建议放在router过滤器之前。如果你的过滤器链里还有CORS、JWT这些过滤器,ext_proc的位置决定了它能看到哪些阶段。想让它做全局的请求预处理,就尽量往前放;想让它只处理业务路由后的阶段,就往后放。这直接影响你能拿到的上下文信息。

3.4 从改头部升级到改包体

头部阶段调通后,很多需求会自然延伸到包体。举个例子:某内部系统要求网关统一对请求体里的敏感字段做脱敏,再转发给后端。这时需要在配置里打开请求体处理:

processing_mode: request_header_mode: SEND response_header_mode: SEND request_body_mode: BUFFERED response_body_mode: NONE

处理器那边,收到的就是RequestBody消息。它的Body字段是完整包体。返回时用BodyMutation字段。比如我要把请求体里的某个JSON字段替换掉,最简单的方式是直接生成新包体:

func handleRequestBody(bodyReq *ext_proc.RequestBody) *ext_proc.ProcessingResponse_RequestBody { // bodyReq.Body 是原始请求体 newBody := someRewrite(bodyReq.Body) return &ext_proc.ProcessingResponse_RequestBody{ RequestBody: &ext_proc.BodyResponse{ Response: &ext_proc.CommonResponse{ BodyMutation: &ext_proc.BodyMutation{ Mutation: &ext_proc.BodyMutation_Body{ Body: newBody, }, }, }, }, } }

这里的BodyMutation_Body会把整个请求体替换成你提供的newBody。简单直接,但有两个坑:

第一,这个操作是把整体替换掉,不是原地上改几个字节。如果你只在原包体上做了小改动,记得要把完整的新包体返回,而不是只返回改动部分。第二,BUFFERED模式意味着整个请求体先攒在Envoy内存里。如果请求体很大,内存会飙升。我实际测过,几十MB的包体会让代理内存明显上涨。所以包体替换要做大小限制,或者走STREAMED模式分段处理,但分段处理时你拿不到完整上下文,改写逻辑会复杂很多。

还有个细节:响应体处理跟请求体处理是对称的,但实际使用中,响应体改造比请求体改造更常踩坑。因为上游响应可能是分chunk的、带压缩的,你如果没关掉压缩或者没设置flush,改出来的响应体可能是乱码。我的建议是:响应体改写尽量少用,如果非用不可,仔细检查上游的content-encoding配置。

4. 典型应用场景与踩坑实录

4.1 场景一:统一认证拦截

很多团队用ext_proc第一个落地的需求就是认证。因为认证逻辑天然适合外置:网关收到请求头后,把它发给认证服务,认证服务返回“放行”或“拒绝”。

实际做的时候有几个地方要特别注意。

首先,拦截响应要用ImmediateResponse,这时候Envoy不会继续转发上游请求。但同时你要考虑CORS预检请求:浏览器发OPTIONS请求时通常不带Authorization头,你把所有没有Authorization的请求都拦截,预检请求就全挂了。正确的做法是先判断请求方法,OPTIONS直接放行,再走到认证逻辑。

其次,认证服务的响应要快。ext_proc是同步阻塞在请求路径上的,认证服务多慢,网关就多慢。所以认证服务本身要扛住高并发,超时也要设置得保守一点。我见过一个团队把认证超时设成10秒,结果认证服务抖动一次,网关全线超时。后来改成500毫秒超时加failure_mode_allow: true,用户体验反而好了——认证挂了不会把整个网关拖死,只是有些功能受限。

如果认证逻辑很重,比如要做多层校验、离线缓存、风险分析,建议不要在ext_proc处理器里同步做完。可以走async_mode,把请求信息异步丢给分析服务,网关本身不等待。但这样网关就失去了拦截能力——异步模式下处理器返回的ImmediateResponse不会生效,HeaderMutation也不会生效。所以这种模式只适合观测、统计类场景。

4.2 场景二:请求头打标与链路信息注入

另一个高频需求是给请求加内部标识。比如网关后面接了很多微服务,每个服务都想在日志里看到一个统一的链路ID,那就可以用ext_proc在每个请求进来时生成一个ID放进请求头。

这个需求用头部阶段就够了。处理器逻辑非常简单:判断请求里有没有x-trace-id,没有就生成一个UUID,用SetHeaders加回去。性能一般不是瓶颈,因为只是内存操作,不涉及外部IO。但这里要把async_mode和同步模式区分开:如果只是加个ID,用同步模式即可,几乎无延迟;如果还想把这个ID上报给日志系统,可以加一个异步上报,用async_mode把消息发给另一个消费服务。

我跟别人合作时还发现一个常见误会:HeaderMutation里的SetHeaders是追加语义,如果请求头里已经有同名头部,默认会保留原来的值还是替换?答案是HeaderValueOption里有一个append字段,默认是append(追加)。如果希望强制覆盖,要显式设置append: false。很多人在这一步栽过跟头:想改写Authorization头,结果变成了追加另一个Authorization头。

4.3 常见问题速查与排查思路

把实际运维中碰到的问题整理成一张表,排查时先对照这个表过一遍,比自己瞎试快得多。

现象可能原因解决方案
请求直接503,处理器服务没收到任何消息cluster配置错误或外部服务未启动;gRPC连接没建立检查cluster地址端口,确认服务监听;用grpcurl手动调Process方法验证
外部服务挂了,网关跟着全挂failure_mode_allow默认false,fail closed评估业务风险后设置failure_mode_allow: true,并配合超时控制
处理器收到请求头消息,但设置的头没生效HeaderValueOption的append语义理解错误;或者返回了错误的响应字段显式设置append: false;检查是否往response_headers而不是request_headers里塞了头
改了请求头却能透传到后端过滤器顺序问题,ext_proc放在router之后把envoy.filters.http.ext_proc放到router之前
处理器里的body消息一直不来processing_mode里body相关模式是NONE显式设置request_body_mode: BUFFERED或STREAMED
gRPC调用报错,连接异常后端cluster没有启用HTTP/2在cluster配置里加上http2_protocol_options
处理服务很慢,网关请求延迟显著上升同步模式下每个请求都要等外部处理完成优化服务性能;考虑只处理头部阶段;能开async_mode的场景用异步模式
修改响应体后客户端收到乱码上游响应启用了压缩,ext_proc处理的是压缩后的数据在转发上游请求时清除accept-encoding,或在上游侧关闭响应压缩

4.4 设计层面的几条实在建议

最后分享几条做ext_proc落地时我认为最重要的设计建议。

第一,处理器保持无状态。虽然前面说同一个请求的消息是串行到达的,但多个请求之间是完全并发的。如果你的处理器为了处理某个阶段的状态,在内存里存了请求维度的变量,高并发下很容易串线。正确做法是每个请求的所有信息都从消息本身拿,必要时用请求头里的ID字段自己构建上下文。

第二,控制处理阶段的数量。默认只处理请求头和响应头,很多团队上来就把四个阶段全打开,结果延迟和内存开销都翻倍。每个阶段都意味着一次流消息和一次外部处理往返。你每多开一个阶段,请求总延迟就会多一段。所以配置阶段时要想清楚:这个阶段我到底用不用?用不上就关掉,这对性能的收益比优化代码还明显。

第三,合理规划超时和降级。ext_proc的gRPC调用默认超时比较宽松,实际生产里一定要显式设置。我的经验是:认证类处理器设短超时,加上fail closed;辅助标记类处理器设短超时,加上fail open。这样核心链路和辅助链路各自的故障影响范围是可控的。还有一个容易被忽略的点:failure_mode_allow: true时,外部处理器超时或者返回错误,Envoy只会跳过处理,不会记录更细的失败原因。排查问题时需要额外记一条日志,标明跳过原因是连接失败还是响应超时。

第四,锁定Envoy版本。ext_proc的API还在演进,不同版本之间协议字段可能有差异。你的proto生成代码基于哪个版本,跟实际运行的Envoy版本必须严格一致。最好不要让Envoy镜像自动更新到latest,升级前先比对一下external_processor.proto的变更记录。我在测试环境就被版本不一致坑过:本地用的是新版本proto,线上Envoy是旧版本,服务端能启动,但某些字段传过去被忽略,排查了很久。

我在实际调试ext_proc时的体会是,不要贪多。先把头部阶段跑通,把外部服务的连接、超时、降级策略都验证稳了,再考虑包体处理。包体处理看着简单,实际上因为内存、编码、分片这些问题,踩坑概率比头部高一个量级。而头部阶段能解决的需求,其实已经覆盖了网关扩展里相当大一部分——认证打标、路由标识、链路追踪、灰度分流,这些大多不需要动包体。从一个最小闭环开始,逐步扩展处理阶段,比一上来就搞全阶段改造要稳得多。最后留一个操作建议:外部处理器上线前,至少做一次故障演练,把外部服务直接停掉,看看网关在fail open和fail closed两种策略下的表现是否符合预期。这一步做扎实了,后续扩容和迭代才有底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询