Skip to main content

03 - eBPF 旁路采集

前置02 篇的四条路线,本篇展开其中的 D · 进程外旁路。

本篇回答:不改一行代码、不装任何库,能拿到多少 Agent 可观测数据。以及为了这份"零改动",你付出了什么。

本篇会用到的词

意思
eBPF内核里的一个受限虚拟机。你可以往内核挂一段经过验证器检查的小程序,它在特定事件发生时执行,能读数据但不能让内核崩溃
uprobe / uretprobe用户态函数的入口断点 / 返回断点。挂在某个共享库的某个符号上,进程每次调用它就触发一次 eBPF 程序
BTFBPF Type Format,内核把自己的数据结构布局编译进去的一份元信息。有它才能写"一次编译到处运行"的 eBPF 程序
libsslOpenSSL 的共享库文件。绝大多数语言的 HTTPS 客户端最终调它做加解密
SSEServer-Sent Events,流式响应的传输格式。data: {...} 一行一个分块
载荷(payload)HTTP 请求体和响应体里的实际内容,相对于头部而言

一、它凭什么能看到加密流量

HTTPS 意味着网络上抓到的是密文。旁路采集要拿到明文,只有一个位置:加密函数被调用的那一刻,参数还是明文。

明文只在一个地方存在:加密函数的入参、解密函数的出参业务进程 · 一行代码都没改openai SDKhttpx明文SSL_writelibssl.so密文内核 socket→ 网卡uprobe 在��此拷走一份明文OBI · 独立进程或 DaemonSet拼回完整 HTTP 请求 / 响应按 vendor 规则解析 JSON 载荷产出带 gen_ai.* 属性的 span经 OTLP 发往任意后端响应方向对称:SSL_read 的返回断点(uretprobe)触发时,缓冲区里已经是解密后的明文。挂点一共六个符号 ——SSL_read / SSL_write / SSL_read_ex / SSL_write_ex / SSL_write_ex2 / SSL_shutdown,最后一个用来判断连接结束。
这也解释了下一节那条最硬的限制:能不能看见,取决于目标进程是不是真的在调 libssl.so 的这几个符号。自己实现 TLS 的运行时不在此列。

代码侧就是一张挂载表:

// pkg/internal/ebpf/generictracer/generictracer.go
func (p *Tracer) UProbes() map[string]map[string][]*ebpfcommon.ProbeDesc {
m := map[string]map[string][]*ebpfcommon.ProbeDesc{
"libssl.so": {
// Start 是入口断点,End 是返回断点。写方向在入口取参数,
// 读方向要在返回时取 —— 因为调用发生时缓冲区还是空的
"SSL_read": {{Start: ...ObiUprobeSslRead, End: ...ObiUretprobeSslRead}},
"SSL_write": {{Start: ...ObiUprobeSslWrite, End: ...ObiUretprobeSslWrite}},
// ...还有 _ex / _ex2 变体和 SSL_shutdown
},
// .NET 不直接链 libssl,它有一层自己的 OpenSSL 封装,符号名不同
"libSystem.Security.Cryptography.Native.OpenSsl.so": {
"CryptoNative_SslRead": {{...}},
"CryptoNative_SslWrite": {{...}},
},
}
}

二、它认识哪些 GenAI 流量

挂上探针只是拿到了 HTTP 载荷,把载荷解成 gen_ai.* 属性是另一回事,而且必须逐家 vendor 写规则。OBI 的开关列在 pkg/config/payload_extraction.go

