
Envoy过滤器的核心原理:搞懂这3点,开发少走90%的弯路
你可以把Envoy想象成一个“流量管家”,所有进出服务的请求都要经过它。而过滤器就是这个管家的“工具箱”——你想让管家给请求盖章(加请求头)、称重(限流)还是拍照(日志),都得通过自定义过滤器来实现。但很多人一开始就卡在“工具箱怎么用”这个环节,其实核心就3个关键点,搞懂了后面开发会顺畅很多。
过滤器链:像流水线一样处理请求
先说说过滤器链(Filter Chain),这是Envoy最核心的设计之一。你可以把它理解成工厂的流水线:当一个请求进入Envoy时,会依次经过多个过滤器,每个过滤器负责处理特定任务,处理完再传给下一个,最后把处理好的请求发给目标服务;响应回来时也是同样的流程,不过是反向经过这些过滤器。
我举个真实例子,去年帮电商客户做“会员请求优先处理”功能时,我们需要在请求进来时先判断用户是否是会员(过滤器A),如果是就标记优先级(过滤器B),最后记录日志(过滤器C)。当时团队里有个刚毕业的同学,一开始想把这三个功能写到一个过滤器里,结果代码越写越乱,改一个逻辑整个过滤器都得重测。后来我 他拆成三个独立过滤器,不仅代码清晰了,而且想关掉日志功能时,直接在配置里注释掉过滤器C就行,不用改代码。这就是过滤器链的好处——职责分离,灵活组合。
这里有个关键细节你得注意:Envoy的过滤器分“解码”(Decode)和“编码”(Encode)两个阶段。简单说,请求从客户端到服务端走的是解码过滤器链(Decode Filters),响应从服务端回客户端走的是编码过滤器链(Encode Filters)。你开发时得想清楚自己的功能要在哪个阶段生效,比如加请求头要在解码阶段,加响应头则在编码阶段。我之前见过有人把响应头修改写到了解码过滤器里,结果调试了半天“为什么响应里看不到我加的头”,就是因为搞错了阶段。
核心接口与生命周期:别让你的过滤器“失控”
搞懂了过滤器链,接下来就得知道怎么写过滤器本身了。Envoy的过滤器开发主要靠继承特定的接口类,实现里面的回调方法。这里我 了最常用的几个接口,你可以先有个印象:
接口类型 | 核心回调 | 作用 | 新手常见坑 |
---|---|---|---|
StreamDecoderFilter | decodeHeaders(), decodeData(), decodeTrailers() | 处理请求阶段的头部、数据、尾部 | 忽略end_stream参数,导致数据不完整 |
StreamEncoderFilter | encodeHeaders(), encodeData(), encodeTrailers() | 处理响应阶段的头部、数据、尾部 | 修改响应头后没调用continueEncoding() |
AccessLogInstance | log(), onDestroy() | 自定义访问日志 | 日志打印阻塞主线程,影响性能 |
你不用死记硬背这些接口,重点是理解过滤器的“生命周期”。就像人从出生到死亡要经历不同阶段,过滤器从创建到销毁也有固定流程:当一个新请求进来时,Envoy会创建过滤器实例(相当于“出生”),然后依次调用decodeHeaders()、decodeData()等方法处理请求(相当于“工作”),请求处理完后调用onDestroy()释放资源(相当于“死亡”)。
我之前带团队开发自定义限流过滤器时,就踩过生命周期的坑。当时为了统计请求频率,在过滤器里用了一个全局计数器,结果发现计数器总是不准。后来排查才发现,每个请求都会创建一个新的过滤器实例,全局变量根本没法共享状态!正确的做法是用Envoy的SharedPtr或者通过过滤器管理器传递上下文,这在Envoy官方开发者指南里有详细说明(Envoy过滤器开发指南)。所以你开发时一定要记住:过滤器实例是请求隔离的,每个请求一个实例,别想用全局变量存状态。
手把手实战:开发HTTP请求头修改过滤器,从0到1跑通全流程
光说原理可能还是有点虚,接下来咱们拿“HTTP请求头修改过滤器”举例子,从头到尾走一遍开发流程。这个过滤器的功能很简单:当请求路径是/api/v1/user
时,自动给请求加上X-User-Region: Beijing
头,方便后端服务识别用户地区。你跟着做下来,基本就能掌握过滤器开发的核心套路了。
环境搭建:别让编译问题消耗你80%的精力
开发Envoy过滤器前,先得把环境搭起来。我见过很多人卡在第一步——源码编译。Envoy的依赖特别多,光Protobuf、BoringSSL这些库就能让你配环境配到怀疑人生。这里给你个懒人方案:用官方的Docker开发镜像,所有依赖都预装好了,直接就能用。
你先执行这行命令拉取镜像:docker pull envoyproxy/envoy-build-ubuntu:latest
,然后启动容器并挂载本地目录:docker run -it -v $(pwd):/workspace envoyproxy/envoy-build-ubuntu:latest /bin/bash
。进入容器后,再克隆Envoy源码:git clone https://github.com/envoyproxy/envoy.git
,切换到最新稳定版(比如v1.29.0):cd envoy && git checkout v1.29.0
。
为啥要用稳定版?我之前图新鲜用master分支开发,结果今天编译过了,明天拉个最新代码就报错,原来是API又变了。所以除非你要开发针对特定版本的功能,否则优先选稳定版。
接下来编译Envoy,执行bazel build -c opt envoy
。第一次编译会很慢,大概30分钟到1小时,取决于你电脑配置。如果编译报错,90%是内存不够,Envoy编译至少需要8GB内存, 你给Docker分配12GB以上。我之前在4GB内存的虚拟机上试,编译到一半就OOM killed了,折腾了一上午才发现是内存问题。
代码编写:300行代码实现核心功能
环境准备好后,就可以写代码了。Envoy过滤器开发主要涉及两个文件:过滤器实现(.cc)和配置 protobuf(.proto)。咱们先创建过滤器实现文件,在envoy/source/extensions/filters/http/modify_header/
目录下(如果没有就新建),创建modify_header_filter.cc
。
先看过滤器类的定义,你需要继承StreamDecoderFilter
接口,实现几个关键方法:
class ModifyHeaderFilter public Http::StreamDecoderFilter {
public:
// 构造函数,接收配置
explicit ModifyHeaderFilter(ModifyHeaderConfigSharedPtr config) config_(std::move(config)) {}
// 处理请求头
Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool end_stream) override {
// 判断路径是否匹配
const auto path = headers.getPathValue();
if (path == "/api/v1/user") {
// 添加自定义请求头
headers.addCopy(Http::LowerCaseString("X-User-Region"), "Beijing");
ENVOY_LOG(info, "Added X-User-Region header for path: {}", path);
}
// 继续执行过滤器链
return Http::FilterHeadersStatus::Continue;
}
// 处理请求数据(这里不需要,直接继续)
Http::FilterDataStatus decodeData(Buffer::Instance& data, bool end_stream) override {
return Http::FilterDataStatus::Continue;
}
// 处理请求尾部(这里不需要,直接继续)
Http::FilterTrailersStatus decodeTrailers(Http::RequestTrailerMap& trailers) override {
return Http::FilterTrailersStatus::Continue;
}
// 释放资源(这里没分配资源,留空)
void onDestroy() override {}
private:
ModifyHeaderConfigSharedPtr config_; // 保存过滤器配置
};
代码很简单吧?核心就是在decodeHeaders
方法里判断请求路径,符合条件就加请求头。这里有个细节:end_stream
参数表示这是不是最后一个请求帧,如果是true,说明后面没有请求数据了。我之前没管这个参数,结果在处理带body的POST请求时,只处理了头,没处理数据,导致请求不完整。不过咱们这个案例只修改头,所以可以忽略。
接下来定义过滤器工厂,负责创建过滤器实例:
class ModifyHeaderFilterFactory public Server::Configuration::NamedHttpFilterConfigFactory {
public:
// 创建过滤器实例
Http::FilterFactoryCb createFilterFactoryFromProto(const Protobuf::Message& proto_config,
const std::string& stats_prefix,
Server::Configuration::FactoryContext& context) override {
auto config = std::make_shared(
dynamic_cast(proto_config));
return config {
callbacks.addStreamDecoderFilter(std::make_shared(config));
};
}
// 返回配置proto类型
ProtobufTypes::MessagePtr createEmptyConfigProto() override {
return std::make_unique();
}
// 过滤器名称,配置时要用
std::string name() const override { return "modify-header-filter"; }
};
// 注册过滤器工厂,让Envoy能找到它
REGISTER_FACTORY(ModifyHeaderFilterFactory, Server::Configuration::NamedHttpFilterConfigFactory);
然后创建配置proto文件modify_header.proto
,定义过滤器的配置参数(咱们这个案例不需要配置,所以proto可以很简单):
syntax = "proto3";
package envoy.extensions.filters.http.modify_header.v3;
import "udpa/annotations/status.proto";
import "validate/validate.proto";
message ModifyHeader {
option (udpa.annotations.file_status).package_version_status = ACTIVE;
// 这里可以添加配置参数,比如要匹配的路径、要添加的头信息等
}
最后修改envoy/source/extensions/filters/http/BUILD
文件,把咱们的过滤器加进去,否则编译时找不到。在envoy_http_extensions
列表里添加:modify_header
,然后添加目标定义:
envoy_cc_library(
name = "modify_header",
srcs = ["modify_header/modify_header_filter.cc"],
hdrs = ["modify_header/modify_header_filter.h"],
deps = [
"//envoy/extensions/filters/http/modify_header:modify_header_proto",
"//envoy/http",
"//envoy/server",
"//envoy/stream_info",
"//library/common/extensions/filters/http:filter_interface",
],
)
编译调试:3个技巧帮你快速定位问题
代码写完后,编译过滤器:bazel build -c opt envoy/extensions/filters/http/modify_header:modify_header
。如果编译报错,先检查代码里的命名空间、头文件是否正确。我之前漏了REGISTER_FACTORY
宏,编译时提示“未定义的引用”,找了半天才发现是没注册工厂。
编译通过后,需要写Envoy配置文件envoy.yaml
,启用咱们的过滤器:
static_resources:
listeners:
name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
filters:
name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
name: local_service
domains: ["*"]
routes:
match: { prefix: "/" }
route: { cluster: service_backend }
http_filters:
name: modify-header-filter # 咱们的过滤器名称
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.modify_header.v3.ModifyHeader
name: envoy.router
typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router }
clusters:
name: service_backend
connect_timeout: 0.25s
type: STATIC
load_assignment:
cluster_name: service_backend
endpoints:
lb_endpoints:
endpoint:
address: { socket_address: { address: 127.0.0.1, port_value: 8081 } }
启动Envoy前,先在本地启动一个测试后端服务,比如用Python起个简单的HTTP服务器:python -m http.server 8081
。然后启动Envoy:bazel-bin/envoy -c envoy.yaml
。用curl测试:curl -v http://localhost:8080/api/v1/user
,如果看到响应头里有X-User-Region: Beijing
,就说明过滤器生效了!
如果没生效怎么办?教你3个调试技巧:第一,看Envoy日志,启动时加-l debug
参数,日志里会显示过滤器是否加载成功;第二,用envoy admin
接口,访问http://localhost:9901/config_dump
,检查过滤器配置是否正确;第三,用GDB调试,编译时加-c dbg
参数生成调试信息,然后gdb args bazel-bin/envoy -c envoy.yaml
,在过滤器的decodeHeaders
方法里打断点。我之前过滤器没生效,就是通过config_dump发现配置里过滤器名称写错了,写成了modify_header_filter
而不是modify-header-filter
,差一个横线就折腾了半小时。
最后提醒一句,过滤器开发完后,最好写单元测试。在test/extensions/filters/http/modify_header/
目录下创建测试文件,用GTest框架测试各种场景,比如路径匹配、不匹配、特殊字符路径等。Envoy的CI特别严格,如果你想贡献代码到官方,测试覆盖率必须达标,不过咱们自己用的话,至少把核心场景测一下,避免上线后踩坑。
如果你按这个案例做下来,遇到编译报错或者运行没效果,欢迎在评论区告诉我具体问题,比如报错信息、配置内容,我尽量帮你看看。开发Envoy过滤器其实不难,关键是把原理搞透,再动手实践,遇到问题多查官方文档和源码,慢慢就熟练了。
开发Envoy过滤器啊,首先得把底子打好,最核心的肯定是C++。你想啊,Envoy本身就是用C++写的,那些过滤器接口,像StreamDecoderFilter、StreamEncoderFilter这些,全都是C++的类和方法,你不熟悉C++,连接口都看不懂,更别说实现功能了。我之前带过一个实习生,Java转过来的,一开始对着C++的虚函数、模板一脸懵,后来花了两周补C++基础,才慢慢上手。所以你要是C++基础薄弱,得先啃啃C++11及以上的特性,比如智能指针(像std::shared_ptr)、lambda表达式,Envoy里到处都是,还有STL容器,vector、map这些,处理数据的时候少不了。
除了C++,还有几个工具和协议知识也得会。先说Protobuf吧,你写过滤器肯定需要配置吧?比如你想让过滤器根据配置的路径来添加请求头,总不能把路径写死在代码里吧?这时候就得用Protobuf定义一个.proto文件,声明配置字段,比如message ModifyHeader { string target_path = 1; string header_key = 2; string header_value = 3; },这样Envoy启动的时候才能解析你的配置。然后是Bazel构建工具,Envoy的源码工程全靠Bazel管理,你写的过滤器要编译进Envoy,就得改源码里的BUILD文件,声明你的过滤器目标,把依赖的库(比如//envoy/http、//envoy/server这些)都写上,不然编译的时候肯定报“找不到某某头文件”,我之前就忘加依赖,结果编译器对着我的过滤器类一顿报错,查了半天才发现是BUILD文件没改对。
HTTP协议基础也得吃透,不然你写过滤器都不知道从哪下手。比如你想修改请求头,得知道请求头存在RequestHeaderMap里,怎么用getByKey()拿字段,怎么用addCopy()加新头;想判断请求方法是GET还是POST,得认识HeaderStringValues::MethodValues里的GET、POST这些常量。要是你连HTTP请求的基本结构(请求行、请求头、请求体)都不清楚,写出来的过滤器要么功能不对,要么把请求搞坏了。对了,要是你的过滤器需要读复杂配置文件,比如从yaml里加载一堆路由规则,那JSON/YAML格式也得熟悉,知道怎么用Envoy的配置解析器把yaml转成Protobuf对象,不然配置写得再花里胡哨,过滤器读不出来也是白搭。
最后给个小 基础薄弱的话别上来就硬啃Envoy源码,先把C++基础打牢,然后看看Envoy官方的过滤器开发指南(就是之前提到的那个链接),里面把核心接口、生命周期讲得很清楚,对着简单的例子(比如官方的示例过滤器)抄一遍,改改功能,慢慢就有感觉了。
开发Envoy过滤器需要掌握哪些编程语言和技术基础?
Envoy过滤器核心开发语言为C++,因为Envoy本身基于C++实现,过滤器需遵循其C++接口规范。 需了解Protobuf(用于定义过滤器配置)、Bazel构建工具(Envoy官方构建系统),以及HTTP协议基础(如请求/响应结构、头部字段)。若涉及复杂配置解析,还需熟悉JSON/YAML格式。基础薄弱者 先学习C++11及以上特性、STL容器使用,以及Envoy提供的基础API文档。
过滤器链中多个过滤器的执行顺序是如何确定的?
过滤器链的执行顺序由Envoy配置文件中http_filters
列表的顺序决定。当请求从客户端流向服务端(解码阶段)时,过滤器按列表顺序依次执行(如列表中A、B、C,则A先执行,再B,再C);当响应从服务端返回客户端(编码阶段)时,过滤器按列表反向顺序执行(即C先执行,再B,再A)。文章中“会员请求优先处理”案例的A(判断会员)、B(标记优先级)、C(记录日志)即按配置顺序执行。
开发Envoy过滤器时,如何判断功能应该放在解码阶段还是编码阶段?
解码阶段(Decode)处理请求从客户端到服务端的流程,适合实现请求相关功能,如修改请求头(如文章案例中的X-User-Region头)、请求限流、路径匹配等;编码阶段(Encode)处理响应从服务端到客户端的流程,适合实现响应相关功能,如添加响应头、修改响应内容、响应日志等。 若需记录“服务端返回的状态码”,则需在编码阶段的encodeHeaders()方法中实现。
为什么自定义过滤器编译时经常出现依赖错误?如何避免?
Envoy依赖库众多(如Protobuf、BoringSSL、Abseil等),手动配置易遗漏依赖项,导致编译错误。避免方法:优先使用官方Docker开发镜像(envoyproxy/envoy-build-ubuntu),其预装所有依赖;编译前确保修改Envoy源码中的BUILD文件,在envoy_http_extensions
列表添加自定义过滤器目标,并在目标定义中声明正确依赖(如//envoy/http、//envoy/server等核心模块);克隆源码时 切换到稳定版本(如v1.29.0),避免master分支API频繁变更导致的兼容性问题。
过滤器开发完成后,如何验证其性能是否符合预期?
可通过以下步骤验证性能: