作者:Yaxiong Zhao
在当今充满微服务的世界中,获取服务之间发送的消息的可观察性对于理解和排除问题至关重要。
不幸的是,HTTP/2 的专用头压缩算法 HPACK 使得跟踪 HTTP/2 变得复杂。虽然 HPACK 有助于提高 HTTP/2 比 HTTP/1 的效率,但它的有状态算法有时会使典型的网络跟踪程序无效。这意味着像 Wireshark 这样的工具不能总是从网络流量中解码明文 HTTP/2 头。
幸运的是,通过使用 eBPF uprobe,可以在流量被压缩之前跟踪它,这样你就可以调试你的 HTTP/2(或 gRPC)应用程序。
这篇文章将回答以下问题
以及分享一个演示项目,展示了如何用 ebpf uprobe 跟踪 HTTP/2 消息。
Wireshark[1]是一种知名的网络嗅探工具,可以捕获 HTTP/2。但是 Wireshark 有时无法解码 HTTP/2 头。让我们看看它的实际应用。
如果我们在启动 gRPC 演示程序之前启动 Wireshark,我们可以在 Wireshark 中看到捕获的 HTTP/2 消息:
Wireshark 抓到了 HTTP/2 头帧。
让我们关注头帧[2],它相当于 HTTP 1 中的头。,记录 HTTP/2 会话的元数据。我们可以看到一个特定的 HTTP/2 头块片段有原始字节 bfbe。在这种情况下,原始字节编码 grpc-status 和 grpc-message 头。Wireshark 将其正确解码如下:
如果 Wireshark 在消息流开始之前启动,则可以解码 HTTP/2 HEADERS。
接下来,在启动 gRPC 客户端和服务器之后,让我们启动 Wireshark。同样的消息被捕获,但是原始字节不再被 Wireshark 解码:
消息流启动后,Wireshark 无法解码 HTTP/2 HEADERS。
在这里,我们可以看到 Header Block Fragment 仍然显示相同的原始字节,但明文头不能被解码。
要自己复制这个实验,请按照这里[3]的说明。
如果 Wireshark 在我们的 gRPC 应用程序开始传输消息后启动,为什么它不能解码 HTTP/2 头?
这是因为,HTTP/2 使用HPACK[4]来编码和解码头,压缩头,比 HTTP 1.x 大大提高了效率[5]。
HPACK 通过在服务器和客户端维护相同的查找表来工作。在这些查找表中,头文件和/或它们的值被它们的索引所替换。因为大多数头文件都是重复传输的,所以它们被索引所取代,索引比明文头文件使用的字节少得多。因此,HPACK 使用的网络带宽显著减少。由于多个 HTTP/2 会话可以在同一个连接上复用,这种效应被放大了。
下图说明了客户机和服务器为响应头维护的表。新的头名称和值对被追加到表中,如果查找表的大小达到限制,将替换旧的条目。编码时,明文头将被它们在表中的索引所取代。要了解更多信息,请查看官方 RFC[6]。
HTTP/2 的 HPACK 压缩算法要求客户端和服务器维护相同的查找表来解码头。这使得无法访问此状态的跟踪程序难以解码 HTTP/2 头。
有了这些知识,就可以清楚地解释上面的 Wireshark 实验的结果了。当 Wireshark 在启动应用程序之前启动程序时,会记录整个头的历史记录,以便 Wireshark 能够重新生成完全相同的头表。
启动应用程序后,Wireshark 启动时,会丢失最初的 HTTP/2 帧,导致后面编码的字节 bebf 在查找表中没有相应的表项。因此 Wireshark 无法解码相应的头。
HTTP/2 头是 HTTP/2 连接的元数据。这些标头是调试微服务的关键信息。例如:path 包含被请求的资源;content-type 需要检测 gRPC 消息,然后应用 protobuf 解析;和 gRPC-status 是确定一个 gRPC 呼叫成功的必要条件。如果没有这些信息,HTTP/2 跟踪将失去它的大部分价值。
如果我们不知道状态就不能正确解码 HTTP/2 流量,我们该怎么办?
幸运的是,eBPF 技术使我们能够通过探究 HTTP/2 实现来获得我们需要的信息,而不需要状态。
具体来说,eBPF uprobe 通过直接跟踪应用程序内存中的明文数据来解决 HPACK 问题。通过将 uprobe 附加到接受明文头信息作为输入的 HTTP/2 库的 API 上,uprobe 能够在被 HPACK 压缩之前直接从应用程序内存中读取头信息。
之前的一篇 eBPF 博文[7]展示了如何为用 Golang 编写的 HTTP 应用程序实现 uprobe 跟踪程序。第一步是确定要附加 BPF 探针的函数。函数的参数需要包含我们感兴趣的信息。理想情况下,参数应该具有简单的结构,这样在 BPF 代码中访问它们很容易(通过手动指针追踪)。而且该函数需要是稳定的,这样探针才能适用于各种版本。
通过研究 Golang 的 gRPC 库的源代码,我们确定 loopyWriter.writeHeader()是一个理想的跟踪点。这个函数接受明文头字段,并将它们发送到内部缓冲区。函数签名和实参的类型定义是稳定的,自2018[8]年以来没有更改过。
现在的挑战是找出数据结构的内存布局,并编写 BPF 代码以在正确的内存地址读取数据。
让我们来看看这个函数的签名:
func (l *loopyWriter) writeHeader(streamID uint32, endStream bool, hf []hpack.HeaderField, onWrite func())
任务是读取第 3 个参数 hf 的内容,它是 HeaderField 的一个切片。我们使用 dlv 调试器来计算嵌套数据元素的偏移量,结果显示在http2-tracing/uprobe_trace/bpf_program.go[9]中。
这段代码执行 3 个任务:
让我们运行 uprobe HTTP/2 跟踪程序,然后启动 gRPC 客户机和服务器。请注意,即使在建立 gRPC 客户机和服务器之间的连接后启动了跟踪程序,这个跟踪程序也能工作。
现在我们看到从 gRPC 服务器发送到客户端的响应头:
[name=':status' value='200']
[name='content-type' value='application/grpc']
[name='grpc-status' value='0']
[name='grpc-message' value='']
我们还对 google.golang.org/grpc/internal/transport.(*http2Server).operateHeaders()在 probe_http2_server_operate_headers()实现了一个探针;跟踪在 gRPC 服务器上接收到的头。
这让我们可以看到 gRPC 服务器从客户端接收到的请求头:
[name=':method' value='POST']
[name=':scheme' value='http']
[name=':path' value='/greet.Greeter/SayHello']
[name=':authority' value='localhost:50051']
[name='content-type' value='application/grpc']
[name='user-agent' value='grpc-go/1.43.0']
[name='te' value='trailers']
[name='grpc-timeout' value='9933133n']
基于 uprobe 的跟踪程序用于生产环境需要进一步的考虑,你可以在脚注中阅读有关的内容。要尝试这个演示,请查看这里的说明。
由于 HPACK 头压缩算法,跟踪 HTTP/2 流量变得很困难。这篇文章演示了另一种捕获消息的方法,即用 eBPF uprobe 直接跟踪 HTTP/2 库中的适当函数。
重要的是要理解这种方法有利有弊。其主要优点是无论何时部署跟踪器,都可以跟踪消息。然而,一个显著的缺点是,这种方法是特定于一个单一的 HTTP/2 库(在这个例子中是 Golang 的库);对于其他库,这个练习必须重复进行,如果上游代码发生更改,则可能需要进行维护。在未来,我们正在考虑为库提供 USDT,这将给我们提供更稳定的跟踪点,并减轻 uprobe 的一些缺点。最后,我们的目标是优化一种开箱即用的方法,而不需考虑部署顺序,这就是导致我们采用基于根的 eBPF 方法的原因。
寻找演示代码?在这里[10]找到它。
问题吗?在Slack[11]或 Twitter 上 @pixie_run 找到我们。
[1]Wireshark: https://www.wireshark.org/
[2]头帧: https://datatracker.ietf.org/doc/html/rfc7540#section-6.2
[3]这里: https://github.com/pixie-io/pixie-demos/tree/main/http2-tracing#trace-http2-headers-with-wireshark
[4]HPACK: https://httpwg.org/specs/rfc7541.html
[5]比 HTTP 1.x 大大提高了效率: https://blog.cloudflare.com/hpack-the-silent-killer-feature-of-http-2/
[6]官方 RFC: https://httpwg.org/specs/rfc7541.html
[7]之前的一篇 eBPF 博文: https://blog.px.dev/ebpf-http-tracing/#tracing-with-uprobes
[8]2018: https://github.com/grpc/grpc-go/commits/master/internal/transport/controlbuf.go
[9]http2-tracing/uprobe_trace/bpf_program.go: https://github.com/pixie-io/pixie-demos/blob/main/http2-tracing/uprobe_trace/bpf_program.go
[10]这里: https://github.com/pixie-io/pixie-demos/tree/main/http2-tracing
[11]Slack: https://slackin.px.dev/
[12]代码: https://github.com/pixie-io/pixie/blob/78931cbbbad08578386fa864155f6d57a63d4d73/src/stirling/source_connectors/socket_tracer/bcc_bpf/go_http2_trace.c#L1026
[13]DWARF query API: https://github.com/pixie-io/pixie/blob/78931cbbbad08578386fa864155f6d57a63d4d73/src/stirling/source_connectors/socket_tracer/uprobe_symaddrs.cc#L171
[14]这 GitHub 问题: https://github.com/pixie-io/pixie/issues/335