Envoy ProtoMessageExtraction 过滤器实战:将 gRPC Proto 消息提取为动态元数据
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
本篇技术指南围绕 Envoy 的envoy.filters.http.proto_message_extractionHTTP 过滤器展开,讲解如何将 gRPC 请求/响应(Protobuf 消息)按配置指令提取为google.protobuf.Struct,写入动态元数据(dynamic metadata)键envoy.filters.http.proto_message_extraction,供访问日志、遥测或后续过滤器使用。读完本文,你将掌握该过滤器的适用场景、处理流程、完整配置语法(含三种提取指令)、输出格式,以及其在 api/envoy/extensions/filters/http/proto_message_extraction/v3/config.proto 与source/extensions/filters/http/proto_message_extraction/目录下的源码级实现原理。
说明:本文依据仓库文档 proto_message_extraction_filter.rst 及其对应 v3 API 定义、源码与集成测试整理而成。
过滤器概述
ProtoMessageExtraction 过滤器支持将 gRPC 请求/响应(即 Proto 消息)提取到google.protobuf.Struct,并将结果存储在动态元数据键envoy.filters.http.proto_message_extraction中,供后续访问。其过滤器类型 URL 为:
type.googleapis.com/envoy.extensions.filters.http.proto_message_extraction.v3.ProtoMessageExtractionConfig该过滤器已注册在扩展元数据中(见 extensions_metadata.yaml 中envoy.filters.http.proto_message_extraction条目),过滤器名称常量为envoy.filters.http.proto_message_extraction(见 filter.h),其核心实现派生自Envoy::Http::PassThroughFilter——这是理解其"只读旁路"定位的关键。
适用场景
当需要对 gRPC 请求与响应做敏感或细粒度日志记录时,该过滤器尤其有用:
- 在客户端流式(Client-Side Streaming)或服务端流式(Server-Side Streaming)调用中,过滤器可以仅保存第一条(first)和最后一条(last)消息,后续可将其用于日志记录,或获得数据流的全局视图。
- 通过只提取白名单字段,可以避免将整个 Proto 消息体写入日志或元数据,从而减少敏感信息暴露与存储开销。
前提假设
该过滤器仅适用于以 Protobuf 作为 payload 的 gRPC。在源码实现中,这一前提体现在 filter.cc:decodeHeaders首先调用Envoy::Grpc::Common::isGrpcRequestHeaders(headers)检查请求头是否携带application/grpccontent-type,若不是 gRPC 请求则直接放行,不做任何提取;响应侧对应检查isGrpcResponseHeaders(filter.cc)。
处理流程
在请求与响应路径上,过滤器按以下逻辑工作:
- 如果到来的 gRPC 请求/响应已配置,过滤器依次执行:
- a. 缓冲(buffer)到来的数据,拼装出完整的 Protobuf 消息;
- b. 按指令(directives)提取单个 Protobuf 消息中的字段;
- c. 将结果写入动态元数据;
- d. 原样透传(pass through)请求/响应数据。
- 否则,直接放行请求/响应。
该过滤器不处于关键路径上:它不修改请求或响应本身,只提取指定字段、写入动态元数据,随后将请求/响应原样透传,不会对业务数据造成任何改动。
结合源码可以更具体地看到这条流程的落地(filter.cc):
- 路径解析:
decodeHeaders将:path形如/package.service/method的 gRPC 路径转换为package.service.method(grpcPathToProtoPath),用于在 proto descriptor 池中查找方法;若路径格式非法,则以BAD_REQUEST错误拒绝请求(filter.cc)。 - 提取器查找:通过
filter_config_.findExtractor(*proto_path)查找该方法是否配置了提取规则;未配置则直接Continue放行。 - 消息缓冲:
decodeData/encodeData借助MessageConverter(构造时传入decoder_callbacks_->bufferLimit()/encoder_callbacks_->bufferLimit()作为缓冲上限)调用accumulateMessages(data, end_stream)累积并切分完整的 gRPC 消息帧;数据不足一个完整消息时返回StopIterationNoBuffer等待后续数据(filter.cc)。 - 提取与回写:对每个完整消息调用
extractor_->processRequest(...)/extractor_->processResponse(...),将结果写入动态元数据,再convertBackToBuffer将原消息放回数据缓冲并继续向下游透传。 - 异常兜底:若配置了提取但始终未能缓冲出任何完整消息,请求/响应会以 gRPC
InvalidArgument("did not receive enough data to form a message.")被拒绝,响应侧还会在 StreamInfo 上设置UnauthorizedExternalService响应标志(filter.cc)。
配置要求
- 提取目标字段必须属于以下原始类型之一:
string、uint32、uint64、int32、int64、sint32、sint64、fixed32、fixed64、sfixed32、sfixed64、float、double。 - 目标字段可以是 repeated(重复字段)。
- 路径上的中间类型也可以是 repeated(例如路径经过 repeated 消息字段)。
实现细节:从源码看,除
EXTRACT_REPEATED_CARDINALITY外,其余指令的字段提取器由FieldValueExtractorFactory创建(extractor_impl.cc),类型约束来自 proto 字段提取库的受支持类型集合;EXTRACT_REPEATED_CARDINALITY则允许作用于任意类型,因为它只统计条目数量而不提取字段值。
配置详解(v3 API)
完整 API 定义见 config.proto,核心消息为ProtoMessageExtractionConfig,包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
descriptor_set | oneof | gRPC 服务的proto descriptor set 二进制数据。二选一:data_source(通过Datasource.filename本地文件路径或Datasource.inline_bytes内嵌字节传入)或proto_descriptor_typed_metadata(未实现,预留为 proto descriptor TypedMetadata 的键,未来可让多个依赖 proto descriptor 的过滤器共享同一份内存中的 descriptor) |
mode | ExtractMode | 提取模式,目前仅支持FIRST_AND_LAST(对于客户端流式、服务端流式或双向流式,提取第一条与最后一条消息) |
extraction_by_method | map<string, MethodExtraction> | 按方法全名指定提取信息。键的格式为${package}.${Service}.${Method},例如endpoints.examples.bookstore.BookStore.GetShelf |
MethodExtraction消息(设计上预留了未来按 route 配置的能力,虽然当前 Istio 尚不支持)包含:
| 字段 | 说明 |
|---|---|
request_extraction_by_field | 请求消息中"字段路径 → 提取指令"的映射 |
response_extraction_by_field | 响应消息中"字段路径 → 提取指令"的映射 |
其中ExtractDirective枚举定义了三种指令:
EXTRACT:提取该字段的值。EXTRACT_REDACT:只能标注在消息类型(Message type)字段上;若该字段非空,则提取出一个空 Struct(即只记录"存在该字段"这一事实,不暴露内部内容,用于脱敏)。EXTRACT_REPEATED_CARDINALITY:提取一个顶层 repeated 字段,并记录其条目数量。该指令在响应中最多作用于一个字段,且不能用于请求字段(filter_config.cc 中会在配置加载时对此进行校验并报错)。
输出格式
提取出的请求与响应将以与原消息相同的布局(same layout)写入动态元数据envoy.filters.http.proto_message_extraction(类型为google.protobuf.Struct)。
默认 FIRST_AND_LAST 模式
场景一:非流式(Non-Streaming)请求/响应
此时只有first条目,输出形如:
{ "requests": { "first": { "foo": "val_foo1" } }, "responses": { "first": { "baz": "val_baz1" } } }场景二:流式(Streaming)请求/响应
此时同时有first与last条目:
{ "requests": { "first": { "foo": "val_foo1" }, "last": { "foo": "val_foo3" } }, "responses": { "first": { "baz": "val_baz1" }, "last": { "baz": "val_foo3" } } }源码佐证:在 filter.cc 的
handleRequestExtractionResult/handleResponseExtractionResult中,result[0]写入"first",若result.size() == 2则将result[1]写入"last";随后通过streamInfo().setDynamicMetadata(kFilterName, dest_metadata)写入动态元数据。响应侧若使用了EXTRACT_REPEATED_CARDINALITY,还会额外写入numResponseItems字符串字段记录条目数。
完整配置示例(双向流式)
假设我们有一个双向流式 RPCpkg.svc.Method,其消息定义如下:
message MethodRequest { string foo = 1; Nested nested = 2; Msg redacted = 3; ... } message MethodResponse { string baz = 1; } message Nested { Msg double_nested = 2; } message Msg { string bar = 1; string not_extracted = 2; }对应的过滤器配置(JSON 形式):
{ "descriptor_set": {}, "mode": "FIRST_AND_LAST", "extraction_by_method": { "pkg.svc.Method": { "request_extraction_by_field": { "foo": "EXTRACT", "nested.doubled_nested.bar": "EXTRACT", "redacted": "EXTRACT_REDACT" }, "response_extraction_by_field": { "bar": "EXTRACT" } } } }运行时,过滤器依次收到三条MethodRequest(JSON 形式):
{ "foo": "val_foo1", "nested": { "double_nested": {"bar": "val_bar1", "not_extracted": "val_not_extracted1"}, "redacted": { "bar": "val_redacted_bar1"} } } { "foo": "val_foo2", "nested": { "double_nested": {"bar": "val_bar2", "not_extracted": "val_not_extracted2"}, "redacted": { "bar": "val_redacted_bar2"} } } { "foo": "val_foo3", "nested": { "double_nested": {"bar": "val_bar3", "not_extracted": "val_not_extracted3"}, "redacted": { "bar": "val_redacted_bar3"} } }以及三条MethodResponse:
{ "baz": "val_baz1" } { "baz": "val_baz2" } { "baz": "val_baz3" }最终写入动态元数据envoy.filters.http.proto_message_extraction的结果为:
{ "requests": { "first": { "foo": "val_foo1", "nested": { "double_nested": {"bar": "val_bar1"} }, "redacted": {} }, "last": { "foo": "val_foo3", "nested": { "double_nested": {"bar": "val_bar3"} }, "redacted": {} } }, "responses": { "first": { "baz": "val_baz1" }, "last": { "baz": "val_foo3" } } }从这个示例可以清晰看到三种指令的效果:
foo: EXTRACT提取字符串字段值;nested.doubled_nested.bar: EXTRACT沿嵌套消息路径提取深层字段,未配置的not_extracted字段不会出现在结果中;redacted: EXTRACT_REDACT将整个消息字段替换为空 Struct{},实现内容脱敏;first/last分别取自流中的第一与最后一条消息。
源码级原理剖析
配置加载与 Descriptor 池
filter_config.cc 中的FilterConfig构造过程分为两步:
initDescriptorPool:从data_source(文件路径或内嵌字节)读取并解析FileDescriptorSet,逐文件BuildFile构建DescriptorPool,再基于它创建TypeHelper(NewTypeResolverForDescriptorPool)与TypeFinder(按 type URL 查询google.protobuf.Type)。initExtractors:遍历extraction_by_method,用descriptor_pool_->FindMethodByName校验方法名是否存在于 descriptor 中;校验EXTRACT_REPEATED_CARDINALITY的合法性(请求字段禁止使用、响应至多一个);随后为每个方法创建Extractor并建立proto_path -> extractor的映射(filter_config.cc)。
提取器:只保留 first 与 last
extractor_impl.cc 的extract函数是"只保留首尾消息"的核心逻辑:
// Only need to keep the result from the first and the last. // Always overwrite the 2nd result as the last one. if (vect.size() < 2) { vect.push_back(std::move(data)); } else { // copy and override the second one as the last one. vect[1] = std::move(data); }即:第一条消息的结果入队为result[0],此后每条消息都覆盖result[1],因此无论调用多少次,最终结果永远是第一条与最后一条。这也解释了 extractor.h 接口注释中的设计:processRequest/processResponse只需在客户端流式/服务端流式的首个与末个消息上调用即可,调用方若无法预知末个消息,也可以每条都调用,提取器自行保证只保留首尾。
此外,提取器内部还会为结果填充@type属性(值为type.googleapis.com/<消息全名>),标识消息类型(extractor_impl.cc)。指令映射上,EXTRACT/EXTRACT_REDACT/EXTRACT_REPEATED_CARDINALITY分别对应内部枚举ExtractedMessageDirective的同名值,未指定时默认按EXTRACT处理(extractor_impl.cc)。
请求与响应的拒绝路径
当请求/响应无法被正确处理时,过滤器通过sendLocalReply直接生成本地响应(filter.cc),并在rc_detail中携带诊断信息,例如:
proto_message_extraction_BAD_REQUEST{...}:gRPC 路径格式非法;proto_message_extraction_REQUEST_OUT_OF_DATA/RESPONSE_OUT_OF_DATA:未能缓冲出完整消息;proto_message_extraction_REQUEST_BUFFER_CONVERSION_FAIL/RESPONSE_BUFFER_CONVERSION_FAIL:消息缓冲转换失败。
响应被拒绝时还会设置UnauthorizedExternalService响应标志,便于在指标与日志中识别。
集成测试佐证
仓库的集成测试 integration_test.cc 完整验证了该过滤器端到端行为,其中给出的真实配置片段(使用apikeys测试 proto 的 descriptor 文件)可直接作为配置范本:
name: proto_message_extraction typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.proto_message_extraction.v3.ProtoMessageExtractionConfig mode: FIRST_AND_LAST data_source: filename: <path-to>/apikeys.descriptor extraction_by_method: apikeys.ApiKeys.CreateApiKey: request_extraction_by_field: parent: EXTRACT response_extraction_by_field: name: EXTRACT repeated_string_field: EXTRACT apikeys.ApiKeys.CreateApiKeyInStream: request_extraction_by_field: parent: EXTRACT response_extraction_by_field: name: EXTRACT apikeys.ApiKeys.ListApiKeys: response_extraction_by_field: keys: EXTRACT_REPEATED_CARDINALITY测试同时展示了如何在访问日志中消费该动态元数据:
%DYNAMIC_METADATA(envoy.filters.http.proto_message_extraction)%即:过滤器写入的动态元数据可以直接被访问日志格式化指令读取并输出,这也是"提取结果用于日志"这一核心用法的官方实践方式。测试覆盖了 Unary(非流式)、双向流式以及EXTRACT_REPEATED_CARDINALITY等场景,验证了本文上述的输出格式与指令语义。
使用注意事项
- 过滤器的
descriptor_set必须与上游 gRPC 服务实际使用的 proto 定义一致(字段号、类型、嵌套结构),否则提取会失败或结果为空。 - 除
EXTRACT_REPEATED_CARDINALITY外,EXTRACT/EXTRACT_REDACT的目标字段必须是受支持的原始类型(见上文配置要求),消息类型字段只能使用EXTRACT_REDACT。 EXTRACT_REPEATED_CARDINALITY仅限响应侧、且每个方法最多一个字段,配置加载期即会校验失败并拒绝加载过滤器。proto_descriptor_typed_metadata字段目前标注为 Unimplemented,请使用data_source(本地文件或内联字节)传入 descriptor。- 提取路径上的中间字段允许为 repeated,但要注意字段路径语义(点号分隔的嵌套字段名,如
nested.double_nested.bar),路径必须能在 descriptor 中解析。
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考