# 全部默认关闭,要显式打开。也可以用等价的环境变量,
# 比如 OTEL_EBPF_HTTP_OPENAI_ENABLED=true
payload_extraction:
http:
genai:
openai: {enabled: true} # 含全部 OpenAI 兼容响应的兜底解析
anthropic: {enabled: true}
gemini: {enabled: true} # Google AI Studio
qwen: {enabled: true} # 阿里 DashScope
bedrock: {enabled: true}
ollama: {enabled: true} # 原生 /api/chat 与 /api/generate
mcp: {enabled: true} # MCP 的 JSON-RPC 调用
embedding: {enabled: true} # Voyage / Cohere / Jina
rerank: {enabled: true} # 同上三家 + DashScope
retrieval: {enabled: true} # Pinecone / Qdrant / Milvus / Zilliz / Chroma / Weaviate
openai_compatible: # 自建网关要显式列白名单,见下
enabled: true
gateways:
- {host: "llm-gateway.internal", port: 8080, provider: "litellm"}

最后那个 openai_compatible 值得单说。官方支持矩阵里点名的是 LiteLLM、vLLM、LocalAI、OpenRouter、Ollama 的 /v1/ 端点 —— 也就是说,你自建的 LLM 网关只要说 OpenAI 协议,加一条 host 白名单就能被识别。 这对国内团队意义不小:绝大多数自建推理服务都是 OpenAI 兼容接口。

配套的 provider 字段是必须填的,因为流量里没有任何东西能告诉 OBI 后面接的是谁。

2.1 与 02 篇那两个埋点库的覆盖对比

覆盖对象OBI(eBPF)OpenInference / OpenLLMetry
模型 API 调用✅ 11 类 vendor
向量库检索✅ 6 家✅ OpenLLMetry 7 家
MCP 调用✅ 走 HTTP 的✅ 含 stdio 传输
编排框架内部节点✅ LangGraph / CrewAI / ADK 等
本地推理(同进程加载模型)❌ 没有网络调用✅ transformers 埋点器
未装任何库的服务

"MCP 走 HTTP 的"那一行是个典型的边界:MCP 的 stdio 传输是父子进程间的管道,不经过 libssl.so,OBI 看不到。而本地 MCP server 恰恰大多用 stdio。

三、它怎么判断"这是一次模型调用"

这是旁路方案最脆弱的一环,值得原文看一遍。OBI 判 OpenAI 的逻辑是三级降级:

// pkg/ebpf/common/http/openai.go
func OpenAISpan(baseSpan *request.Span, req *http.Request, resp *http.Response) (request.Span, bool) {
// 第一级:认响应头。OpenAI 会带这几个自有头,命中即确定
isOpenAI := false
for _, header := range []string{"Openai-Version", "Openai-Organization",
"Openai-Project", "Openai-Processing-Ms"} {
if val := resp.Header.Get(header); val != "" { isOpenAI = true; break }
}

maybeOpenAI := false
if !isOpenAI {
// 第二级:HTTP/2 场景下头部被 HPACK 压缩,探针拿不到可用的头,
// 只能退回看路径里有没有 /v1/。不满足就直接放弃这条流量
if !isHTTP2Request(req) || !strings.Contains(baseSpan.Path, "/v1/") {
return *baseSpan, false
}
maybeOpenAI = true
}
// ...读请求体和响应体...
if maybeOpenAI {
// 第三级:靠模型名的字符串前缀猜
if !looksLikeOpenAIBody(reqB, respB, baseSpan.Path) { return *baseSpan, false }
}
}

func looksLikeOpenAIBody(reqB, respB []byte, path string) bool {
model := strings.ToLower(genaiModel(reqB, respB))
// DashScope 的嵌入模型叫 text-embedding-v<N>,让给 Qwen 的识别器
if isDashScopeEmbeddingModel(model) { return false }
// 「gpt」覆盖 chat/completions 与 responses,「text-embedding」覆盖 embeddings
return strings.HasPrefix(model, "gpt") ||
(strings.HasPrefix(model, "text-embedding") && strings.Contains(path, "/v1/embeddings"))
}
三级降级:越往右越靠猜��,而每一级不满足都是直接放弃整条流量确定是 OpenAI属性准确认定为 OpenAI但可能误判一条 HTTPS 流量① 认响应头Openai-Version 等四个② 认路径HTTP/2 且路径含 /v1/③ 猜模型名前缀是不是 gpt放弃这条流量不产生 GenAI span放弃这条流量不产生 GenAI span自建服务把模型名起成 gpt-internal-v3 → 第三级认成 OpenAI,gen_ai.provider.name 从此标错。模型名叫 qwen-max 走 HTTP/2 打到兼容端点 → 三级全不命中,这次调用在后台压根不存在。两种都不会报错。所以自建网关一定要走 openai_compatible 白名单显式声明,别指望启发式认得出你的模型名。
注意两个灰色终局都是「什么都不产生」而不是「产生一条不完整的记录」—— 排查时看到的是数据缺失,而不是数据可疑,这让问题更难被发现。

