做网关二次开发的同学应该都体会过扩展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如果设置了SetHeadersEnvoy会把这些头加到请求里再往后端发。这跟响应头阶段返回的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/2Envoy到后端的连接会退化成HTTP/1.1gRPC调用直接失败。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默认falsefail 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或STREAMEDgRPC调用报错连接异常后端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两种策略下的表现是否符合预期。这一步做扎实了后续扩容和迭代才有底气。
阅读完成 · 觉得有帮助?