Skip to content

开源之夏2025

背景

正值暑假,和母亲在北京租了一个房子,原先联系的实习泡汤了,痛定思痛后开启疯狂找实习模式。

在出租房中使用Pocket进行面试

缘起

开源之夏2025,我看了可观测和RAG这两个方向的题目,其中有个极其酷炫的题目——“卫星场景下 openEuler 系统资源可观测性组件的Rust实现” 深深地吸引着我。 吸引我的有三点:

  • 卫星场景:从来没接触过,感觉很新奇,让我联想到星辰大海
  • 可观测:目标求职领域,感觉很有潜力
  • Rust语言:做这个项目可以提升我的Rust编程能力 归根到底是以就业为导向的,之所以选择可观测赛道,因为:
  1. 我在这方面有点积累:22年开源实习的时候在openEuler社区QA-SIG为Prometheus写过测试样例,24年开源之夏为蚂蚁铜锁社区的RustyVault用Prometheus实现了可观测
  2. 我感觉可观测赛道没有AI那么卷

从本科开始我就有了一种寻找不那么多人的赛道的习惯,但总换新赛道,没有深入是不行的

面试

确定了题目后,我用学校邮箱写了封邮件给导师发去,阐述了自己在可观测领域的积累,以及对这个项目的渴望,希望老师可以给一个面试的机会。 老师很快就回信了,邀请我去和团队聊一下。 聊的过程中我得知其他同学也联系过他们,我回去后便更加努力的“预研”这个项目,写出了很高“完成度”的计划书。

预研并编写计划书

项目申请书

云原生可观测领域的OTel Collector比较耗资源,这对现在的卫星计算场景来说是不适合,所以这个项目想要用Rust语言重写OpenTelemetry Collector,达到降低资源消耗的效果。

  • 为云计算数据中心场景开发的OTel Collector会在内存中维护一个队列,遥测信号在其中进行缓存,无需从硬盘读取,这样就减少了遥测信号到可观测后端的延迟 我阅读了官方文档,大致确定了Collector的主要的组件有哪些,这些组件是做什么的。 下载了源码,估算了需要重写的代码量是多少。越到后面,我越感觉到挑战,这个项目的工作量不小,官方实现中,最基础的Collector功能就需要十几万行的Go语言代码,经过和AI的探讨,若我用Rust一行一行去翻译,可能得需要1.5倍的代码量……

这里是有误区的,重写并不意味着要按行翻译,应当在了解了目标软件后,充分发挥语言的特性来进行实现。

下图是我用AI生成的计划书封面,一个望远镜从地面长出来,杵到太空中的卫星跟前,观测对方的状态。

开工会

在开工会上,和导师确定了使用单周报这一周期更短、对齐更频繁的会议模式。此举在于,若项目前进过程中走错了路,可以快速发现并纠正。

这次项目分为三个阶段:

  1. 第一阶段:阅读源码,搞清楚OTel Collector官方实现的主要组件是如何实现的
  2. 第二阶段:使用Rust进行重写
  3. 第三阶段:实现Demo

阅读源码

AI捷径

我尝试了DeepWiki等工具,希望AI直接基于整个代码仓库告诉我这个项目的核心原理是什么。事与愿违,AI工具能够说出的内容和文档告诉我的差不多,并没有达到我想要的效果。

  • 我想要的:这个项目是有A、B、C、D这几个组件组成的,A是通过……实现的,B是通过……实现的,C是通过……实现的,D是通过……实现的;数据通过……方式在A->B->C->D中进行流动。

Old School

意识到我对AI的运用,并不能达到预期的效果,便选择使用Old School的方式来阅读源码。

静态分析

我所谓的静态分析,就是一行一行阅读源码。 OTel Collector的设计非常巧妙,用户在使用的时候可以按照业务需要,灵活地使用各种扩展来组装Collector,但这对我理解源码来说却是增加了复杂度。 官方实现中运用了很多设计模式,在很多时候,静态阅读很难搞清楚。 我想到用动态调试,这是我了解一个新项目最常用的杀手锏了。