第三级是字符串前缀匹配。 直接后果:

场景结果
HTTP/2 + 模型名叫 qwen-max 打到 OpenAI 兼容端点三级都不命中,这次调用不产生 GenAI span
自建服务把模型名起成 gpt-internal-v3被认成 OpenAI,gen_ai.provider.name 标错
走 HTTP/1.1 且是真 OpenAI第一级就命中,准确

所以自建网关一定要走 openai_compatible 白名单显式声明,别指望启发式认得出你的模型名。

3.1 流式响应是分开处理的

func parseOpenAICompatibleResponse(respB []byte) (*request.VendorOpenAI, []request.ToolCall) {
if looksLikeJSON(respB) { // 非流式:整个响应体是一个 JSON
resp := parseVendorOpenAI(respB)
return &resp, extractToolCalls(resp.Choices)
}
return parseOpenAIStream(bytes.NewReader(respB)) // 流式:按 SSE 逐行解
}

流式能解,但要注意:OBI 是在整段响应收完之后才解析的。 这意味着它给得出总耗时和 token 用量,给不出 01 篇 3.1 节那个 time_to_first_chunk(TTFT)—— 首字延迟需要在第一个分块到达的瞬间打点,那是进程内埋点的活。

要 TTFT 就得走做法 B/C,或者在网关侧记(06 篇)。这条在选型时最容易被忽略,因为 TTFT 恰恰是用户感知最强的那个指标。

四、部署门槛

不是所有集群都跑得起来。硬要求来自官方支持矩阵:

要求
内核Linux 5.8+;RHEL 系带 eBPF backport 的可放宽到 4.18+(RHEL 8 / CentOS 8 / Rocky 8 / AlmaLinux 8)
BTF内核必须暴露 BTF 信息
架构只有 amd64arm64 有发布产物
权限root,或所启用功能对应的那组 Linux capabilities

权限那一条是实际落地时的主要摩擦点,和 02 篇 4.3 节里 Operator 注入 Go 应用要 privileged: true 是同一类问题:旁路方案的能力来自特权,而多数生产集群的 PodSecurity 策略默认不给特权。 这需要平台团队和安全团队先谈拢,不是技术选型能单方面决定的。

另外 OBI 自己也说了它还在 Development 阶段,v0 的小版本之间允许破坏性变更,README 里明确写着:不要用 latest 标签、升级前读发布说明、别假设仪表盘和告警能跨版本延续。

五、四条跨不过去的边界

5.1 进程内的编排结构

一次 LangGraph 执行内部走了哪几个节点、哪条条件边被选中、第几轮决定收敛 —— 这些从来没有变成网络包。旁路方案永远看不到。

这条不是实现不足,是原理性的。任何"从外部观察进程"的方案都受同一条限制。

5.2 上下文传播

支持矩阵的 GenAI 那一行里,"Context propagation" 一栏写的是 No。HTTP/1.1 的通用追踪能传,HTTP/2 只能靠 Go 的库级埋点传。

后果是 OBI 产出的 GenAI span 很可能挂不进应用自己那棵 trace 树,成了一批孤立的 span。你能看到"有这么一次模型调用、花了多少 token",但看不到"它属于哪次用户请求"。

要把两边接上,只能反过来 —— 让应用侧埋点注入 traceparent,而这就回到做法 B/C 了。

5.3 非 OpenSSL 的 TLS 实现

