为什么不用 gRPC-Go: VictoriaTraces 中实现 OTLP/gRPC 的幕后故事
我们不妨先从结论开始,不使用 gRPC-Go 来构建 OTLP/gRPC 的 gRPC Server ,可以让:
- 二进制包的体积:降低 25%
- CPU 使用率:降低 36%
背景
OpenTelemetry 协议( OTLP )是 “有 OpenTelemetry 插装的应用” 与 “OpenTelemetry (兼容的) Collector/后端” 之间进行数据传输所用的协议。
现在假设你就有这样一个应用,想要传输数据到 Collector ,那么你可以配置通过以下 Exporter 完成:
- OTLP/gRPC Exporter
-
OTLP/HTTP Exporter, 其中 Protobuf 的 Payloads 可以编码成:
- - binary 格式
- - JSON 格式
出于一些原因,VictoriaTraces 仅暴露了一个 HTTP 接口,通过 OTLP/HTTP 接收 binary 或者 JSON 格式的数据。然而,有很多的应用只支持通过 OTLP/gRPC Exporter 输出数据,kube-apiserver 就是其中的一个典型例子。所以,完善对 OTLP/gRPC 的支持是当前的刚需。
目标
我们的目标是实现一个 gRPC Server,它作为 TraceService
服务提供 Export
方法给远程调用方进行调用,这些信息是定义在 OpenTelemetry Proto 中的。
// Service that can be used to push spans between one Application instrumented with
// OpenTelemetry and a collector, or between a collector and a central collector (in this
// case spans are sent/received to/from multiple Applications).
service TraceService {
rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse) {}
}
那么,是什么原因让我们考虑弃用 gRPC-Go ,或者更准确地说,弃用整套 protoc
工具链呢?
原因 1: Protoc
工具链不好用
以 Go 为例,最常见的构建 gRPC Server 的方式就是:
- 用
protoc
和protoc-gen-go
生成.proto
中定义的 Message 对应的结构体。 - 用
protoc
和protoc-gen-go-grpc
生成.proto
中定义的服务 Interface 并实现它。
说起来简单,不妨先试想一下这些步骤具体是怎么做的。假设我(对 Protobuf 熟悉程度一般)现在 git clone
了一个项目,然后想往其中的 .proto
添加几个新的 Message 和方法:
- 首先想起来
protoc
并不是 Ubuntu/MacOS 自带的; - 从 Release 页下载最新版本的
protoc
; - 诶,光靠
protoc
不能生成 Go 代码,所以还需要go install protoc-gen-go
和go install protoc-gen-go-grpc
; - 都准备好了,生成代码的命令是什么来着? Google 一下 “gRPC 如何编译 Go 代码”;
- 终于,抄到了命令在本地运行,回车。Boom !弹了个依赖 Error ,因为
.proto
里面有一些import
,还要将这些引用的内容的目录在命令中指定清楚; - 整理好所有目录和命令,重新运行,终于成功生成出了代码。
不过,别高兴得太早,protoc
可能还有惊喜在等你。
“为什么这些新生成的代码看起来和老代码好像不太一样?”
新代码:
type TracesData struct {
state protoimpl.MessageState `protogen:"open.v1"`
ResourceSpans []*ResourceSpans `protobuf:"bytes,1,rep,name=resource_spans,json=resourceSpans,proto3" json:"resource_spans,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
老代码:
type TracesData struct {
ResourceSpans []*ResourceSpans `protobuf:"bytes,1,opt,name=resource_spans,proto3" json:"resource_spans,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
7.Google 对应原因,行,然后用不同版本的 protoc
工具链重新走了遍步骤 2-6 。(甚至有时候还要 git log
找之前的作者问用的是哪个版本。)
幸好,这些步骤只是个玩笑,在 VictoriaMetrics 里面我们不这么干。这只是想说明,编译 Protobuf 相关的内容并不是那么直来直去的,不像写个 HTTP JSON 接口那么简单。
当然,它可以变得简单一点:
- 把所有的命令都写到
Makefile
。 - 或者使用 Buf CLI,把代码生成完全在线化。
不过仍然有很多工程师就是喜欢 HTTP JSON 。
尽管麻烦不断,但是这些仍不足以说服我们弃用 protoc
工具链。还有别的理由 (借口) 吗?
写在原因 2 之前
原因 2 或许能给你新的启发,不过需要声明,因为 VictoriaTraces 中存在一些历史包袱,它才能在该项目中作为一个 “原因”。也就是说,这个 “原因” 并不是每个项目都该纳入考虑的。
原因 2: easyproto
已经代替了 golang/protobuf
在 VictoriaMetrics ,VictoriaLogs 和 VictoriaTraces 中,对于 Protobuf 的内容,我们是用 easyproto
来进行 Marshal 和 Unmarshal 操作的,而非常见的 golang/protobuf
包。原因在 easyproto
的 README
中写得很清楚:
easyproto
不需要protoc
或者go generate
。easyproto
不会像传统的protoc
那样让编译的二进制包的体积增大。easyproto
正确使用的话可以达成零内存分配。
不过,如果真的要实现 OTLP/gRPC 支持,我们得考虑:
- 如果用了
protoc
生成 gRPC Server ,那编译的二进制包会大多少? -
有可能将
easyproto
和 gRPC 结合起来用吗?protoc
只需要生成 gRPC 服务的代码,而 Protobuf Message 的结构体还是用easyproto
处理。- 注意这仍然需要引入 gRPC 相关的包,这会弱化使用
easyproto
的第二个理由(减小二进制包体积)。
- 注意这仍然需要引入 gRPC 相关的包,这会弱化使用
- 还有其他办法可以复用现有的
easyproto
,而不用引入任何新的包吗?
邪门歪道: 用 HTTP/2 Server 代替 gRPC Server
按理来说
gRPC 是个实现在 HTTP/2 上的协议,所以按理来说,实现一个 HTTP/2 Server 来处理对应接口的请求就可以了。
gRPC 也可以实现在 HTTP/3 (QUIC) 或者 HTTP/1.1 之上,不过这已经超过了这篇博客讨论的范围,我们把它留给读者探索。
根据 gRPC over HTTP2,Data Frame 的格式是这样的:
// +------------+---------------------------------------------+
// | 1 byte | 4 bytes |
// +------------+---------------------------------------------+
// | Compressed | Message Length |
// | Flag | (uint32) |
// +------------+---------------------------------------------+
// | |
// | Message Data |
// | (variable length) |
// | |
// +----------------------------------------------------------+
同时很容易知道,TraceService
服务的 Export
方法对应的 HTTP 接口是 /opentelemetry.proto.collector.trace.v1.TraceService/Export
。
下面这段代码简单展示了如何用 HTTP/2 Server 处理 gRPC 请求:
// Init 启动一个 HTTP Server 。
func Init() {
logger.Infof("starting OTLP gPRC server at :4317...")
go httpserver.Serve(
[]string{":4317"},
OTLPGRPCRequestHandler,
httpserver.ServeOptions{UseProxyProtocol: nil, DisableBuiltinRoutes: true, EnableHTTP2: true},
)
}
// OTLPGRPCRequestHandler 管理 gRPC 请求的路由。
func OTLPGRPCRequestHandler(r *http.Request, w http.ResponseWriter) bool {
switch r.URL.Path {
case `/opentelemetry.proto.collector.trace.v1.TraceService/Export`:
otlpExportTracesHandler(r, w)
default:
grpc.WriteErrorGrpcResponse(w, grpc.StatusCodeUnimplemented, fmt.Sprintf("gRPC method not found: %s", r.URL.Path))
}
return true
}
// otlpExportTracesHandler 处理 OTLP Export 请求。
func otlpExportTracesHandler(r *http.Request, w http.ResponseWriter) {
// gzip 解压缩
...
// 解析前 5 bytes ,用 easyproto unmarshalling 剩余 []bytes
...
// 写数据等操作
...
writeExportTraceServiceResponse()
}
完整的代码可以查看 VictoriaTraces #59。
代价是什么?
这个实现看起来简单直接,那作为交换,一定有什么代价吧?
到目前为止这个实现只在 Unary RPC 中测试过,而对于 Streaming RPC ,我们没有场景和动力去做相关的测试,所以暂且认为它是只能处理 Unary RPC 请求的。
不过这样的实现足以覆盖我们在 OTLP/gRPC 上的需求,它可能在其他场景不适用,如果你知道具体是哪些场景,非常欢迎在评论中发表看法!
对比测试
我们做了一轮测试来对比 VictoriaTraces 中不同 OTLP/gRPC 实现方案的二进制包的体积和资源使用率,这些方案包括:
- 用原生 HTTP/2 Server 处理请求,用
easyproto
来处理 Protobuf Message 。 - 用
protoc
生成 gRPC Server 代码,用 gRPC 原生的 Encoder 和 Decoder 来处理 Protobuf Message 。
另外,生成的 gRPC Server 也支持通过以下代码自定义 Encoder 和 Decoder ,所以我们也用将 easyproto
设为 Encoder 和 Decoder 作为另一组对比。
import (
"google.golang.org/grpc/encoding"
)
func init() {
encoding.RegisterCodec(&easyProtoCodec{})
}
结果如下:
编译二进制包体积:
-
Release 的所有二进制包总体积 (
tar.gz
):- HTTP/2 +
easyproto
: 87M - gRPC +
easyproto
: 113M (+29%) - gRPC: 113M (+29%)
- HTTP/2 +
-
单个
linux-amd64
包体积:- 参考基准 (v0.4.0): 21M
- HTTP/2 +
easyproto
: 21M (+0%) - gRPC +
easyproto
: 28M (+33%) - gRPC: 28M (+33%)
请求处理的资源使用率( CPU 使用率, no-op: 对每个请求仅进行 Decompression 和 Unmarshalling):
- HTTP/2 +
easyproto
: 31.3% - gRPC +
easyproto
: 45.6% (+45%) - gRPC: 49.1% (+56%)
从这些结果可以看出,HTTP/2 + easyproto
确实更占优一些。
总结
这篇博客分享的是“为什么 VictoriaTraces 用 HTTP/2 + easyproto
来实现 OTLP/gRPC 所需的 gRPC Server”。它的关键实现是由 JayiceZ 完成的,而最初的想法来自于 @makasim。
这个实现的背后有很多特定的原因,我们并不是想说服你也这样做,但是我们在测试中确实看到了这种方案的潜力和价值。
VictoriaStack 的亮点是高性能和资源优化,所以每一分 CPU 、内存和网络流量都很重要。当然,这同样也适用于二进制包、Docker 镜像的体积等等,就如这些要素也曾在 Aliaksandr Valialkin( VictoriaMetrics 的作者)写的这篇博客中被提到,一直以来它们都没变过。