动态调试

OTel Collector的使用方式是这样的,用户按照自己的需求编写一个config文件,随后使用builder来构建Collector,生成的文件中会有一个main.go。 我下的第一个断点就在main.go里。 新的问题出现了,我调试了好几天,依旧还在初始化阶段,似乎离我想要看的Receiver组件还有十万八千里。 为什么不直接在Receiver的代码里下断点?因为找不到这个组件的入口点。

导师建议我寻找能够进行函数调用分析的工具,在一番搜索下,我发现了Delve。 其实我在VS Code里调试Go项目,用的就是Delve,只不过,我没有在命令行里单独的使用过它。 Delve可以打印Go项目在运行过程中经过的代码行,配合正则表达式就能够输出目标模块的内容。

我想要看OtlpExporter组件的内容,就用如下的表达式:

bash
'go\.opentelemetry\.io/collector/exporter/otlpexporter(/.*)?\..*'
如此,看到了组件被执行的第一行代码,也就知道在哪里下断点了。

另外,在函数调用栈中可以快速确定函数调用链是什么样子的。

数据流

OTel Collector中有三个关键的组件,ReceiverProcessorExporter。遥测信号被Receiver接收,通过pdata模块转换为pipeline data这一内部数据格式,通过Processor时可以选择对数据进行预处理(例如筛选),从Exporter向可观测后端(如Prometheus、Jaeger等)发送前会将数据从内部格式转换为目标格式。

pdata:Collector中最关键的模块

OpenTelemetry Collector中的[pdata](opentelemetry-collector/pdata at main · open-telemetry/opentelemetry-collector)模块缩写自 pipeline data,OtlpReceiver组件会调用pdata模块将收到的OTLP遥测信号解析为内部表示格式。 不同的Recevier组件可以接收不同的信号,但都要转换成内部表示格式才能在后续的组件中进行处理。

探索基于已有开源项目进行开发的可能性

经过搜索,我发现OpenTelemetry有个名为**otel-arrow** 的项目,旨在使用Apache Arrow协议来发送OpenTelemetry数据。otel-arrow下有一个名为Beaubourg的项目,使用Rust制作了一个用来创建管道系统的库,起初我以为可以基于这个项目进行开发,于是给项目作者发了封邮件,得到的回复是若基于此项目进行开发我需要实现OtlpReceiver、OtlpExporter和BatchProcessor。

但我发现了一些点,最终让我放弃了基于此项目进行开发的想法:

  • 最重要的pdata模块并未实现
  • Beaubourg通过Flume Channel在不同组件间传递数据,而官方实现是通过函数调用在组件间传递数据

OTLP协议层次结构:

OTLP完整数据流:

Consumer:组件之间的接口抽象

为了让用户能够按照需求自由组装数据处理管道,OTel Collector要求每个组件都必须实现consumer 模块提供的接口,这样组件之间就可以通过接口传递数据,实现了组件间的解耦。

Receiver对Pdata模块的使用

OtlpReceiver支持通过HTTP或gRPC两种协议来接收OTLP格式的遥测数据,随后调用pdata模块进行解析。

这张胶片展示了receiver调用pdata的Export方法将Protobuf格式的OTLP请求转换为pdata格式并通过consume接口传递至下一节点

Memory Limiter:内存占用监控

Collector会将接收到的遥测信号缓存到内存队列中,面对海量的遥测数据,需要使用Memory Limiter来监控Collector所占用的内存。Memory Limiter会对内存进行周期性检查,检测到内存超限后,便设置一个名为mustRefuse的状态标志位,Receiver在接收新数据之前会检查这个标志位,若发现标志位已设置则停止接收新数据并返回错误。这种背压机制将问题归因于上游数据源,通过协议级错误码通知上游客户端降低发送速率,从而实现流量调控。

BatchProcessor:批处理

BatchProcessor通过consumer接口接收上游送来的数据并打包成一个batch,当积攒到指定的batchsize后再送给下游消费者。

