V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
RedisMasterNode
V2EX  ›  Go 编程语言

为什么不用 gRPC-Go: VictoriaTraces 中实现 OTLP/gRPC 的幕后故事

  •  
  •   RedisMasterNode · 10 小时 31 分钟前 · 713 次点击

    我们不妨先从结论开始,不使用 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 的方式就是:

    • protocprotoc-gen-go 生成 .proto 中定义的 Message 对应的结构体。
    • protocprotoc-gen-go-grpc 生成 .proto 中定义的服务 Interface 并实现它。

    说起来简单,不妨先试想一下这些步骤具体是怎么做的。假设我(对 Protobuf 熟悉程度一般)现在 git clone 了一个项目,然后想往其中的 .proto 添加几个新的 Message 和方法:

    1. 首先想起来 protoc 并不是 Ubuntu/MacOS 自带的;
    2. Release 页下载最新版本的 protoc
    3. 诶,光靠 protoc 不能生成 Go 代码,所以还需要 go install protoc-gen-gogo install protoc-gen-go-grpc
    4. 都准备好了,生成代码的命令是什么来着? Google 一下 “gRPC 如何编译 Go 代码”;
    5. 终于,抄到了命令在本地运行,回车。Boom !弹了个依赖 Error ,因为 .proto 里面有一些 import,还要将这些引用的内容的目录在命令中指定清楚;
    6. 整理好所有目录和命令,重新运行,终于成功生成出了代码。

    不过,别高兴得太早,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 接口那么简单。

    当然,它可以变得简单一点:

    1. 把所有的命令都写到 Makefile
    2. 或者使用 Buf CLI,把代码生成完全在线化。

    不过仍然有很多工程师就是喜欢 HTTP JSON 。

    尽管麻烦不断,但是这些仍不足以说服我们弃用 protoc 工具链。还有别的理由 (借口) 吗?

    写在原因 2 之前

    原因 2 或许能给你新的启发,不过需要声明,因为 VictoriaTraces 中存在一些历史包袱,它才能在该项目中作为一个 “原因”。也就是说,这个 “原因” 并不是每个项目都该纳入考虑的。

    原因 2: easyproto 已经代替了 golang/protobuf

    在 VictoriaMetrics ,VictoriaLogs 和 VictoriaTraces 中,对于 Protobuf 的内容,我们是用 easyproto 来进行 Marshal 和 Unmarshal 操作的,而非常见的 golang/protobuf 包。原因在 easyprotoREADME 中写得很清楚:

    • easyproto 不需要 protoc 或者 go generate
    • easyproto 不会像传统的 protoc 那样让编译的二进制包的体积增大。
    • easyproto 正确使用的话可以达成零内存分配。

    不过,如果真的要实现 OTLP/gRPC 支持,我们得考虑:

    1. 如果用了 protoc 生成 gRPC Server ,那编译的二进制包会大多少
    2. 有可能将 easyproto 和 gRPC 结合起来用吗?protoc 只需要生成 gRPC 服务的代码,而 Protobuf Message 的结构体还是用 easyproto 处理。
      • 注意这仍然需要引入 gRPC 相关的包,这会弱化使用 easyproto 的第二个理由(减小二进制包体积)。
    3. 还有其他办法可以复用现有的 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 实现方案的二进制包的体积资源使用率,这些方案包括:

    1. 用原生 HTTP/2 Server 处理请求,用 easyproto 来处理 Protobuf Message 。
    2. 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%)
    • 单个 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%)

    • 资源监控 Snapshot 可以查看这里

    • CPU 和内存的 Profiles 可以在这里下载。

    从这些结果可以看出,HTTP/2 + easyproto 确实更占优一些。

    总结

    这篇博客分享的是“为什么 VictoriaTraces 用 HTTP/2 + easyproto 来实现 OTLP/gRPC 所需的 gRPC Server”。它的关键实现是由 JayiceZ 完成的,而最初的想法来自于 @makasim

    这个实现的背后有很多特定的原因,我们并不是想说服你也这样做,但是我们在测试中确实看到了这种方案的潜力和价值。

    VictoriaStack 的亮点是高性能和资源优化,所以每一分 CPU 、内存和网络流量都很重要。当然,这同样也适用于二进制包、Docker 镜像的体积等等,就如这些要素也曾在 Aliaksandr Valialkin( VictoriaMetrics 的作者)写的这篇博客中被提到,一直以来它们都没变过。

    12 条回复    2025-10-24 12:04:40 +08:00
    JimLee0921
        1
    JimLee0921  
       4 小时 35 分钟前   ❤️ 1
    最近刚好学 Go 学到这里,收藏一下
    czyt
        2
    czyt  
       3 小时 44 分钟前   ❤️ 1
    Desdemor
        3
    Desdemor  
       3 小时 42 分钟前   ❤️ 1
    我们内部通信一直走的是 http2
    qW7bo2FbzbC0
        4
    qW7bo2FbzbC0  
       3 小时 33 分钟前   ❤️ 1
    AI 翻译的?
    swananan
        5
    swananan  
       3 小时 32 分钟前
    CPU 使用率:降低 36%

    我没太细看帖子内容,我就好奇一个点,为啥 CPU 性能差这么多,这块有分析过细节原因吗,感觉成熟的轮子,不应该有这么大优化空间。
    xiuming
        6
    xiuming  
       3 小时 8 分钟前
    看了半天也没看懂在说什么
    mcfog
        7
    mcfog  
       2 小时 0 分钟前   ❤️ 1
    某些特殊服务自己封装 grpc 实现跑了几年了,客户端服务端都有
    Google 的实现一直都是神鬼两面性
    mcfog
        8
    mcfog  
       1 小时 49 分钟前   ❤️ 1
    @swananan 如果 http 服务是 10 个功能点,http 上叠上 grpc unary 是 12 个,叠上完整 grpc 是 20 个功能的话,估计 Google 的 grpc-go 实现大概有 50 个功能点吧
    RedisMasterNode
        9
    RedisMasterNode  
    OP
       1 小时 46 分钟前 via Android
    @mcfog 100%✓
    RedisMasterNode
        10
    RedisMasterNode  
    OP
       1 小时 37 分钟前 via Android
    @czyt 确认过好用~!
    eijnix
        11
    eijnix  
       1 小时 18 分钟前   ❤️ 1
    你说的几个确实是 protobuf go 的痛点,非常痛,但是确实也不会用这个 easyproto ,毕竟没维护保障。为啥国内没有个公司做 buf 的事,那个 proto 注册中心的收费有点离谱,按类型收费
    RedisMasterNode
        12
    RedisMasterNode  
    OP
       1 小时 11 分钟前 via Android
    @eijnix 用就不必用,因为这个也不是纯面相使用体验的,要手写很多结构体的序列化反序列化只会比自动生成更辛苦。

    我们要用是因为''很多(反)序列化和结构体都已经实现好了''。

    至于 buf.build ,之前个人使用体验感觉挺好的,而且注册之后可以解除限流,个人使用量肯定是够的(企业级调用量的话就不确定了,不过为什么有这么多需要持续编译 proto 的场景?)
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   Solana   ·   3737 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 05:16 · PVG 13:16 · LAX 22:16 · JFK 01:16
    ♥ Do have faith in what you're doing.