挂载表里只有 libssl.so 和 .NET 的 OpenSSL 封装。Java 的 JSSE 是纯 Java 实现的 TLS 栈,不调 OpenSSL —— libssl 上的 uprobe 一个都挂不上。

能不能看见,取决于这个运行时的 TLS 最终有没有落到 OpenSSL 上✓ 截得到明文Python · Node.js · Ruby · PHP链接 libssl.so.NETCryptoNative_* 封装层,已适配Gocrypto/tls 静态编译,走独立 Go 探针✗ 截不到Java(默认 JSSE)纯 Java 实现Rust(rustls)纯 Rust 实现libssl 上的 uprobe 一个都挂不上流量看得见,内容看不见Java 这一格和 02 篇 4.2 节叠起来最难受:javaagent 侧与 GenAI 相关的模块只��有 openai 一个,eBPF 这边又截不到 TLS。结果是 Java 为主的技术栈基本只剩网关侧这一条路。这件事要在选型阶段确认,不是部署完才发现。
右栏那句「流量看得见,内容看不见」是关键区别:OBI 仍然知道这个进程和某个域名建立了连接、耗时多久,只是解不开里面的 JSON。
运行时TLS 走哪OBI 能否截明文
Python(ssl 模块)、Node.js、Ruby、PHP链 OpenSSL
.NETOpenSSL 封装层,符号名不同但已适配
Gocrypto/tls 静态编译进二进制,OBI 走独立的 Go 探针路径✅ 另一条路
Java(默认 JSSE)纯 Java 实现
Rust(rustls)纯 Rust 实现

Java 这一格和 02 篇 4.2 节的结论叠在一起就很难受:Java 技术栈上 GenAI 可观测的选项最少 —— javaagent 只有一个 openai 模块,eBPF 又截不到 TLS。Java 为主的团队基本只剩网关侧这一条路。

5.4 载荷体积与压缩

支持矩阵里其他协议行的限制值得类推:MongoDB "不支持压缩载荷"、Aerospike "type-4 压缩载荷不解析"。eBPF 程序跑在内核里,有指令数和栈空间上限,做不了解压。

同理,超长的请求体会被截断。一个塞了三十轮历史对话和十个工具定义的 prompt,能不能被完整拼回来,取决于探针的缓冲策略。这类数据缺失是静默的 —— 你看到的是一条属性不全的 span,而不是一个报错。

六、什么时候该用它

它是「底座」不是「主力」——判断标准是你要它回答哪一类问题✓ 适合全局盘点:全公司到底有多少服务在调模型、调了谁影子 AI 发现:没走网关、没报备的那些调用跨语言统一底座:一套探针管所有服务,不管谁用什么框架遗留系统:代码没人敢动、也没人愿意加依赖的那些共同点:要的是「覆盖面」,不是「细节」✗ 不适合当唯一数据源:拿不到编排结构,排查止步�于「哪次调用慢」需要 TTFT:整段响应收完才解析,首字延迟测不出Java 或 Rust 为主的栈:TLS 明文根本截不到拿不到特权的集群:PodSecurity 一卡就没得谈共同点:要的是「细节」或「保证」,这两样它都给不了最常见的正确用法:OBI 铺全量做盘点与兜底,重点服务另外走 02 篇的做法 B/C 拿细节,两份数据各回答各的问题。
左右两栏不是互斥的选项,而是同一套部署在不同问题上的表现。把它当兜底层用,它的每一条限制都不致命;把它当唯一数据源,每一条都致命。

还有一个常被忽略的用法:合规盘点。 "公司里哪些服务在往外发 prompt、发给了谁、发了多少" —— 这个问题问的是覆盖面而不是细节,而且要防的恰恰是"某个团队没接埋点",正好落在 eBPF 的强项上。这与 Agent 安全 · 威胁模型里"未登记的外部调用"是同一件事的两面。

下一篇04 - 语义约定与内容治理

← 回到 专题索引  ·  Agent Infra 板块总览