Exporter对Pdata模块的使用

OtlpExporter通过pdata提供的方法将遥测数据从内部格式转换为Protobuf格式,然后通过HTTP或gRPC发送。

实现

pdata

pdata模块的官方实现由几个阶段组成:

  1. 第一阶段将opentelemetry-proto项目中使用Protobuf定义的OTLP协议通过工具链编译为 .pb.go代码
  2. 第二阶段,在cmd/pdatagen/internal目录下编写基础结构体(如base_*.go)及对应的代码生成方法。配置每个包的生成信息(如p*_package.go)。这里需要着重讲解一下,p*_package.go中是声明式配置,用于后续生成代码,具体的方式是使用base_*.go中定义的基础类型来描述OTLP协议。当运行main.go的时候会遍历这里定义的每个package,每个package中的各种结构体会使用对应的方法和模板(templates/*.go.tmpl)去生成代码。

用Rust实现第一阶段比较顺利,使用tonic-prost-build库将Protobuf定义的OTLP协议编译为Rust代码,输出路径完全还原官方设计,如图所示。

第二阶段在进行的过程中遇到了问题,官方实现中在base_fields.go代码中为不同类型的结构体编写了大量的访问方法(getter),这依赖于Go语言本身的text/template特性。经过一天的尝试,发现在Rust中实现这样的效果并不容易。

在和Qwen的对话中,我发现生成的Rust代码中使用了派生宏(drive macro)的特性已经为结构体实现了访问方法。这说明了应该充分发挥编程语言自身的特性,而非在一个语言里模拟另一个语言的特性。

从协议生成的Rust代码:

rust
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ExportTraceServiceRequest {
    #[prost(message, repeated, tag = "1")]
    pub resource_spans: ::prost::alloc::vec::Vec<
        super::super::super::trace::v1::ResourceSpans,
    >,
}

我发现协议中还定义了服务,可以方便的生成服务相关的代码,客户端、服务端、请求及回复的实现。 opentelemetry-proto/opentelemetry/proto/collector/trace/v1/trace_service.proto:

protobuf
syntax = "proto3";
package opentelemetry.proto.collector.trace.v1;
import "opentelemetry/proto/trace/v1/trace.proto";

service TraceService {
  rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse) {}
}

message ExportTraceServiceRequest {
  repeated opentelemetry.proto.trace.v1.ResourceSpans resource_spans = 1;
}

message ExportTraceServiceResponse {
  ExportTracePartialSuccess partial_success = 1;
}

message ExportTracePartialSuccess {
  int64 rejected_spans = 1;
  string error_message = 2;
}

OtlpReceiver:🪄AI 编程在我手上的 Aha Moment

项目进行的这段时间,我搬了三次房子,过程非常熬心态。也许是太灰暗,一点点进度就现得耀眼。

我在第二次搬的房里,携手Qwen、Cursor(Claude)完成了opentelemetry-proto生成Pdata代码的任务。

Aha Moment发生在NBS图书馆。我靠着窗户坐下,请Cursor帮我实现OtlpReceiver,并调用pdata进行解析。 几分钟后,Cursor和我说测试成功!已经可以正确接收OTLP信号并解析了。我不信,我让它写一个DebugExporter,将收到的遥测信息打印出来。 又是几分钟,Cursor再次通知我。我把VSCode Terminal Output放大看,仔细看,我天!真打印出来结构体了,这就是pdata的结构体。

此时我打心底里觉得这项目能成。

后来,我搬了第三次房子。在这里,我完成了剩下的任务。

我猜测Qwen的训练数据或者知识库里应该有阿里团队对OpenTelemetry的笔记,因为很多知识点得通过动态调试才能得知。

Demo 验证环境

我在LF Training Platform上学习了OpenTelemetry的课程,并将部署有两个前端服务、一个后端服务、Jaeger和Prometheus的实验环境应于验证我写的这个Collector。 遥测信号有三种:

  • Metrics:指标
  • Traces:追踪,可以组成一个请求的完整流程,在每个服务中发生了什么
  • Logs:日志 在实现OtlpExporter后,使用我写的Rust Collector来替换OTel Collector,在Jaeger和Prometheus上取得了相同的结果:

在Jaeger上可以看到用户请求的完整过程

在Prometheus上可以看到采集的指标

后来我和Cursor创建了一套新的Demo环境

根据本项目的场景,Rust Collector以上的部分模拟部署在卫星上,以下的部分部署在地面上。 在卫星不能保持24小时通信的时候,需要设计能够快速将遥测数据落盘存储的机制即下文讲述的WAL,待进入通信窗口的时候再读取发送。

WAL 持久化存储机制

参考数据库的快速落盘技术,即Write-Ahead Logging,将数据按顺序在文件末尾追加写入,无需将文件全部内容读取到内存中修改后再存入。

OtlpHttpExporter收到遥测批次后会调用WalWriter将批次追加写入到持久化文件的尾部。进入Rust Collector容器中持久化文件的存储目录,可以看到三种遥测数据的持久化文件。

为了实现续传功能,设置了记录有当前发送进度的.state文件,基于此文件可以实现两个重要的功能:

  • 文件整理:当前通信窗口结束后,会根据.state中记录的发送进度,将已经发送到内容删除,达到压缩持久化存储文件的效果
  • 断点续传:下次通信窗口到来时,可以从上次中断的地方继续发送

Exporter

为了快速验证,用于中期检查的版本中不包含BatchProcessor和MemoryLimiter。收到上游发来的数据后直接发送给可观测后端。

我实现了三种Exporter:

  • DebugExporter
  • OtlpGrpcExporter
  • OtlpHttpExporter

DebugExporter将遥测数据直接打印到终端,方便进行调试。

根据在pdata部分的描述,因为已经定义好了服务,通过gRPC协议进行通信的OtlpExporter可以快速实现,直接调用生成好的TraceServiceClient即可。

rust
#[tonic::async_trait]
impl ConsumeTraces for OtlpGrpcExporter {
    /// Export traces to the gRPC endpoint
    async fn consume(&self, data: ExportTraceServiceRequest) {
        let mut client = TraceServiceClient::new(self.channel.clone());
        let _ = client.export(data).await;
    }
}

通过HTTP协议通信的OtlpHttpExporter则通过 tokio 启动 sender 和 reader 两个异步任务,同时维护一个内存队列。reader 任务通过 WAL 提供的方法从硬盘读取数据放入内存队列;sender 任务从内存队列读取数据,通过 HTTP POST 请求将数据发送出去。

MemoryLimiter

Memory Limiter具有内存使用监控和限制的功能,通过定期检查进程内存使用量和增长速度来防止内存溢出,在超限时拒绝将新数据放入数据管道,保护系统稳定性。

通过Tokio task后台任务定期监控内存使用量。支持内存用量限制和内存用量增长速度限制两种检查模式,使用原子变量记录超限状态。

BatchProcessor

Batch Processor实现遥测数据的批量聚合处理,通过缓冲区和定时器机制将小批次数据合并成大批次以提高传输效率。为traces、metrics、logs分别实现BatchProcessor,使用TokioMutex保护批处理状态。支持通过遥测计数和超时两种条件触发对下游consume方法的调用。

结语

有志者事竟成。

无论是在前AI Coding时代,通过阅读和调试等Old School的方法了解项目结构,逐步实现任务目标。还是在Vibe Coding时代,携手AI不断迭代实现来逼近目标。无不透露出一件事,朝着目标不断前进,及时调整,终会到达。

我想到一个命运论题:一些事,总是要有人做。在这个时间,你去做了,这件事便由你完成。在前AI时代,代码不会凭空出现,需要多花些力气;在AI时代,完成一件事情的确定性仿佛更高了,该是你完成的事情,只要你努力去做,用正确的方法做,AI会助你一臂之力。