2 Studying《BPF.Performance.Tools》10-18

发布于:2025-06-27 ⋅ 阅读:(13) ⋅ 点赞:(0)

目录

10 NetWorking

10.1 Background

10.2 Traditional Tools

10.3 BPF Tools

10.4 BPF One-Liners

10.5 Optional Exercises

10.6 Summary

11 Security

11.1 Background

11.2 BPF Tools

11.3 BPF One-Liners

11.4 Summary

12 Languages

12.1 Background

12.2 C

12.3 Java

12.4 Bash Shell

12.5 Other Languages

12.6 Summary

13 Applications

13.1 Background

13.2 BPF Tools

13.3 BPF One-Liners

13.4 BPF One-Liners Examples

13.5 Summary

14 Kernel

14.1 Background

14.2 Strategy

14.3 Traditional Tools

14.4 BPF Tools

14.5 BPF One-Liners

14.6 BPF One-Liners Examples

14.7 Challenges

14.8 Summary

15 Containers

15.1 Background

15.2 Traditional Tools

15.3 BPF Tools

15.4 BPF One-Liners

15.5 Optional Exercises

15.6 Summary

16 Hypervisors

16.1 Background

16.2 Traditional Tools

16.3 Guest BPF Tools

16.4 Host BPF Tools

16.5 Summary

17 Other BPF Performance Tools

17.1 Vector and Performance Co-Pilot (PCP)

17.2 Grafana and Performance Co-Pilot (PCP)

17.3 Cloudflare eBPF Prometheus Exporter (with Grafana)

17.4 kubectl-trace

17.5 Other Tools

17.6 Summary

18 Tips,Tricks,and Common Problems

18.1 Typical Event Frequency and Overhead

18.2 Sample at 49 or 99 Hertz

18.3 Yellow Pigs and Gray Rats

18.4 Write Target Software

18.5 Learn Syscalls

18.6 Keep It Simple

18.7 Missing Events

18.8 Missing Stacks Traces

18.9 Missing Symbols (Function Names) When Printing

18.10 Missing Functions When Tracing

18.11 Feedback Loops


10 NetWorking

 网络在系统性能分析中扮演着越来越重要的角色,分布式云计算模型的兴起增加了数据中心或云环境中的网络流量,在线应用程序则增加了外部网络流量。随着服务器扩展到每秒处理数百万个数据包,对高效网络分析工具的需求也在增加。扩展 BPF 最初是为了数据包处理而设计的,因此它能够在这些速率下运行。Cilium 项目用于容器网络和安全策略,以及 Facebook 的 Katran 可扩展网络负载均衡器,都是 BPF 能够在生产环境中处理高数据包速率的进一步示例,包括分布式拒绝服务攻击 (DDoS) 缓解。
网络 I/O 由许多不同的层和协议处理,包括应用程序、协议库、系统调用、TCP 或 UDP、IP 以及网络接口的设备驱动程序。所有这些都可以通过本章展示的 BPF 工具进行追踪,从而提供有关请求的工作负载和遇到的延迟的洞察。
学习目标:
- 获得网络堆栈和可扩展性方法的高层次视图,包括接收和发送扩展、TCP 缓冲区和排队学科
- 学习成功分析网络性能的策略
- 特征化套接字、TCP 和 UDP 工作负载以识别问题
- 测量不同的延迟指标:连接延迟、首字节延迟、连接持续时间
- 学习有效跟踪和分析 TCP 重传的方法
- 调查网络堆栈间的延迟
- 量化在软件和硬件网络队列中花费的时间
- 使用 bpftrace 单行命令以自定义方式探索网络
本章首先介绍网络分析所需的背景,总结网络堆栈和可扩展性方法。我探索 BPF 可以回答的问题,并提供总体策略。然后,我重点介绍工具,从传统工具到 BPF 工具,包括 BPF 单行命令的列表。本章以可选练习结束。


10.1 Background

本节涵盖了网络基础知识、BPF 功能、建议的网络分析策略以及常见的追踪错误。
10.1.1 Networking Fundamentals
本章假设读者具备 IP 和 TCP 的基本知识,包括 TCP 的三次握手、确认包以及主动/被动连接术语。
网络堆栈
图 10-1 展示了 Linux 网络堆栈,说明了数据从应用程序如何常见地传递到网络接口卡(NIC)。

主要组成部分包括:
- **套接字(Sockets)**:用于发送或接收数据的端点。这些还包括 TCP 使用的发送和接收缓冲区。
- **TCP(传输控制协议,Transmission Control Protocol)**:一种广泛使用的传输协议,用于以有序和可靠的方式传输数据,并进行错误检查。
- **UDP(用户数据报协议,User Datagram Protocol)**:一种简单的传输协议,用于发送消息,但不具备 TCP 的开销或保证。
- **IP(互联网协议,Internet Protocol)**:一种用于在网络上主机之间传递数据包的网络协议。主要版本包括 IPv4 和 IPv6。
- **ICMP(互联网控制消息协议,Internet Control Message Protocol)**:一种在 IP 层支持 IP 的协议,用于传递路由和错误信息。
- **排队策略(Queueing discipline)**:一个可选的层,用于流量分类(tc)、调度、操控、过滤和整形 [95]2。
- **设备驱动程序(Device drivers)**:可能包括自己的驱动程序队列(NIC RX-ring 和 TX-ring)。
- **NIC(网络接口卡,Network Interface Card)**:包含物理网络端口的设备。这些也可以是虚拟设备,如隧道、veth(虚拟以太网设备)和回环接口。
图 10-1 展示了最常见的数据路径,但为了提高某些工作负载的性能,也可以使用其他路径。这些不同的路径包括内核绕过和基于 BPF 的新技术 XDP。
### 内核绕过(Kernel Bypass)
应用程序可以使用数据平面开发工具包(DPDK)等技术绕过内核网络堆栈,从而实现更高的数据包处理速度和性能。这涉及到应用程序在用户空间实现自己的网络协议,并通过 DPDK 库和内核用户空间 I/O(UIO)或虚拟功能 I/O(VFIO)驱动程序向网络驱动程序进行写入。通过直接访问 NIC 上的内存,可以避免复制数据包数据的开销。由于绕过了内核网络堆栈,传统工具和指标无法用于性能分析,这使得性能分析变得更加困难。
### XDP
eXpress Data Path(XDP)技术提供了另一种网络数据包的处理路径:一个可编程的快速路径,使用扩展的 BPF,并集成到现有的内核堆栈中,而不是绕过它 [Høiland-Jørgensen 18]。由于它通过 NIC 驱动程序中的 BPF 钩子尽早访问原始网络以太网帧,因此可以在没有 TCP/IP 堆栈处理开销的情况下做出早期的转发或丢弃决策。当需要时,它也可以回退到常规网络堆栈处理。使用场景包括更快速的 DDoS 缓解和软件定义路由。
### 内部结构(Internals)
了解一些内核内部结构将帮助你理解后续的 BPF 工具。基本内容包括:数据包通过内核使用 sk_buff 结构体(socket buffer)传递。套接字由 sock 结构体定义,该结构体嵌入在像 tcp_sock 这样的协议变体的开头。网络协议通过 struct proto 附加到套接字上,例如 tcp_prot、udp_prot 等;这个结构体定义了操作协议的回调函数,包括 connect、sendmsg 和 recvmsg。
接收与传输扩展
没有针对网络数据包的 CPU 负载均衡策略时,网络接口卡(NIC)可能只会中断一个 CPU,这会使其在中断和网络堆栈处理上达到 100% 的利用率,从而成为瓶颈。可以采用各种策略来缓解中断并将 NIC 中断和数据包处理分配到多个 CPU,从而提高可扩展性和性能。这些策略包括新的 API(NAPI)接口、接收侧扩展(RSS)、接收数据包调度(RPS)、接收流调度(RFS)、加速 RFS 和传输数据包调度(XPS)。这些都在 Linux 源码中有详细说明。
套接字接受扩展
处理高频率的被动 TCP 连接的常用模型是使用一个线程处理 accept(2) 调用,然后将连接传递给工作线程池。为了进一步扩展,在 Linux 3.9 中添加了 SO_REUSEPORT setsockopt(3) 选项,允许一组进程或线程绑定到相同的套接字地址,所有线程可以调用 accept(2)。然后由内核在绑定线程池之间平衡新的连接。可以通过 SO_ATTACH_REUSEPORT_EBPF 选项提供一个 BPF 程序来引导这种平衡:此选项在 Linux 4.5 中为 UDP 添加,在 Linux 4.6 中为 TCP 添加。
TCP 待处理队列
被动的 TCP 连接由内核接收 TCP SYN 数据包来启动。内核必须跟踪此潜在连接的状态,直到握手完成,过去攻击者利用 SYN 洪水来耗尽内核内存。Linux 使用两个队列来防止这种情况:一个具有最小元数据的 SYN 待处理队列,可以更好地抵御 SYN 洪水;另一个是应用程序消费的完成连接的监听待处理队列。这在图 10-2 中有所示意。
在洪水情况下,SYN 待处理队列中的数据包可以被丢弃,或者在应用程序无法快速接受连接时,监听待处理队列中的数据包也会被丢弃。合法的远程主机将响应基于计时器的重传。
除了两个队列模型,TCP 监听路径也被设计为无锁,以提高对 SYN 洪水攻击的可扩展性。

TCP 重传

TCP 使用两种技术来检测和重传丢失的数据包:
■ 基于定时器的重传:当时间过去后未收到数据包确认时发生。这段时间是 TCP 重传超时(RTO),根据连接的往返时间(RTT)动态计算。在 Linux 上,第一次重传的 RTO 至少为 200 毫秒(TCP_RTO_MIN),之后的重传会变得更慢,遵循指数退避算法,超时会翻倍。
■ 快速重传:当接收到重复的 ACK 时,TCP 可以假定数据包丢失并立即重传。
基于定时器的重传特别会导致性能问题,引入 200 毫秒及更高的延迟到网络连接中。拥塞控制算法在重传的情况下可能会限制吞吐量。
重传可能需要重新发送一系列数据包,从丢失的数据包开始,即使后续的数据包已正确接收。选择性确认(SACK)是一个常用的 TCP 选项,用于避免这种情况:它允许确认后续的数据包,从而无需重传,提高性能。
TCP 发送和接收缓冲区
通过使用套接字发送和接收缓冲区管理,可以提高 TCP 数据吞吐量。Linux 根据连接活动动态调整缓冲区的大小,并允许调整其最小、默认和最大大小。更大的缓冲区可以提高性能,但每个连接需要更多内存。

网络设备和网络接受的最大数据包大小(MSS)可能小至 1500 字节。为了避免发送大量小数据包带来的网络栈开销,TCP 使用通用分段卸载(GSO)发送最多 64 Kbytes 大的小包(“超级数据包”),这些包在送达网络设备之前会被拆分成 MSS 大小的段。如果网络接口卡(NIC)和驱动程序支持 TCP 分段卸载(TSO),GSO 将拆分工作留给设备,从而进一步提高网络栈的吞吐量。还有一种与 GSO 互补的通用接收卸载(GRO)。GRO 和 GSO 在内核软件中实现,而 TSO 由 NIC 硬件实现。
TCP 拥塞控制
Linux 支持不同的 TCP 拥塞控制算法,包括 Cubic(默认)、Reno、Tahoe、DCTCP 和 BBR。这些算法根据检测到的拥塞情况修改发送和接收窗口,以保持网络连接的最佳运行状态。
排队规则
这一可选层管理流量分类(tc)、调度、操作、过滤和网络数据包的整形。Linux 提供了许多排队规则算法,可以使用 tc(8) 命令进行配置。每个算法都有其手册页,可以使用 man(1) 命令列出它们:


**BPF 可以通过 BPF_PROG_TYPE_SCHED_CLS 和 BPF_PROG_TYPE_SCHED_ACT 类型的程序增强该层的能力。**
**其他性能优化**
网络栈中还有其他算法用于提高性能,包括:
- **Nagle 算法**:通过延迟小网络数据包的传输,减少数据包的数量,使更多的数据包能够到达并合并,从而减少网络负载。
- **字节队列限制(BQL)**:自动调整驱动程序队列的大小,既避免饥饿现象,又减小队列中数据包的最大延迟。其工作原理是在必要时暂停向驱动程序队列添加数据包,此功能在 Linux 3.3 中添加[95]。
- **节奏控制(Pacing)**:控制数据包的发送时间,将发送过程分散开来,避免可能影响性能的突发情况。
- **TCP 小队列(TSQ)**:控制(减少)网络栈中排队的数据量,以避免包括缓冲区膨胀在内的问题[101]。
- **早期离开时间(EDT)**:使用时间轮来排序发送到网络接口卡(NIC)的数据包,而不是使用队列。根据策略和速率配置,为每个数据包设置时间戳。此功能在 Linux 4.20 中添加,具备类似 BQL 和 TSQ 的能力[Jacobson 18]。
这些算法通常结合使用以提高性能。在数据包到达 NIC 之前,TCP 发送的数据包可以经过任何拥塞控制、TSO、TSQ、节奏控制和队列纪律处理[Cheng 16]。
**延迟测量**
各种网络延迟测量可以提供对性能的洞察,帮助确定瓶颈是出现在发送或接收应用程序中,还是在网络本身。这些测量包括[Gregg 13b]:
- **名称解析延迟**:将主机解析为 IP 地址的时间,通常通过 DNS 解析完成——这是性能问题的常见来源。
- **Ping 延迟**:从 ICMP 回显请求到响应的时间。这测量了每个主机上数据包的网络和内核栈处理。
- **TCP 连接延迟**:从发送 SYN 到接收 SYN, ACK 的时间。由于没有应用程序介入,这测量了每个主机上网络和内核栈的延迟,类似于 ping 延迟,但包括了 TCP 会话的额外内核处理。TCP Fast Open(TFO)是一项技术,通过在 SYN 中提供加密 cookie 来消除后续连接的延迟,从而立即验证客户端,使服务器能够在三次握手完成之前就响应数据。
- **TCP 首字节延迟**:也称为首次字节延迟(TTFB),测量从建立连接到客户端接收到第一个数据字节的时间。这包括主机的 CPU 调度和应用程序思考时间,因此更多地反映了应用程序性能和当前负载,而不是 TCP 连接延迟。
- **往返时间(RTT)**:网络数据包往返于端点之间的时间。内核可能会使用这些测量值与拥塞控制算法结合使用。
- **连接寿命**:从初始化到关闭的网络连接持续时间。某些协议如 HTTP 可以使用保持连接策略,将连接保持开放并闲置以处理未来的请求,从而避免重复建立连接的开销和延迟。
结合使用这些测量可以通过排除法帮助定位延迟的来源。它们还应与其他指标结合使用,以了解网络健康状况,包括事件发生率和吞吐量。
**进一步阅读**
本总结概述了网络分析工具的背景。Linux 网络栈的实现描述在内核源代码的 Documentation/networking 中[102],网络性能在《系统性能》第十章中有更深入的讨论[Gregg 13a]。
10.1.2 BPF Capabilities
传统的网络性能工具依赖于内核统计信息和网络数据包捕获。BPF 跟踪工具可以提供更多的洞察,回答以下问题:
- 当前发生了哪些 socket I/O 操作?为什么会这样?用户级的堆栈是什么?
- 哪些新的 TCP 会话被创建了?由哪些进程创建的?
- 是否存在 socket、TCP 或 IP 层级的错误?
- TCP 窗口大小是多少?是否有零大小的传输?
- 在不同的栈层中,I/O 大小是多少?到设备的 I/O 大小是多少?
- 网络栈中哪些数据包被丢弃了?原因是什么?
- TCP 连接延迟、首字节延迟和连接寿命是多少?
- 内核网络栈之间的延迟是多少?
- 数据包在 qdisc 队列和网络驱动程序队列中停留了多长时间?
- 正在使用哪些高级协议?
这些问题可以通过使用 BPF 来回答,首先通过 tracepoints 进行监控,如果需要更详细的信息,则使用 kprobes 和 uprobes。
**事件源**
表 10-1 列出了网络目标以及可以对其进行仪器化的源。

在许多情况下,由于缺少 tracepoints,必须使用 kprobes。 tracepoints 数量较少的一个原因是历史上(BPF 之前)需求不大。现在由于 BPF 的推动,需求增加了,首批 TCP tracepoints 已在 4.15 和 4.16 内核中添加。到 Linux 5.2 时,TCP tracepoints 包括:

未来的内核中可能会增加更多网络协议的 tracepoints。虽然为不同协议添加发送和接收 tracepoints 看起来很自然,但这涉及到修改关键的延迟敏感代码路径,因此需要谨慎对待,以了解这些新增 tracepoints 可能带来的未启用的开销。
**开销**
网络事件可能非常频繁,有些服务器和工作负载的网络流量每秒可能超过数百万个数据包。幸运的是,BPF 最初作为一种高效的每包过滤器出现,因此对每个事件只增加了微小的开销。然而,当事件数量达到每秒几百万或一千万时,这些微小的开销加起来可能会变得明显甚至显著。
幸运的是,许多观察需求可以通过跟踪频率较低的事件来满足,从而减少开销。例如,可以仅通过 `tcp_retransmit_skb()` 内核函数跟踪 TCP 重传,而无需跟踪每个数据包。我最近处理的一个生产问题中,服务器的数据包速率超过 100,000 个/秒,而重传率为 1,000 个/秒。对于数据包跟踪的开销,我选择跟踪的事件将开销降低了百倍。
在需要跟踪每个数据包的情况下,原始 tracepoints(在第 2 章介绍)比 tracepoints 和 kprobes 更高效。
一种常见的网络性能分析技术涉及收集每包数据(如 `tcpdump`、`libpcap` 等),这不仅对每个数据包增加了开销,还在将这些数据包写入文件系统时增加了额外的 CPU、内存和存储开销,随后在读取进行后处理时还会增加额外的开销。相比之下,BPF 每包跟踪已经是一种大幅提高效率的方法,因为它只在内核内存中计算摘要,而不需要使用捕获文件。
10.1.3 Strategy
如果你是网络性能分析的新手,这里有一个建议的总体策略可以供参考。接下来的章节会更详细地解释这些工具。
这个策略从使用工作负载特性分析来发现低效率开始(步骤 1 和 2),然后检查接口限制(步骤 3)和不同来源的延迟(步骤 4、5 和 6)。在这一点上,尝试实验性分析可能是值得的(步骤 7)—不过要记住,这可能会影响生产负载—接着进行更高级和定制化的分析(步骤 8、9 和 10)。
1. 使用基于计数器的工具来理解基本的网络统计信息:数据包速率和吞吐量,以及如果使用 TCP,则 TCP 连接速率和 TCP 重传率(例如使用 ss(8)、nstat(8)、netstat(1) 和 sar(1))。
2. 跟踪新创建的 TCP 连接及其持续时间,以特性化工作负载并寻找低效率(例如使用 BCC 的 tcplife(8))。例如,你可能会发现频繁的连接用于从远程服务读取资源,这些资源可以在本地进行缓存。
3. 检查是否已达到网络接口的吞吐量限制(例如使用 sar(1) 或 nicstat(1) 的接口利用率百分比)。
4. 跟踪 TCP 重传和其他异常的 TCP 事件(例如使用 BCC 的 tcpretrans(8)、tcpdrop(8) 和 skb:kfree_skb tracepoint)。
5. 测量主机名解析(DNS)延迟,因为这是性能问题的常见来源(例如使用 BCC 的 gethostlatency(8))。
6. 从不同点测量网络延迟:连接延迟、首字节延迟、内部堆栈延迟等。
   - 注意,网络延迟的测量在负载变化时可能会有显著变化,这是由于网络中的缓冲过多引起的延迟。如果可能的话,可以在负载期间和空闲网络期间测量这些延迟,以进行比较。
7. 使用负载生成工具来探索主机之间的网络吞吐量限制,并根据已知的工作负载检查网络事件(例如使用 iperf(1) 和 netperf(1))。
8. 浏览并执行本书 BPF 工具部分中列出的 BPF 工具。
9. 使用高频 CPU 分析内核堆栈跟踪,量化在协议和驱动程序处理中消耗的 CPU 时间。
10. 使用 tracepoints 和 kprobes 探索网络堆栈的内部。
10.1.4 Common Tracing Mistakes
一些在开发用于网络分析的 BPF 工具时常见的错误:
- **事件可能不在应用程序上下文中发生。** 数据包可能在空闲线程处于 CPU 上时被接收,而 TCP 会话可能在此时被初始化或更改状态。检查这些事件的 CPU 上的 PID 和进程名称无法显示连接的应用程序端点。你需要选择在应用程序上下文中发生的事件,或者使用标识符(例如 `struct sock`)缓存应用程序上下文,以便稍后获取。
- **可能存在快速路径和慢速路径。** 你编写的程序可能看起来正常工作,但只跟踪了其中一种路径。使用已知的工作负载,确保数据包和字节计数匹配。
- **在 TCP 中存在完整的套接字和非完整的套接字。** 后者是三次握手完成前的请求套接字,或者套接字处于 TCP TIME_WAIT 状态时。一些套接字结构字段在非完整套接字中可能无效。


10.2 Traditional Tools

传统的性能工具可以显示内核统计信息,如数据包速率、各种事件和吞吐量,并展示打开的套接字的状态。许多此类统计信息通常被监控工具收集并绘制图表。另一类工具则捕获数据包进行分析,允许研究每个数据包的头部和内容。
除了用于解决问题,传统工具还可以提供线索,以指导你进一步使用 BPF 工具。它们根据来源和测量类型(内核统计信息或数据包捕获)在表 10.2 中进行了分类。

以下章节总结了这些可观察性工具的关键功能。有关更多使用方法和解释,请参考它们的手册页以及其他资源,包括《Systems Performance》[Gregg 13a]。
请注意,还有一些工具用于进行网络分析实验。这些工具包括微基准测试工具,如 `iperf(1)` 和 `netperf(1)`,ICMP 工具如 `ping(1)`,以及网络路由发现工具,如 `traceroute(1)` 和 `pathchar`。此外,还有用于自动化网络测试的 Flent GUI [103]。还有静态分析工具:检查系统和硬件的配置,而不一定需要应用任何负载 [Elling 00]。这些实验性和静态工具在其他地方有详细介绍(例如,[Gregg 13a])。
首先介绍 `ss(8)`、`ip(8)` 和 `nstat(8)` 工具,因为它们来自由网络内核工程师维护的 iproute2 包。这个包中的工具最有可能支持最新的 Linux 内核特性。
10.2.1 ss

此输出是当前状态的快照。第一列显示了套接字使用的协议:这些是 TCP 协议。由于此输出列出了所有已建立的连接及其 IP 地址信息,因此可以用来描述当前的工作负载,并回答包括开放了多少客户端连接、对某个依赖服务有多少并发连接等问题。
通过使用选项,可以获得更多信息。例如,可以显示仅 TCP 套接字(-t)、TCP 内部信息(-i)、扩展套接字信息(-e)、进程信息(-p)以及内存使用情况(-m):

该输出包含许多详细信息。以下是重点突出部分:
- **"java",pid=4195**: 进程名称 "java",PID 4195
- **fd=10865**: 文件描述符 10865(针对 PID 4195)
- **rto:204**: TCP 重传超时:204 毫秒
- **rtt:0.159/0.009**: 平均往返时间为 0.159 毫秒,标准偏差为 0.009 毫秒
- **mss:1448**: 最大报文段大小:1448 字节
- **cwnd:152**: 拥塞窗口大小:152 × MSS
- **bytes_acked:347681**: 成功传输的字节数:340 KB
- **bytes_received:1798733**: 接收的字节数:1.72 MB
- **bbr:...**: BBR 拥塞控制统计信息
- **pacing_rate 2422.4Mbps**: 配速率为 2422.4 Mbps
此工具使用 netlink 接口,通过 AF_NETLINK 套接字从内核获取信息。
10.2.2 ip
`ip(8)` 是一个用于管理路由、网络设备、接口和隧道的工具。为了进行可观测性分析,它可以用于打印各种对象的统计信息:如链路、地址、路由等。例如,使用 `-s` 选项可以打印接口(链路)的额外统计信息。

打印路由对象可以显示路由表。路由表是网络设备用于决定数据包如何转发到目标网络的核心组件。输出中通常包括以下信息:
- **目的地 (Destination)**: 数据包的目标网络或地址。
- **网关 (Gateway)**: 数据包转发的下一跳网关地址。
- **接口 (Iface)**: 数据包通过哪个网络接口发送。
- **标志 (Flags)**: 路由的属性标志,如是否可用、是否为默认路由等。
- **网络掩码 (Netmask)**: 用于确定网络地址和主机地址部分的掩码。
- **Metric**: 路由的度量值,用于选择最佳路由。
通过查看路由表,你可以获得网络流量如何被路由的详细信息,有助于诊断网络问题或优化网络性能。

10.2.3 nstat

`-s` 选项用于避免重置计数器,这是 `nstat(8)` 的默认行为。重置计数器是有用的,因为这样你可以在第二次运行 `nstat(8)` 时查看跨越该时间间隔的计数,而不是从启动以来的总计。如果你有一个可以通过命令重现的网络问题,可以在命令执行前后运行 `nstat(8)`,以显示哪些计数器发生了变化。
`nstat(8)` 还具有一个守护进程模式(`-d`),用于收集间隔统计信息,这些信息在使用时会显示在最后一列。
10.2.4 netstat
`netstat(8)` 是一个传统的工具,用于根据所用选项报告不同类型的网络统计信息。以下是一些常用的选项:
- **默认**: 列出开放的套接字。
- **-a**: 列出所有套接字的信息。
- **-s**: 显示网络栈统计信息。
- **-i**: 显示网络接口统计信息。
- **-r**: 列出路由表。
例如,使用 `-a` 选项可以修改默认输出以显示所有套接字,使用 `-n` 选项可以避免解析 IP 地址(否则,可能会导致较重的名称解析工作负载),并使用 `-p` 选项显示进程信息。

接口 `eth0` 是主接口。字段显示接收(RX-)和发送(TX-):
- **OK**: 成功传输的数据包
- **ERR**: 数据包错误
- **DRP**: 数据包丢失
- **OVR**: 数据包溢出
额外的 `-c`(连续)选项每秒打印一次该总结。`-s` 选项打印网络栈统计信息。例如,在一个繁忙的生产系统上(输出已截断):

这显示了自系统启动以来的总计。通过研究这些输出,可以了解很多信息:你可以计算不同协议的数据包速率、连接速率(TCP 活跃和被动)、错误率、吞吐量以及其他事件。我首先关注的一些指标已用粗体突出显示。
这些输出提供了可读的指标描述;不应由其他软件(如监控代理)解析。那些软件应直接从 `/proc/net/snmp` 和 `/proc/net/netstat` 中读取指标(或者使用 `nstat(8)`)。
10.2.5 sar
系统活动报告工具 `sar(1)` 可以打印各种网络统计报告。`sar(1)` 可以实时使用,也可以配置为定期记录数据作为监控工具。`sar(1)` 的网络选项包括:
- `-n DEV`:网络接口统计
- `-n EDEV`:网络接口错误
- `-n IP,IP6`:IPv4 和 IPv6 数据报统计
- `-n EIP,EIP6`:IPv4 和 IPv6 错误统计
- `-n ICMP,ICMP6`:ICMP IPv4 和 IPv6 统计
- `-n EICMP,EICMP6`:ICMP IPv4 和 IPv6 错误统计
- `-n TCP`:TCP 统计
- `-n ETCP`:TCP 错误统计
- `-n SOCK,SOCK6`:IPv4 和 IPv6 套接字使用
例如,以下展示了在生产 Hadoop 实例中使用这些选项中的四个,并以每秒间隔打印的示例:

这些多行输出在每个间隔中重复。它们可以用于确定:
- 打开的 TCP 套接字数量 (tcpsck)
- 当前 TCP 连接速率 (active/s + passive/s)
- TCP 重传率 (retrans/s / oseg/s)
- 接口的数据包速率和吞吐量 (rxpck/s + txpck/s, rxkB/s + txkB/s)
这是一个云实例,我预期网络接口错误为零:在物理服务器上,可以包含 EDEV 组来检查此类错误。
10.2.6 nicstat

这包括一个饱和统计数据,它综合了不同的错误,指示接口饱和的程度。使用 `-U` 选项可以分别打印读取和写入的利用率百分比,以确定是否有一个方向达到了限制。
10.2.7 ethtool

10.2.8 tcpdump
最后,`tcpdump(8)` 可以捕获数据包以供研究,这称为“数据包嗅探”。例如,可以使用以下命令在接口 `en0` 上嗅探数据包 (`-i`),将其写入转储文件 (`-w`),然后读取 (`-r`),且不进行名称解析 (`-n`):


`tcpdump(8)` 输出文件可以被其他工具读取,包括 Wireshark GUI。Wireshark 允许轻松检查数据包头,并“跟踪” TCP 会话,通过重组传输和接收的字节,研究客户端/主机之间的交互。
尽管数据包捕获在内核和 `libpcap` 库中已进行优化,但在高数据率下,执行捕获仍然可能代价高昂,需要额外的 CPU 开销来收集数据,以及 CPU、内存和磁盘资源来存储和后处理这些数据。可以通过使用过滤器来减少这些开销,以便只记录具有特定头部详细信息的数据包。然而,即使对于未被收集的数据包,也会有 CPU 开销,因为过滤器表达式必须应用于所有数据包。为此,Berkeley Packet Filter (BPF) 被创建用于数据包捕获过滤,后来被扩展成为我在本书中用于跟踪工具的技术。有关 `tcpdump(8)` 过滤器程序的示例,请参见第 2.2 节。
尽管数据包捕获工具似乎展示了网络的全面细节,但它们仅显示通过网络传输的细节。它们对内核状态是盲目的,包括负责数据包的进程、栈跟踪以及套接字和 TCP 的内核状态。这些细节可以通过 BPF 跟踪工具查看。
10.2.9 /proc

`netstat(1)` 和 `sar(1)` 工具可以展示许多网络统计指标,如系统范围的包率、TCP 的活动和被动新连接、TCP 重传、ICMP 错误等。
`/proc/interrupts` 和 `/proc/softirqs` 可以显示网络设备中断在 CPU 之间的分布。例如,在一个双 CPU 系统上,这些文件可以帮助分析中断的分配情况,从而优化系统性能。

这个系统有一个使用 ena 驱动程序的 eth0 网络接口。上述输出显示 eth0 为每个 CPU 使用了一个队列,并且接收的软中断(softirq)分布在两个 CPU 上。(发送操作似乎不平衡,但网络栈通常会跳过这些软中断,直接向设备发送数据。)`mpstat(8)` 也有一个 -I 选项,用于打印中断统计信息。
下面的 BPF 工具旨在扩展网络可观测性,而不是重复这些 `/proc` 和传统工具的指标。`sockstat(8)` 是一个 BPF 工具,用于系统范围的套接字指标,因为这些特定的指标在 `/proc` 中不可用。但没有类似的 `tcpstat(8)`、`udpstat(8)` 或 `ipstat(8)` 工具用于系统范围的指标:虽然可以在 BPF 中编写这些工具,但它们只需使用已维护的 `/proc` 中的指标。实际上,编写这些工具并不是必要的:`netstat(1)` 和 `sar(1)` 已经提供了这些可观测性。
以下 BPF 工具通过按进程 ID、进程名称、IP 地址和端口拆分统计数据、揭示导致事件的堆栈跟踪、暴露内核状态以及显示自定义延迟测量来扩展可观测性。这些工具可能看起来很全面,但实际上并非如此。它们旨在与 `/proc/net` 和早期的传统工具一起使用,以扩展可观测性。


10.3 BPF Tools

`bpftrace` 如图 10-4 所示,用于观察设备驱动程序。有关示例,请参见第 10.4.3 节。该图中的其他工具来自第 4 章和第 5 章中介绍的 BCC 或 bpftrace 存储库,或是为本书创建的。一些工具同时出现在 BCC 和 bpftrace 中。表 10-3 列出了这些工具的来源(BT 是 bpftrace 的缩写)。


10.3.1 sockstat

每秒钟打印一个时间(例如“21:22:56”),然后是各种套接字事件的计数。这个例子显示每秒有 10,547 次 `sock_recvmsg()` 和 5,280 次 `sock_sendmsg()` 事件,以及少于一百次 `accept4(2)` 和 `connect(2)` 调用。这个工具的作用是提供高层次的套接字统计信息,用于工作负载特征分析,并作为进一步分析的起点。输出包括探针名称,以便进一步调查;例如,如果你发现 `kprobe:sock_sendmsg` 事件的频率高于预期,可以使用这个 bpftrace 单行命令来获取进程名称。

用户级堆栈跟踪也可以通过将 `ustack` 添加到映射键来进行检查。`sockstat(8)` 工具通过使用 tracepoints 跟踪关键的套接字相关系统调用,并使用 kprobes 跟踪 `sock_recvmsg()` 和 `sock_sendmsg()` 内核函数。kprobes 的开销可能最为显著,并且在高网络吞吐量系统上可能会变得可测量。
`sockstat(8)` 的源代码如下:

使用这些 kprobes 是一种捷径。这些操作也可以通过 syscall tracepoints 来跟踪。例如,可以通过在代码中添加更多 tracepoints 来跟踪 `recvfrom(2)`、`recvmsg(2)`、`sendto(2)` 和 `sendmsg(2)` 系统调用及其他变体。对于 `read(2)` 和 `write(2)` 系统调用系列,情况更为复杂,因为需要处理文件描述符以确定文件类型,以仅匹配套接字的读写操作。
10.3.2 sofamily
`sofamily(8)` 跟踪通过 `accept(2)` 和 `connect(2)` 系统调用的新套接字连接,并总结进程名称和地址族。这对于工作负载特征分析非常有用:可以量化施加的负载并查找任何需要进一步调查的意外套接字使用情况。例如,在生产边缘服务器上:

`sofamily(8)` 输出显示了 420 个 AF_INET (IPv4) 接受连接和 215 次由 Java 发起的连接尝试,这对于此服务器来说是预期的。输出中展示了套接字接受 (@accept) 和连接 (@connect) 的映射,键包括进程名称、地址族编号以及如果已知的地址族名称。地址族编号映射(例如 AF_INET == 2)特定于 Linux,并在 `include/linux/socket.h` 头文件中定义。其他内核使用自己的编号映射。由于跟踪调用的发生率相对较低(与数据包事件相比),预计该工具的开销可以忽略不计。
`sofamily(8)` 的源代码如下:

地址族从 `struct sockaddr` 的 `sa_family` 成员中读取。它是 `sa_family_t` 类型的一个数字,该类型解析为 `unsigned short`。该工具在输出中包含这个编号,并将一些常见的地址族映射为字符串名称以提高可读性,这基于来自 `linux/socket.h` 的表格。

在运行这个 bpftrace 程序时,`linux/socket.h` 头文件被包含进来,使得这行代码:
```bpftrace
@fam2str[AF_INET] = "AF_INET";
```
变成:
```bpftrace
@fam2str[2] = "AF_INET";
```
这将数字 2 映射为字符串 "AF_INET"。
对于 `connect(2)` 系统调用,所有细节在系统调用入口时读取。`accept(2)` 系统调用的跟踪方式有所不同:`sockaddr` 指针在系统调用的入口处被保存到哈希表中,并在这些系统调用退出时检索,以读取地址族。这是因为 `sockaddr` 在系统调用过程中被填充,所以必须在结束时读取。`accept(2)` 的返回值也会被检查(是否成功?);否则,`sockaddr` 结构的内容将无效。可以增强这个脚本,以对 `connect(2)` 进行类似的检查,以确保仅对成功的新连接进行输出计数。`soconnect(8)` 工具显示了这些 `connect(2)` 系统调用的不同返回结果。
10.3.3 soprotocol
`soprotocol(8)` 追踪新的套接字连接并总结进程名称和传输协议。这是另一个用于传输协议的工作负载特征化工具。例如,在生产边缘服务器上:

`soprotocol(8)` 的源代码用于追踪和总结新套接字连接,特别是记录接受(`@accept`)和连接(`@connect`)的数量,以及进程名称、协议编号、已知协议名称和协议模块名称。由于这些调用的发生频率相对较低(与数据包事件相比),因此该工具的开销预计是微不足道的。源代码可以在相关的开源代码库中找到,通常在工具的官方文档或其项目页面上提供。

这提供了一个简短的查找表,用于将协议编号转换为字符串,以及四种常见的协议。这些协议来自 in.h 头文件:

`bpftrace @prot2str` 表可以根据需要进行扩展。协议模块名称,如前面输出中的 "TCP"、"UDP" 等,可以通过 `struct sock` 的 `__sk_common.skc_prot->name` 作为字符串获取。这非常方便,我在其他工具中使用过这个方法来打印传输协议。以下是来自 `net/ipv4/tcp_ipv4.c` 的一个示例:

`name` 字段(例如 `.name = "TCP"`) 是 Linux 内核的实现细节。虽然使用起来很方便,但这个 `.name` 成员可能会在未来的内核版本中发生变化或消失。然而,传输协议编号应该始终存在,这也是我在这个工具中包含它的原因。`accept(2)` 和 `connect(2)` 的系统调用 tracepoints 并没有提供一个简单的方法来获取协议,目前也没有其他 tracepoints 用于这些事件。因此,我转而使用 kprobes,并选择了 LSM `security_socket_*` 函数,这些函数将 `struct sock` 作为第一个参数,并且是一个相对稳定的接口。
10.3.4 soconnect

这显示了两个 SSH 连接到端口 22,然后是一个 curl 进程,该进程首先连接到端口 53(DNS),接着尝试连接到端口 80 的 IPv6 地址,但结果是“网络不可达”,随后是成功的 IPv4 连接。各列含义如下:
- **PID**: 调用 `connect(2)` 的进程 ID
- **PROCESS**: 调用 `connect(2)` 的进程名称
- **FAM**: 地址族编号(参见之前 `sofamily(8)` 的描述)
- **ADDRESS**: IP 地址
- **PORT**: 远程端口
- **LAT(us)**: `connect(2)` 系统调用的延迟(持续时间)(参见下面的说明)
- **RESULT**: 系统调用错误状态
请注意,IPv6 地址可能非常长,以至于导致列溢出(如本例所示)。
这通过在 `connect(2)` 系统调用 tracepoints 上进行检测来实现。一个好处是这些事件发生在进程上下文中,因此你可以可靠地知道是哪个进程进行了系统调用。与此相比,后来的 `tcpconnect(8)` 工具更深入地追踪 TCP 连接,可能无法识别负责的进程。这些 `connect(8)` 系统调用的频率也相对较低,与数据包和其他事件相比,其开销应该是微不足道的。
报告的延迟仅针对 `connect()` 系统调用。对于一些应用程序,包括之前输出中的 SSH 进程,这个延迟跨度包括建立连接所需的网络延迟。其他应用程序可能会创建非阻塞套接字(SOCK_NONBLOCK),此时 `connect()` 系统调用可能在连接完成之前就返回。这可以在最终的 curl(1) 连接中看到,其结果是“进行中”。要测量这些非阻塞调用的完整连接延迟,需要检测更多事件;一个例子是后来的 `soconnect(8)` 工具。
`soconnect(8)` 的源代码是:

这记录了系统调用开始时从 `args->uservaddr` 获取的 `struct sockaddr` 指针及其时间戳,以便在系统调用退出时获取这些细节。`sockaddr` 结构体包含连接详情,但必须根据 `sin_family` 成员首先转换为 IPv4 `sockaddr_in` 或 IPv6 `sockaddr_in6`。连接的错误代码通过与 `connect(2)` 手册页中的描述相匹配的错误代码表进行映射。端口号使用位运算从网络字节序转换为主机字节序。
10.3.5 soaccept

以下是该内容的翻译:
这显示了 Java 从不同地址接受的多个连接。所显示的端口是远程临时端口。有关显示两个端点端口(本地端口和远程端口)的工具,请参见后面的 `tcpaccept(8)` 工具。各列说明如下:
■ **PID**:调用 `connect(2)` 的进程 ID
■ **COMM**:调用 `connect(2)` 的进程名称
■ **FAM**:地址族编号(参见第 10.3.2 节的描述)
■ **ADDRESS**:IP 地址
■ **PORT**:远程端口
■ **RESULT**:系统调用的错误状态
该工具通过插装 `accept(2)` 系统调用的跟踪点来工作。与 `soconnect(8)` 类似,这发生在进程上下文中,因此可以可靠地识别出正在进行这些 `accept(8)` 调用的进程。相较于数据包和其他事件,这些调用的频率也相对较低,因此开销应该是微乎其微的。
`soaccept(8)` 的源代码是:

这类似于 `soconnect(8)`,在系统调用返回时处理并重新转换 `sockaddr`。错误代码的描述已经根据 `accept(2)` 手册页中的描述进行了更改。
10.3.6 socketio

输出的最后一行显示,Java 进程 ID 3929 在跟踪期间从 TCP 端口 8980 执行了 44,462 次套接字读取。每个映射键中的五个字段是进程名称、进程 ID、方向、协议和端口。
该工具通过跟踪 `sock_recvmsg()` 和 `sock_sendmsg()` 内核函数来工作。为了说明选择这些函数的原因,可以参考 `net/socket.c` 中的 `socket_file_ops` 结构体。

这段代码将套接字读写函数定义为 `sock_read_iter()` 和 `sock_write_iter()`,我最初尝试跟踪这些函数。然而,经过各种负载测试,发现跟踪这些特定函数会漏掉一些事件。代码摘录中的块注释解释了原因:存在一些不在操作结构体中的额外特殊操作,这些操作也可以对套接字进行 I/O,包括直接通过系统调用或其他代码路径调用的 `sock_recvmsg()` 和 `sock_sendmsg()`,其中包括 `sock_read_iter()` 和 `sock_write_iter()`。这使得它们成为跟踪套接字 I/O 的常见点。在网络 I/O 繁忙的系统中,这些套接字函数可能会非常频繁地被调用,从而使开销变得可测量。`socketio(8)` 的源代码是:

目标端口是大端格式,工具在将其包含到 @io 映射中之前,会将其转换为小端格式(对于这个 x86 处理器)。这个脚本可以修改为显示传输的字节数,而不是 I/O 计数;例如,可以参考 `socksize(8)` 工具中的代码。`socketio(8)` 基于 kprobes,这会对内核实现细节进行插桩,这些细节可能会变化,从而导致工具失效。要实现更高级的功能,可以用 syscall tracepoints 重写这个工具,尽管这需要更多的努力,涉及跟踪 `sendto(2)`、`sendmsg(2)`、`sendmmsg(2)`、`recvfrom(2)`、`recvmsg(2)` 和 `recvmmsg(2)`。对于某些套接字类型,例如 UNIX 域套接字,还需要跟踪 `read(2)` 和 `write(2)` 系列系统调用。尽管为套接字 I/O 插桩 tracepoints 会更容易,但它们尚未存在。
10.3.7 socksize


主要应用程序是 Java,读取和写入操作都显示了套接字 I/O 大小的双峰分布。这些模式可能由不同的原因造成,例如不同的代码路径或消息内容。可以修改工具以包含堆栈跟踪和应用程序上下文来解决这个问题。
`socksize(8)` 的工作原理是跟踪 `sock_recvmsg()` 和 `sock_sendmsg()` 内核函数,`socketio(8)` 也是如此。`socksize(8)` 的源代码如下:


这些函数的返回值包含转移的字节数或一个负的错误代码。为了过滤错误代码,`if (retval >= 0)` 测试似乎是合适的;然而,`retval` 并不具备类型感知能力:它是一个 64 位无符号整数,而 `sock_recvmsg()` 和 `sock_sendmsg()` 函数返回的是 32 位有符号整数。解决方法应该是将 `retval` 强制转换为正确的类型,使用 `(int)retval`,但由于 `bpftrace` 目前不支持 `int` 类型转换,因此 0x7fffffff 测试是一个解决方法。
如果需要,还可以添加更多的键,例如 PID、端口号和用户堆栈跟踪。映射也可以从 `hist()` 更改为 `stats()`,以提供不同类型的汇总信息。

这显示了 I/O 的数量("count")、字节的平均大小("average")和总吞吐量("total")。在跟踪期间,Java 写入了 41 兆字节的数据。
10.3.8 sormem
`sormem(8)` 追踪套接字接收队列的大小,以直方图的形式显示队列的满度与可调限制的比较。如果接收队列超出限制,数据包会被丢弃,从而导致性能问题。例如,在生产环境的边缘服务器上运行此工具:


`@rmem_alloc` 显示了接收缓冲区已分配的内存量。`@rmem_limit` 是接收缓冲区的限制大小,可以通过 `sysctl(8)` 调节。这个例子显示,限制通常在 8 兆字节到 16 兆字节范围内,而实际分配的内存则要低得多,通常在 512 字节到 256 千字节之间。
以下是一个示例,帮助解释这一点;执行了一个 iperf(1) 吞吐量测试,并使用了这个 `sysctl(1)` 的 `tcp_rmem` 设置(在调节时需小心,因为较大的尺寸可能会由于 skb 崩溃和合并 [105] 引入延迟):

`rmem_limit` 现在已降到 64 到 128 千字节范围,与配置的 100 千字节限制相匹配。注意,`net.ipv4.tcp_moderate_rcvbuf` 已启用,这有助于调整接收缓冲区,以更快地达到这个限制。
这个功能通过使用 kprobes 跟踪内核的 `sock_rcvmsg()` 函数实现,这可能会对繁忙的工作负载造成可测量的开销。
`sormem(8)` 的源代码是:

有两个 sock tracepoints 会在缓冲区限制被超越时触发,该工具也会跟踪这些事件。如果发生这种情况,会打印每个事件的详细信息行。(在之前的输出中,这些事件没有发生。)
10.3.9 soconnlat
`soconnlat(8)` 显示了以直方图形式展示的套接字连接延迟,并包含用户级堆栈跟踪。这提供了与 `soconnect(8)` 不同的套接字使用视图:它通过代码路径识别连接,而不是通过 IP 地址和端口。示例输出:

`soconnlat(8)` 显示了两个堆栈跟踪:第一个来自开源 Java 游戏,代码路径表明为何会调用 `connect`,其连接延迟在 32 到 64 微秒之间,且仅出现了一次。第二个堆栈显示了 200 多个连接,延迟在 128 微秒到 2 毫秒之间,这些连接也来自 Java。不过,第二个堆栈跟踪不完整,仅显示了一个帧 `__connect+71`,然后突然结束。这是因为该 Java 应用使用了默认的 libc 库,该库在编译时未启用帧指针。有关解决方法,请参见第 13 章第 13.2.9 节。
此连接延迟显示了建立连接所需的时间,包括网络中的三次握手以及远程主机处理入站 SYN 并响应的延迟。工具通过跟踪 `connect(2)`、`select(2)` 和 `poll(2)` 系统调用的 tracepoints 来工作。在频繁调用这些 syscalls 的繁忙系统中,可能会产生可测量的开销。
`soconnlat(8)` 的源代码是:

这解决了早前描述的 `soconnect(8)` 工具中的问题。连接延迟被测量为 `connect(2)` 系统调用完成的时间,除非它以 `EINPROGRESS` 状态完成,这种情况下真正的连接完成会在稍后发生,当 `poll(2)` 或 `select(2)` 系统调用成功找到该文件描述符的事件时。这款工具本应该记录每个 `poll(2)` 或 `select(2)` 系统调用的进入参数,然后在退出时再次检查,以确保连接套接字文件描述符是发生事件的那个。然而,这款工具采取了一个巨大的捷径,假设在同一线程上,`connect(2)` 后第一个成功的 `poll(2)` 或 `select(2)` 是相关的。虽然这种情况很可能是正确的,但请注意,如果应用程序调用了 `connect(2)`,然后在同一线程上收到了一个不同文件描述符上的事件,这种假设可能会有误差。你可以通过增强工具或调查应用程序对这些系统调用的使用来检查这种情况的可能性。例如,统计在生产边缘服务器上应用程序通过 `poll(2)` 正在等待的文件描述符数量。

在跟踪过程中,Java 只对一个文件描述符调用 `poll(2)`,所以我刚才描述的情况似乎更不可能发生,除非它对不同的文件描述符分别调用 `poll(2)`。类似的测试可以对其他 `poll(2)` 和 `select(2)` 系统调用进行。
这个输出还显示 python3 调用了 `poll(2)` 来处理...96 个文件描述符?通过在映射中添加 PID 来识别具体的 python3 进程,然后使用 `lsof(8)` 检查其文件描述符,我发现它确实错误地打开了 96 个文件描述符,并且在生产服务器上频繁进行轮询。我应该能够修复这个问题,从而节省一些 CPU 资源。
10.3.10 so1stbyte
`so1stbyte(8)` 跟踪从发出 IP 套接字 `connect(2)` 调用到接收到该套接字的第一个字节的时间。虽然 `soconnlat(8)` 是衡量建立连接的网络和内核延迟的指标,但 `so1stbyte(8)` 包括了远程主机应用程序被调度和生成数据的时间。这提供了远程主机的繁忙程度的视图,并且如果随着时间的推移进行测量,可能会揭示远程主机负载较高和延迟较高的时段。例如:

该输出显示,这个 Java 进程的连接通常在 1 到 16 毫秒内接收到第一个字节。这个过程通过使用系统调用跟踪点来对 `connect(2)`、`read(2)` 和 `recv(2)` 系列系统调用进行仪器化。由于这些系统调用在高 I/O 系统中可能非常频繁,因此在运行时其开销可能是可测量的。

这个工具在进入 `connect(2)` 时记录一个起始时间戳到 `@connstart` 映射中,以进程 ID 和文件描述符作为键。如果 `connect(2)` 调用失败(除非它是非阻塞的并且返回了 `EINPROGRESS`)或发出了 `close(2)` 调用,它会删除时间戳以停止跟踪该连接。当对之前看到的套接字文件描述符执行第一次 `read` 或 `recv` 系统调用时,它会在 `@readfd` 中跟踪文件描述符,以便在系统调用退出时提取,并最终从 `@connstart` 映射中读取起始时间。
这个时间跨度类似于之前描述的 TCP 第一个字节时间,但有一个小的区别:它包括了 `connect(2)` 的持续时间。
为了捕获套接字的第一次读取,许多系统调用跟踪点需要被仪器化,这会增加所有这些读取路径的开销。可以通过改用 `kprobes`(例如 `sock_recvmsg()` 用于套接字函数)来减少这种开销和跟踪事件的数量,并跟踪套接字指针作为唯一 ID,而不是 PID 和 FD 对。这种权衡是,`kprobes` 的稳定性较差。
10.3.11 tcpconnect
`tcpconnect(8)` 是一个 BCC 和 bpftrace 工具,用于跟踪新的 TCP 活跃连接。与早期的套接字工具不同,`tcpconnect(8)` 及后续的 TCP 工具深入到网络栈中的 TCP 代码层,而不是跟踪套接字系统调用。`tcpconnect(8)` 以套接字系统调用 `connect(2)` 命名,这些连接通常被称为外向连接,尽管它们也可能是本地连接。
`tcpconnect(8)` 对于工作负载特征化非常有用:可以确定谁正在连接到谁,以及连接的速率。以下是来自 BCC 的 `tcpconnect(8)`:

这段描述展示了 `tcpconnect(8)` 捕获到的多个连接,这些连接都指向不同的远程主机,但使用相同的端口 6001。列信息如下:
- **TIME(s)**: 接受连接的时间(以秒为单位),从第一个事件开始计算。
- **PID**: 接受连接的进程 ID。这个字段尽力匹配当前进程,但在 TCP 层级,这些事件可能不会在进程上下文中发生。要获取可靠的 PID,请使用套接字跟踪。
- **COMM**: 接受连接的进程名称。与 PID 相似,这也是尽力而为的,使用套接字跟踪可以获得更好的可靠性。
- **IP**: IP 地址协议。
- **SADDR**: 源地址。
- **DADDR**: 目标地址。
- **DPORT**: 目标端口。
支持 IPv4 和 IPv6,尽管 IPv6 地址可能非常长,导致输出列显示不整齐。
该工具通过跟踪与创建新的 TCP 会话相关的事件来工作,而不是逐包跟踪。在这台生产服务器上,数据包的速率大约为 50,000/s,而新 TCP 会话的速率约为 350/s。通过跟踪会话级别的事件,而不是数据包,开销减少了大约一百倍,变得可以忽略不计。
当前的 BCC 版本通过跟踪 `tcp_v4_connect()` 和 `tcp_v6_connect()` 内核函数来工作。未来版本应切换到使用 `sock:inet_sock_set_state` 追踪点(如果可用)。
### BCC
命令行用法:
```bash
tcpconnect [options]
```
**选项包括:**
- **-t**:包括时间戳列
- **-p PID**:仅跟踪此进程
- **-P PORT[,PORT,...]**:仅跟踪这些目的端口
### bpftrace
以下是 `tcpconnect-tp(8)` 的代码,它是 `tcpconnect(8)` 的 bpftrace 版本,使用了 `sock:inet_sock_set_state` 追踪点:

这段代码匹配了通过从 `TCP_CLOSE` 到 `TCP_SYN_SENT` 的过渡来表示的活动打开连接。bpftrace 仓库中有一个针对较旧 Linux 内核的 `tcpconnect(8)` 版本,它不具备 `sock:inet_sock_set_state` 追踪点,而是跟踪 `tcp_connect()` 内核函数。
10.3.12 tcpaccept
`tcpaccept(8)26` 是一个用于跟踪新 TCP 被动连接的 BCC 和 bpftrace 工具,它是 `tcpconnect(8)` 的对应工具。这个工具以 socket 系统调用 `accept(2)` 命名。这些连接通常被称为入站连接,尽管它们也可能来自本地主机。与 `tcpconnect(8)` 一样,这个工具对于工作负载特征分析非常有用:可以确定谁正在连接到本地系统以及连接的速率。
ept(8)` 的 BCC 版本的示例,来自一个具有 48 个 CPU 的生产实例,运行时使用了 `-t` 选项以打印时间戳列:

该输出显示了来自不同远程地址的新连接,这些连接都通过本地端口 6001 被一个 PID 为 4218 的 Java 进程接受。这些列与 `tcpconnect(8)` 类似,但有以下不同之处:
- **RADDR**:远程地址
- **RPORT**:远程端口
- **LADDR**:本地地址
- **LPORT**:本地端口
这个工具通过跟踪 `inet_csk_accept()` 内核函数来工作。这个函数的名称可能与其他高级 TCP 函数相比显得不太常见,你可能会想知道为什么选择它。我选择它是因为它是 `tcp_prot` 结构中的 `accept` 函数(定义在 `net/ipv4/tcp_ipv4.c`)。


IPv6 地址也是支持的,尽管由于其宽度,输出列可能会变得不整齐。以下是来自不同生产服务器的一个示例:

### BCC
#### Command Line Usage:
```
tcpaccept [options]
```
`tcpaccept(8)` 的选项类似于 `tcpconnect(8)`, 包括:
- `-t`:包含时间戳列
- `-p PID`:仅跟踪此进程
- `-P PORT[,PORT,...]`:仅跟踪这些本地端口
### bpftrace
以下是 `tcpaccept-tp(8)` 的代码,这是一种使用 `sock:inet_sock_set_state` 跟踪点的 `tcpaccept(8)` 的 bpftrace 版本,为本书开发:


由于在 TCP 状态转换时,进程 ID 预计不会处于 CPU 上,因此在这个版本中省略了 pid 和 comm 内置函数。以下是示例输出:

bpftrace 仓库中有一个版本的 `tcpaccept(8)`,它使用内核动态跟踪 `inet_csk_accept()` 函数,这与 BCC 版本使用的相同。由于此函数预计会与应用进程同步,因此使用 `pid` 和 `comm` 内置函数打印进程 ID 和进程名称。以下是一个摘录:


这个程序从 `sock` 结构体中提取协议细节,还提取了 TCP 监听队列的详细信息,并且是扩展这些工具以提供额外见解的一个示例。这个监听队列的功能是为了诊断 Shopify 生产环境中的一个问题,当时 Redis 在峰值负载下性能下降,发现问题是 TCP 监听队列丢包。添加一个列到 `tcpaccept.bt` 使得可以看到当前的监听队列长度,这对于特性分析和容量规划非常有用。
未来对 bpftrace 变量作用域的更改可能会导致在 if 语句中的变量只作用于该语句块,这可能会对这个程序造成问题,因为 `$daddr` 和 `$saddr` 会在语句块之外被使用。为了避免这一未来限制,该程序在前面初始化了这些变量为 `ntop(0)`(`ntop(0)` 返回类型 `inet`,以字符串形式打印)。这种初始化在当前版本的 bpftrace(0.9.1)中并不必要,但为了使程序具有未来兼容性,已将其包含在内。
10.3.13 tcplife
`tcplife(8)` 是一个 BCC 和 bpftrace 工具,用于跟踪 TCP 会话的生命周期:显示它们的持续时间、地址细节、吞吐量,以及在可能的情况下,相关的进程 ID 和名称。以下展示了来自 BCC 的 `tcplife(8)` 工具的输出,来自一个 48 核心的生产环境实例:


这个输出显示了一系列的连接,这些连接要么是短暂的(少于 20 毫秒),要么是长期的(超过三秒钟),这些信息在“MS”列中以毫秒为单位表示。这是一个监听 6001 端口的应用服务器池。截图中的大多数会话显示了与远程应用服务器 6001 端口的连接,只有一个连接到本地的 6001 端口。还观察到一个由 `sshd` 拥有的 SSH 会话,连接到本地端口 22,这是一个入站会话。
这个工具通过追踪 TCP 套接字状态变化事件来工作,当状态变化为 `TCP_CLOSE` 时,会打印总结细节。这些状态变化事件发生的频率远低于数据包,使得这种方法在开销上比逐包嗅探工具要低得多。这使得 `tcplife(8)` 可以在 Netflix 的生产服务器上持续运行,作为 TCP 流日志记录器。
最初的 `tcplife(8)` 通过 kprobes 追踪 `tcp_set_state()` 内核函数。自 Linux 4.16 版本以来,为此目的添加了一个新的 tracepoint:`sock:inet_sock_set_state`。`tcplife(8)` 工具会优先使用这个 tracepoint,如果不可用则回退到 kprobe。这些事件之间存在微妙的差别,可以通过以下单行命令查看。这条命令计算每个事件的 TCP 状态编号:

明白了吧?`tcp_set_state()` 的 kprobe 从未看到状态 3,即 `TCP_SYN_RECV`。这是因为 kprobe 直接暴露了内核的实现,而内核从未以 `TCP_SYN_RECV` 状态调用 `tcp_set_state()`:它不需要这么做。这是一个通常对最终用户隐藏的实现细节。不过,随着添加了一个 tracepoint 来暴露这些状态变化,发现省略这个状态转换会造成困惑,因此这个 tracepoint 被设计为显示所有状态转换。
### BCC
命令行使用:
```
tcplife [选项]
```
**选项包括:**
- **`-t`**:包含时间列(HH:MM:SS格式)
- **`-w`**:使用更宽的列(以更好地适应IPv6地址)
- **`-p PID`**:仅跟踪指定的进程ID(PID)
- **`-L PORT[,PORT[,...]]`**:仅跟踪这些本地端口的会话
- **`-D PORT[,PORT[,...]]`**:仅跟踪这些远程端口的会话
### `bpftrace`
以下是为本书开发的 `bpftrace` 版本的代码,概述了其核心功能。该版本使用了 `tcp_set_state()` 的 kprobe,以便在较旧的内核上运行,但不支持选项。

这个工具的逻辑相对复杂,我在 BCC 和 bpftrace 版本中都添加了块注释来解释其功能。其主要功能包括:
- **测量时间**:从套接字的第一次状态转换到 TCP_CLOSE 的时间。这被打印为持续时间。
- **获取吞吐量统计信息**:从内核中的 `struct tcp_sock` 中获取吞吐量统计数据。这避免了跟踪每个数据包并从其大小中计算吞吐量。这些吞吐量计数器是相对较新的,自2015年以来才添加[109]。
- **缓存进程上下文**:在 TCP_SYN_SENT 或 TCP_LAST_ACK 状态时缓存进程上下文,或者(如果未在这些状态下缓存)在 TCP_CLOSE 状态时缓存。这种方法效果相当好,但依赖于这些事件发生在进程上下文中,这是内核的实现细节。未来的内核可能会更改其逻辑,使这种方法的可靠性大大降低,到时候这个工具需要更新以从套接字事件中缓存任务上下文(参见早期的工具)。
BCC 版本的这个工具已被 Netflix 网络工程团队扩展,以记录来自 `sock` 和 `tcp_sock` 结构的其他有用字段。
这个 bpftrace 工具可以更新为使用 `sock:inet_sock_set_state` tracepoint,需要额外检查 `args->protocol == IPPROTO_TCP`,因为该 tracepoint 不仅仅在 TCP 时触发。使用这个 tracepoint 可以提高稳定性,但仍然存在不稳定的部分:例如,传输的字节数仍需从 `tcp_sock` 结构中获取。
10.3.14 tcptop
`tcptop(8)` 是一个 BCC 工具,用于显示使用 TCP 的前端进程。举个例子,在一个拥有36个 CPU 的生产环境中的 Hadoop 实例中,它可能会这样工作:

该输出显示在这个时间间隔内,某个连接接收了超过 16 兆字节的数据。默认情况下,屏幕每秒更新一次。
`tcptop` 通过跟踪 TCP 发送和接收代码路径,并在 BPF map 中高效地总结数据来实现其功能。即便如此,这些事件可能会很频繁,对于高网络吞吐量的系统,开销可能会变得可测量。
实际跟踪的函数是 `tcp_sendmsg()` 和 `tcp_cleanup_rbuf()`。我选择 `tcp_cleanup_rbuf()`,因为它提供了套接字结构和大小作为入口参数。从 `tcp_recvmsg()` 获取相同的细节需要两个 kprobes,从而增加了开销:一个 kprobe 用于入口的套接字结构,一个 kretprobe 用于返回的字节数。
请注意,`tcptop(8)` 当前并不跟踪通过 `sendfile(2)` 系统调用发送的 TCP 流量,因为该调用可能不会调用 `tcp_sendmsg()`。如果你的工作负载使用了 `sendfile(2)`,请检查是否有更新版本的 `tcptop(8)` 或对其进行扩展。
命令行用法:
```sh
tcptop [options] [interval [count]]
```
选项包括:
- **`-C`**: 不清除屏幕
- **`-p PID`**: 仅测量指定的进程
未来的版本应增加一个选项,用于截断显示的行数。
10.3.15 tcpsnoop
`tcpsnoop(8)` 是我曾经在 Solaris 上使用的一个受欢迎的 DTrace 工具。如果它存在于 Linux BPF 中,我会在本章的这一部分介绍它,但我选择不移植它;下文所示的是 Solaris 版本。我在这里分享它,因为它让我通过艰难的经验学到了一些重要的教训。
`tcpsnoop(8)` 会为每个数据包打印一行,包括地址、数据包大小、进程 ID 和用户 ID。例如:

当我在2004年编写这个工具时,网络事件分析主要依赖于数据包嗅探器:Solaris 的 `snoop(1M)` 和 Linux 的 `tcpdump(8)`。这些工具的一个盲点是它们不显示进程 ID。我想要一个能显示哪些进程在产生网络流量的工具,而这似乎是显而易见的解决方案:创建一个带有 PID 列的 `snoop(1M)` 版本。为了测试我的解决方案,我将其与 `snoop(1M)` 一起运行,以确保它们都能看到相同的数据包事件。
这证明是相当具有挑战性的:我需要在套接字级事件期间缓存 PID,并在 MTU 分片后从栈的另一端获取数据包大小。我需要跟踪数据传输代码、TCP 握手代码以及处理关闭端口和其他事件的数据包的其他代码。我成功了,但我的工具在内核中跟踪了十一处不同的点,并遍历了各种内核结构,这使得它非常脆弱,因为它依赖于许多不稳定的内核细节。工具本身超过了 500 行代码。
在六年的时间里,Solaris 内核更新了十多次,而 `tcpsnoop(8)` 在其中七次更新后停止工作。修复它变得非常麻烦:我可以为一个内核版本修复它,但然后必须测试所有先前版本,以查看修复是否引入了回归问题。这变得不切实际,我开始为特定的内核版本发布单独的 `tcpsnoop(8)` 版本。
这里有两个教训。首先:内核代码是会变化的,使用的 kprobes 和结构体越多,你的工具崩溃的可能性就越大。本书中的工具故意使用了尽可能少的 kprobes,这使得当它们崩溃时更容易维护。在可能的情况下,改用 tracepoints。
其次:工具的整个前提是错误的。如果我的目标是识别哪些进程在造成网络流量,我不需要逐个数据包地进行分析。我可以编写一个仅总结数据传输的工具,虽然它会遗漏其他数据包,包括 TCP 握手,但它足以解决大多数问题。例如,之前介绍的 `socketio(8)` 或 `tcptop(8)`,每个工具仅使用两个 kprobes,而 `tcplife(8)` 使用一个 tracepoint 加上一些结构体遍历。
10.3.16 tcpretrans
`tcpretrans(8)` 是一个 BCC 和 bpftrace 工具,用于跟踪 TCP 重传,显示 IP 地址和端口详情以及 TCP 状态。以下展示了在生产实例上运行的 BCC 版本 `tcpretrans(8)`:

该输出显示了重传率较低,每秒几次(TIME 列),这些重传大多发生在 ESTABLISHED 状态的会话中。在 ESTABLISHED 状态下的高重传率可能表明存在外部网络问题。而在 SYN_SENT 状态下的高重传率则可能表明服务器应用程序过载,无法及时处理其 SYN 后备队列。
该工具通过跟踪内核中的 TCP 重传事件来工作。由于这些事件应当发生得很少,因此开销应当是微不足道的。相比之下,传统上使用数据包嗅探器捕获所有数据包并后处理以查找重传的方式,这两个步骤都可能会消耗大量的 CPU 资源。数据包捕获只能看到在网络上传输的数据,而 `tcpretrans(8)` 直接从内核中打印 TCP 状态,并且如果需要的话,可以扩展以打印更多的内核状态。
在 Netflix,这个工具被用来帮助诊断一个由于网络流量超过外部网络限制而引发的生产问题,导致丢包和重传。通过观察不同生产实例中的重传情况,并能够立即查看源地址、目的地址和 TCP 状态的详细信息,而不需要处理逐包转储的开销,这非常有帮助。
Shopify 也使用了这个工具来调试生产网络问题,当时负载导致 `tcpdump(8)` 丢失了大量的数据包,使得其输出不可靠,并且开销过大。于是,`tcpretrans(8)` 和稍后提到的 `tcpdrop(8)` 被用来收集足够的信息,以指向一个外部问题:在这个案例中,是一个防火墙配置在负载下变得饱和,从而导致丢包。
BCC
命令行使用:
`tcpretrans [选项]`
选项包括:
- `-l`:包括尾部丢失探测尝试(添加一个 `kprobe` 以探测 `tcp_send_loss_probe()`)
- `-c`:按流统计重传
`-c` 选项改变了 `tcpretrans(8)` 的行为,使其打印出统计摘要,而不是每个事件的详细信息。
bpftrace
以下是 bpftrace 版本的代码,概述了其核心功能。该版本不支持选项。


这个版本跟踪 `tcp_retransmit_skb()` 内核函数。在 Linux 4.15 中,添加了 `tcp:tcp_retransmit_skb` 和 `tcp:tcp_retransmit_synack` 追踪点,这个工具可以更新以使用这些追踪点。
10.3.17 tcpsynbl
`tcpsynbl(8)32` 跟踪 TCP SYN 后备队列的限制和大小,显示每次检查后备队列时测量的大小直方图。例如,在一个 48 CPU 的生产边缘服务器上:

第一个直方图显示,限制为 128 的后备队列接收到了两个连接,此时后备队列长度为 0。第二个直方图显示,限制为 500 的后备队列接收到了超过两千个连接,后备队列长度通常为零,但有时达到了四到八的范围。如果后备队列超过限制,此工具会打印一行信息,指出一个 SYN 数据包被丢弃,这会导致客户端主机出现延迟,因为它必须重新发送。
该后备队列大小是可调的,是 `listen(2)` 系统调用的一个参数:
```c
int listen(int sockfd, int backlog);
```
它还受到 `/proc/sys/net/core/somaxconn` 中设置的系统限制的影响。
该工具通过跟踪新连接事件,并检查后备队列的限制和大小来工作。由于这些事件通常相对于其他事件发生得不频繁,因此开销应当是微不足道的。
`tcpsynbl(8)` 的源代码是:


如果后备队列超过了限制,`time()` 内置函数将被用来打印一行输出,其中包含时间戳以及一个表明 SYN 数据包被丢弃的消息。在之前的生产输出中没有看到这种情况,因为后备队列未超过限制。
10.3.18 tcpwin
`tcpwin(8)34` 跟踪 TCP 发送拥塞窗口大小和其他内核参数,以便研究拥塞控制的性能。该工具生成逗号分隔的值输出,便于导入到绘图软件中。例如,运行 `tcpwin.bt` 并将输出保存到文本文件中:

输出的第二行是标题行,随后是事件详细信息。第二字段是 `sock` 结构的地址,可用于唯一标识连接。可以使用 `awk(1)` 工具来统计这些 `sock` 地址的出现频率:

这表明,在跟踪过程中,具有最多 TCP 接收事件的套接字地址是 `0xffff92150a03c800`。可以使用 `awk` 工具将仅针对该地址的事件以及标题行提取到一个新文件 `out.csv` 中:
```bash
awk -F, '$2 == "0xffff92150a03c800" || NR == 2' out.tcpwin01.txt > out.csv
```
这个 CSV 文件被导入到 R 统计软件中进行绘图(见图 10-5)。

该系统使用了 Cubic TCP 拥塞控制算法,显示出发送拥塞窗口大小的增加,然后在遇到拥塞(数据包丢失)时出现急剧下降。这种现象会发生多次,形成一个锯齿状的模式,直到找到一个最佳的窗口大小。

这可以进一步扩展。第一个字段是事件类型,但此工具仅使用“rcv”。你可以添加更多的 `kprobes` 或 `tracepoints`,每个都有其自己的事件字符串来进行识别。例如,可以在建立套接字时添加事件类型“new”,并包含字段来识别 IP 地址和 TCP 端口。
用于这种类型的拥塞控制分析的内核模块是 `tcp_probe`,在 Linux 4.16 版本中已成为一个 tracepoint:`tcp:tcp_probe`。`tcpwin(8)` 工具可以被重写为基于这个 tracepoint,尽管从 tracepoint 参数中并不能看到所有套接字的详细信息。
10.3.19 tcpnagle
`tcpnagle(8)` 追踪 TCP Nagle 算法在 TCP 发送路径上的使用,并以直方图的形式测量传输延迟的持续时间。这些延迟是由 Nagle 算法及其他事件引起的。例如,在一个生产环境中的边缘服务器上:


在追踪过程中,发现 Nagle 算法经常被禁用(可能是因为应用程序调用了 `setsockopt(2)` 设置了 `TCP_NODELAY`)或设置为推送(可能是因为应用程序使用了 `TCP_CORK`)。只有五次传输包被延迟,延迟时间最多为四到八微秒桶。
该工具通过追踪 TCP 发送函数的入口和出口来工作。由于这是一个频繁调用的函数,因此在高网络吞吐量系统上可能会显著增加开销。
`tcpnagle(8)` 的源代码是:

在追踪过程中,发现 Nagle 算法经常被禁用(可能是因为应用程序调用了 `setsockopt(2)` 设置了 `TCP_NODELAY`)或设置为推送(可能是因为应用程序使用了 `TCP_CORK`)。只有五次传输包被延迟,延迟时间最多为四到八微秒桶。
该工具通过追踪 TCP 发送函数的入口和出口来工作。由于这是一个频繁调用的函数,因此在高网络吞吐量系统上可能会显著增加开销。
`tcpnagle(8)` 的源代码是:


在进入 `tcp_write_xmit()` 函数时,`nonagle` 标志(`arg2`)通过 `@flags` 查找映射转换为可读字符串。同时,还保存了一个 `sock` 结构体指针,因为它在 `kretprobe` 中用于与连接一起保存时间戳,以测量传输延迟的持续时间。这个持续时间是从 `tcp_write_xmit()` 第一次返回非零值(这表示由于某种原因它没有发送数据包,原因可能包括 Nagle 算法)开始计算的,直到 `tcp_write_xmit()` 下一次成功发送数据包为止。
10.3.20 udpconnect
`udpconnect(8)` 追踪从本地主机发起的使用 `connect(2)` 的新 UDP 连接(不追踪未连接的 UDP)。例如:

`udpconnect(8)` 通过追踪内核中的 UDP 连接函数来工作。这个工具可以显示从本地主机发起的使用 `connect(2)` 的新 UDP 连接。由于 UDP 连接函数的调用频率较低,因此该工具的开销通常是微不足道的。例如,它可以显示两个连接,均连接到远程端口 53,其中一个来自 DNS 解析器,另一个来自 `Chrome_IOThread`。

`ip4_datagram_connect()` 和 `ip6_datagram_connect()` 函数是 `udp_prot` 和 `udpv6_prot` 结构体的连接成员,它们定义了处理 UDP 协议的函数。类似于之前的工具,它们的详细信息也会被打印。你可以查看 `socketio(8)`,它展示了按进程划分的 UDP 发送和接收情况。要专门针对 UDP,可以通过追踪 `udp_sendmsg()` 和 `udp_recvmsg()` 来编写特定的工具,这样可以将开销局限于 UDP 函数,而不是所有的套接字函数。
10.3.21 gethostlatency
`gethostlatency(8)` 是一个 BCC 和 bpftrace 工具,用于追踪主机解析调用(DNS),例如通过解析器库调用 `getaddrinfo(3)`, `gethostbyname(3)` 等。这个工具可以帮助监控和分析 DNS 查询的延迟。举个例子,这个工具可以展示查询主机名的时间和相关信息,从而帮助你了解 DNS 请求的性能和可能存在的延迟问题。

这些输出显示了系统范围内各种解析的延迟。第一次是 `ping(1)` 命令解析 `www.netflix.com`,耗时 9.65 毫秒。随后的查询耗时 2.64 毫秒(可能得益于缓存)。输出中还可以看到其他线程和查询,其中最慢的是解析 `www.kubernetes.io`,耗时 279 毫秒。
该工具通过对库函数进行用户级动态插桩来工作。在 `uprobe` 阶段,记录主机名和时间戳;在 `uretprobe` 阶段,计算并打印持续时间及保存的名称。由于这些事件通常发生频率较低,因此该工具的开销应当是微不足道的。
DNS 是生产环境中常见的延迟源。在 Shopify,bpftrace 版本的该工具在 Kubernetes 集群上运行,用于表征生产环境中的 DNS 延迟问题。数据未指向某个特定服务器或查询目标的问题,而是在大量查询同时进行时的延迟问题。进一步调试发现问题是由于云服务对每台主机开放的 UDP 会话数量设置了限制。增加这个限制后,问题得到了解决。
BCC
命令行用法:
`gethostlatency [options]`
目前唯一支持的选项是 `-p PID`,用于仅跟踪一个进程 ID。
bpftrace
以下是 bpftrace 版本的代码,不支持选项:

不同的解析器调用通过 libc 的 `/lib/x86_64-linux-gnu/libc.so.6` 路径被跟踪。如果使用了不同的解析器库,或者函数由应用程序实现,或静态链接(静态构建),那么这个工具需要进行修改以跟踪这些其他位置。
10.3.22 ipecn
`ipecn(8)39` 跟踪 IPv4 入站显式拥塞通知(ECN)事件,是一个概念验证工具。例如:


这显示了来自 100.65.76.247 的拥塞遇到(CE)事件。CE 可以由网络中的交换机和路由器设置,以通知端点发生了拥塞。内核也可以根据 qdisc 策略设置 CE,尽管这通常用于测试和模拟(使用 netem qdisc)。DataCenter TCP (DCTCP) 拥塞控制算法也利用了 ECN [Alizadeh 10] [113]。
`ipecn(8)` 通过跟踪内核的 `ip_rcv()` 函数并从 IP 头部读取拥塞遇到状态来工作。由于这为每个接收的包增加了开销,这种方法并不理想,更多地可以被视为概念验证。更好的做法是只跟踪处理 CE 事件的内核函数,因为这些函数触发的频率较低。然而,它们是内联的,无法直接跟踪(在我的内核中)。最理想的是为 ECN 拥塞遇到事件设置一个 tracepoint。
`ipecn(8)` 的源代码是:

这也是一个从 `struct sk_buff` 解析 IPv4 头部的例子。它使用与内核的 `skb_network_header()` 函数类似的逻辑,并且需要根据该函数的任何更改进行更新(这也是为何更稳定的 tracepoints 更受欢迎的原因)。这个工具还可以扩展以跟踪出站路径和 IPv6(参见第 10.5 节)。
10.3.23 superping
`superping(8)40` 测量了从内核网络栈到 ICMP 回显请求的响应延迟,用以验证 `ping(8)` 报告的往返时间。旧版本的 `ping(8)` 从用户空间测量往返时间,这可能包括繁忙系统上的 CPU 调度延迟,从而夸大了测得的时间。这个旧方法也被 `ping(8)` 用于没有套接字时间戳支持的内核(SIOCGSTAMP 或 SO_TIMESTAMP)。由于我有新版的 `ping(8)` 和更新的内核,为了演示旧行为,我用 `-U` 选项运行它,这个选项测量原始的用户到用户延迟。例如,在一个终端会话中:

输出可以进行比较:它显示 `ping(8)` 报告的时间可能会夸大超过 0.10 毫秒,对于当前的系统和工作负载而言。使用 `-U` 选项时,`ping(8)` 使用套接字时间戳,时间差异通常在 0.01 毫秒以内。这是通过对 ICMP 数据包的发送和接收进行仪器化实现的,为每个 ICMP 回显请求在 BPF 映射中保存时间戳,并比较 ICMP 头部细节以匹配回显数据包。由于只对原始 IP 数据包进行仪器化,而非 TCP 数据包,因此开销应当是微不足道的。`superping(8)` 的源代码是:

IPv4 和 IPv6 由不同的内核函数处理,并且分别进行追踪。此代码是数据包头分析的另一个示例:IPv4、IPv6、ICMP 和 ICMPv6 数据包头由 BPF 读取。通过 `struct sk_buff` 查找这些头部结构的方法依赖于内核源代码及其函数 `skb_network_header()` 和 `skb_transport_header()`。与 kprobes 一样,这是一种不稳定的接口,网络栈处理头部的方式的更改将需要更新此工具以保持匹配。一个小细节是:ICMP 标识符和序列号在从网络字节序转换为主机字节序后打印出来(见 `$idhost =` 和 `$seqhost =`)。对于保存时间戳的 `@start` 映射,我使用了网络字节序;这在发送 kprobes 时节省了一些指令。
10.3.24 qdisc-fq
`qdisc-fq(8)41` 显示了在公平队列 (FQ) 队列调度器上的时间。例如,从一个繁忙的生产边缘服务器:


这表明,数据包通常在此队列上花费的时间少于四微秒,只有很小的比例会达到两到四毫秒的范围。如果队列延迟存在问题,它会在直方图中显示为更高的延迟。
这个工具通过跟踪该队列调度器的入队和出队函数来工作。对于高网络 I/O 系统,这些操作可能很频繁,因此开销可能变得可测量。
`qdisc-fq(8)` 的源代码是:

`fq_enqueue()` 的参数和 `fq_dequeue()` 的返回值是 `struct sk_buff` 地址,这个地址用作存储时间戳的唯一键。请注意,这个工具仅在 FQ qdisc 调度器加载时有效。如果未加载,将会出现错误。


10.3.25 qdisc-cbq, qdisc-cbs, qdisc-codel, qdisc-fq_codel, qdisc-red, and qdisc-tbf
有许多其他的 qdisc 调度器,之前的 `qdisc-fq(8)` 工具通常可以调整以追踪每一种。例如,这里是一个基于类队列 (CBQ) 的版本:

被追踪的入队和出队函数来自 `struct Qdisc_ops`,它定义了这些函数的参数和返回值(位于 `include/net/sch_generic.h`)。


这就是为什么 `skb_buff` 地址是入队函数的第一个参数,也是出队函数的返回值。其他调度器也声明了 `Qdisc_ops`。对于 CBQ qdisc(在 `net/sched/sch_cbq.c` 中):

10.3.26 netsize
`netsize(8)` 显示了从网络设备层接收和发送的数据包的大小,包括软件分段卸载 (GSO 和 GRO) 之前和之后的数据包大小。这些输出可以用于调查数据包在发送之前如何被分段。例如,从一个繁忙的生产服务器:


输出显示了网络接口卡(@nic_recv_bytes,@nic_send_bytes)和内核网络栈(@recv_bytes,@send_bytes)的数据包大小。这表明服务器接收的数据包通常较小,常小于64字节,而发送的数据包则主要在8到64千字节范围内(在网络接口卡分段后,变为一到两千字节范围)。这些很可能是1500 MTU 的发送。
该接口不支持 TCP 分段卸载(TSO),因此使用了 GSO 在发送到网络接口卡之前进行分段。如果支持并启用了 TSO,@nic_send_bytes 直方图也会显示较大的数据包大小,因为分段会在网络接口卡硬件中稍后发生。
切换到 jumbo 帧将增加数据包大小和系统吞吐量,但在数据中心启用 jumbo 帧可能会遇到问题,包括占用更多交换机内存和加剧 TCP incast 问题。
此输出可以与之前的 socksize(8) 输出进行比较。
该工具通过追踪网络设备的跟踪点并在 BPF 映射中汇总长度参数来工作。对于高网络 I/O 系统,这种开销可能会变得可测量。
还有一个 Linux 工具叫做 iptraf-ng(8),它也显示网络数据包大小的直方图。然而,iptraf-ng(8) 通过数据包嗅探和在用户空间处理数据包来工作。这比在内核空间汇总的 netsize(8) 花费更多的 CPU 开销。例如,检查在 localhost 上进行 iperf(1) 基准测试时每个工具的 CPU 使用情况:


iptraf-ng(8) 使用超过 90% 的一个 CPU 来汇总数据包大小直方图,而 netsize(8) 的 CPU 消耗为 0%。这突显了这两种方法之间的主要区别,尽管这里没有显示内核处理的额外开销。
netsize(8) 的源码是:

10.3.27 nettxlat
`nettxlat(8)` 显示了网络设备的传输延迟:从将数据包推送到驱动程序层,排队到 TX 环上,等待硬件发送,直到硬件向内核发出信号,表示数据包传输已完成(通常通过 NAPI),并且数据包被释放的时间。例如,从一个繁忙的生产边缘服务器上:


`nettxlat(8)` 通过测量从数据包通过 `net:net_dev_start_xmit` 追踪点发出到设备队列的时间,到数据包通过 `skb:consume_skb` 追踪点释放的时间来计算延迟,这发生在设备完成发送数据包之后。存在一些边缘情况,其中数据包可能不会经过常规的 `skb:consume_skb` 路径,这会导致时间戳被重用,从而在直方图中出现延迟异常值。为了避免这种情况,时间戳在 `net:net_dev_queue` 上被删除,以帮助消除它们的重用。
为了按设备名称进行详细拆分,以下是将 `nettxlat(8)` 修改为 `nettxlat-dev(8)` 的示例行:


在只有 `eth0` 的服务器上,如果有其他接口在使用中,`nettxlat-dev(8)` 会为每个接口生成一个单独的直方图。需要注意的是,这种修改降低了工具的稳定性,因为它现在引用了不稳定的结构内部,而不仅仅是追踪点和追踪点参数。
10.3.28 skbdrop
`skbdrop(8)` 追踪异常的 `skb` 丢弃事件,并在追踪过程中显示这些事件的内核栈跟踪以及网络计数器。例如,在生产服务器上:


这开始时显示了在追踪过程中网络计数器的增量,然后显示了 `skb` 丢弃的栈跟踪和计数以便进行比较。上面的输出显示,最常见的丢弃路径是通过 `tcp_v4_rcv()`,共丢弃了 276 次。网络计数器显示的计数也类似:`TcpPassiveOpens` 和 `TcpExtTCPDeferAcceptDrop` 中的计数为 278。(稍高的数字可以解释为:获取这些计数器需要额外的时间。)这表明这些事件可能是相关的。
该工具通过对 `skb:kfree_skb` 追踪点进行插桩来实现,并在追踪期间自动运行 `nstat(8)` 工具以计算网络统计信息。为了使这个工具正常工作,必须安装 `nstat(8)`,它包含在 `iproute2` 包中。
`skb:kfree_skb` 追踪点是 `skb:consume_skb` 的对等体。`consume_skb` 追踪点在正常的 `skb` 消耗代码路径中触发,而 `kfree_skb` 则在其他可能值得调查的不寻常事件中触发。

这开始时在 BEGIN 动作中将 `nstat(8)` 计数器设置为零,然后在 END 动作中再次使用 `nstat(8)` 打印区间计数,并将 `nstat(8)` 重置回其原始状态(-rs)。在追踪期间,这将干扰其他使用 `nstat(8)` 的用户。请注意,由于使用了 `system()`,执行时需要 `bpftrace --unsafe` 选项。
10.3.29 skblife
`skblife(8)` 用于测量 `sk_buff`(简称 skb)的生命周期。`sk_buff` 是用于在内核中传递数据包的对象。测量生命周期可以帮助发现网络堆栈中的延迟问题,包括数据包在等待锁时的延迟。例如,在繁忙的生产服务器上:


这表明 `sk_buff` 的生命周期通常在 16 到 64 微秒之间,不过也有一些异常值达到了 128 到 256 毫秒的范围。这些异常值可以通过其他工具进一步调查,包括之前提到的队列延迟工具,以确定延迟是否来自这些位置。
该工具通过跟踪内核 slab 缓存的分配情况来工作,以确定 `sk_buff` 何时被分配和释放。这种分配可能非常频繁,因此该工具可能会在非常繁忙的系统上造成明显或显著的开销。它适用于短期分析,而不是长期监控。
`skblife(8)` 的源代码如下:


`kmem_cache_alloc()` 函数被加了仪器,缓存参数会被匹配以检查它是否是 `sk_buff` 缓存。如果是,那么在 `kretprobe` 中会为 `sk_buff` 地址关联一个时间戳,这个时间戳随后在 `kmem_cache_free()` 时被检索。
这种方法有一些注意事项:`sk_buff` 可能会在 GSO(大分段卸载)过程中被分段为其他 `sk_buff`,或者在 GRO(大接收卸载)过程中与其他 `sk_buff` 连接。TCP 也可能会合并 `sk_buff`(`tcp_try_coalesce()`)。这意味着,尽管可以测量 `sk_buff` 的生命周期,但完整数据包的生命周期可能会被低估。可以改进该工具以考虑这些代码路径:在创建新的 `sk_buff` 时,将原始出生时间戳复制到新的 `sk_buff`。
由于这会给所有 `kmem` 缓存分配和释放调用(不仅仅是 `sk_buff`)增加 kprobe 开销,因此开销可能会变得显著。未来可能会有减少这种开销的方法。内核已经有 `skb:consume_skb` 和 `skb:free_skb` 的 tracepoints。如果添加一个 `alloc skb` 的 tracepoint,可以仅将开销减少到 `sk_buff` 的分配上。
10.3.30 ieee80211scan

这表明扫描很可能是由 `wpa_supplicant` 进程启动的,它会逐步扫描不同的频道和频率。此次扫描耗时 3205 毫秒。这些信息对调试 WiFi 问题非常有用。
该工具通过对 IEEE 802.11 扫描例程进行仪器化来工作。由于这些例程通常不频繁调用,因此开销应该是微不足道的。
`ieee80211scan(8)` 的源代码如下:

可以添加更多信息来显示在扫描过程中使用的不同标志和设置。请注意,该工具当前假设一次只有一个扫描处于活动状态,并且具有一个全局的 @start 时间戳。如果可能会有多个扫描同时进行,则需要一个关键字来将时间戳与每个扫描关联起来。
10.3.31 Other Tools
其他值得提及的 BPF 工具包括:
- **solisten(8)**: 一个 BCC 工具,用于打印带有详细信息的套接字监听调用。
- **tcpstates(8)**: 一个 BCC 工具,每当 TCP 会话状态发生变化时,会打印一行输出,包括 IP 地址、端口详细信息以及每个状态的持续时间。
- **tcpdrop(8)**: 一个 BCC 和 bpftrace 工具,用于打印被内核 tcp_drop() 函数丢弃的包的 IP 地址和 TCP 状态详细信息,以及内核栈跟踪。
- **sofdsnoop(8)**: 一个 BCC 工具,用于跟踪通过 Unix 套接字传递的文件描述符。
- **profile(8)**: 在第 6 章中介绍,通过采样内核栈跟踪,可以量化在网络代码路径上花费的时间。
- **hardirqs(8)** 和 **softirqs(8)**: 在第 6 章中介绍,可用于测量在网络硬中断和软中断上花费的时间。
- **filetype(8)**: 来自第 8 章,跟踪 vfs_read() 和 vfs_write(),通过 inode 识别哪些是套接字的读写操作。
`tcpstates(8)` 的示例输出:

10.4 BPF One-Liners

这些部分展示了 BCC 和 bpftrace 的一行命令。尽可能地,相同的一行命令会同时用 BCC 和 bpftrace 实现。
10.4.1 BCC
统计失败的 socket connect(2) 错误码:
`argdist -C 't:syscalls:sys_exit_connect():int:args->ret:args->ret<0'`
按用户栈跟踪统计 socket connect(2):
`stackcount -U t:syscalls:sys_enter_connect`
TCP 发送字节直方图:
`argdist -H 'p::tcp_sendmsg(void *sk, void *msg, int size):int:size'`
TCP 接收字节直方图:
`argdist -H 'r::tcp_recvmsg():int:$retval:$retval>0'`
统计所有 TCP 函数(对 TCP 开销较大):
`funccount 'tcp_*'`
UDP 发送字节直方图:
`argdist -H 'p::udp_sendmsg(void *sk, void *msg, int size):int:size'`
UDP 接收字节直方图:
`argdist -H 'r::udp_recvmsg():int:$retval:$retval>0'`
统计所有 UDP 函数(对 UDP 开销较大):
`funccount 'udp_*'`
统计传输栈跟踪:
`stackcount t:net:net_dev_xmit`
统计 ieee80211 层函数(对数据包开销较大):
`funccount 'ieee80211_*'`
统计所有 ixgbevf 设备驱动函数(对 ixgbevf 开销较大):
`funccount 'ixgbevf_*'`
10.4.2 bpftrace
1. **按 PID 和进程名称统计 socket accept(2) 调用:**
   ```bash
   bpftrace -e 't:syscalls:sys_enter_accept* { @[pid, comm] = count(); }'
   ```
2. **按 PID 和进程名称统计 socket connect(2) 调用:**
   ```bash
   bpftrace -e 't:syscalls:sys_enter_connect { @[pid, comm] = count(); }'
   ```
3. **按进程名称和错误代码统计失败的 socket connect(2) 调用:**
   ```bash
   bpftrace -e 't:syscalls:sys_exit_connect /args->ret < 0/ { @[comm, - args->ret] = count(); }'
   ```
4. **按用户栈跟踪统计 socket connect(2) 调用:**
   ```bash
   bpftrace -e 't:syscalls:sys_enter_connect { @[ustack] = count(); }'
   ``
5. **按方向、在 CPU 上的 PID 和进程名称统计 socket send/recv:**
   ```bash
   bpftrace -e 'k:sock_sendmsg,k:sock_recvmsg { @[func, pid, comm] = count(); }'
   ```
6. **按在 CPU 上的 PID 和进程名称统计 socket send/recv 字节数:**
   ```bash
   bpftrace -e 'kr:sock_sendmsg,kr:sock_recvmsg /(int32)retval > 0/ { @[pid, comm] = sum((int32)retval); }'
   ```
7. **按在 CPU 上的 PID 和进程名称统计 TCP 连接:**
   ```bash
   bpftrace -e 'k:tcp_v*_connect { @[pid, comm] = count(); }'
   ```
8. **按在 CPU 上的 PID 和进程名称统计 TCP 接受:**
   ```bash
   bpftrace -e 'k:inet_csk_accept { @[pid, comm] = count(); }'
   ```
9. **统计 TCP 发送/接收:**
   ```bash
   bpftrace -e 'k:tcp_sendmsg,k:tcp*recvmsg { @[func] = count(); }'
   ```
10. **按在 CPU 上的 PID 和进程名称统计 TCP 发送/接收:**
    ```bash
    bpftrace -e 'k:tcp_sendmsg,k:tcp_recvmsg { @[func, pid, comm] = count(); }'
    ```
11. **以直方图形式显示 TCP 发送字节数:**
    ```bash
    bpftrace -e 'k:tcp_sendmsg { @send_bytes = hist(arg2); }'
    ```
12. **以直方图形式显示 TCP 接收字节数:**
    ```bash
    bpftrace -e 'kr:tcp_recvmsg /retval >= 0/ { @recv_bytes = hist(retval); }'
    ```
13. **按类型和远程主机(假设是 IPv4)统计 TCP 重传:**
    ```bash
    bpftrace -e 't:tcp:tcp_retransmit_* { @[probe, ntop(2, args->saddr)] = count(); }'
    ```
14. **统计所有 TCP 函数(对 TCP 增加高开销):**
    ```bash
    bpftrace -e 'k:tcp_* { @[func] = count(); }'
    ```
15. **按在 CPU 上的 PID 和进程名称统计 UDP 发送/接收:**
    ```bash
    bpftrace -e 'k:udp*_sendmsg,k:udp*_recvmsg { @[func, pid, comm] = count(); }'
    ```
16. **以直方图形式显示 UDP 发送字节数:**
    ```bash
    bpftrace -e 'k:udp_sendmsg { @send_bytes = hist(arg2); }'
    ```
17. **以直方图形式显示 UDP 接收字节数:**
    ```bash
    bpftrace -e 'kr:udp_recvmsg /retval >= 0/ { @recv_bytes = hist(retval); }'
    ```
18. **统计所有 UDP 函数(对 UDP 增加高开销):**
    ```bash
    bpftrace -e 'k:udp_* { @[func] = count(); }'
    ```
19. **统计传输内核栈跟踪:**
    ```bash
    bpftrace -e 't:net:net_dev_xmit { @[kstack] = count(); }'
    ```
20. **显示每个设备的接收 CPU 直方图:**
    ```bash
    bpftrace -e 't:net:netif_receive_skb { @[str(args->name)] = lhist(cpu, 0, 128, 1); }'
    ```
21. **统计 ieee80211 层函数(对数据包增加高开销):**
    ```bash
    bpftrace -e 'k:ieee80211_* { @[func] = count()'
    ```
22. **统计所有 ixgbevf 设备驱动函数(对 ixgbevf 增加高开销):**
    ```bash
    bpftrace -e 'k:ixgbevf_* { @[func] = count(); }'
    ```
23. **统计所有 iwl 设备驱动追踪点(对 iwl 增加高开销):**
    ```bash
    bpftrace -e 't:iwlwifi:*,t:iwlwifi_io:* { @[probe] = count(); }'
    ```
10.4.3 BPF One-Liners Examples

这段一行命令生成了很多页的输出;这里只包括了最后两个堆栈跟踪。最后一个显示了一个通过 VFS、套接字、TCP、IP、网络设备的 write(2) 系统调用,然后开始传输到驱动程序。这展示了从应用程序到设备驱动程序的堆栈。第一个堆栈跟踪更有趣。它从空闲线程接收中断开始,执行 net_rx_action() softirq,ena 驱动程序 ena_io_poll(),NAPI(新 API)网络接口接收路径,然后是 IP、tcp_rcv_established(),接着是 __tcp_push_pending_frames()。实际的代码路径是 tcp_rcv_established() -> tcp_data_snd_check() -> tcp_push_pending_frames() -> tcp_push_pending_frames()。然而,中间两个函数很小并被编译器内联,导致它们在堆栈跟踪中被省略了。发生的情况是 TCP 在接收代码路径中检查待发送的数据。

10.5 Optional Exercises

如果未指定,可以使用 bpftrace 或 BCC 完成以下任务:
1. 编写一个 `solife(8)` 工具,用于打印每个会话的持续时间,从 `connect(2)` 和 `accept(2)`(及其变体)到 `close(2)`,以获取该套接字文件描述符的生命周期。它可以类似于 `tcplife(8)`,但不必具有所有相同的字段(有些字段比其他字段更难获取)。
2. 编写一个 `tcpbind(8)` 工具,用于对 TCP 绑定事件进行逐事件跟踪。
3. 扩展 `tcpwin.bt`,添加一个“retrans”事件类型,包含套接字地址和时间字段。
4. 扩展 `tcpwin.bt`,添加一个“new”事件类型,包含套接字地址、时间、IP 地址和 TCP 端口字段。当 TCP 会话达到已建立状态时应打印此事件。
5. 修改 `tcplife(8)`,以 DOT 格式输出连接详细信息,然后使用图形软件(例如 GraphViz)进行绘制。
6. 开发 `udplife(8)`,以类似于 `tcplife(8)` 的方式显示 UDP 连接的生命周期。
7. 扩展 `ipecn.bt`,以检测出站 CE 事件以及 IPv6。CE 事件可以通过 netem qdisc 层引入。以下示例命令将 eth0 上的当前 qdisc 替换为一个导致 1% ECN CE 事件的 qdisc:
   ```
   tc qdisc replace dev eth0 root netem loss 1% ecn
   ```
   如果在开发过程中使用此 qdisc,请注意它在 IP 之下的更低级别插入 CE 事件。如果你跟踪了,比如说 `ip_output()`,可能不会看到 CE 事件,因为它们是在稍后添加的。
8. (高级)开发一个工具,按主机显示 TCP 往返时间(RTT)。此工具可以显示每个主机的平均 RTT,或按主机的 RTT 直方图。工具可以通过序列号计时发送的数据包,并关联 ACK 的时间戳,或使用 `struct tcp_sock->rtt_min`,或其他方法。如果使用第一个方法,可以通过以下 `bpftrace` 代码读取 TCP 头部(给定 `$skb` 中的 `struct sk_buff *`):
   ```
   $tcph = (struct tcphdr *)($skb->head + $skb->transport_header);
   ```
9. (高级,未解决)开发一个工具,显示 ARP 或 IPv6 邻居发现延迟,可以是逐事件显示或以直方图形式显示。
10. (高级,未解决)开发一个工具,显示完整的 `sk_buff` 生命周期,并处理(如有必要)GRO、GSO、`tcp_try_coalesce()`、`skb_split()`、`skb_append()`、`skb_insert()` 等,以及在生命周期内修改 `sk_buff` 的其他事件。这个工具将比 `skblife(8)` 更复杂。
11. (高级,未解决)开发一个工具,分解 `sk_buff` 生命周期(来自第 10 项)为组件或等待状态。
12. (高级,未解决)开发一个工具,显示由 TCP pacing 引起的延迟。
13. (高级,未解决)开发一个工具,显示字节队列限制引起的延迟。


10.6 Summary

本章总结了 Linux 网络栈的特点,并通过传统工具如 netstat(8)、sar(1)、ss(8) 和 tcpdump(8) 进行了分析。随后使用 BPF 工具提供了对套接字层、TCP、UDP、ICMP、qdisc、网络驱动队列以及网络设备驱动的扩展可观察性。这些可观察性包括高效显示新连接及其生命周期、连接和首字节延迟、SYN 后备队列大小、TCP 重传以及各种其他事件。

11 Security

 本章总结了 BPF 的安全性及其在安全分析中的应用,提供了各种有助于安全和性能可观察性的工具。你可以使用这些工具来检测入侵、创建正常可执行文件和特权使用的白名单,以及实施策略。学习目标包括:
- 理解 BPF 安全性的使用场景
- 显示新进程执行,以检测可能的恶意软件
- 显示 TCP 连接和重置,以检测可能的可疑活动
- 研究 Linux 权限使用,以帮助创建白名单
- 理解其他取证来源,如 shell 和控制台日志
本章从安全任务的背景开始,然后总结了 BPF 功能、配置 BPF 安全性、策略以及 BPF 工具。


11.1 Background

“安全”一词涵盖了广泛的任务,包括:
- 安全分析
- 实时取证的嗅探活动
- 特权调试
- 可执行文件使用的白名单
- 恶意软件的逆向工程
- 监控
- 自定义审计
- 基于主机的入侵检测系统(HIDS)
- 基于容器的入侵检测系统(CIDS)
- 政策执行
- 网络防火墙
- 检测恶意软件、动态阻止数据包和采取其他预防措施
安全工程与性能工程类似,因为它也涉及对各种软件的分析。
11.1.1 BPF Capabilities
BPF 可以帮助处理这些安全任务,包括分析、监控和政策执行。对于安全分析,BPF 可以回答以下类型的问题:
- 正在执行哪些进程?
- 正在建立哪些网络连接?由哪些进程建立?
- 哪些进程正在请求哪些系统权限?
- 系统上发生了哪些权限拒绝错误?
- 这个内核/用户函数是否以这些参数执行(用于检查活动漏洞)?
另一种总结 BPF 跟踪分析和监控能力的方法是展示可以被跟踪的目标,如图 11-1 所示。

虽然这个图展示了许多特定的目标,但使用 uprobes 和 kprobes,也可以对任何用户级别或内核函数进行插桩,这在零日漏洞检测中非常有用。
**零日漏洞检测**
有时,急需检测新软件漏洞是否被利用;理想情况下,能够在漏洞公开的第一天(零日)检测到它。bpftrace 特别适合这个角色,因为它易于编程的语言允许在几分钟内创建自定义工具,并且可以访问不仅仅是 tracepoints 和 USDT 事件,还包括 kprobes 和 uprobes 及其参数。
例如,在撰写时,发现了一个 Docker 漏洞,该漏洞利用了 symlink-race 攻击。这涉及在循环中调用带有 RENAME_EXCHANGE 标志的 renameat2(2) 系统调用,同时使用 docker cp。
可以通过多种方式检测到这一点。由于带有 RENAME_EXCHANGE 标志的 renameat2(2) 系统调用在我的生产系统中并不常见(我没有捕捉到其自然使用的案例),一种检测此漏洞是否被利用的方法是跟踪该系统调用和标志组合。例如,可以在主机上运行以下命令以跟踪所有容器:

这行代码通常不会输出任何结果,但在这种情况下,由于漏洞证明概念代码在测试中运行,输出却发生了洪水般的增加。输出内容包括时间戳、进程名称和 renameat2(2) 的文件名参数。另一种方法是跟踪 docker cp 进程在操作符号链接时的系统调用或内核函数调用。
我可以想象未来漏洞披露时会附带一个 bpftrace 一行代码或工具来检测其在实际环境中的使用。可以建立入侵检测系统在公司的基础设施上执行这些工具,这与一些网络入侵检测系统(如 Snort)通过共享规则来检测新蠕虫的方式类似。
**安全监控**
BPF 跟踪程序可以用于安全监控和入侵检测。当前的监控解决方案通常使用可加载的内核模块来提供内核和数据包事件的可见性。然而,这些模块引入了自身的内核漏洞和风险。BPF 程序经过验证器处理,并利用现有的内核技术,使其更安全、更可靠。
BPF跟踪的效率也得到了优化。在2016年的一项内部研究中,我比较了auditd日志记录和类似BPF程序的开销;BPF引入的开销比auditd少了六倍[117]。BPF监控在极端负载下的表现是一个重要的行为。BPF输出缓冲区和映射有可能超出限制,导致事件未被记录。这可能被攻击者利用,通过淹没系统事件来试图逃避正确的日志记录或策略执行。BPF能够意识到这些限制何时被超越,并可以将信息报告给用户空间以采取适当的行动。任何使用BPF跟踪构建的安全解决方案都需要记录这些溢出或丢失的事件,以满足不可否认性的要求。
另一种方法是添加每个CPU的映射来计数重要事件。与perf输出缓冲区或涉及键的映射不同,一旦BPF创建了固定计数器的每CPU映射,就没有丢失事件的风险。这可以与perf事件输出结合使用以提供更多细节:虽然可能会丢失更多细节,但事件计数不会丢失。
**策略执行**
许多策略执行技术已经使用了BPF。虽然这些主题超出了本书的范围,但它们是BPF的重要发展,值得总结。它们包括:
- **seccomp**:安全计算(seccomp)功能可以执行BPF程序(目前为经典BPF),以做出关于允许系统调用的策略决策[118]。seccomp的可编程操作包括杀死调用进程(SECCOMP_RET_KILL_PROCESS)和返回错误(SECCOMP_RET_ERRNO)。复杂的决策也可以由BPF程序转交给用户空间程序(SECCOMP_RET_USER_NOTIF);这会阻塞进程,同时用户空间助手程序通过文件描述符被通知。该程序可以读取和处理事件,然后对相同的文件描述符写入一个struct seccomp_notif_resp[119]。
- **Cilium**:Cilium提供并透明地保护网络连接性和负载均衡,适用于应用容器或进程等工作负载。它利用在不同层次(如XDP、cgroup和tc(流量控制))的BPF程序组合。例如,在tc层的主要网络数据路径中,使用了sch_clsact qdisc,并通过cls_bpf与BPF程序结合,以篡改、转发或丢弃数据包[24][120][121]。
- **bpfilter**:bpfilter是一个用BPF完全替代iptables防火墙的概念验证。为了帮助从iptables过渡,将iptables规则集发送到内核后,可以重定向到用户模式助手,以将其转换为BPF[122][123]。
- **Landlock**:Landlock是一个基于BPF的安全模块,通过BPF提供对内核资源的细粒度访问控制[124]。一个示例用例是根据BPF inode映射限制对文件系统子集的访问,该映射可以从用户空间进行更新。
- **KRSI**:内核运行时安全仪器(KRSI)是Google推出的用于可扩展审计和执行的新LSM。它使用了一种新的BPF程序类型,BPF_PROG_TYPE_KRSI[186]。一个新的BPF助手bpf_send_signal()预计会包含在即将发布的Linux 5.3版本中[125]。这将允许一种新的执行程序类型,仅通过BPF程序就能向进程发送SIGKILL和其他信号,而无需使用seccomp。进一步举例,想象一个bpftrace程序不仅检测到一个漏洞,而且立即杀死使用它的进程。例如:

这些工具可以作为临时解决方法,直到软件能够被正确地修补。在使用信号时必须谨慎:这个例子会终止所有使用 `renameat2(2)` 并且使用 `RENAME_EXCHANGE` 标志的进程,而无法区分这些进程是正常的还是恶意的。其他信号,例如 SIGABRT,可以用来生成进程的核心转储,以便对恶意软件进行取证分析。在 `bpf_send_signal()` 可用之前,可以通过用户空间的跟踪器来终止进程,这些跟踪器基于从 perf 缓冲区读取的事件。例如,可以使用 `bpftrace` 的 `system()` 函数来实现这一点。

`system()` 是一种异步操作(参见第 5 章),通过 perf 输出缓冲区发送到 bpftrace,然后在事件发生后的一段时间由 bpftrace 执行。这会在检测和执行之间引入延迟,这在某些环境中可能是不可接受的。`bpf_send_signal()` 通过在 BPF 程序的内核上下文中立即发送信号来解决这个问题。
11.1.2 Unprivileged BPF Users
对于没有 `CAP_SYS_ADMIN` 权限的非特权用户,从 Linux 5.2 开始,BPF 目前只能用于套接字过滤器。这个限制可以在 `bpf(2)` 系统调用的源代码中找到,位置在 `kernel/bpf/syscall.c` 文件中。

这段代码还允许使用 cgroup skb 程序来检查和丢弃 cgroup 数据包。然而,这些程序需要 `CAP_NET_ADMIN` 权限才能附加到 `BPF_CGROUP_INET_INGRESS` 和 `BPF_CGROUP_INET_EGRESS` 上。
对于没有 `CAP_SYS_ADMIN` 权限的用户,`bpf(2)` 系统调用将会因为权限不足(EPERM)而失败,BCC 工具会报告“需要超级用户权限才能运行”。bpftrace 程序目前会检查 UID 是否为 0,如果不是 0,则会报告“bpftrace 目前仅支持以 root 用户身份运行”。这也是为什么本书中的所有 BPF 工具都在手册页的第 8 节:它们是超级用户工具。
未来,BPF 应该支持更多非特权用户的使用场景,而不仅仅是套接字过滤器。一个特别的使用场景是容器环境,在这些环境中,主机访问权限有限,因此能够从容器内部运行 BPF 工具是非常理想的。(这一使用场景在第 15 章中提到。)
11.1.3 Configuring BPF Security

这是一次性操作:将此可调项重置为零将被拒绝。以下 sysctl 参数也可以使用类似的命令进行设置。
- `net.core.bpf_jit_enable` 启用即时编译器(JIT)。这可以提高性能和安全性。作为 Spectre v2 漏洞的缓解措施,内核添加了一个 `CONFIG_BPF_JIT_ALWAYS_ON` 选项,以永久启用 JIT 编译器,并排除 BPF 解释器。可能的设置(在 Linux 5.2 中):
  - 0: 禁用 JIT(默认)
  - 1: 启用 JIT
  - 2: 启用 JIT,并将编译器调试跟踪写入内核日志(此设置仅应在调试时使用,不应用于生产环境)
包括 Netflix 和 Facebook 在内的公司默认启用了此功能。请注意,JIT 是处理器架构依赖的。Linux 内核附带了针对绝大多数受支持架构的 BPF JIT 编译器,包括 x86_64、arm64、ppc64、s390x、sparc64 以及 mips64 和 riscv。虽然 x86_64 和 arm64 编译器功能完整且经过生产环境的考验,但其他架构的编译器可能尚未完全成熟。
- `net.core.bpf_jit_harden` 可以设置为 1,以启用额外的保护,包括对 JIT 喷射攻击的缓解,代价是性能的降低。可能的设置(在 Linux 5.2 中):
  - 0: 禁用 JIT 加固(默认)
  - 1: 仅为非特权用户启用 JIT 加固
  - 2: 为所有用户启用 JIT 加固
- `net.core.bpf_jit_kallsyms` 通过 `/proc/kallsyms` 向特权用户暴露编译后的 BPF JIT 图像,提供符号以帮助调试。如果启用了 `bpf_jit_harden`,则此选项将被禁用。
- `net.core.bpf_jit_limit` 设置可以消耗的模块内存的字节限制。一旦达到限制,非特权用户的请求将被阻止并重定向到解释器(如果编译了的话)。
有关 BPF 加固的更多信息,请参见 BPF 维护者 Daniel Borkmann 撰写的 Cilium BPF 参考指南中的加固部分。
11.1.4 Strategy
以下是针对未被其他 BPF 工具覆盖的系统活动进行安全分析的建议策略:
1. 检查是否有可用的 tracepoints 或 USDT 探针来对活动进行仪器化。
2. 检查是否可以追踪 LSM 内核钩子,这些钩子以 "security_" 开头。
3. 根据需要使用 kprobes/uprobes 对原始代码进行仪器化。


11.2 BPF Tools

有关 BCC 和 bpftrace 工具的详细和最新选项以及功能,请参阅它们的代码库。以下是一些在前面的章节中介绍过的工具的回顾,也可以参考其他章节,以获取对任何子系统的更多可观测性,特别是第 10 章中的网络连接、第 8 章中的文件使用情况以及第 6 章中的软件执行。
11.2.1 execsnoop

这显示了一个从 `/tmp` 执行的名为 `a.out` 的进程。`execsnoop(8)` 通过追踪 `execve(2)` 系统调用来工作。这是创建新进程的典型步骤,首先通过调用 `fork(2)` 或 `clone(2)` 来创建新进程,然后调用 `execve(2)` 来执行不同的程序。请注意,这并不是新软件执行的唯一方式:缓冲区溢出攻击可以将新指令添加到现有进程中,并执行恶意软件,而无需调用 `execve(2)`。有关 `execsnoop(8)` 的更多信息,请参见第 6 章。
11.2.2 elfsnoop
`elfsnoop(8)` 是一个 bpftrace 工具,用于追踪可执行和链接格式(ELF)的二进制文件的执行,这种格式在 Linux 上常用。它从内核深处追踪执行,从一个所有 ELF 执行必须经过的函数开始。例如:

这显示了执行的文件及其各种详细信息。列包括:
■ TIME:时间戳,格式为 HH:MM:SS。
■ PID:进程 ID。
■ INTERPRETER:对于脚本,执行的解释器。
■ FILE:执行的文件。
■ MOUNT:执行文件的挂载点。
■ INODE:执行文件的索引节点号:与挂载点一起,这形成了唯一标识符。
■ RET:尝试执行的返回值。0 表示成功。
挂载点和索引节点号用于进一步验证执行的二进制文件。攻击者可能会创建具有相同名称的系统二进制文件的自定义版本(并可能使用控制字符,使其显示时看起来有相同的路径),但这些攻击无法伪造挂载点和索引节点组合。
该工具通过追踪 `load_elf_binary()` 内核函数来工作,该函数负责加载新的 ELF 程序进行执行。该工具的开销应该是微不足道的,因为这个函数的调用频率应该较低。
`elfsnoop(8)` 的源代码在:

这个工具可以增强以打印执行文件的额外详细信息,包括完整路径。请注意,bpftrace 目前对 `printf()` 的元素数有七个的限制,因此需要使用多个 `printf()` 来打印额外字段。
11.2.3 modsnoop

这表明在 10:50:26 时,`modprobe(8)` 工具以 UID 0 加载了 "msr" 模块。加载模块是系统执行代码的另一种方式,也是各种 rootkit 的工作方式之一,因此成为安全追踪的目标。

这通过追踪 `do_init_module()` 内核函数来实现,该函数可以访问来自模块结构体的详细信息。还有一个 `module:module_load` 跟踪点,用于后续的一行命令。
11.2.4 bashreadline

此输出显示了在跟踪期间输入的命令,包括 shell 内建命令(如 `echo`)和失败的命令(如 `eccho`)。这是通过追踪 `readline()` 函数实现的,因此所有输入的命令都会被显示。需要注意的是,虽然这可以跟踪系统上所有正在运行的 shell 的命令,但它无法跟踪其他 shell 程序的命令,攻击者可能会安装自己的 shell(例如,nanoshell),这样不会被跟踪。

这通过使用 `uretprobe` 跟踪 `/bin/bash` 中的 `readline()` 函数实现。一些 Linux 发行版可能以不同的方式构建 `bash`,使得 `readline()` 是从 `libreadline` 库中使用的;有关更多信息,请参阅第 12 章第 12.2.3 节,其中讨论了 `readline()` 的追踪方法。
11.2.5 shellsnoop

这显示了 PID 为 7866 的 shell 会话中的命令和输出。它通过跟踪该进程及其子进程对 STDOUT 或 STDERR 的写入实现,包括子进程的输出,例如此输出中看到的 `date(1)` 的输出。`shellsnoop(8)` 还有一个选项可以生成回放 shell 脚本。例如:

BCC
命令行用法:
`shellsnoop [选项] PID`
选项包括:
■ `-s`:仅显示 shell 输出(不包括子命令)
■ `-r`:回放 shell 脚本
bpftrace
这个 bpftrace 版本展示了核心功能。


11.2.6 ttysnoop

BCC
命令行用法:
`ttysnoop [选项] 设备`
选项包括:
■ `-C`:不清屏
设备可以是伪终端的完整路径,例如 `/dev/pts/2`,或者仅是数字 `2`,或者其他 tty 设备路径,例如 `/dev/tty0`。在 `/dev/console` 上运行 `ttysnoop(8)` 可以显示系统控制台上打印的内容。
bpftrace
以下是 bpftrace 版本的代码:

这是一个 bpftrace 程序的示例,它需要一个参数。如果未指定设备名称,将打印一个使用说明消息,并退出 bpftrace。这种退出是必要的,因为跟踪所有设备会混合输出并与工具本身创建反馈循环。
11.2.7 opensnoop
`opensnoop(8)` 在第八章中已介绍,并在早期章节中展示过;它是一个 BCC 和 bpftrace 工具,用于跟踪文件打开操作,可以用于多种安全任务,如了解恶意软件行为和监控文件使用。以下是 BCC 版本的示例输出:


这些输出展示了 `opensnoop(8)` 正在搜索并加载一个 ASCII Python 模块:前三次尝试打开操作都失败了。随后,`polkitd(8)`(PolicyKit 守护进程)被捕捉到正在打开 `passwd` 文件并检查进程状态。`opensnoop(8)` 通过跟踪 `open(2)` 系统调用的不同变体来工作。
有关 `opensnoop(8)` 的更多信息,请参见第八章。
11.2.8 eperm
`eperm(8)` 是一个 bpftrace 工具,用于统计因 EPERM(“操作不被允许”)或 EACCES(“权限被拒绝”)错误而失败的系统调用,这两种错误对于安全分析可能很有意义。例如:

这展示了进程名称和失败的系统调用,并按失败类型分组。例如,这个输出显示 `cat(1)` 在 `openat(2)` 系统调用中出现了一次 EPERM 错误。这些失败可以使用其他工具进一步调查,例如使用 `opensnoop(8)` 来跟踪打开失败的情况。
该工具通过跟踪 `raw_syscalls:sys_exit` 跟踪点来工作,该跟踪点会在所有系统调用结束时触发。在 I/O 速率较高的系统上,开销可能会变得明显;因此,建议在实验环境中进行测试。

`raw_syscalls:sys_exit` 跟踪点仅提供系统调用的标识号。要将其转换为名称,可以使用系统调用的查找表,这也是 BCC 工具 `syscount(8)` 的工作方式。`eperm(8)` 使用不同的技术:它读取内核系统调用表(`sys_call_table`),找到处理该系统调用的函数,然后将该函数地址转换为内核符号名称。
11.2.9 tcpconnect and tcpaccept
`tcpconnect(8)` 和 `tcpaccept(8)` 在第十章中介绍:它们是 BCC 和 bpftrace 工具,用于跟踪新的 TCP 连接,可用于识别可疑的网络活动。许多类型的攻击至少涉及一次连接到系统。以下是 BCC `tcpconnect(8)` 的示例输出:

`tcpconnect(8)` 的输出显示一个 `a.out` 进程正在连接到 `10.0.0.1` 的 `8080` 端口,这听起来有些可疑。(`a.out` 是一些编译器的默认文件名,通常不是已安装软件的常用名称。)

该输出显示了来自 `10.10.1.201` 的多个连接请求到 `22` 端口,由 `sshd(8)` 服务。这些连接大约每 200 毫秒发生一次(从 "TIME(s)" 列可见),这可能是暴力破解攻击的迹象。
这些工具的一个关键特性是,它们仅对 TCP 会话事件进行插桩,这样可以提高效率。而其他工具则追踪每一个网络数据包,这在繁忙的系统上可能会带来较高的开销。
有关 `tcpconnect(8)` 和 `tcpaccept(8)` 的更多信息,请参见第十章。
11.2.10 tcpreset
`tcpreset(8)` 是一个 bpftrace 工具,用于跟踪 TCP 何时发送重置 (RST) 数据包。这可以用于检测 TCP 端口扫描,因为端口扫描会向一系列端口发送数据包,包括关闭的端口,从而触发 RST 响应。例如:


这表明在同一秒钟内,许多不同本地端口发送了 TCP RST 包,这看起来像是端口扫描。它通过跟踪发送重置的内核函数来工作,因此开销应该是微不足道的,因为这种情况在正常操作中发生得很少。请注意,TCP 端口扫描有不同的类型,TCP/IP 堆栈可能会对它们作出不同的响应。我使用 nmap(1) 端口扫描器测试了一个 Linux 4.15 内核,它对 SYN、FIN、NULL 和 Xmas 扫描都以 RST 响应,从而使它们都能通过 tcpreset(8) 可见。
列包含:
* TIME:时间,格式为 HH:MM:SS
* LADDR:本地地址
* LPORT:本地 TCP 端口
* RADDR:远程 IP 地址
* RPORT:远程 TCP 端口
tcpreset(8) 的源代码是:


这个工具跟踪 tcp_v4_send_reset() 内核函数,只追踪 IPv4 流量。如果需要,工具也可以扩展以追踪 IPv6 流量。
该工具也是从套接字缓冲区读取 IP 和 TCP 头部的一个示例:即设置 $tcp 和 $ip 的行。这一逻辑基于内核的 ip_hdr() 和 tcp_hdr() 函数,如果内核更改了这些逻辑,将需要对工具进行更新。
11.2.11 capable
`capable(8)` 是一个 BCC 和 bpftrace 工具,用于显示安全能力的使用情况。这对于构建应用程序所需能力的白名单可能很有用,目的是阻止其他不必要的能力,以提高安全性。

这个输出展示了 capable(8) 工具检查 CAP_SYS_ADMIN 能力(超级用户),接着 ssh(1) 检查 CAP_SETUID,然后 sshd(8) 检查各种能力。有关这些能力的文档可以在 capabilities(7) 手册页中找到。
列包括:
* CAP:能力编号
* NAME:能力的代码名称(参见 capabilities(7))
* AUDIT:此能力检查是否写入审计日志
这通过跟踪内核 cap_capable() 函数来工作,该函数决定当前任务是否具有给定的能力。通常,这种操作的频率非常低,因此开销应该是微不足道的。
可以选择显示用户和内核堆栈跟踪。例如,包括两者:

### BCC
命令行使用:
```
capable [options]
```
选项包括:
- **-v**: 包含非审计检查(详细模式)
- **-p PID**: 仅测量此进程
- **-K**: 包含内核堆栈跟踪
- **-U**: 包含用户级别堆栈跟踪
某些检查被认为是“非审计”检查,不会写入审计日志。这些检查默认会被排除,除非使用 `-v` 选项。
### bpftrace
以下是 bpftrace 版本的代码,它总结了其核心功能。此版本不支持选项,并且跟踪所有能力检查,包括非审计检查。


11.2.12 setuids

这展示了一个 `sudo(8)` 命令,它将 UID 从 1000 更改为 0,以及它所使用的各种系统调用。通过 `setuids(8)` 也可以看到通过 `sshd(8)` 进行的登录,因为它们也会更改 UID。
列包括:
- **UID**: 在 `setuid` 调用之前的用户 ID。
- **SYSCALL**: 系统调用名称。
- **ARGS**: 系统调用的参数。
- **(RET)**: 返回值。对于 `setuid(2)` 和 `setresuid(2)`,这显示调用是否成功。对于 `setfsuid(2)`,它显示之前的 UID。
这通过对这些系统调用的跟踪点进行插桩来工作。由于这些系统调用的发生率应该很低,因此该工具的开销应该是微不足道的。
`setuids(8)` 的源代码是:

11.3 BPF One-Liners

这部分展示了 BCC 和 bpftrace 的单行命令。尽可能地,同一个功能的单行命令在 BCC 和 bpftrace 中都有实现。
11.3.1 BCC
1. **统计 PID 1234 的安全审计事件:**
   ```bash
   funccount -p 1234 'security_*'
   ```
2. **跟踪可插拔认证模块 (PAM) 会话的启动:**
   ```bash
   trace 'pam:pam_start "%s: %s", arg1, arg2'
   ```
3. **跟踪内核模块的加载:**
   ```bash
   trace 't:module:module_load "load: %s", args->name'
   ```
11.3.2 bpftrace
1. **统计 PID 1234 的安全审计事件:**
   ```bash
   bpftrace -e 'k:security_* /pid == 1234 { @[func] = count(); }'
   ```
2. **跟踪可插拔认证模块 (PAM) 会话的启动:**
   ```bash
   bpftrace -e 'u:/lib/x86_64-linux-gnu/libpam.so.0:pam_start { printf("%s: %s\n", str(arg0), str(arg1)); }'
   ```
3. **跟踪内核模块的加载:**
   ```bash
   bpftrace -e 't:module:module_load { printf("load: %s\n", str(args->name)); }'
   ```
11.3.3 BPF One-Liners Examples
包括一些示例输出,就像对每个工具所做的那样,也有助于说明一行命令的用法。
以下是一些精选的一行命令及其示例输出。

11.4 Summary

BPF 可以用于各种安全用途,包括实时取证的活动嗅探、权限调试、使用白名单等。本章介绍了这些功能,并通过一些 BPF 工具进行演示。

12 Languages

有许多编程语言,以及用于执行这些语言的编译器和运行时环境,而每种语言的执行方式会影响其跟踪方法。本章解释了这些差异,并将帮助您找到跟踪任何给定语言的方法。
学习目标:
- 理解编译语言的插桩(例如:C)
- 理解 JIT 编译语言的插桩(例如:Java、Node.js)
- 理解解释语言的插桩(例如:bash shell)
- 尽可能跟踪函数调用、参数、返回值和延迟
- 跟踪给定语言的用户级栈跟踪
本章首先总结了编程语言的实现,然后使用几种语言作为示例:C 代表编译语言,Java 代表 JIT 编译语言,bash shell 脚本代表完全解释语言。对于每种语言,我都会介绍如何查找函数名称(符号)、函数参数,并如何调查和跟踪栈跟踪。在本章末尾,我还包括了跟踪其他语言的说明:JavaScript(Node.js)、C++ 和 Golang。
无论您对哪种语言感兴趣,本章都将为您提供插桩的起步点,并帮助您了解其他语言中已经解决的挑战和解决方案。


12.1 Background

要理解如何对给定的语言进行插桩,您需要检查它是如何转换为机器代码以供执行的。这通常不是语言本身的特性,而是语言实现的特性。例如,Java 并不是一种 JIT 编译语言:Java 只是语言本身。常用的 OracleJDK 或 OpenJDK 提供的 JVM 运行时通过一个从解释到 JIT 编译的流水线来执行 Java 方法,但这属于 JVM 的特性。JVM 本身也是编译自 C++ 代码的,执行诸如类加载和垃圾回收等功能。在一个完全插桩的 Java 应用中,您可能会遇到已经编译的(C++ JVM 函数)、解释的(Java 方法)以及 JIT 编译的(Java 方法)代码——每种类型的代码有不同的插桩方法。其他语言也有各自的编译器和解释器实现,您需要了解正在使用的是哪一种,以便理解如何进行跟踪。
简而言之:如果您的任务是跟踪语言 X,您首先要问的问题是,我们目前使用的是什么东西来运行 X,它是如何工作的?它是编译器、JIT 编译器、解释器,还是其他什么?
本节提供了通过 BPF 跟踪任何语言的一般建议,通过根据语言实现生成机器代码的方式(编译、JIT 编译或解释)对语言实现进行分类。一些实现(例如 JVM)支持多种技术。
12.1.1 Compiled
常见的编译语言包括 C、C++、Golang、Rust、Pascal、Fortran 和 COBOL。
对于编译语言,函数被编译成机器代码并存储在可执行的二进制文件中,通常为 ELF 格式,具有以下特点:
- 对于用户级软件,ELF 二进制文件中包含符号表,用于将地址映射到函数和对象名称。这些地址在执行期间不会移动,因此符号表可以在任何时候读取以获得正确的映射。内核级软件有所不同,因为它在 `/proc/kallsyms` 中有自己的动态符号表,随着模块的加载而增长。
- 函数参数及其返回值存储在寄存器和栈偏移中。它们的位置通常遵循每种处理器类型的标准调用约定;然而,某些编译语言(例如 Golang)使用不同的约定,还有一些(例如 V8 内建函数)根本不使用约定。
- 帧指针寄存器(x86_64 上的 RBP)可以用于显示栈跟踪,如果编译器在函数前言中初始化了它。编译器通常会将其重用于通用寄存器(这是对寄存器有限的处理器的一种性能优化)。其副作用是会破坏基于帧指针的栈跟踪。
编译语言通常容易进行跟踪,对于用户级软件使用 uprobes,对于内核级软件使用 kprobes。本书中有许多相关示例。
在处理编译软件时,检查是否存在符号表(例如使用 `nm(1)`、`objdump(1)` 或 `readelf(1)`)。如果不存在,检查是否有适用于该软件的调试信息包,它可以提供缺失的符号。如果仍然无果,检查编译器和构建软件,了解符号缺失的原因:它们可能被 `strip(1)` 工具剥离。一个解决方法是重新编译软件时不调用 `strip(1)`。
还要检查基于帧指针的栈跟踪是否正常工作。这是当前通过 BPF 遍历用户空间栈的默认方式,如果它不工作,软件可能需要用编译器标志(例如 `gcc -fno-omit-frame-pointer`)重新编译以保留帧指针。如果不可行,可以探索其他栈跟踪技术,如最后分支记录(LBR)、DWARF、用户级 ORC 和 BTF。这些技术的 BPF 工具仍需要进一步的开发,相关讨论见第 2 章。
12.1.2 JIT Compiled
常见的 JIT 编译语言包括 Java、JavaScript、Julia、.Net 和 Smalltalk。
JIT 编译语言会先编译成字节码,然后在运行时将字节码编译成机器代码,通常会利用运行时操作的反馈来指导编译器优化。这些语言具有以下特点(仅讨论用户级):
- 由于函数是在运行时编译的,因此没有预构建的符号表。这些映射通常存储在 JIT 运行时的内存中,用于打印异常堆栈等目的。这些映射也可能会改变,因为运行时可能会重新编译并移动函数。
- 函数参数和返回值可能遵循标准调用约定,也可能不遵循。
- JIT 运行时可能会考虑帧指针寄存器,也可能不会,因此基于帧指针的栈遍历可能会有效,也可能会失败(在这种情况下,你会看到栈跟踪以虚假的地址突然结束)。运行时通常有一种方法可以遍历自己的栈,以便在出现错误时由异常处理程序打印栈跟踪。
跟踪 JIT 编译语言是困难的。由于其符号表是动态的并存储在内存中,所以在二进制文件中没有符号表。一些应用程序提供了 JIT 映射的补充符号文件(例如 `/tmp/perf-PID.map`);然而,由于以下两个原因,这些符号文件不能与 uprobes 一起使用:
1. 编译器可能会在内存中移动插桩的函数而未通知内核。当不再需要插桩时,内核会将指令恢复正常,但现在它正在写入错误的位置,可能会破坏用户空间内存。
2. uprobes 基于 inode,需要文件位置才能工作,而 JIT 函数可能存储在匿名私有映射中。
如果运行时提供了 USDT 探针(User Statically Defined Tracing),则可能可以跟踪编译函数,尽管这种技术通常会带来较高的开销,无论是否启用。一个更高效的方法是用动态 USDT 对选定的点进行插桩。(USDT 和动态 USDT 在第 2 章中介绍。)USDT 探针还提供了一种解决方案,用于将函数参数和返回值作为参数插桩。
如果 BPF 的栈跟踪已经有效,可以使用补充符号文件将其转换为函数名称。对于不支持 USDT 的运行时,这提供了一种可见化正在运行的 JIT 函数的路径:可以在系统调用、内核事件和定时剖析中收集栈跟踪,揭示正在运行的 JIT 函数。这可能是获取 JIT 函数可见性的最简单方法,有助于解决许多问题。
如果栈跟踪无法工作,请检查运行时是否支持带选项的帧指针,或是否可以使用 LBR(Last Branch Record)。如果这些方法都不可行,还有其他几种修复栈跟踪的方法,尽管这些方法可能需要大量工程工作。一个方法是修改运行时编译器以保留帧指针。另一个方法是添加 USDT 探针,使用语言自身的方式获取调用栈,并将其作为字符串参数传递。还有一种方法是通过 BPF 向进程发送信号,让用户空间的辅助程序将栈跟踪写入 BPF 可以读取的内存中,就像 Facebook 为 hhvm 实现的那样。
本章稍后将以 Java 为例,讨论这些技术在实践中的工作方式。
12.1.3 Interpreted
常见的解释型语言包括 Bash shell、Perl、Python 和 Ruby。此外,还有一些语言在 JIT 编译之前常常会经历解释阶段,例如 Java 和 JavaScript。在这些分阶段语言的解释阶段进行分析,与仅使用解释的语言分析类似。
解释型语言的运行时不会将程序函数编译成机器代码,而是使用其自身内置的例程解析和执行程序。它们具有以下特征:
- 二进制符号表显示了解释器的内部结构,但没有用户提供程序中的函数。函数很可能存储在特定于解释器实现的内存表中,并映射到解释器对象。
- 函数参数和返回值由解释器处理。它们可能通过解释器函数调用传递,并可能被打包为解释器对象,而不是简单的整数和字符串。
- 如果解释器本身被编译以支持帧指针,那么帧指针栈遍历将有效,但只会显示解释器的内部结构,而没有运行的用户提供程序中的函数名称上下文。程序栈很可能为解释器所知,并为异常栈打印,但存储在自定义数据结构中。
USDT 探针可能存在,用于显示函数调用的开始和结束,以及函数名称和参数作为 USDT 探针的参数。例如,Ruby 运行时在解释器中内置了 USDT 探针。这提供了一种跟踪函数调用的方法,但可能会带来高开销:通常意味着需要对所有函数调用进行插桩,然后根据函数名称进行过滤。如果语言运行时有动态 USDT 库,可以用来仅在感兴趣的函数中插入自定义的 USDT 探针,而不是跟踪所有函数然后过滤。(有关动态 USDT 的介绍,请参见第 2 章。)例如,ruby-static-tracing 包为 Ruby 提供了这种功能。
如果运行时没有内置的 USDT 探针,并且没有提供运行时 USDT 支持的包(如 libstapsdt/libusdt),其解释器函数可以通过 uprobes 进行跟踪,并可以获取函数名称和参数等详细信息。这些可能以解释器对象的形式存储,并需要一些结构导航来解析。
从解释器的内存中提取栈跟踪可能非常困难。一种方法(尽管开销很高)是跟踪 BPF 中的所有函数调用和返回,并在 BPF 内存中构建每个线程的合成栈,以便在需要时读取。与 JIT 编译语言一样,可能还有其他方法可以添加栈跟踪支持,包括通过自定义 USDT 探针、运行时自身的获取栈的方法(如 Ruby 的 “caller” 内置方法或异常方法),或通过 BPF 信号发送到用户空间辅助程序。
12.1.4 BPF Capabilities
使用BPF(Berkeley Packet Filter)对语言进行跟踪的目标能力是回答以下问题:
- 调用了哪些函数?
- 函数的参数是什么?
- 函数的返回值是什么?是否出现错误?
- 导致某个事件的代码路径(堆栈跟踪)是什么?
- 函数的执行时长是多少?以直方图形式展示?
能回答这些问题的数量取决于语言的实现。许多语言实现自带的调试工具可以轻松回答前四个问题,因此你可能会想知道为什么我们还需要BPF。主要原因是能够在一个工具中跟踪软件栈的多个层次。与仅使用内核上下文来检查磁盘I/O或页面错误不同,你可以将这些事件与负责的用户级代码路径一并跟踪,并结合应用程序上下文:即哪些用户请求导致了多少磁盘I/O或页面错误等等。在许多情况下,内核事件可以识别并量化问题,但用户级代码则显示如何解决问题。
对于一些语言(例如Java),显示哪个堆栈跟踪导致了某个事件比跟踪其函数/方法调用更容易实现。结合BPF可以插装的众多其他内核事件,堆栈跟踪可以完成很多工作。你可以看到哪些应用程序代码路径导致了磁盘I/O、页面错误和其他资源使用;你可以看到哪些代码路径导致了线程阻塞并离开CPU;还可以使用定时采样来分析CPU使用情况并生成CPU火焰图。
12.1.5 Strategy
以下是分析语言的建议总体策略:
1. **确定语言的执行方式**:了解运行该语言的软件是使用编译为二进制文件、即时编译(JIT)还是解释执行,或是这些方法的混合。这将决定你在本章讨论的方法。
2. **浏览本章的工具和单行命令**:理解每种语言类型可能实现的功能。
3. **在互联网上搜索**“[e]BPF语言”、“BCC语言”和“bpftrace语言”,查看是否已有用于使用BPF分析该语言的工具和方法。
4. **检查语言软件是否有USDT探针**,以及这些探针是否在分发的二进制文件中启用(或者你是否需要重新编译以启用它们)。这些探针提供了稳定的接口,优先使用。如果语言软件没有USDT探针,可以考虑添加它们。大多数语言软件是开源的。
5. **编写一个示例程序进行插装**:调用一个已知名称的函数,调用次数和延迟(显式的睡眠)都是已知的。这样可以检查你的分析工具是否正常工作,确保它们正确识别所有这些已知的条件。
6. **对于用户级软件,使用uprobes检查语言在本地级别的执行**;对于内核级软件,使用kprobes。
接下来的章节将详细讨论三个示例语言:用于编译型语言的C,用于即时编译语言的Java,以及用于解释型语言的bash shell。
12.1.6 BPF Tools

12.2 C

C语言是最容易进行跟踪的语言。
对于内核级的C,内核有自己的符号表,并且大多数发行版会在其内核构建中保留帧指针(CONFIG_FRAME_POINTER=y)。这使得使用kprobes跟踪内核函数变得简单:可以看到并跟踪函数,参数遵循处理器ABI,并且可以获取堆栈跟踪。至少,大多数函数都可以被看到和跟踪:例外包括内联函数,以及那些被内核标记为不安全的跟踪黑名单上的函数。
对于用户级的C,如果编译后的二进制文件没有剥离符号表,并且没有省略帧指针,那么使用uprobes进行跟踪也是简单的:可以看到并跟踪函数,参数遵循处理器ABI,并且可以获取堆栈跟踪。不幸的是,许多二进制文件会剥离符号表,编译器也会省略帧指针,这意味着你需要重新编译这些文件或找到其他方式来读取符号和堆栈。
USDT探针可以在C程序中用于静态插装。一些C库,包括libc,默认提供USDT探针。
本节讨论了C函数符号、C堆栈跟踪、C函数跟踪、C函数偏移量跟踪、C USDT以及C语言单行命令。表12-1列出了已在其他章节中介绍的用于插装自定义C代码的工具。

12.2.1 C Function Symbols
函数符号可以从ELF符号表中读取。可以使用`readelf(1)`来检查这些符号是否存在。例如,以下是一个微基准程序中的符号:

符号表“.symtab”有数十个条目(此处已截断)。还有一个用于动态链接的附加符号表“.dynsym”,其中包含六个函数符号。现在考虑在二进制文件经过`strip(1)`处理后的符号表,这在许多打包的二进制文件中很常见:

`strip(1)` 移除了 `.symtab` 符号表,但保留了 `.dynsym` 表。`.dynsym` 包含被调用的外部全局符号,而 `.symtab` 包含相同的符号以及应用程序的本地符号。没有 `.symtab`,虽然二进制文件中仍有一些库调用符号,但可能缺少最有趣的符号。静态编译并去除符号的应用程序可能会丢失所有符号,因为它们全部放在被删除的 `.symtab` 中。
有至少两种解决方法:
- 从软件构建过程中移除 `strip(1)` 并重新编译软件。
- 使用其他符号来源:DWARF 调试信息或 BTF。
调试信息有时作为软件包提供,扩展名为 -dbg、-dbgsym 或 -debuginfo。`perf(1)` 命令、BCC 和 bpftrace 都支持这些调试信息。
调试信息
调试信息文件的名称可能与二进制文件相同,扩展名为“.debuginfo”,或者使用唯一的构建 ID 校验和作为文件名,并存放在 `/usr/lib/debug/.build-id` 或该路径的用户版本下。对于后一种情况,构建 ID 存储在二进制 ELF 的注释部分中,可以通过 `readelf -n` 查看。
例如,本系统安装了 `openjdk-11-jre` 和 `openjdk-11-dbg` 包,分别提供了 `libjvm.so` 和 `libjvm.debuginfo` 文件。以下是它们的符号计数:

轻量级调试信息
虽然总是安装调试信息文件可能看起来很有必要,但这会带来文件大小的开销:调试信息文件为 222 兆字节,而 `libjvm.so` 为 17 兆字节。这个大小的大部分不是符号信息,而是其他调试信息部分。可以使用 `readelf(1)` 来检查符号信息的大小:

这显示 `.symtab` 的大小仅为 1.2 兆字节。相比之下,提供 `libjvm.so` 的 openjdk 包为 175 兆字节。
如果完整的调试信息大小成为问题,可以考虑精简调试信息文件。以下命令使用 `objcopy(1)` 删除其他调试信息部分(以“.debug_”开头),以创建一个轻量级的调试信息文件。这个文件可以作为包含符号的调试信息替代品,或者也可以使用 `eu-unstrip(1)` 重新附加到二进制文件中。示例命令:

新的 `libjvm.new.so` 只有 20 兆字节,并且包含所有符号。请注意,这是我为本书开发的概念验证技术,尚未经过生产环境测试。
BTF
未来,BPF 类型格式(BTF)可能提供另一种轻量级的调试信息源,而且它是为 BPF 使用而设计的。目前 BTF 仅在内核中使用:尚未开始开发用户级版本。有关 BTF 的更多信息,请参见第 2 章。
使用 bpftrace
除了使用 `readelf(1)`,`bpftrace` 也可以通过匹配哪些 uprobe 可用于插装来列出二进制文件中的符号:

12.2.2 C Stack Traces
BPF 目前支持基于帧指针的栈遍历。为了使这一功能正常工作,软件必须编译为使用帧指针寄存器。对于 GCC 编译器,可以使用 `-fno-omit-frame-pointer` 选项。未来,BPF 可能还会支持其他类型的栈遍历。
由于 BPF 是可编程的,我能够在真正的支持添加之前使用纯 BPF 编写了一个帧指针栈遍历器 [134]。Alexei Starovoitov 添加了官方支持,引入了一种新的映射类型 BPF_MAP_TYPE_STACK_TRACE 和一个助手函数 `bpf_get_stackid()`。该助手函数返回栈的唯一 ID,而映射则存储栈的内容。这可以最大限度地减少栈追踪的存储,因为重复的栈使用相同的 ID 和存储。
在 `bpftrace` 中,栈信息可以通过内置的 `ustack` 和 `kstack` 获取,分别用于用户级和内核栈。以下是一个跟踪 bash shell 的示例,bash 是一个大型 C 程序,并打印出导致读取文件描述符 0(STDIN)的栈跟踪:

这个栈实际上是损坏的:在 `read()` 函数之后出现了一个看起来不像地址的十六进制数字。(可以使用 `pmap(1)` 检查一个 PID 的地址空间映射,以确定它是否在某个范围内;在这个例子中,它不在。)
现在,使用 `-fno-omit-frame-pointer` 重新编译的 bash shell:


现在,栈跟踪是可见的。它是从叶子到根的自上而下打印的。换句话说,自上而下也就是从子到父到祖父,依此类推。
这个例子显示了 shell 通过 `readline()` 函数从 STDIN 读取数据,处于 `read_command()` 代码路径中。这是 bash shell 正在读取输入。
栈的底部是另一个虚假的地址,位于 `__libc_start_main` 之后。问题是,栈现在进入了系统库 libc,而这个库在编译时没有使用帧指针。
有关 BPF 如何遍历栈以及未来工作的更多信息,请参见第2章第2.4节。
12.2.3 C Function Tracing
可以使用 `kprobes` 和 `kretprobes` 跟踪内核函数,以及 `uprobes` 和 `uretprobes` 跟踪用户级函数。这些技术在第2章中介绍过,第5章则讲解了如何使用 bpftrace 来操作它们。书中有许多它们使用的示例。
作为本节的一个示例:以下代码跟踪了 `readline()` 函数,该函数通常包含在 bash shell 中。由于这是用户级软件,因此可以使用 `uprobes` 进行跟踪。函数签名如下:
char * readline(char *prompt)
它接受一个字符串参数 `prompt`,并返回一个字符串。使用 `uprobe` 来跟踪 `prompt` 参数,该参数可以作为 `arg0` 内置变量访问:

除了主二进制文件之外,共享库也可以通过用库的路径替换探测中的 "/bin/bash" 路径来进行跟踪。一些 Linux 发行版将 `bash` 构建为通过 `libreadline` 调用 `readline`,因此上面的单行命令可能会失败,因为 `readline()` 符号不在 `/bin/bash` 中。它们可以通过 `libreadline` 的路径进行跟踪,例如:

12.2.4 C Function Offset Tracing
有时你可能希望跟踪函数内的任意偏移量,而不仅仅是函数的开始和返回点。除了提供对函数代码流的更大可见性外,通过检查寄存器,你还可以确定局部变量的内容。
`uprobes` 和 `kprobes` 支持在任意偏移量处进行跟踪,BCC 的 Python API 中的 `attach_uprobe()` 和 `attach_kprobe()` 也支持这种功能。然而,这种能力尚未通过 BCC 工具(如 `trace(8)` 和 `funccount(8)`)或 bpftrace 提供。将其添加到这些工具中应该是比较直接的,但难点在于安全地实现这一点。`uprobes` 不检查指令对齐情况,因此跟踪错误的地址(例如,位于多字节指令的中间)可能会破坏目标程序中的指令,从而导致程序以不可预测的方式失败。其他跟踪工具,例如 `perf(1)`,使用调试信息来检查指令对齐。
12.2.5 C USDT
USDT 探针可以添加到 C 程序中以提供静态插装:为跟踪工具提供一个可靠的 API。一些程序和库已经提供了 USDT 探针,例如,可以使用 `bpftrace` 列出 `libc` 的 USDT 探针:

不同的库提供 USDT 插装支持,包括 `systemtap-sdt-dev` 和 Facebook 的 Folly。有关如何将 USDT 探针添加到 C 程序的示例,请参阅第 2 章。
12.2.6 C One-Liners
以下部分展示了 BCC 和 bpftrace 的一行命令。在可能的情况下,展示了使用 BCC 和 bpftrace 实现的相同命令。
**BCC**
- 统计以 "attach" 开头的内核函数调用:
  ```bash
  funccount 'attach*'
  ```
- 统计从二进制文件(例如 /bin/bash)中以 "a" 开头的函数调用:
  ```bash
  funccount '/bin/bash:a*'
  ```
- 统计从库文件(例如 libc.so.6)中以 "a" 开头的函数调用:
  ```bash
  funccount '/lib/x86_64-linux-gnu/libc.so.6:a*'
  ```
- 跟踪一个函数及其参数(例如 bash readline()):
  ```bash
  trace '/bin/bash:readline "%s", arg1'
  ```
- 跟踪一个函数及其返回值(例如 bash readline()):
  ```bash
  trace 'r:/bin/bash:readline "%s", retval'
  ```
- 跟踪一个库函数及其参数(例如 libc fopen()):
  ```bash
  trace '/lib/x86_64-linux-gnu/libc.so.6:fopen "%s", arg1'
  ```
- 统计一个库函数的返回值(例如 libc fopen()):
  ```bash
  argdist -C 'r:/lib/x86_64-linux-gnu/libc.so.6:fopen():int:$retval'
  ```
- 统计用户级别的函数调用栈(例如 bash readline()):
  ```bash
  stackcount -U '/bin/bash:readline'
  ```
- 以49赫兹采样用户栈:
  ```bash
  profile -U -F 49
  ```
**bpftrace**
- 统计以 "attach" 开头的内核函数调用:
  ```bash
  bpftrace -e 'kprobe:attach* { @[probe] = count(); }'
  ```
- 统计从二进制文件(例如 /bin/bash)中以 "a" 开头的函数调用:
  ```bash
  bpftrace -e 'uprobe:/bin/bash:a* { @[probe] = count(); }'
  ```
- 统计从库文件(例如 libc.so.6)中以 "a" 开头的函数调用:
  ```bash
  bpftrace -e 'u:/lib/x86_64-linux-gnu/libc.so.6:a* { @[probe] = count(); }'
  ```
- 跟踪一个函数及其参数(例如 bash readline()):
  ```bash
  bpftrace -e 'u:/bin/bash:readline { printf("prompt: %s\n", str(arg0)); }'
  ```
- 跟踪一个函数及其返回值(例如 bash readline()):
  ```bash
  bpftrace -e 'ur:/bin/bash:readline { printf("read: %s\n", str(retval)); }'
  ```
- 跟踪一个库函数及其参数(例如 libc fopen()):
  ```bash
  bpftrace -e 'u:/lib/x86_64-linux-gnu/libc.so.6:fopen { printf("opening: %s\n", str(arg0)); }'
  ```
- 统计一个库函数的返回值(例如 libc fopen()):
  ```bash
  bpftrace -e 'ur:/lib/x86_64-linux-gnu/libc.so.6:fopen { @[retval] = count(); }'
  ```
- 统计用户级别的函数调用栈(例如 bash readline()):
  ```bash
  bpftrace -e 'u:/bin/bash:readline { @[ustack] = count(); }'
  ```
- 以49赫兹采样用户栈:
  ```bash
  bpftrace -e 'profile:hz:49 { @[ustack] = count(); }'
  ```


12.3 Java

Java 是一个复杂的追踪目标。Java 虚拟机(JVM)通过将 Java 方法编译为字节码并在解释器中运行这些方法来执行它们。当这些方法超过一个执行阈值(-XX:CompileThreshold)时,它们会被即时编译(JIT)为本地指令。JVM 还会对方法执行进行分析,并重新编译方法以进一步提高性能,同时动态更改它们的内存位置。JVM 包含用 C++ 编写的库,用于编译、线程管理和垃圾回收。最常用的 JVM 是 HotSpot,它最初由 Sun Microsystems 开发。
JVM 的 C++ 组件(libjvm)可以像编译语言一样进行插装,这在上一节中已经讨论过。JVM 提供了许多 USDT 探针,以便更容易地追踪 JVM 内部。这些 USDT 探针也可以对 Java 方法进行插装,但它们也带来了本节将要讨论的挑战。
本节首先简要介绍 libjvm C++ 插装,然后讨论 Java 线程名称、Java 方法符号、Java 堆栈跟踪、Java USDT 探针和 Java 一行命令。还会涵盖表 12-2 中列出的与 Java 相关的工具。


这些工具中的一些显示了 Java 方法,而在 Netflix 生产服务器上展示它们的输出需要对内部代码进行脱敏处理,这使得示例难以跟随。因此,我将用一个开源 Java 游戏来演示这些工具:freecol。这个游戏的软件复杂且对性能敏感,使它成为类似于 Netflix 生产代码的目标。freecol 的官方网站是:http://www.freecol.org。
12.3.1 libjvm Tracing
JVM 主库 libjvm 包含了数千个用于运行 Java 线程、加载类、编译方法、分配内存、垃圾回收等功能的函数。这些函数大多是用 C++ 编写的,可以通过追踪来提供运行中的 Java 程序的不同视图。
作为示例,我将使用 BCC 的 `funccount(8)`(也可以使用 bpftrace)来追踪所有的 Java 本地接口(JNI)函数。

这段代码追踪了 libjvm.so 中所有匹配 "jni_*" 的函数,并发现最频繁的函数是 `jni_GetPrimitiveArrayCritical()`,在追踪过程中调用了 3552 次。为了防止输出换行,libjvm.so 的路径在输出中被截断了。
libjvm 符号
通常与 JDK 一起打包的 libjvm.so 已被剥离,这意味着本地符号表不可用,因此这些 JNI 函数在没有额外步骤的情况下无法被追踪。可以使用 `file(1)` 来检查其状态:

可能的解决方案:
- 从源代码构建自己的 libjvm,并且不要使用 `strip(1)`。
- 安装 JDK 的 debuginfo 包(如果可用),BCC 和 bpftrace 支持该包。
- 安装 JDK 的 debuginfo 包,并使用 `elfutils unstrip(1)` 将符号表添加回 libjvm.so(参见前面的“Debuginfo”部分,第 12.2.1 节)。
- 使用 BTF(如果可用,详见第 2 章)。
在这个示例中,我使用了第二个选项。
12.3.2 jnistacks
作为一个示例 libjvm 工具,`jnistacks(8)` 计算了导致 `jni_NewObject()` 调用的栈信息,以及其他以 "jni_NewObject" 开头的调用。这将揭示哪些 Java 代码路径(包括 Java 方法)导致了新的 JNI 对象的创建。以下是一些示例输出:


为了简洁起见,这里仅包含了最后一个栈。可以从底部到顶部检查以显示调用路径,或从顶部到底部检查继承关系。这个栈似乎从事件队列(EventQueue)开始,然后经过绘制方法,最后调用 `sun.awt.X11GraphicsConfig::pGetBounds()`,这正在进行 JNI 调用——我猜是因为它需要调用 X11 图形库。
可以看到一些 `Interpreter()` 框架:这是 Java 使用其解释器执行方法的情况,直到它们跨越 `CompileThreshold` 并成为本地编译的方法。
由于 Java 符号是类签名,读取这个栈有些困难。`bpftrace` 目前还不支持解码这些符号。`c++filt(1)` 工具也不支持此版本的 Java 类签名。为了显示这些符号应如何解码,这个符号:
`Ljavax/swing/RepaintManager;::prePaintDirtyRegions+1556`
应为:
`javax.swing.RepaintManager::prePaintDirtyRegions()+1556`
`jnistacks(8)` 的源代码是:

`uprobe` 跟踪所有来自 `libjvm.so` 的以 "jni_NewObject*" 开头的调用,并对用户栈跟踪进行频率统计。
`END` 子句运行一个外部程序 `jmaps`,该程序在 `/tmp` 目录下设置一个补充的 Java 方法符号文件。这使用了 `system()` 函数,该函数需要 `--unsafe` 命令行参数,因为 `system()` 运行的命令无法通过 BPF 安全验证器进行验证。
`jmaps` 的输出已包含在之前的 `bpftrace` 输出中。详细解释见第 12.3.4 节。`jmaps` 可以在外部运行,并不需要包含在这个 `bpftrace` 程序中(你可以删除 `END` 子句);然而,执行 `jmaps` 和使用符号转储之间的时间间隔越长,符号变陈旧和误译的可能性就越大。通过将其包含在 `bpftrace` 的 `END` 子句中,可以在栈信息输出之前立即执行,最小化收集和使用之间的时间间隔。
12.3.3 Java Thread Names
JVM 允许为每个线程指定自定义名称。如果你尝试将 "java" 作为进程名称进行匹配,可能会找不到任何事件,因为线程的名称可能不同。例如,使用 `bpftrace`:

`comm` 内置函数返回的是线程(任务)名称,而不是父进程名称。这的好处在于提供了更多的线程上下文:上述配置文件显示 C2 `ComplierThread`(名称已截断)在采样期间消耗了最多的 CPU。但这也可能造成混淆,因为其他工具包括 `top(1)` 显示的是父进程名称:"java"。
这些线程名称可以在 `/proc/PID/task/TID/comm` 中查看。例如,使用 `grep(1)` 以文件名的方式打印它们:


接下来的部分中的示例是根据 Java PID 进行匹配,而不是名称 "java",这就是原因所在。另一个原因是:使用信号量的 USDT 探针需要 PID,以便 `bpftrace` 知道为该 PID 设置信号量。有关这些信号量探针的更多细节,请参见第 2 章第 2.10.1 节。
12.3.4 Java Method Symbols
开源的 `perf-map-agent` 可以用于创建包含已编译 Java 方法地址的补充符号文件 [135]。每当你需要打印包含 Java 方法的栈跟踪或地址时,这是必要的;否则,地址将无法确定。`perf-map-agent` 使用 Linux `perf(1)` 创建的约定,将一个文本文件写入 `/tmp/perf-PID.map`,格式如下 [136]:
```
START SIZE symbolname
```
以下是来自生产环境 Java 应用程序的一些示例符号,其中符号包含 "sun"(仅作为示例):

`perf-map-agent` 可以按需运行,附加到一个活动的 Java 进程并转储符号表。请注意,这个过程在符号转储期间可能会产生一些性能开销,对于大型 Java 应用程序,它可能需要超过一秒钟的 CPU 时间。由于这是符号表的快照,随着 Java 编译器重新编译方法,这些符号很快就会变得过时,尤其是在工作负载看似已达到稳定状态后。符号快照和 BPF 工具翻译方法符号之间的时间间隔越长,符号过时和误翻译的可能性就越大。对于编译率高的繁忙生产工作负载,我不信任超过 60 秒的 Java 符号转储。第 12.3.5 节提供了一个没有 `perf-map-agent` 符号表的栈跟踪示例,然后是在运行 `jmaps` 后有了符号表的示例。
自动化
你可以自动化这些符号转储,以最小化它们创建和被 BPF 工具使用之间的时间间隔。`perf-map-agent` 项目包含了自动化这一步骤的软件,我也发布了自己的程序,称为 `jmaps` [137]。`jmaps` 会找到所有 Java 进程(基于它们的进程名称)并转储它们的符号表。以下是运行 `jmaps` 在一个 48 CPU 的生产服务器上的示例:

这些输出包括各种统计信息:`jmaps` 对最终的符号转储运行了 `wc(1)`,结果显示它包含 116,000 行(符号)和 9.4 兆字节(9829226 字节)。我还用 `time(1)` 运行了它,以显示所需时间:这是一个繁忙的 Java 应用程序,主内存为 174 Gbytes,运行时间为 10.5 秒。(大部分 CPU 时间被 JVM 使用,用户和系统统计信息中未能反映。)
为了与 BCC 工具一起使用,可以在工具之前立即运行 `jmaps`。例如:
```
./jmaps; trace -U '...'
```
这样会在 `jmaps` 完成后立即调用 `trace(8)` 命令,从而最小化符号变得过时的时间。对于收集堆栈跟踪摘要的工具(例如 `stackcount(8)`),可以修改工具本身以在打印摘要之前立即调用 `jmaps`。
对于 `bpftrace`,可以在使用 `printf()` 的工具中将 `jmaps` 放在 BEGIN 子句中,而在打印地图摘要的工具中放在 END 子句中。之前的 `jnistacks(8)` 工具就是后者的一个例子。
其他技术和未来工作
虽然这些技术减少了符号的频繁变化,`perf-map-agent` 方法在许多环境中表现良好,但其他方法可能更好地解决符号表过时的问题,并可能在未来由 BCC 支持。总结如下:
- **时间戳符号日志记录**:`perf(1)` 支持此功能,相关软件在 Linux 源代码中。目前需要持续记录,这会带来一定的性能开销。理想情况下,它不应要求持续记录,而应在跟踪开始时按需启用,然后在禁用时生成完整的符号表快照。这将允许从时间跟踪 + 快照数据中重建符号状态,而不必承受持续记录的性能开销。
- **使过时符号可见**:应该能够转储前后的符号表,找到发生变化的位置,然后构建一个新符号表,将这些位置标记为不可靠。
- **async-profile**:将 `perf_events` 堆栈跟踪与通过 Java 的 `AsyncGetCallTrace` 接口获取的跟踪结合。这种方法不需要启用帧指针。
- **内核支持**:在 BPF 社区中已讨论过。未来我们可能会增加内核支持,以改进堆栈跟踪收集,并在内核中进行符号转换。这在第 2 章中有提到。
- **JVM 内建符号转储支持**:`perf-map-agent` 是一个单线程模块,受限于 JVMTI 接口。如果 JVM 支持直接写入 `/tmp/perf-PID.map` 补充符号文件——例如,当它接收到信号或其他 JVMTI 调用时——这种内建的 JVM 版本可能会更高效。
这是一个不断发展的领域。
12.3.5 Java Stack Traces
默认情况下,Java 不会使用帧指针寄存器,因此这种堆栈遍历方法不起作用。例如,使用 `bpftrace` 来对 Java 进程进行定时堆栈采样:


这个输出包含了损坏的堆栈,表现为只有一两个十六进制地址。Java 编译器使用了帧指针寄存器来处理局部变量,这是一个编译器优化。这使得 Java 程序在寄存器有限的处理器上稍微更快,但代价是破坏了调试器和跟踪器使用的堆栈遍历方法。尝试遍历堆栈跟踪通常在第一个地址之后失败。上述输出包括了这样的失败情况,同时也包含了一个完全 C++ 的有效堆栈:因为代码路径没有进入任何 Java 方法,所以帧指针保持完整。
**PreserveFramePointer**
自 Java 8 更新 60 以来,JVM 提供了 `-XX:+PreserveFramePointer` 选项来启用帧指针,这修复了基于帧指针的堆栈跟踪。现在,使用相同的 `bpftrace` 一行命令,但需要在运行 Java 时启用此选项(这涉及在启动脚本 `/usr/games/freecol` 的 `run_java` 行中添加 `-XX:+PreserveFramePointer` 选项):

**堆栈和符号**
如第 12.3.4 节所述,可以使用 `perf-map-agent` 软件创建一个补充的符号文件,并通过 `jmaps` 自动化此过程。在 `END` 子句中采取此步骤后:

堆栈现已完整,并已完全翻译。这个堆栈看起来是在绘制用户界面中的按钮(`FreeColButtonUI::paint()`)。
**库堆栈**
最后一个示例,这次是跟踪 `read(2)` 系统调用的堆栈跟踪:

这些堆栈仍然存在问题,即使 Java 正在使用 `-XX:+PreserveFramePointer` 选项运行。问题在于这个系统调用进入了 libc 库的 `read()` 函数,而该库并没有使用帧指针进行编译。解决方法是重新编译该库,或者在 BPF 工具支持时使用不同的堆栈跟踪工具(例如,DWARF 或 LBR)。
修复堆栈跟踪可能需要很多工作,但这是值得的:它使得包括 CPU 火焰图和来自任何事件的堆栈跟踪上下文的性能分析成为可能。
12.3.6 Java USDT Probes
USDT 探针(在第 2 章中介绍)具有提供稳定事件插装接口的优点。JVM 中有多个事件的 USDT 探针,包括:
- 虚拟机生命周期
- 线程生命周期
- 类加载
- 垃圾回收
- 方法编译
- 监视器
- 应用程序跟踪
- 方法调用
- 对象分配
- 监视器事件
这些探针仅在 JDK 使用 `--enable-dtrace` 选项编译时可用,而不幸的是,这一选项在 Linux 发行版的 JDK 中尚未广泛启用。要使用这些 USDT 探针,你需要从源代码编译 JDK 并使用 `--enable-dtrace` 选项,或请求包维护者启用此选项。
探针的详细信息记录在《Java 虚拟机指南》的“HotSpot VM 中的 DTrace 探针”部分,描述了每个探针的目的及其参数。表 12-3 列出了一些选定的探针。

Java USDT 实现
以下展示了如何将 USDT 探针插入 JDK 的示例代码,以 `hotspot:gc__begin` 探针为例。对于大多数人来说,了解这些细节并不必要;这些细节仅用于提供探针如何工作的洞察。
探针在 `src/hotspot/os/posix/dtrace/hotspot.d` 文件中定义,这是 USDT 探针的定义文件:
```c
provider hotspot {
[...]
probe gc__begin(uintptr_t);
```
从这个定义可以看出,探针将被称为 `hotspot:gc__begin`。在构建时,该文件会被编译成 `hotspot.h` 头文件,其中包含 `HOTSPOT_GC_BEGIN` 宏:
```c
#define HOTSPOT_GC_BEGIN(arg1) \
DTRACE_PROBE1 (hotspot, gc__begin, arg1)
```
这个宏随后被插入到 JVM 代码中需要的位置。它被放置在 `notify_gc_begin()` 函数中,以便在执行探针时可以调用该函数。来自 `src/hotspot/share/gc/shared/gcVMOperations.cpp`:

这个函数恰好具有一个 DTrace 错误的解决方法宏,该宏在 `dtrace.hpp` 头文件中声明,注释为“// 在 Solaris 10 修复 DTrace 尾调用错误 6672627 之前的解决方法”。
如果 JDK 是在没有 `--enable-dtrace` 选项的情况下构建的,则会使用 `dtrace_disabled.hpp` 头文件来代替,该文件对这些宏返回空值。
此外,还使用了一个 `HOTSPOT_GC_BEGIN_ENABLED` 宏来处理此探针:当探针处于跟踪器的实时仪器化下时,该宏返回 `true`。代码使用这个宏来判断是否需要计算昂贵的探针参数,如果探针被启用,则计算这些参数;如果没有人当前使用该探针,则可以跳过这些参数的计算。
**列出 Java USDT 探针**
BCC 的 `tplist(8)` 工具可以用于从文件或正在运行的进程中列出 USDT 探针。在 JVM 上,它列出了超过 500 个探针。以下是部分输出,已被截断以展示一些有趣的探针,同时 libjvm.so 的完整路径被省略("..."):


探针被分为 `hotspot` 和 `hotspot_jni` 两个库。这些输出包括了与类加载、垃圾收集、安全点、对象分配、方法、线程等相关的探针。使用双下划线的目的是创建探针名称,使 DTrace 可以通过单个破折号来引用这些探针,避免了在代码中使用减号的问题。
以下是一个示例,该示例运行了 `tplist(8)` 工具在一个进程上;它也可以在 `libjvm.so` 上运行。类似地,`readelf(1)` 也可以用来查看 ELF 二进制文件注释部分中的 USDT 探针(使用 `-n` 选项):

使用 Java USDT 探针
使用 Java USDT 探针可以在 BCC 和 bpftrace 中进行。它们的角色和参数在 Java 虚拟机指南中有详细记录。例如,使用 BCC 的 `trace(8)` 工具对 `gc-begin` 探针进行插桩,首个参数是布尔值,显示这是否是一次完整的垃圾收集(1)还是部分垃圾收集(0)。

这会显示在 9:30:34 发生的部分 GC 和在 9:30:38 发生的完整 GC。注意,JVM 指南将此参数记为 `args[0]`,但 `trace(8)` 从 1 开始编号,因此它是 `arg1`。
以下是一个带有字符串参数的示例:`method__compile__begin` 探针的第一个、第三个和第五个参数分别是编译器名称、类名称和方法名称。使用 `trace(8)` 可以显示方法名称。

前 11 行显示了方法名称作为最后一列,之后出现了关于解码字节为 ASCII 的 Python 错误。问题在于 Java 虚拟机指南中对这些探针的解释:字符串没有 NULL 终止符,长度作为额外参数提供。为避免此类错误,你的 BPF 程序需要使用探针中的字符串长度。
切换到 bpftrace,可以使用 `str()` 内置函数来处理长度参数。

输出中没有更多错误,现在字符串以正确的长度打印。任何使用这些探针的 BCC 或 bpftrace 程序都需要以这种方式使用长度参数。
作为另一个示例,下面的频率统计会计算所有以 "method" 开头的 USDT 探针的调用次数:

在跟踪过程中,`method_compile__begin` 和 `method__compile__end` 探针触发了 2056 次。然而,`method__entry` 和 `method__return` 探针没有被跟踪。原因是它们属于扩展 USDT 探针集,这部分内容将在接下来的章节中讨论。
Extended Java USDT Probes
一些 JVM USDT 探针默认未使用,如方法入口和返回、对象分配及 Java 监控探针。由于这些是高频事件,它们的启用会产生较高的性能开销,可能超过 10%。如果启用这些探针,它们会使 Java 运行速度大幅下降,可能降低至 10 倍或更多。
为了避免用户为未使用的探针支付不必要的性能代价,这些探针默认不可用,除非 Java 以 `-XX:+ExtendedDTraceProbes` 选项运行。下面的示例展示了启用了 ExtendedDTraceProbes 的 Java 游戏 freecol,以及如前所述的以 "method" 开头的 USDT 探针的频率计数。

在跟踪过程中,`method__entry` 和 `method__return` 探针被调用了 2600 万次。游戏也遭遇了极端的延迟,任何输入的处理时间约为三秒钟。作为对比,freecol 游戏从启动到显示启动画面的时间默认为 2 秒,而在启用这些方法探针后,时间增加到 22 秒:这是超过 10 倍的减慢。
这些高频探针在实验室环境中用于排查软件问题可能更有用,而在生产环境中分析工作负载时则不太适用。接下来的章节将展示用于 Java 可观察性的不同 BPF 工具,前提是我已经介绍了必要的背景知识:libjvm、Java 符号、Java 堆栈跟踪以及 Java USDT 探针。
12.3.7 profile
在第六章中介绍了 BCC profile(8) 工具。虽然有许多 Java 的分析工具,BCC profile(8) 的优势在于其高效性,能够在内核上下文中频次计数堆栈,并且提供完整的视图,显示用户模式和内核模式的 CPU 消耗者。通过 profile(8),可以查看在本地库(例如 libc)、libjvm、Java 方法以及内核中花费的时间。
**Java 先决条件**
为了让 profile(8) 能够看到完整的堆栈,Java 必须以 `-XX:+PreserveFramePointer` 启动,并且需要使用 perf-map-agent 创建一个补充的符号文件,profile(8) 将使用这个文件(见第 12.3.4 节)。为了翻译 libjvm.so 中的帧,需要符号表。这些要求在之前的章节中已有讨论。
**CPU Flame Graph**
下面是使用 profile(8) 生成混合模式 CPU flame graph 的一个示例。这个 Java 程序 freecol 以 `-XX:+PreserveFramePointer` 启动,并且为其 libjvm 函数提供了 ELF 符号表。在运行 profile(8) 工具之前,先运行了 jmaps 实用程序,以最小化符号的更改。该工具以默认速率(99 赫兹)进行分析,使用内核注释符号名称(-a),以 flame graph 的折叠格式(-f),针对 PID 16914(-p),分析时间为 10 秒:


`wc(1)` 工具被 jmaps 用来显示符号文件的大小,该文件有 9078 行,因此包含 9078 个符号。我还使用 `wc(1)` 来显示 profile 文件的大小。profile(8) 工具在折叠模式下的输出每行代表一个堆栈,由分号分隔的帧和堆栈出现次数构成。`wc(1)` 报告了 profile 输出中有 215 行,所以收集到了 215 个独特的堆栈跟踪。
这个 profile 输出可以使用我开源的 FlameGraph 软件 [37] 和以下命令转换为 flame graph:
```
flamegraph.pl --color=java --hash < out.profile01.txt > out.profile02.svg
```
`--color=java` 选项使用不同色调的调色板来区分代码类型:Java 为绿色,C++ 为黄色,用户级本地代码为红色,内核级本地代码为橙色。`--hash` 选项基于函数名使用一致的颜色,而不是随机饱和度水平。
生成的 flame graph SVG 文件可以在网页浏览器中打开。图 12-2 显示了一个截图。

鼠标悬停在每个帧上会显示额外的详细信息,例如该帧在 profile 中的存在百分比。这些数据显示,55% 的 CPU 时间花费在 C2 编译器上,表现为 C++ 帧中间的大型宽塔(垂直矩形列)。只有 29% 的时间花费在 Java 的 freecol 游戏上,这些时间显示为包含 Java 帧的塔。
通过点击左侧的 Java 塔,可以对 Java 帧进行缩放,如图 12-3 所示。

在分析 Java freecol 游戏及其方法的详细信息时,您会发现大部分 CPU 时间集中在 `paint` 方法中。通过查看 flame graph 的顶边,您可以准确地看到 CPU 周期的消耗情况。
如果您有兴趣提高 freecol 的性能,这个 CPU flame graph 已经提供了两个初步的目标:
1. **减少 C2 编译器的 CPU 消耗**:您可以检查 JVM 的调优选项,看看有哪些设置可以使 C2 编译器消耗更少的 CPU 时间。
2. **优化 `paint` 方法**:可以详细检查 `paint` 方法及其实现,利用 freecol 的源代码寻找更高效的技术和改进方法。
对于较长时间的 profile(例如,超过两分钟),在符号表转储和堆栈跟踪收集之间的时间间隔可能很长,这可能导致 C2 编译器在此期间移动了一些方法,从而使符号表不再准确。这可能会表现为一些毫无意义的代码路径,因为某些帧被错误地转换。更常见的问题是内联,这可能导致意外的代码路径。
**内联**
由于这是可视化正在 CPU 上运行的堆栈跟踪,它显示的是内联后的 Java 方法。JVM 的内联可以非常激进,可能将每三帧中的两帧进行内联。这可能会使浏览 flame graph 有些混乱,因为方法似乎直接调用了源代码中并不存在的其他方法。
针对内联的问题,有一个解决方案:`perf-map-agent` 软件支持转储包含所有内联符号的符号表。`jmaps` 可以使用 `-u:` 选项来利用这一功能。

符号的数量大幅增加,从之前看到的 9078 个增加到超过 75,000 个。(我再次运行了 `jmaps`,使用 `-u` 选项,但数量仍然在 9000 左右。)
图 12-4 显示了使用未内联帧信息生成的 flame graph。

在 FreeCol 堆栈中,塔的高度现在显著增加,因为它包含了未内联的帧(呈青绿色)。包含内联帧会减慢 `jmaps` 步骤,因为它必须转储更多的符号,同时生成 flame graph 时需要解析和包含这些符号。在实际操作中,这有时是必要的。通常,未内联帧的 flame graph 足以解决问题,因为它仍然展示了整体代码流,但要记住某些方法可能不可见。
**bpftrace**
`profile(8)` 功能也可以在 `bpftrace` 中实现,这有一个优势:`jmaps` 工具可以在 `END` 子句中使用 `system()` 函数运行。例如,以下单行命令在之前的部分中展示过:
```bash
bpftrace --unsafe -e 'profile:hz:99 /pid == 4663/ { @[ustack] = count(); } END { system("jmaps"); }'
```
这会以 99 赫兹的频率采样 PID 4663 的用户级堆栈跟踪,跨所有 PID 正在运行的 CPU。通过将映射调整为 `@[kstack, ustack, comm]`,可以包括内核堆栈和进程名称。
12.3.8 offcputime
BCC 的 `offcputime(8)` 工具在第六章中介绍过。它在 CPU 阻塞事件(调度器上下文切换)发生时收集堆栈,并按堆栈跟踪汇总被阻塞的时间。要使 `offcputime(8)` 与 Java 配合使用,请参见第 12.3.7 节。  
例如,使用 `offcputime(8)` 监控 Java FreeCol 游戏:

第一个堆栈显示 Java 在一个 safepoint 上总共阻塞了 5.1 毫秒(5717 微秒),这是通过内核中的 futex 锁处理的。这些时间是总计的,因此这 5.1 毫秒可能包含多个阻塞事件。  
最后一个堆栈显示 Java 在 `pthread_cond_timedwait()` 中阻塞了几乎相同的 10 秒钟的时间:这是一个名为 "VM Periodic Tas"(被截断以去掉 "k")的 WatcherThread 等待工作。对于一些使用大量线程等待工作的应用程序类型,`offcputime(8)` 的输出可能会被这些等待堆栈主导,你需要跳过这些堆栈以找到重要的堆栈:应用程序请求期间的等待事件。  
第二个堆栈让我感到惊讶:它显示 Java 在 `unlink(2)` 系统调用上被阻塞,用于删除文件,这最终导致了磁盘 I/O 阻塞(如 `io_schedule()` 等)。FreeCol 在游戏过程中删除了什么文件?一个 `bpftrace` 单行命令显示了删除的文件路径:

FreeCol 正在删除自动保存的游戏。  
libpthread 堆栈  
由于这可能是一个常见问题,以下是 libpthread 默认安装情况下最终堆栈的样子:

堆栈在 `pthread_cond_timedwait()` 处结束。当前许多 Linux 发行版附带的默认 libpthread 已使用 `-fomit-frame-pointer` 编译,这是一种破坏基于帧指针的堆栈遍历的编译优化。我之前的例子使用了我自己编译的 libpthread 版本,并使用了 `-fno-omit-frame-pointer`。有关更多信息,请参见第 2 章第 2.4 节。  
离线 CPU 时间火焰图  
`offcputime(8)` 的输出长达数百页。为了更快地浏览,可以使用它来生成离线 CPU 时间火焰图。以下是使用 FlameGraph 软件的一个示例:[37]。

这个火焰图的顶部已经被截断。每个帧的宽度与阻塞的离线 CPU 时间相关。由于 `offcputime(8)` 显示了总阻塞时间的堆栈跟踪,使用 `flamegraph.pl` 的 `--countname=us` 选项来匹配,这会更改鼠标悬停时显示的信息。背景颜色也改为蓝色,以便视觉上提醒这是显示阻塞堆栈的图。(CPU 火焰图使用黄色背景。)  
这个火焰图主要显示等待事件的线程。由于线程名称作为堆栈中的第一个帧被包含,它将具有相同名称的线程分组在一起形成塔。这个火焰图中的每个塔显示了等待的线程。  
但我对等待事件的线程不感兴趣:我对在应用程序请求期间等待的线程感兴趣。这个应用程序是 FreeCol,使用火焰图搜索功能查找“freecol”将这些帧高亮显示为品红色(见图 12-6)。


图 12-7 显示了 FreeCol 中的阻塞路径,提供了开始优化的目标。其中许多帧仍显示为“Interpreter”,因为 JVM 还没有执行该方法足够次数以达到 CompileThreshold。  
有时,由于其他等待线程,应用程序代码路径可能非常狭窄,以至于在火焰图中被省略。解决这个问题的一种方法是使用 `grep(1)` 命令行工具只包含感兴趣的堆栈。例如,匹配包含应用程序名称 "freecol" 的堆栈:  
```
# grep freecol out.offcpu01.txt | flamegraph.pl ... > out.offcpu01.svg
```  
这就是折叠文件格式的一个好处:在生成火焰图之前,可以根据需要轻松地进行操作。
12.3.9 stackcount
BCC stackcount(8) 工具(在第 4 章中介绍)可以收集任何事件的堆栈,显示导致事件的 libjvm 和 Java 方法代码路径。有关 stackcount(8) 如何与 Java 一起使用,请参见第 12.3.7 节。  
例如,使用 stackcount(8) 显示用户级页面错误,这是主内存增长的一种衡量指标:


尽管显示了许多堆栈,但这里只包含了两个。第一个显示了通过 FreeCol AI 代码的页面错误;第二个来自 JVM C2 编译器生成的代码。  
页面错误火焰图可以从堆栈计数输出生成,以帮助浏览。例如,使用 FlameGraph 软件:[37]。


绿色背景色被用作视觉提示,表示这是一个与内存相关的火焰图。在这个截图中,我已缩放以检查 FreeCol 代码路径。这提供了一个应用程序内存增长的视图,每条路径可以通过其宽度进行量化,并从火焰图中进行研究。  
bpftrace  
stackcount(8) 的功能可以通过 bpftrace 一行命令实现,例如:


Java 方法符号的 jmaps 执行已经移至 END 子句中,因此它会在堆栈被打印之前立即运行。
12.3.10 javastat
javastat(8) 是一个 BCC 工具,提供高层次的 Java 和 JVM 统计信息。它会像 top(1) 一样刷新屏幕,除非使用了 -C 选项。例如,运行 javastat(8) 来查看 Java FreeCol 游戏的统计信息:

列显示了:
- **PID**:进程 ID。
- **CMDLINE**:进程命令行。这个示例中截断了自定义 JDK 构建的路径。
- **METHOD/s**:每秒方法调用次数。
- **GC/s**:每秒垃圾回收事件次数。
- **OBJNEW/s**:每秒新对象创建次数。
- **CLOAD/s**:每秒类加载次数。
- **EXC/s**:每秒异常次数。
- **THR/s**:每秒创建线程次数。
这通过使用 Java USDT 探针实现。除非使用 -XX:+ExtendedDTraceProbes 选项来激活这些探针,否则 METHOD/s 和 OBJNEW/s 列将为零,但启用这些探针会带来较高的开销。如前所述,启用和仪器化这些探针的应用程序可能会运行得慢 10 倍。
命令行用法:
```
javastat [options] [interval [count]]
```
选项包括:
- **-C**:不清除屏幕
javastat(8) 实际上是 BCC 的工具/lib 目录中的 ustat(8) 工具的一个包装器,处理多种语言。
12.3.11 javathreads


这显示了线程的创建和执行情况,以及一些在跟踪期间短暂存在并结束的线程(“<=”)。
该工具使用了 Java USDT 探针。由于线程创建的速率较低,因此该工具的开销应该可以忽略不计。源代码:

源代码中库的路径已被截断(“...”),但需要用你自己的 libjvm.so 库路径替换。在未来,bpftrace 也应支持指定库名称而无需路径,因此可以简单地写成“libjvm.so”。
12.3.12 javacalls

在跟踪期间,最频繁的方法是 `java/lang/String.code()`,该方法被调用了 1,268,791 次。
这通过使用 Java USDT 探针与 `-XX:+ExtendedDTraceProbes` 实现,这会带来高性能开销。如前所述,启用和仪器化后,应用程序的运行速度可能会变慢 10 倍。
### BCC
**命令行用法:**
```
javacalls [options] pid [interval]
```
**选项包括:**
- **-L**: 显示方法延迟而不是调用次数
- **-m**: 以毫秒为单位报告方法延迟
`javacalls(8)` 实际上是 BCC 工具/lib 目录中的 `ucalls(8)` 工具的一个包装器,用于处理多种语言。
### bpftrace
这是 bpftrace 版本的源代码:

映射的关键是两个字符串:类名和方法名。与 BCC 版本一样,此工具仅在启用 `-XX:+ExtendedDTraceProbes` 的情况下工作,并且预期会有高性能开销。还需要注意,libjvm.so 的完整路径已被截断,需要替换为你自己的 libjvm.so 路径。
12.3.13 javaflow


这显示了代码的流程:哪个方法调用了哪个其他方法,依此类推。每个子方法调用会增加 `METHOD` 列的缩进。
这通过使用 Java USDT 探针与 `-XX:+ExtendedDTraceProbes` 实现,具有高性能开销。如前所述,启用和仪器化后,应用程序的运行速度可能会变慢 10 倍。此示例还显示了“可能丢失了 9 个样本”消息:BPF 工具无法跟上事件,作为安全措施,允许丢失事件而不是阻塞应用程序,同时通知用户发生了这种情况。
**命令行用法:**
```
javaflow [options] pid
```
**选项包括:**
- **-M METHOD**: 仅跟踪调用具有此前缀的方法
`javaflow(8)` 实际上是 BCC 工具/lib 目录中的 `uflow(8)` 工具的一个包装器,用于处理多种语言。
12.3.14 javagc

这显示了 GC 事件发生的时间,作为相对于 `javagc(8)` 开始运行时的偏移量(`START` 列,以秒为单位),以及 GC 事件的持续时间(`TIME` 列,以微秒为单位)。
这通过使用标准的 Java USDT 探针实现。
**命令行用法:**
```
javagc [options] pid
```
**选项包括:**
- **-m**: 以毫秒为单位报告时间
`javagc(8)` 实际上是 BCC 工具/lib 目录中的 `ugc(8)` 工具的一个包装器,用于处理多种语言。
12.3.15 javaobjnew


在跟踪过程中,最常见的新对象是 `java/util/HashMap$KeyIterator`,它被创建了 904,244 次。由于该语言类型不支持 `BYTES` 列,因此该列的值为零。
这通过使用 Java USDT 探针与 `-XX:+ExtendedDTraceProbes` 实现,具有高性能开销。如前所述,启用和仪器化后,应用程序的运行速度可能会变慢 10 倍。
**命令行用法:**
```
javaobjnew [options] pid [interval]
```
**选项包括:**
- **-C TOP_COUNT**: 按计数显示此数量的对象
- **-S TOP_SIZE**: 按大小显示此数量的对象
`javaobjnew(8)` 实际上是 BCC 工具/lib 目录中的 `uobjnew(8)` 工具的一个包装器,用于处理多种语言(其中一些语言支持 `BYTES` 列)。
12.3.16 Java One-Liners
这些部分展示了 BCC 和 bpftrace 的一行命令。尽可能地,用 BCC 和 bpftrace 实现相同的命令。
**BCC**
- 统计以 "jni_Call" 开头的 JNI 事件:
  ```bash
  funccount '/.../libjvm.so:jni_Call*'
  ```
- 统计 Java 方法事件:
  ```bash
  funccount -p $(pidof java) 'u:/.../libjvm.so:method*'
  ```
- 以 49 赫兹频率分析 Java 堆栈跟踪和线程名称:
  ```bash
  profile -p $(pidof java) -UF 49
  ```
**bpftrace**
- 统计以 "jni_Call" 开头的 JNI 事件:
  ```bash
  bpftrace -e 'u:/.../libjvm.so:jni_Call* { @[probe] = count(); }'
  ```
- 统计 Java 方法事件:
  ```bash
  bpftrace -e 'usdt:/.../libjvm.so:method* { @[probe] = count(); }'
  ```
- 以 49 赫兹频率分析 Java 堆栈跟踪和线程名称:
  ```bash
  bpftrace -e 'profile:hz:49 /execname == "java"/ { @[ustack, comm] = count(); }'
  ```
- 跟踪方法编译:
  ```bash
  bpftrace -p $(pgrep -n java) -e 'U:/.../libjvm.so:method__compile__begin { printf("compiling: %s\n", str(arg4, arg5)); }'
  ```
- 跟踪类加载:
  ```bash
  bpftrace -p $(pgrep -n java) -e 'U:/.../libjvm.so:class__loaded { printf("loaded: %s\n", str(arg0, arg1)); }'
  ```
- 统计对象分配(需要 ExtendedDTraceProbes):
  ```bash
  bpftrace -p $(pgrep -n java) -e 'U:/.../libjvm.so:object__alloc { @[str(arg1, arg2)] = count(); }'
  ```


12.4 Bash Shell

最后的语言示例是解释型语言:bash shell。解释型语言通常比编译型语言慢得多,因为它们通过运行自己的函数来执行目标程序的每一步。这使得它们不常作为性能分析的目标,因为通常会选择其他语言来处理性能敏感的工作负载。虽然可以进行 BPF 跟踪,但这可能更多是为了排查程序错误,而不是寻找性能改进。
每种解释型语言的跟踪方法不同,这反映了运行它们的软件的内部结构。本节将展示我如何处理未知的解释型语言,并首次确定如何跟踪它们:这是你可以用来跟踪其他语言的方法。
本章早些时候已经跟踪了 bash 的 readline() 函数,但我尚未深入跟踪 bash。在本章中,我将确定如何跟踪 bash 函数和内建调用,并开发一些工具来自动化这一过程。请参见表 12-4。

正如前面提到的,bash 的构建方式会影响符号的位置。以下是 Ubuntu 上的 bash,使用 `ldd(1)` 工具显示其动态库使用情况:

目标是跟踪 `/bin/bash` 和上述列出的共享库。举个例子,这种情况如何导致差异:在许多发行版中,bash 使用的是 `/bin/bash` 中的 `readline()` 函数,但有些发行版则链接到 `libreadline` 并从那里调用它。
**准备工作**
在准备阶段,我通过以下步骤构建了 bash 软件:

这会保留帧指针寄存器,以便我在分析过程中可以使用基于帧指针的栈遍历。此外,它还提供了一个带有本地符号表的 bash 二进制文件,而不是像 `/bin/bash` 那样已经被剥离的版本。
**示例程序**
以下是我为分析编写的示例 bash 程序,`welcome.sh`:

这段话以我构建的 bash 的路径开始。程序调用了七次 `"welcome"` 函数,每次函数调用又调用三次 `echo(1)`(我预计这是一个 bash 内建命令),总共进行了 21 次 `echo(1)` 调用。我选择这些数字是希望它们在跟踪时比其他活动更为突出。
12.4.1 Function Counts

在跟踪时,我运行了 `welcome.sh` 程序,该程序调用了 `welcome` 函数七次。看来我的猜测是正确的:有七次调用了 `restore_funcarray_state()` 和 `execute_function()`,而后者仅从名字来看最有前景。`execute_function()` 这个名字给了我一个想法:还有哪些调用以 `"execute_"` 开头?通过使用 `funccount(8)` 检查:

一些数字更为突出:`execute_builtin()` 被调用了 21 次,与 `echo(1)` 的调用次数相等。如果我想跟踪 `echo(1)` 和其他内建命令,我可以从跟踪 `execute_builtin()` 开始。还有 `execute_command()` 被调用了 23 次,这可能是 `echo(1)` 调用次数加上函数声明加上 `sleep(1)` 调用。这个函数听起来也是一个值得跟踪的函数,以了解 bash。
12.4.2 Function Argument Tracing (bashfunc.bt)
现在跟踪 `execute_function()` 调用。我想知道哪个函数被调用,希望它能显示正在执行 `"welcome"` 函数。希望可以从某个参数中找到这一点。bash 源代码中有(`execute_cmd.c`):

浏览这些源代码表明,`var`,即第一个参数,是正在执行的函数。它的类型是 `SHELL_VAR`,即 `variables.h` 中的 `struct variable`:

`char *` 的跟踪很直接。我们可以使用 bpftrace 查看 `name` 成员。我可以选择包括这个头文件或直接在 bpftrace 中声明这个结构体。我将展示两种方法,从包含头文件开始。这是 `bashfunc.bt21`:

太好了!现在我可以跟踪 bash 函数调用了。它还打印了关于另一个缺失头文件的警告。我将展示第二种方法,即直接声明结构体。实际上,由于我只需要第一个成员,我将只声明那个成员,并称之为“部分”结构体。

这行得通,没有错误,也不需要 bash 源代码。请注意,`uprobes` 是一个不稳定的接口,因此如果 bash 更改了其函数名称和参数,这个程序可能会停止工作。
12.4.3 Function Latency (bashfunclat.bt)
既然我可以跟踪函数调用了,让我们来看看函数延迟:即函数的持续时间。首先,我修改了 `welcome.sh`,使得函数变成了:

这提供了一个已知的函数调用延迟:0.3 秒。现在,我将使用 BCC 的 `funclatency(8)` 检查 `execute_function()` 是否等待 shell 函数完成,通过测量其延迟来确认。

它的延迟在 256 到 511 毫秒的范围内,这与我们已知的延迟相符。这表明我可以简单地测量 `execute_function()` 的延迟来确定 shell 函数的延迟。
接下来,将其转化为工具,使得 shell 函数的延迟可以按 shell 函数名称以直方图的形式打印出来,`bashfunclat.bt` 文件如下:


这段代码在 `uprobe` 上保存了一个函数名称的指针和时间戳。在 `uretprobe` 上,它获取函数名称和起始时间戳,以便创建直方图。
输出:

12.4.4 /bin/bash
到目前为止,跟踪 bash 的过程非常简单,这让我开始担心这是否代表了在跟踪解释器时通常遇到的复杂调试冒险。然而,我无需再找其他例子,因为默认的 `/bin/bash` 就足以提供这种冒险。这些早期的工具已对我自己构建的 bash 进行了插桩,该版本包括了本地符号表和帧指针。我对这些工具和 `welcome.sh` 程序进行了修改,以使用 `/bin/bash` 替代,并发现我编写的 BPF 工具不再有效。

`execute_function()` 是一个局部符号,这些局部符号已经从 `/bin/bash` 中剥离,以减小文件大小。
幸运的是,我仍然有线索:`funccount(8)` 的输出显示 `restore_funcarray_state()` 被调用了七次,这与我们已知的工作负载相符。为了检查它是否与函数调用有关,我将使用来自 BCC 的 `stackcount(8)` 来显示其堆栈跟踪。

堆栈信息损坏了:我本想包括这部分内容以展示 `/bin/bash` 默认的堆栈情况。这也是我编译自己版本的 bash 并包含帧指针的原因之一。现在切换到这个版本以调查这个函数:

这表明 `restore_funcarray_state()` 是 `execute_function()` 的子函数调用,因此它确实与 shell 函数调用有关。这个函数位于 `execute_cmd.c` 文件中。


这似乎用于在运行函数时创建本地上下文。我猜测 `funcname_a` 或 `funcname_v` 可能包含我所需的函数名,因此我声明了结构体并以类似于我早期的 `bashfunc.bt` 的方式打印字符串以寻找它。但我无法找到函数名。
接下来的步骤有很多,考虑到我使用的是不稳定的接口(uprobes),可能没有绝对正确的方法(正确的方法是 USDT)。以下是一些可能的下一步:
- `funccount(8)` 还显示了一些其他有趣的函数:`find_function()`、`make_funcname_visible()` 和 `find_function_def()`,它们的调用次数超过了我们已知的函数。也许函数名在它们的参数或返回值中,我可以将其缓存以便在 `restore_funcarray_state()` 中查找。
- `stackcount(8)` 显示了更高级别的函数:这些符号是否仍然存在于 `/bin/bash` 中,它们是否提供了另一种跟踪函数的路径?
下面是第二种方法的一个例子,通过检查 `/bin/bash` 中可见的 "execute" 函数来查看。

源代码显示 `execute_command()` 执行了许多操作,包括函数,这些操作可以通过第一个参数中的类型编号来识别。这是一个前进的路径:过滤出仅函数调用,并探索其他参数以找到函数名。
我发现第一种方法立即奏效了:`find_function()` 的参数中包含了函数名,我可以缓存这些名称以便后续查找。以下是更新后的 `bashfunc.bt`:

12.4.5 /bin/bash USDT
为了使跟踪 `bash` 时不受 `bash` 内部变化的影响,可以向代码中添加 USDT 探针。例如,假设 USDT 探针的格式如下:
```
bash:execute__function__entry(char *name, char **args, char *file, int linenum)
bash:execute__function__return(char *name, int retval, char *file, int linenum)
```
这样,打印函数名、显示参数、返回值、延迟、源文件和行号都将变得非常简单。
作为对 shell 进行仪器化的一个例子,USDT 探针被添加到 Solaris 系统的 Bourne shell 中。以下是探针定义的示例:

12.4.6 bash One-Liners

这些部分展示了用于 `bash` shell 分析的 BCC 和 bpftrace 一行命令示例。
**BCC:**
- 计数执行类型(需要符号):
  ```bash
  funccount '/bin/bash:execute_*'
  ```
- 跟踪交互式命令输入:
  ```bash
  trace 'r:/bin/bash:readline "%s", retval'
  ```
**bpftrace:**
- 计数执行类型(需要符号):
  ```bash
  bpftrace -e 'uprobe:/bin/bash:execute_* { @[probe] = count(); }'
  ```
- 跟踪交互式命令输入:
  ```bash
  bpftrace -e 'ur:/bin/bash:readline { printf("read: %s\n", str(retval)); }'
  ```


12.5 Other Languages

要对其他编程语言和运行时进行仪器化,首先需要确定它们的实现方式:它们是编译成二进制文件的、JIT 编译的、解释执行的,还是这些方式的组合。研究 C(编译型)、Java(JIT 编译型)和 bash shell(解释型)的相关章节,可以帮助你理解方法和面临的挑战。
在本书网站上,我将链接到关于使用 BPF 对其他语言进行仪器化的文章。以下是我之前使用 BPF 跟踪的一些语言的提示:JavaScript(Node.js)、C++ 和 GoLang。
12.5.1 JavaScript (Node.js)
BPF 跟踪类似于 Java。Node.js 当前使用的运行时是 Google 为 Chrome 浏览器开发的 V8 引擎。V8 可以解释执行 Java 函数,也可以将它们 JIT 编译为原生执行。这个运行时还负责内存管理,并有一个垃圾回收例程。以下是关于 Node.js USDT 探针、堆栈遍历、符号和函数跟踪的总结。
**USDT 探针**
Node.js 具有内置的 USDT 探针,并且可以使用 `node-usdt` 库向 JavaScript 代码中添加动态 USDT 探针。目前,Linux 发行版并不默认启用 USDT 探针:要使用它们,必须从源代码重新编译 Node.js,并使用 `--with-dtrace` 选项。以下是示例步骤:

这些展示了用于垃圾回收、HTTP 请求和网络事件的 USDT 探针。有关 Node.js USDT 的更多信息,请参见我的博客文章“Linux bcc/BPF Node.js USDT Tracing”。
**堆栈遍历**
堆栈遍历应正常工作(基于帧指针),尽管将 JIT 编译的 JavaScript 函数转换为符号需要额外的步骤(下面解释)。
**符号**
与 Java 一样,需要在 /tmp 中的补充符号文件来将 JIT 编译的函数地址转换为函数名。如果使用 Node.js v10.x 或更高版本,有两种方法可以创建这些符号文件:
1. 使用 v8 标志 `--perf_basic_prof` 或 `--perf_basic_prof_only_functions`。这些标志会创建持续更新的符号日志,而不是像 Java 那样生成符号状态的快照。由于这些滚动日志在进程运行时无法禁用,随着时间推移,它们可能会导致非常大的映射文件(G字节),其中大部分是过时的符号。
2. 使用 `linux-perf` 模块,它结合了标志的工作方式和 Java 的 `perf-map-agent` 的工作方式:它会捕获堆上的所有函数并写入映射文件,然后在编译新函数时继续写入文件。可以随时开始捕获新函数。推荐使用这种方法。
使用这两种方法时,我需要对补充的符号文件进行后处理,以删除过时的条目。另一个推荐的标志是 `--interpreted-frames-native-stack`(也适用于 Node.js v10.x 及以上版本)。使用此标志时,Linux perf 和 BPF 工具将能够将解释执行的 JavaScript 函数翻译为其实际名称(而不是在堆栈上显示“Interpreter”帧)。
需要外部 Node.js 符号的常见用例是 CPU 性能分析和 CPU 火焰图 [144]。这些可以使用 `perf(1)` 或 BPF 工具生成。
**函数跟踪**
目前没有用于跟踪 JavaScript 函数的 USDT 探针,由于 V8 的架构,添加这些探针将很具挑战性。即使有人添加了探针,正如我与 Java 讨论过的那样,开销可能会很大:在使用过程中使应用程序变慢 10 倍。JavaScript 函数在用户级堆栈跟踪中可见,可以在诸如定时采样、磁盘 I/O、TCP 事件和上下文切换等内核事件上收集。这提供了许多对 Node.js 性能的洞察,包括函数上下文,而没有直接跟踪函数的惩罚。
12.5.2 C++
C++ 的追踪方法与 C 类似,可以使用 uprobes 来追踪函数入口、函数返回,以及基于帧指针的堆栈(前提是编译器保留了帧指针)。不过有几点不同之处:
- 符号名称是 C++ 签名的形式。与 ClassLoader::initialize() 不同,这个符号可能会被追踪为 _ZN11ClassLoader10initializeEv。BCC 和 bpftrace 工具在打印符号时会使用解码功能。
- 函数参数可能不符合处理器 ABI 对对象和 self 对象的支持要求。
函数调用计数、测量函数延迟以及显示堆栈跟踪应该都是直接的。尽可能使用通配符匹配函数名称(例如,uprobe:/path:*ClassLoader*initialize*)可能会有所帮助。
检查参数则需要更多的工作。有时它们只是通过一个偏移量来适应 self 对象作为第一个参数。字符串通常不是原生的 C 字符串,而是 C++ 对象,不能直接解引用。对象需要在 BPF 程序中声明为结构体,以便 BPF 可以解引用其成员。
这所有工作可能会变得更简单,特别是引入了 BTF(在第二章中介绍),它可能提供参数和对象成员的位置。
12.5.3 Golang
Golang 编译为二进制文件,对这些文件进行追踪类似于追踪 C 二进制文件,但在函数调用约定、goroutines 和动态堆栈管理方面存在一些重要差异。由于后者,当前在 Golang 上使用 uretprobes 是不安全的,因为它们可能会导致目标程序崩溃。编译器的不同也会影响这些问题:默认情况下,Go gc 编译器生成的是静态链接的二进制文件,而 gccgo 编译器生成的是动态链接的二进制文件。这些主题将在后续部分讨论。
请注意,还有其他调试和追踪 Go 程序的方法,例如 gdb 的 Go 运行时支持、go 执行跟踪器 [145] 和 GODEBUG 结合 gctrace 和 schedtrace。
**堆栈遍历和符号**
Go gc 和 gccgo 默认都遵循帧指针(Go 从 1.7 版本开始)并在生成的二进制文件中包含符号。这意味着可以始终收集包含 Go 函数的堆栈跟踪,无论是来自用户级还是内核级事件,基于时间采样的性能分析也会立即生效。
**函数入口追踪**
可以使用 uprobes 追踪函数的入口。例如,使用 bpftrace 计数以 "fmt" 开头的函数调用,在名为 "hello" 的 "Hello, World!" Golang 程序中,这个程序是使用 Go gc 编译的:

在跟踪过程中,我运行了一次 hello 程序。输出显示了多个 fmt 函数被调用过一次,包括 fmt.Println(),我怀疑它在打印 "Hello, World!"。现在,我要统计 gccgo 二进制文件中的相同函数。在这种情况下,这些函数位于 libgo 库中,因此必须跟踪该位置。


函数的命名约定略有不同。输出中包括 fmt.Println(),如前所见。这些函数也可以使用 BCC 工具 `funccount(8)` 进行计数。针对 Go gc 版本和 gccgo 版本的命令如下:
对于 Go gc 版本:
```bash
funccount 'go:fmt.*'
`
对于 gccgo 版本:
```bash
funccount '/home/bgregg/hello:fmt.*'
```
### 函数入口参数
Go 的 gc 编译器和 gccgo 使用不同的函数调用约定:gccgo 使用标准的 AMD64 ABI,而 Go 的 gc 编译器使用 Plan 9 的栈传递方法。这意味着获取函数参数的方式不同:在 gccgo 中,通常的方法(例如,通过 bpftrace 的 arg0...argN)会有效,但在 Go gc 中则不行:需要使用自定义代码从栈中获取(参见 [146][147])。
例如,考虑 Golang 教程中的 `add(x int, y int)` 函数,它的参数是 42 和 13。要在 gccgo 二进制文件中对其参数进行插桩:

这次需要从栈的偏移量读取参数,通过 `reg("sp")` 访问。未来版本的 bpftrace 可能会支持这些作为别名,例如 `sarg0`、`sarg1`,即“栈参数”的缩写。请注意,我需要使用 `go build -gcflags '-N -l' ...` 编译,以确保 `add()` 函数没有被编译器内联。
### 函数返回
不幸的是,当前实现的 `uretprobe` 跟踪不安全。Go 编译器可以随时修改栈,而内核已经在栈上添加了 `uretprobe` 跳板处理程序。这可能导致内存损坏:一旦 `uretprobe` 被停用,内核会将这些字节恢复正常,但这些字节可能现在包含其他 Golang 程序数据,并且会被内核破坏。这可能导致 Golang 崩溃(如果运气好)或继续运行时数据损坏(如果运气不好)。
Gianluca Borello 研究了一种解决方案,即在函数的返回位置使用 `uprobes` 而不是 `uretprobes`。这涉及到反汇编函数以找到返回点,然后在这些点上放置 `uretprobe`。
另一个问题是 goroutines:它们在运行时可以在不同的操作系统线程之间调度,因此使用基于线程 ID 的时间戳(例如,使用 bpftrace:`@start[tid] = nsecs`)来测量函数延迟的方法不再可靠。
### USDT
Salp 库通过 libstapsdt 提供动态 USDT 探针。这允许在 Go 代码中放置静态探针点。


12.6 Summary

无论你感兴趣的编程语言是编译型、JIT 编译型还是解释型,通常都有方法可以使用 BPF 进行分析。在这一章中,我讨论了这三种类型,并展示了如何跟踪每种类型的示例:C、Java 和 Bash shell。通过跟踪,你可以检查它们的函数或方法调用,查看其参数和返回值,测量函数或方法的延迟,并显示其他事件的栈跟踪。还包括了对 JavaScript、C++ 和 Golang 等其他语言的提示。

13 Applications

系统上运行的应用程序可以通过静态和动态插装直接进行研究,这为理解其他事件提供了重要的应用程序上下文。前面的章节通过它们使用的资源:CPU、内存、磁盘和网络来研究应用程序。这种基于资源的方法可以解决许多问题,但可能会忽略来自应用程序的线索,例如它当前正在处理的请求的详细信息。要全面观察一个应用程序,你需要结合资源分析和应用程序级别的分析。使用 BPF 跟踪,可以研究从应用程序及其代码和上下文,通过库和系统调用,内核服务和设备驱动程序的流程。
本章将以 MySQL 数据库为案例进行研究。MySQL 数据库查询是应用程序上下文的一个例子。想象一下,将第 9 章中对磁盘 I/O 的各种插装方式与查询字符串作为另一维度进行分析。现在你可以看到哪些查询引起了最多的磁盘 I/O,以及它们的延迟和模式等。
学习目标:
- 发现过多的进程和线程创建问题
- 使用分析工具解决 CPU 使用问题
- 通过调度程序跟踪解决 CPU 以外的阻塞问题
- 通过显示 I/O 栈跟踪解决过度 I/O 问题
- 使用 USDT 探针和 uprobes 跟踪应用程序上下文
- 调查导致锁竞争的代码路径
- 识别显式的应用程序休眠
本章是对先前基于资源的章节的补充;要全面了解软件栈,还请参见:
- 第 6 章,“CPU”
- 第 7 章,“内存”
- 第 8 章,“文件系统”
- 第 9 章,“磁盘 I/O”
- 第 10 章,“网络”
其他章节未涵盖的应用程序行为将在此处讨论:获取应用程序上下文、线程管理、信号、锁和休眠。


13.1 Background

一个应用程序可能是一个响应网络请求的服务,一个响应直接用户输入的程序,或者一个处理来自数据库或文件系统数据的程序,或其他类型的程序。应用程序通常作为用户模式软件实现,表现为进程,通过系统调用接口(或内存映射)访问资源。
13.1.1 Application Fundamentals
线程管理
线程管理是多 CPU 系统中关键的操作系统构造,允许应用程序高效地在多个 CPU 上并行执行工作,同时共享相同的进程地址空间。应用程序可以以不同的方式利用线程:
- **服务线程池**:处理网络请求的线程池,每个线程服务一个客户端连接,线程会在资源阻塞时休眠。例子包括 MySQL 数据库服务器。
- **CPU 线程池**:为每个 CPU 创建一个线程,常用于批处理应用,如视频编码。
- **事件工作线程**:处理客户工作队列的线程,可以是一个或多个,每个线程同时处理多个客户端。Node.js 就使用了这种模式。
- **分阶段事件驱动架构 (SEDA)**:将应用程序请求分解为多个阶段,由线程池处理。
**锁**
锁是多线程应用程序中的同步原语;它们负责管理并行线程对内存的访问,类似于交通信号灯调控交叉口的通行。就像交通信号灯一样,锁也可能会阻碍流量,导致等待时间(延迟)。在 Linux 系统中,应用程序通常通过 `libpthread` 库使用锁,这个库提供了多种锁类型,包括互斥锁(mutex)、读写锁和自旋锁。
虽然锁可以保护内存,但它们也可能成为性能问题的源头。当多个线程争用同一个锁时,会发生锁竞争,并且在等待轮到自己时会被阻塞。
**睡眠**
应用程序可以故意让线程睡眠一段时间。这种睡眠可能有其合理性(具体取决于原因),也可能没有——因此也可能是优化的机会。如果你曾经开发过应用程序,可能会有这样的想法:“我在这里加一个一秒的睡眠,以确保我等待的事件已经完成;我们可以稍后删除这个睡眠,改成基于事件的处理。”然而,那个“稍后”往往不会到来,最终用户可能会困惑于为何某些请求至少需要一秒钟。
13.1.2 Application Example: MySQL Server
作为本章分析的示例应用程序,我将讨论 MySQL 数据库服务器。这个服务通过服务线程池响应网络请求。根据数据访问的频率和大小,可以预期 MySQL 会在两种情况下表现不同:对于大规模工作集,它可能会受到磁盘访问限制,而对于小规模工作集(查询从内存缓存中返回),则可能会受到 CPU 限制。
MySQL 服务器是用 C++ 编写的,并嵌入了 USDT 探针,用于监控查询、命令、文件排序、插入、更新、网络 I/O 以及其他事件。表 13-1 提供了一些示例。

在 MySQL 参考手册中可以查看“mysqld DTrace Probe Reference”以获取完整的探针列表[152]。这些 MySQL USDT 探针只有在 MySQL 使用 `-DENABLE_DTRACE=1` 参数通过 `cmake` 编译时才会可用。当前的 Linux mysql-server 软件包没有启用这一选项,因此你需要自己编译 MySQL 服务器软件以使用 USDT 探针,或者请求软件包维护者在构建过程中包括此设置。
由于在许多情况下 USDT 探针可能不可用,本章还包含了使用 uprobes 对 MySQL 服务器进行检测的工具。这些工具提供了替代方案来监控和分析 MySQL 的性能。
13.1.3 BPF Capabilities
BPF(Berkeley Packet Filter)跟踪工具可以提供超越应用程序提供的指标的额外洞察,包括自定义工作负载和延迟指标、延迟直方图,以及从内核中查看资源使用情况。这些能力可以帮助回答以下问题:
- **应用程序请求是什么?它们的延迟是多少?**
- **在应用程序请求期间时间花费在哪里?**
- **为什么应用程序会占用 CPU?**
- **为什么应用程序会阻塞并切换 CPU?**
- **应用程序正在执行什么 I/O 操作?为什么(代码路径)?**
- **应用程序阻塞了哪些锁?阻塞了多长时间?**
- **应用程序正在使用哪些其他内核资源?为什么?**
这些问题可以通过以下方式得到解答:
1. **使用 USDT 和探针对应用程序进行仪器化**:这包括请求上下文、内核资源和阻塞事件,通过 tracepoints(包括系统调用)和 kprobes 来进行监控。
2. **通过定时采样的 on-CPU 栈跟踪**:对 CPU 上的栈进行定时采样,以获取性能数据。
### 额外的开销
跟踪应用程序的开销取决于被跟踪事件的频率。通常情况下,跟踪请求本身的开销微乎其微,而跟踪锁竞争、离 CPU 事件和系统调用可能会对繁忙的工作负载产生明显的开销。因此,在使用 BPF 跟踪工具时,需要平衡获取的详细信息与可能产生的性能影响。
13.1.4 Strategy
以下是一个建议的整体策略,用于应用程序分析。接下来的部分将更详细地解释这些工具。
1. 了解应用程序的功能:它的工作单元是什么?它可能已经在应用程序指标和日志中暴露了工作单元。还需确定如何提高其性能:是提高吞吐量、降低延迟,还是减少资源使用(或这些因素的组合)?
2. 查看是否有文档描述应用程序内部结构:主要组件如库和缓存,它的 API,以及它是如何处理请求的:线程池、事件工作线程,还是其他方式。
3. 除了应用程序的主要工作单元,还需了解是否有任何后台定期任务可能影响性能(例如,每30秒发生一次的磁盘刷新事件)。
4. 检查是否有可用于应用程序或其编程语言的 USDT 探针。
5. 执行 on-CPU 分析以了解 CPU 消耗情况,并查找低效之处(例如,使用 BCC profile(8))。
6. 执行 off-CPU 分析以了解应用程序为何阻塞,并寻找优化区域(例如,BCC offcputime(8)、wakeuptime(8)、offwaketime(8))。重点关注应用程序请求期间的阻塞时间。
7. 对系统调用进行分析以了解应用程序的资源使用情况(例如,BCC syscount(8))。
8. 浏览并执行第6至10章列出的 BPF 工具。
9. 使用 uprobes 探索应用程序内部结构:之前的 on-CPU 和 off-CPU 分析栈跟踪应已识别出许多函数以开始跟踪。
10. 对于分布式计算,考虑同时跟踪服务器端和客户端。例如,在 MySQL 中,可以通过跟踪 MySQL 客户端库来跟踪服务器和发起请求的客户端。
已知应用程序是 CPU 绑定、磁盘绑定还是网络绑定,基于其大部分时间等待的资源。在确认该假设正确后,可以从本书适当的资源章节中调查限制资源。如果您希望编写 BPF 程序以跟踪应用程序请求,需考虑请求的处理方式。由于服务线程池从同一线程完全处理请求,因此可以使用线程 ID(任务 ID)将来自不同来源的事件关联起来,前提是它们是异步的。例如,当数据库开始处理查询时,可以将查询字符串存储在以线程 ID 为键的 BPF map 中。此查询字符串可以在磁盘 I/O 首次初始化时读取,从而将磁盘 I/O 与引起它的查询关联起来。其他应用程序架构如事件工作线程则需要不同的方法,因为一个线程同时处理不同的请求,线程 ID 并不唯一。


13.2 BPF Tools

这些工具要么来自第4章和第5章中介绍的 BCC 和 bpftrace 库,要么是为本书专门创建的。一些工具在 BCC 和 bpftrace 中都有出现。表 13-2 列出了本节中涵盖的工具的来源(BT 是 bpftrace 的缩写)。

这些工具来自 BCC 和 bpftrace 的库,具体的工具选项和功能更新请参见它们的库。这里总结了一些最重要的功能,这些工具可以分为以下几个主题:
- **CPU 分析**: profile(8)、threaded(8) 和 syscount(8)
- **Off-CPU 分析**: offcputime(8)、offcpuhist(8) 和 ioprofile(8)
- **应用上下文**: mysqld_slower(8) 和 mysqld_clat(8)
- **线程执行**: execsnoop(8)、threadsnoop(8) 和 threaded(8)
- **锁分析**: rmlock(8) 和 pmheld(8)
- **信号**: signals(8) 和 killsnoop(8)
- **睡眠分析**: naptime(8)
本章末尾还有一些一行命令的工具。此外,以下工具部分还包括关于 libc 框架指针的内容,作为 ioprofile(8) 的后续。
13.2.1 execsnoop

这表明服务器并不那么闲置:它捕获了系统活动记录器的调用。execsnoop(8) 对于捕获应用程序的意外进程使用非常有用。有时应用程序会调用 shell 脚本以实现某些功能,这可能是暂时的解决方案,直到可以在应用程序中正确编码,从而导致低效。有关 execsnoop(8) 的更多信息,请参见第6章。
13.2.2 threadsnoop


这段话展示了通过检查 TIME(ms) 列来观察线程创建的速率,以及谁在创建线程(PID, COMM),以及线程的启动函数(FUNC)。输出结果显示了 MySQL 正在创建其服务器工作线程池(srv_worker_thread())、I/O 处理线程(io_handler_thread())以及其他用于运行数据库的线程。
该工具通过跟踪 `pthread_create()` 库调用来工作,预计这个调用相对不频繁,因此该工具的开销应该是微不足道的。
`threadsnoop(8)` 工具的源代码可以在以下位置找到:

这展示了导致线程创建的代码路径。对于 MySQL,线程的角色从启动函数中已经很明显,但对于所有应用程序来说并非总是如此,可能需要堆栈跟踪来识别新线程的用途。
13.2.3 profile
`profile(8)` 是一种 BCC 工具,通过定时采样 on-CPU 堆栈跟踪来展示哪些代码路径消耗了 CPU 资源。这是一种便宜且粗略的方法来进行 CPU 使用情况分析。举例来说,使用 `profile(8)` 对 MySQL 服务器进行分析,可以帮助识别哪些函数或代码路径在占用 CPU 资源。


输出包含了数百个堆栈跟踪及其频率计数。这里仅展示了三个示例。第一个堆栈展示了 MySQL 语句变成连接操作,并最终在 CPU 上执行到 `my_hash_sort_simple()` 函数。最后一个堆栈展示了内核中的套接字发送操作:该堆栈中包含了内核和用户栈之间的分隔符("–"),这是由于使用了 `profile(8)` 工具的 `-d` 选项。
由于输出结果包含数百个堆栈跟踪,将其可视化为火焰图可能会非常有帮助。`profile(8)` 可以生成折叠格式的输出(`-f` 选项),以便火焰图软件进行处理。例如,在进行 30 秒的性能分析后:


火焰图展示了 CPU 时间的主要消耗区域,最宽的框架代表了最消耗 CPU 时间的函数:在图中,`dispatch_command()` 占样本的 69%,而 `JOIN::exec()` 占 19%。这些数字可以通过鼠标悬停查看,每个框架也可以点击以放大查看详细信息。
除了说明 CPU 消耗,CPU 火焰图还显示了哪些函数正在执行,这些函数可以成为 BPF 跟踪的潜在目标。这个火焰图显示了如 `do_command()`、`mysqld_stmt_execute()`、`JOIN::exec()` 和 `JOIN::optimize()` 等函数,这些函数可以通过 uprobes 进行直接插桩,从而研究它们的参数和延迟。
这只在我对一个编译了帧指针的 MySQL 服务器进行分析时有效,并且其 libc 和 libpthread 版本也具有帧指针。没有这些,BPF 将无法正确地遍历堆栈。这一点在第 13.2.9 节中讨论。有关 `profile(8)` 和 CPU 火焰图的更多信息,请参见第 6 章。
13.2.4 threaded

这个工具每秒打印一次输出,对于这个 MySQL 服务器的工作负载,它显示只有一个线程(线程 ID 2534)在 CPU 上占据了显著的时间。这旨在评估多线程应用程序如何将工作负载分配到各个线程上。由于使用了定时采样,它可能会遗漏在样本之间发生的短暂线程唤醒。
一些应用程序会更改线程名称。例如,使用 `threaded(8)` 工具对前一章中的 FreeCol Java 应用程序进行分析时:


这表明该应用程序消耗的 CPU 时间主要集中在编译线程上。`threaded(8)` 通过使用定时采样来工作,在这种低频率下,开销应该是微不足道的。
`threaded(8)` 的源代码是:

13.2.5 offcputime
`offcputime(8)` 是一种 BCC 工具,用于跟踪线程何时阻塞并离开 CPU,同时记录它们离开 CPU 的时间和堆栈跟踪。在第六章中介绍了这种工具。以下是 MySQL 服务器的示例输出:


输出包含了数百个堆栈;这里只选择了一些作为示例。第一个示例显示了一个 MySQL 语句变成提交、日志写入,然后进行 fsync()。接着,代码路径进入内核(“--”),由 ext4 处理 fsync,然后线程最终在 `jbd2_log_wait_commit()` 函数上阻塞。跟踪期间,`mysqld` 在这个堆栈上被阻塞的时间为 2458362 微秒(2.45 秒):这是所有线程的总和。
最后两个堆栈显示了 `lock_wait_timeout_thread()` 通过 `pthread_cond_timewait()` 等待事件,以及 `srv_master_thread()` 处于休眠状态。`offcputime(8)` 的输出通常会被这些等待和休眠的线程主导,这通常是正常行为而非性能问题。你的任务是找到在应用请求期间发生阻塞的堆栈,这才是问题所在。
离线 CPU 时间火焰图
创建离 CPU 时间火焰图提供了一种快速聚焦于感兴趣的阻塞堆栈的方法。以下命令捕获 10 秒钟的离 CPU 堆栈,然后使用我的火焰图软件生成火焰图:

这生成了图 13-3 所示的火焰图,在图中我使用搜索功能高亮显示了包含 "do_command" 的帧(用品红色标出):这些是 MySQL 请求的代码路径,是客户端阻塞的地方。

图 13-3 中的大部分火焰图都被线程池等待工作的部分主导。被阻塞在服务器命令中的时间由包含 `do_command()` 帧的狭窄塔显示,这部分用品红色标出。幸运的是,火焰图是交互式的,可以点击这个塔进行缩放。这在图 13-4 中展示了。

鼠标指针悬停在 `ext4_sync_file()` 上,底部显示了这条路径上花费的时间:总计 3.95 秒。这是 `do_command()` 中阻塞时间的主要部分,表明了优化的目标,以提高服务器性能。
**bpftrace**
我编写了 `offcputime(8)` 的 bpftrace 版本;有关源代码,请参见下一节的 `offcpuhist(8)`。
**最终说明**
这种离 CPU 分析能力是 `profile(8)` CPU 分析的补充,两者结合可以揭示各种性能问题。
`offcputime(8)` 的性能开销可能会很大,超过 5%,这取决于上下文切换的频率。这至少是可管理的:在生产环境中可以根据需要短时间运行。在 BPF 之前,进行离 CPU 分析需要将所有堆栈转储到用户空间进行后处理,这种开销通常对生产使用来说是不可接受的。
与 `profile(8)` 一样,由于我重新编译了 MySQL 服务器和系统库并启用了帧指针,因此这里只生成了所有代码的完整堆栈。有关更多信息,请参见第 13.2.9 节。
有关 `offcputime(8)` 的更多信息,请参见第 6 章。第 14 章涵盖了其他离 CPU 分析工具:`wakeuptime(8)` 和 `offwaketime(8)`。
13.2.6 offcpuhist
`offcpuhist(8)` 与 `offcputime(8)` 类似。它跟踪调度器事件以记录离 CPU 时间及堆栈跟踪,但它以直方图的形式显示时间,而不是总和。以下是 MySQL 服务器的示例输出:

输出被截断,只显示了最后两个堆栈跟踪。第一个显示了双峰延迟分布,因为 `srv_worker_thread()` 线程在等待工作:输出范围为纳秒,并显示一个峰值在 16 微秒左右,另一个在 8 到 16 毫秒之间(标记为 "[8M, 16M)")。第二个堆栈显示了在 `net_read_packet()` 代码路径中较短的等待时间,通常少于 128 微秒。
这种方法通过使用 kprobes 跟踪调度器事件。与 `offcputime(8)` 一样,其开销可能会很大,因此仅打算在短时间内运行。
`offcpuhist(8)` 的源代码是:

它记录了线程离开 CPU 的时间戳,同时也记录了线程开始在 CPU 上运行的直方图,这些都在一个 `finish_task_switch()` kprobe 中完成。
13.2.7 syscount
`syscount(8)` 是一个 BCC 工具,用于计数系统调用,提供应用程序资源使用情况的视图。它可以在整个系统范围内运行,也可以针对单个进程。例如,在 MySQL 服务器上,使用每秒输出(-i 1)时:

这表明 `sched_yield()` 系统调用最为频繁,每秒调用超过 10,000 次。可以通过系统调用的 tracepoints 和其他工具来探查最频繁的系统调用。例如,BCC 的 `stackcount(8)` 可以显示导致该调用的堆栈跟踪,而 `argdist(8)` 可以总结其参数。每个系统调用也应有一个 man 页,解释其目的、参数和返回值。
`syscount(8)` 还可以使用 `-L` 选项显示系统调用的总时间。例如,跟踪 10 秒 (`-d 10`) 并以毫秒 (`-m`) 进行总结:

在跟踪期间,这个 10 秒的跟踪中 `futex(2)` 的总时间超过了 108 秒:这可能是因为多个线程并行调用它。需要检查参数和代码路径以理解 `futex(2)` 的功能:它可能被频繁调用是作为等待工作的机制,就像之前通过 `offcputime(8)` 工具发现的一样。
从上到下,这个输出中最有趣的系统调用是 `fsync(2)`,总共耗时 4393 毫秒。这表明一个优化的目标:文件系统和存储设备。
有关 `syscount(8)` 的更多信息,请参见第 6 章。
13.2.8 ioprofile


输出结果中包含了数百个堆栈,这里仅展示了其中的几个。第一个堆栈显示了 `mysqld` 从事务写入和文件写入代码路径调用了 `pwrite64(2)`。第二个堆栈显示了 `mysqld` 通过 `recvfrom(2)` 读取数据包。
一个应用程序执行过多的 I/O 或不必要的 I/O 是常见的性能问题。这可能是由于可以禁用的日志写入、小的 I/O 大小应该增加等等。这些工具可以帮助识别这些类型的问题。
这通过跟踪系统调用的 tracepoints 来实现。由于这些系统调用可能非常频繁,因此开销可能会比较明显。
`ioprofile(8)` 的源代码为:

13.2.9 libc Frame Pointers
需要特别指出的是,`ioprofile(8)` 工具的输出仅包含完整的堆栈,因为这个 MySQL 服务器使用了带有帧指针编译的 libc。应用程序通常通过 libc 调用进行 I/O,而 libc 通常编译时不包含帧指针。这意味着从内核到应用程序的堆栈遍历往往会停在 libc 上。虽然这个问题在其他工具中也存在,但在 `ioprofile(8)` 中表现得尤为明显,同样的问题也出现在第 7 章的 `brkstack(8)` 工具中。
问题的表现如下:这个 MySQL 服务器有帧指针,但使用的是标准打包的 libc。


堆栈跟踪是不完整的,在一两个帧后就停止了。修复的方法包括:
- 使用 `-fno-omit-frame-pointer` 重新编译 libc。
- 在帧指针寄存器被重用之前,跟踪 libc 接口函数。
- 跟踪 MySQL 服务器函数,如 `os_file_io()`,这是一种特定于应用程序的方法。
- 使用不同的堆栈遍历工具。有关其他方法的总结,请参见第 2 章第 2.4 节。
libc 包含在 glibc 包中[153],该包还提供 libpthread 和其他库。之前曾建议 Debian 提供一个带有帧指针的替代 libc 包[154]。有关堆栈损坏的更多讨论,请参见第 2 章第 2.4 节和第 18 章第 18.8 节。
13.2.10 mysqld_qslower
`mysqld_qslower(8)` 是一个 BCC 和 bpftrace 工具,用于跟踪服务器上执行时间超过阈值的 MySQL 查询。这也是一个展示应用程序上下文的工具示例:查询字符串。以下是 BCC 版本的示例输出:

该输出显示了查询的时间偏移、MySQL 服务器的 PID、查询的持续时间(以毫秒为单位)以及查询字符串。类似的功能已经可以通过 MySQL 的慢查询日志获得;使用 BPF,这个工具可以定制化以包含日志中未出现的细节,如查询的磁盘 I/O 和其他资源使用情况。
该工具通过使用 MySQL USDT 探针:`mysql:query__start` 和 `mysql:query__done` 来工作。由于服务器查询的相对低频率,预计该工具的开销很小或可忽略。
**BCC**
命令行使用:
`mysqld_qslower PID [min_ms]`
可以提供一个最小的毫秒阈值;否则,默认为一毫秒。如果提供零,则打印所有查询。
**bpftrace**
以下是为本书开发的 bpftrace 版本代码:

这个程序使用位置参数 $1 来设定毫秒延迟阈值。如果未提供参数,工具默认阈值为零,因此会打印所有查询。由于 MySQL 服务器使用服务线程池,并且同一线程会处理整个请求,所以我可以使用线程 ID 作为请求的唯一标识。这与 @query 和 @start 映射一起使用,以便我可以保存每个请求的查询字符串指针和开始时间戳,然后在请求完成时提取它们。一些示例输出:

执行时必须使用 -p 参数以启用 USDT 探针,就像 BCC 版本需要 PID 一样。这使得命令行使用为:
```
mysqld_qslower.bt -p PID [min_ms]
```
bpftrace: uprobes
如果你的 mysqld 没有编译 USDT 探针,可以使用内部函数的 uprobes 实现类似的工具。先前命令中看到的堆栈跟踪显示了几个可能需要仪器化的函数;例如,从之前的 profile(8) 输出中:


13.2.11 mysqld_clat


这表明查询的时间在 8 到 256 微秒之间,语句执行是双峰的,不同的延迟模式。该方法通过在 USDT 探针 `mysql:command__start` 和 `mysql:command__done` 之间测量时间(延迟),并从开始探针读取命令类型来实现。由于命令的速率通常很低(每秒不到一千个),因此开销应当微不足道。


这包括一个查找表,用于将命令 ID 整数转换为人类可读的字符串,即命令名称。这些名称来自 MySQL 服务器源代码中的 `include/my_command.h`,并且在 USDT 探针参考文献 [155] 中也有记录。
如果 USDT 探针不可用,可以将该工具重写为使用 `dispatch_command()` 函数的 uprobes。为了避免重新编写整个工具,这里提供一个差异对比(diff),突出显示所需的更改:

13.2.12 signals
`signals(8)` 追踪进程信号,并显示信号和目标进程的汇总分布。这是一个有用的故障排除工具,用于调查应用程序为何可能会意外终止,这可能是由于它们接收到信号。示例输出:

该输出显示,`SIGKILL` 只发送了一次到 PID 3022 的 `sleep` 进程,而 `SIGALRM` 发送了 87 次到 PID 1882 的 `Xorg` 进程。它通过追踪 `signal:signal_generate` 跟踪点来工作。由于这些信号发生频率较低,预计开销是微不足道的。`signals(8)` 的源代码是:


这使用查找表将信号编号转换为可读的代码。在内核源代码中,信号编号零没有名称;然而,它被用于健康检查,以确定目标 PID 是否仍在运行。
13.2.13 killsnoop
`killsnoop(8)` 是一个 BCC 和 bpftrace 工具,用于追踪通过 `kill(2)` 系统调用发送的信号。它可以显示是谁发送了信号,但与 `signals(8)` 不同,它不追踪系统上发送的所有信号,只追踪通过 `kill(2)` 发送的信号。示例输出:

这个输出显示了 bash shell 向 PID 3593 发送了信号 9 (KILL)。它通过追踪 `syscalls:sys_enter_kill` 和 `syscalls:sys_exit_kill` 跟踪点来工作。其开销应该是微不足道的。
### BCC
**命令行用法:**
```
killsnoop [options]
```
**选项包括:**
- `-x`:只显示失败的 `kill` 系统调用
- `-p PID`:仅测量指定的进程
### bpftrace
以下是 bpftrace 版本的代码,概述了其核心功能。该版本不支持选项。


这个程序在系统调用入口时存储了目标 PID 和信号,以便在系统调用退出时可以引用和打印。可以像 `signals(8)` 一样进行改进,添加一个信号名称的查找表。
13.2.14 pmlock and pmheld
`pmlock(8)` 和 `pmheld(8)` bpftrace 工具记录 libpthread 互斥锁的延迟和持有时间,以直方图形式显示,并包含用户级堆栈信息。`pmlock(8)` 可用于识别锁竞争问题,然后 `pmheld(8)` 可以显示具体原因:即哪个代码路径负责。以下是以 `pmlock(8)` 开始分析 MySQL 服务器的示例:


最后两个堆栈显示了锁地址 0x7f37280019f0 上的延迟,涉及的代码路径包括 `THD::set_query()`,且延迟时间通常在 4 到 16 微秒范围内。
现在运行 `pmheld(8)`:


这将以直方图的形式显示持有相同锁的路径及其持有时长。根据这些数据,可以采取各种措施:可以调整线程池的大小以减少锁竞争,并且开发人员可以查看持锁的代码路径,以优化它们以减少锁持有时间。
建议将这些工具的输出保存到文件中以便后续分析。例如:
```
# pmlock.bt PID > out.pmlock01.txt
# pmheld.bt PID > out.pmheld01.txt
```
可以提供一个可选的 PID 以仅跟踪该进程 ID,这也有助于减少系统负担。如果不提供,所有 pthread 锁事件会在系统范围内记录。
这些工具通过使用 uprobes 和 uretprobes 对 libpthread 函数进行插桩:`pthread_mutex_lock()` 和 `pthread_mutex_unlock()`。由于这些锁事件可能非常频繁,开销可能会很大。例如,使用 BCC 的 `funccount` 计时一秒钟的情况:


这会在 `pthread_mutex_lock()` 开始时记录时间戳和锁地址,然后在结束时获取这些信息以计算延迟,并将其与锁地址和堆栈跟踪一起保存。`ustack(5)` 可以调整以记录你希望的帧数。
`/lib/x86_64-linux-gnu/libpthread.so.0` 的路径可能需要根据你的系统进行调整。如果调用的软件和 libpthread 没有帧指针,堆栈跟踪可能会失效。(由于跟踪的是库的入口点,可能在没有 libpthread 帧指针的情况下仍然可以工作,因为帧指针寄存器可能尚未被重用。)
`pthread_mutex_trylock()` 的延迟没有被跟踪,因为它被认为是快速的,这也是 try-lock 调用的目的。(可以使用 BCC 的 `funclatency(8)` 进行验证。)


现在,时间测量从 `pthread_mutex_lock()` 或 `pthread_mutex_trylock()` 函数返回(即调用者持有锁)开始,到调用 `unlock()` 时结束。
这些工具使用了 uprobes,但 libpthread 也有 USDT 探针,因此这些工具可以重写为使用这些探针。
13.2.15 naptime
`naptime(8)` 追踪 `nanosleep(2)` 系统调用,并显示谁在调用它以及请求的睡眠持续时间。我编写这个工具是为了调试一个缓慢的内部构建过程,该过程在没有明显操作的情况下会持续几分钟,我怀疑其中包含了自愿的睡眠。输出结果如下:

这段输出捕捉到了 `build-init` 进行的 30 秒的睡眠。我能够追踪到那个程序,并“调整”了这个睡眠时间,使得构建速度提高了 10 倍。这个输出还显示了 `mysqld` 和 `iscsid` 线程每秒都在睡眠一秒钟。(我们在早期工具的输出中也看到过 `mysqld` 的睡眠情况。)有时,应用程序可能将睡眠作为解决其他问题的变通办法,而这种变通办法可能在代码中存在多年,从而导致性能问题。这个工具可以帮助检测到这种问题。
该工具通过追踪 `syscalls:sys_enter_nanosleep` 追踪点来工作,预计开销是微不足道的。
`naptime(8)` 的源代码可以在以下位置找到:

父进程的详细信息是从 `task_struct` 中获取的,但这种方法不稳定,可能需要在 `task_struct` 更改时进行更新。这个工具可以进行改进:可以打印出用户级的堆栈跟踪,以显示导致睡眠的代码路径(前提是代码路径已使用帧指针编译,以便通过 BPF 遍历堆栈)。
13.2.16 Other Tools
另一个 BPF 工具是 `deadlock(8)`,这是 BCC 提供的工具之一,用于检测潜在的死锁问题,特别是锁顺序反转的形式。它构建了一个有向图来表示互斥量的使用情况,以便检测死锁。尽管这个工具的开销可能较高,但它对于调试复杂的死锁问题非常有帮助。


13.3 BPF One-Liners

这些部分展示了 BCC 和 bpftrace 的单行命令。在可能的情况下,相同的单行命令会使用 BCC 和 bpftrace 实现。
13.3.1 BCC
这些是用于性能分析和调试的 BCC 和 bpftrace 命令行工具:
1. **新进程及其参数**:
   - `execsnoop`: 监视系统调用 `execve`,显示新启动的进程及其参数。
2. **按进程统计系统调用次数**:
   - `syscount -P`: 按进程统计系统调用次数。
3. **按系统调用名称统计系统调用次数**:
   - `syscount`: 按系统调用名称统计系统调用次数。
4. **以 49 Hertz 频率采样用户级堆栈,针对 PID 189**:
   - `profile -U -F 49 -p 189`: 以 49 Hertz 频率采样 PID 189 的用户级堆栈。
5. **计算离 CPU 用户堆栈跟踪**:
   - `stackcount -U t:sched:sched_switch`: 统计在调度切换时的用户堆栈跟踪。
6. **采样所有堆栈跟踪和进程名称**:
   - `profile`: 采样所有堆栈跟踪和进程名称。
7. **统计 libpthread 互斥锁函数调用,持续一秒钟**:
   - `funccount -d 1 '/lib/x86_64-linux-gnu/libpthread.so.0:pthread_mutex_*lock'`: 统计 libpthread 中互斥锁相关函数的调用次数。
8. **统计 libpthread 条件变量函数调用,持续一秒钟**:
   - `funccount -d 1 '/lib/x86_64-linux-gnu/libpthread.so.0:pthread_cond_*'`: 统计 libpthread 中条件变量相关函数的调用次数。
13.3.2 bpftrace
1. **显示新进程及其参数:**
   ```bash
   bpftrace -e 'tracepoint:syscalls:sys_enter_execve { join(args->argv); }'
   ```
   这个命令会显示新进程的创建及其参数。
2. **按进程统计系统调用次数:**
   ```bash
   bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[pid, comm] = count(); }'
   ```
   这个命令会统计每个进程的系统调用次数。
3. **按系统调用名称统计系统调用次数:**
   ```bash
   bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'
   ```
   这个命令会按系统调用名称统计系统调用次数。
4. **以49赫兹的频率采样PID为189的用户级堆栈:**
   ```bash
   bpftrace -e 'profile:hz:49 /pid == 189/ { @[ustack] = count(); }'
   ```
   这个命令会以49赫兹的频率采样PID为189的进程的用户级堆栈。
5. **以49赫兹的频率采样名为"mysqld"的进程的用户级堆栈:**
   ```bash
   bpftrace -e 'profile:hz:49 /comm == "mysqld"/ { @[ustack] = count(); }'
   ```
   这个命令会以49赫兹的频率采样名为"mysqld"的进程的用户级堆栈。
6. **统计离CPU的用户堆栈跟踪:**
   ```bash
   bpftrace -e 'tracepoint:sched:sched_switch { @[ustack] = count(); }'
   ```
   这个命令会统计离CPU的用户堆栈跟踪次数。
7. **采样所有堆栈跟踪及进程名称:**
   ```bash
   bpftrace -e 'profile:hz:49 { @[ustack, stack, comm] = count(); }'
   ```
   这个命令会以49赫兹的频率采样所有堆栈跟踪和进程名称。
8. **按用户堆栈跟踪统计malloc()请求的字节总和(开销较大):**
   ```bash
   bpftrace -e 'u:/lib/x86_64-linux-gnu/libc-2.27.so:malloc { @[ustack(5)] = sum(arg0); }'
   ```
   这个命令会统计每个用户堆栈跟踪中malloc()请求的字节总和,注意这可能会产生较大的开销。
9. **跟踪kill()信号,显示发送进程名称、目标PID和信号编号:**
   ```bash
   bpftrace -e 't:syscalls:sys_enter_kill { printf("%s -> PID %d SIG %d\n", comm, args->pid, args->sig); }'
   ```
   这个命令会跟踪kill()信号,并显示发送进程名称、目标PID和信号编号。
10. **统计libpthread互斥锁函数的调用次数,时间为一秒:**
    ```bash
    bpftrace -e 'u:/lib/x86_64-linux-gnu/libpthread.so.0:pthread_mutex_*lock { @[probe] = count(); } interval:s:1 { exit(); }'
    ```
    这个命令会统计libpthread库中互斥锁函数的调用次数,时间为一秒。
11. **统计libpthread条件变量函数的调用次数,时间为一秒:**
    ```bash
    bpftrace -e 'u:/lib/x86_64-linux-gnu/libpthread.so.0:pthread_cond_* { @[probe] = count(); } interval:s:1 { exit(); }'
    ```
    这个命令会统计libpthread库中条件变量函数的调用次数,时间为一秒。
12. **按进程名称统计LLC(最后级缓存)未命中次数:**
    ```bash
    bpftrace -e 'hardware:cache-misses: { @[comm] = count(); }'
    ```
    这个命令会统计每个进程的LLC未命中次数。


13.4 BPF One-Liners Examples

包括一些示例输出,就像对每个工具所做的那样,也有助于说明一行命令的用法。以下是一个选定的示例命令及其示例输出。
13.4.1 Counting libpthread Conditional Variable Functions for One Second


这些 `pthread` 函数可能会被频繁调用,因此为了减少性能开销,只追踪了一秒钟。这些计数显示了条件变量(CV)的使用情况:有线程在等待CV时的定时等待,以及其他线程发送信号或广播以唤醒它们。这个一行命令可以被修改以进一步分析这些情况:包括进程名称、堆栈跟踪、定时等待时长和其他细节。


13.5 Summary

在本章中,我展示了除之前资源导向章节之外的额外 BPF 工具,用于应用程序分析,涵盖了应用程序上下文、线程使用、信号、锁和睡眠。我以 MySQL 服务器作为示例目标应用程序,并通过 USDT 探针和 uprobes 从 BPF 中读取其查询上下文。由于其重要性,本章再次涵盖了使用 BPF 工具进行的 CPU 上和 CPU 外分析。

14 Kernel

内核是系统的核心,同时也是一个复杂的软件体。Linux 内核采用了多种不同的策略来改善 CPU 调度、内存分配、磁盘 I/O 性能和 TCP 性能。像所有软件一样,有时也会出现问题。之前的章节通过对内核进行仪器化来帮助理解应用程序的行为。本章则利用内核仪器化来理解内核软件,这对于内核故障排除和内核开发将会非常有用。
学习目标:
- 继续通过追踪唤醒来进行 CPU 外分析
- 识别内核内存消耗者
- 分析内核互斥锁争用
- 展示工作队列事件的活动
如果你正在研究特定的子系统,应该首先浏览相关前几章中的工具。按 Linux 子系统名称,具体如下:
- sched:第 6 章
- mm:第 7 章
- fs:第 8 章
- block:第 9 章
- net:第 10 章
第 2 章也涵盖了跟踪技术,包括 BPF、tracepoints 和 kprobes。本章重点研究内核而非资源,并包括前几章之外的额外内核主题。我将从背景讨论开始,然后介绍 BPF 能力、内核分析策略、包括 Ftrace 在内的传统工具,以及用于额外分析的 BPF 工具:唤醒、内核内存分配、内核锁、tasklets 和工作队列。


14.1 Background

内核管理对资源的访问并在 CPU 上调度进程。之前的章节已经介绍了许多内核主题。特别是,请参见:
- 第 6.1.1 节,了解 CPU 模式和 CPU 调度程序部分
- 第 7.1.1 节,了解内存分配器、内存页面和交换、页面驱逐守护进程以及文件系统缓存和缓冲部分
- 第 8.1.1 节,了解 I/O 堆栈和文件系统缓存部分
- 第 9.1.1 节,了解块 I/O 堆栈和 I/O 调度程序部分
- 第 10.1.1 节,了解网络堆栈、扩展和 TCP 部分
本章将探讨内核分析的其他主题。
14.1.1 Kernel Fundamentals
唤醒
当线程被阻塞并离开 CPU 等待某个事件时,它们通常会在唤醒事件触发时返回到 CPU。例如,在磁盘 I/O 中:一个线程可能会在进行文件系统读取时被阻塞,该操作会发起一个磁盘 I/O,然后被一个处理完成中断的工作线程唤醒。
在某些情况下,唤醒会形成依赖链:一个线程唤醒另一个线程,而那个线程再唤醒另一个线程,直到最终唤醒被阻塞的应用程序。
图 14-1 展示了一个应用线程如何因系统调用被阻塞并离开 CPU,随后被一个资源线程(可能还有依赖线程)唤醒的过程。

**内核内存分配**
内核中的两个主要分配器是:
- **slab 分配器**:用于固定大小对象的一般用途内存分配器,支持缓存分配和回收以提高效率。在 Linux 中,这被称为 slub 分配器,它基于 slab 分配器的设计,但复杂度较低。
- **page 分配器**:用于分配内存页面。它使用伙伴算法,即寻找相邻的空闲内存页面以便一起分配,同时也支持 NUMA。
这些分配器在第 7 章中提到过,作为应用程序内存使用分析的背景。本章将重点分析内核内存使用情况。
内核内存分配的 API 调用包括 kmalloc()、kzalloc() 和 kmem_cache_alloc()(slab 分配)用于小块内存,vmalloc() 和 vzalloc() 用于大块区域,以及 alloc_pages() 用于页面 [156]。
**内核锁**
第 13 章介绍了用户级锁。内核支持多种类型的锁:自旋锁、互斥锁和读写锁。由于锁会阻塞线程,因此是性能问题的一个来源。
Linux 内核的互斥锁是三种获取路径的混合体,尝试的顺序如下 [157]:
1. 快速路径:使用比较和交换指令(cmpxchg)
2. 中间路径:乐观地自旋,如果锁持有者正在运行,可能会释放
3. 慢路径:阻塞直到锁可用
此外,还有读-复制-更新(RCU)同步机制,允许多个读取与更新同时进行,提高了主要以读取为主的数据的性能和可扩展性。
**Tasklets 和工作队列**
在 Linux 中,设备驱动程序被建模为两部分:上半部分迅速处理中断,并将工作调度到下半部分以便稍后处理 [Corbet 05]。快速处理中断很重要,因为上半部分在禁用中断的模式下运行,以延迟新中断的到达,这可能会导致延迟问题。如果运行时间过长会产生问题。下半部分可以是 tasklets 或工作队列;后者是可以由内核调度的线程,必要时可以进入睡眠状态。这个过程如图 14-2 所示。

14.1.2 BPF Capabilities
BPF 跟踪工具提供了超越内核指标的额外洞察,包括回答如下问题:
- 线程为何离开 CPU?离开的时间有多长?
- 离开 CPU 的线程等待了什么事件?
- 当前谁在使用内核 slab 分配器?
- 内核是否在移动页面以平衡 NUMA?
- 工作队列事件发生了什么?延迟是多少?
- 对于内核开发者:哪些函数被调用了?参数和返回值是什么?延迟如何?
这些问题可以通过在 tracepoints 和内核函数中添加探针来回答,以测量它们的延迟、参数和堆栈跟踪。定时采样堆栈跟踪也能提供 on-CPU 代码路径的视图,因为内核通常编译时支持堆栈(无论是帧指针还是 ORC)。


检查你的内核版本以查看有哪些其他 tracepoints,可以使用 bpftrace 命令:
```bash
bpftrace -l 'tracepoint:*'
```
或者使用 perf 工具:
```bash
perf list tracepoint
```
前面的章节涵盖了资源事件,包括块 I/O 和网络 I/O。


14.2 Strategy

如果你是内核性能分析的新手,以下是一个建议的总体策略,你可以按照这些步骤进行操作。接下来的章节将详细介绍涉及的工具。
1. **创建工作负载**:如果可能的话,创建一个能够触发你感兴趣事件的工作负载,最好是已知次数。这可能涉及编写一个简短的 C 程序。
2. **检查 tracepoints**:检查是否存在能够跟踪事件的 tracepoints,或现有的工具(包括本章介绍的工具)。
3. **进行 CPU 性能分析**:如果事件可以频繁调用,以至于消耗了显著的 CPU 资源(>5%),CPU 性能分析可以快速显示涉及的内核函数。如果事件的调用频率较低,可以使用较长时间的分析以捕获足够的样本进行研究(例如,使用 `perf` 或 BCC 的 `profile`,结合 CPU flame graphs)。CPU 性能分析还会揭示自旋锁和在乐观自旋期间的互斥锁使用情况。
4. **计数相关函数调用**:另一种找到相关内核函数的方法是计数可能匹配事件的函数调用。例如,如果你在分析 ext4 文件系统事件,可以尝试计数所有匹配 "ext4_*" 的调用(使用 BCC 的 `funccount`)。
5. **计数堆栈跟踪**:从内核函数中计数堆栈跟踪,以理解代码路径(使用 BCC 的 `stackcount`)。如果已经使用了性能分析,这些代码路径应该已经是已知的。
6. **跟踪函数调用流程**:通过其子事件跟踪函数调用流程(使用 perf-tools 的基于 Ftrace 的 `funcgraph`)。
7. **检查函数参数**:检查函数参数(使用 BCC 的 `trace` 和 `argdist`,或使用 bpftrace)。
8. **测量函数延迟**:测量函数的延迟(使用 BCC 的 `funclatency` 或 bpftrace)。
9. **编写自定义工具**:编写一个自定义工具来跟踪事件并打印或总结结果。
接下来的章节将展示一些使用传统工具的这些步骤,你可以尝试这些方法,然后再转向 BPF 工具。


14.3 Traditional Tools

很多传统工具在前面的章节中已经介绍过了。这里包含了一些额外的工具,这些工具可以用于内核分析,并在表 14-2 中列出。

14.3.1 Ftrace
Ftrace2 是由 Steven Rostedt 创建的,并在 2008 年添加到 Linux 2.6.27 中。与 perf(1) 类似,Ftrace 是一个多功能工具,具有许多功能。使用 Ftrace 的方式至少有四种:
A. 通过 /sys/kernel/debug/tracing 文件,使用 cat(1) 和 echo(1) 或更高级的语言进行控制。这种用法在内核源代码中的 Documentation/trace/ftrace.rst [158] 文档中有说明。
B. 通过 Steven Rostedt 开发的 trace-cmd 前端 [159][160]。
C. 通过 Steven Rostedt 和其他人开发的 KernelShark GUI [161]。
D. 通过我自己编写的 perf-tools 集合中的工具 [78]。这些是对 /sys/kernel/debug/tracing 文件的 shell 包装器。
我将使用 perf-tools 演示 Ftrace 的功能,但这些方法中的任何一种都可以使用。
**函数计数**
假设我想分析内核中的文件系统预读功能。我可以通过使用 funccount(8)(来自 perf-tools)来计数所有包含 "readahead" 的函数,同时生成一个预期会触发它的工作负载:


**堆栈跟踪**
下一步是了解这些函数的更多信息。Ftrace 可以在事件上收集堆栈跟踪,这些跟踪显示了函数被调用的原因——它们的父函数。通过使用 kprobe(8) 分析上一个输出中的第一个跟踪:

这会在每个事件中打印堆栈跟踪,显示它是在 read() 系统调用期间触发的。kprobe(8) 还允许检查函数参数和返回值。
为了提高效率,这些堆栈跟踪可以在内核上下文中进行频率计数,而不是逐一打印。这需要一个较新的 Ftrace 功能,即 hist triggers(直方图触发器)。示例:


14.3.2 perf sched

此输出显示了每个调度事件的指标,包括被阻塞和等待唤醒的时间(“wait time”)、调度延迟(即运行队列延迟,“sch delay”)以及 CPU 上的运行时间(“run time”)。
14.3.3 slabtop

这个输出显示了大约 43 兆字节的 radix_tree_node 缓存和大约 23 兆字节的 TCP 缓存。对于一个总内存为 180 兆字节的系统来说,这些内核缓存相对较小。这是一个有用的工具,用于排查内存压力问题,检查是否有内核组件意外消耗了大量内存。
14.3.4 Other Tools
`/proc/lock_stat` 显示了内核锁的各种统计信息,但仅在 CONFIG_LOCK_STAT 设置为启用时可用。`/proc/sched_debug` 提供了许多帮助调度器开发的指标。


14.4 BPF Tools

这一部分介绍了用于内核分析和故障排除的附加 BPF 工具。它们显示在图 14-3 中。

这些工具来自于第 4 和第 5 章中介绍的 BCC 和 bpftrace 存储库,或者是为本书创建的。有些工具在 BCC 和 bpftrace 中都有出现。表 14-3 列出了工具的来源(BT 是 bpftrace 的缩写)。

对于 BCC 和 bpftrace 的工具,请查阅它们的存储库,以获取完整和最新的工具选项及功能列表。有关内核分析的更多工具(包括系统调用、网络和块 I/O),请参见之前的章节。接下来的工具总结包括对自旋锁和任务处理器的仪器化讨论。
14.4.1 loads

正如第 6 章中讨论的那样,这些负载平均值并不是特别有用,你应该尽快转向更深入的指标。`loads(8)` 工具可能作为一个示例更有用,它展示了如何获取和打印内核变量,在这种情况下是 `avenrun`。

`kaddr()` 内置函数用于获取 `avenrun` 内核符号的地址,然后对其进行解引用。其他内核变量也可以通过类似的方式获取。
14.4.2 offcputime
在第 6 章中介绍了 `offcputime(8)`。在这一节中,我将探讨它检查任务状态的能力,以及导致本章创建额外工具的问题。
### 非可中断 I/O
匹配 `TASK_UNINTERRUPTIBLE` 线程状态可以揭示应用程序在等待资源时被阻塞的时间。这有助于排除应用程序在工作之间的睡眠时间,这些时间可能会掩盖 `offcputime(8)` 轮廓中的实际性能问题。此 `TASK_UNINTERRUPTIBLE` 时间也包含在 Linux 的系统负载平均值中,这可能会导致混淆,因为人们通常期望它们仅反映 CPU 时间。
测量此线程状态(2)仅适用于用户级进程和内核栈。

仅包含了最后一个栈,这个栈显示了一个 `tar(1)` 进程在通过 XFS 文件系统等待存储 I/O。此命令过滤掉了其他线程状态,包括:
- **TASK_RUNNING (0)**:在这种状态下,线程可能因非自愿的上下文切换而被阻塞,因为 CPU 达到了饱和状态。在这种情况下,中断的栈跟踪并不十分有趣,因为它没有显示线程为何离开 CPU。
- **TASK_INTERRUPTIBLE (1)**:这种状态通常会使输出被许多离 CPU 的栈污染,这些栈显示线程在睡眠和等待工作的代码路径。
过滤这些状态有助于集中输出,显示在应用请求期间的阻塞栈,这些栈对性能的影响更大。
### 不确定的栈
许多 `offcputime(8)` 打印的栈跟踪是不确定的,显示了一个阻塞路径但没有显示其原因。以下是一个示例,显示了一个 `gzip(1)` 进程在五秒钟内的离 CPU 内核栈跟踪:

输出显示在五秒钟中有 4.4 秒处于 `pipe_read()`,但从输出中无法得知 `gzip` 等待的管道另一端是什么,或为什么等待如此之久。栈跟踪只是告诉我们它在等待其他进程。
这种不确定的离 CPU 栈跟踪很常见,不仅在管道中,还在 I/O 和锁争用中。你可能会看到线程在等待锁,但无法看到锁为何不可用(例如,谁持有锁以及他们在做什么)。
使用 `wakeuptime(8)` 检查唤醒栈通常可以揭示等待的另一端是什么。有关 `offcputime(8)` 的更多信息,请参见第六章。
14.4.3 wakeuptime
`wakeuptime(8)` 是一个 BCC 工具,它显示了执行调度器唤醒操作的线程的栈跟踪以及目标被阻塞的时间。这可以用来进一步探索离 CPU 的时间。继续之前的示例:

这个输出显示 `gzip(1)` 进程被阻塞在执行 `vfs_write()` 的 `tar(1)` 进程上。现在我将揭示导致这一负载的命令:
```bash
tar cf - /mnt/data | gzip - > /mnt/backup.tar.gz
```
从这个一行命令中,很明显 `gzip(1)` 大部分时间都在等待 `tar(1)` 提供数据。而 `tar(1)` 又大部分时间在等待来自磁盘的数据,这可以通过 `offcputime(8)` 显示出来。


这个堆栈显示了 `tar(1)` 被阻塞在 `io_schedule()` 上:即块设备 I/O。根据 `offcputime(8)` 和 `wakeuptime(8)` 的输出,你可以看到一个应用程序被阻塞的原因(来自 `offcputime(8)` 的输出),以及随后该应用程序被唤醒的原因(来自 `wakeuptime(8)` 的输出)。有时,唤醒的原因比阻塞的原因更能明确地识别问题的来源。
为了使这些示例简洁,我使用了 `-p` 选项来匹配特定的 PID。如果不指定 `-p`,则可以进行全系统范围的跟踪。
该工具通过跟踪调度函数 `schedule()` 和 `try_to_wake_up()` 来工作。这些函数在繁忙的系统上可能非常频繁,因此开销可能会很大。
命令行用法:
```
wakeuptime [选项] [持续时间]
```
选项包括:
- `-f`:以折叠格式输出,用于生成唤醒时间的火焰图
- `-p PID`:仅对该进程进行跟踪
与 `offcputime(8)` 一样,如果不使用 `-p`,它将进行全系统范围的跟踪,并可能生成数百页的输出。火焰图将帮助你快速浏览这些输出。
14.4.4 offwaketime


这个输出显示了 `tar(1)` 唤醒了 `gzip(1)`,后者在此路径中被阻塞了 4.49 秒。两个堆栈跟踪被显示,并用“--”分隔,顶端的唤醒器堆栈被倒置。这样,堆栈跟踪在中间汇合,显示唤醒器堆栈(顶部)如何唤醒被阻塞的堆栈(底部)。
该工具通过跟踪调度函数 `schedule()` 和 `try_to_wake_up()` 来工作,并将唤醒器堆栈跟踪保存在 BPF 堆栈映射中,以便后续被阻塞线程查找,从而可以在内核上下文中总结。这些函数在繁忙的系统上可能非常频繁,因此开销可能会很大。
命令行用法:
```
offwaketime [选项] [持续时间]
```
选项包括:
- `-f`:以折叠格式输出,用于生成离线唤醒时间的火焰图
- `-p PID`:仅对该进程进行跟踪
- `-K`:仅跟踪内核堆栈
- `-U`:仅跟踪用户级堆栈
如果不使用 `-p`,将进行全系统范围的跟踪,可能会生成数百页的输出。使用 `-p`、`-K` 和 `-U` 等选项将有助于减少开销。
离线唤醒时间火焰图
折叠输出(使用 `-f`)可以使用相同的方向可视化为火焰图:唤醒器堆栈在顶部,倒置,阻塞堆栈在底部。图 14-4 显示了一个示例。

14.4.5 mlock and mheld
`mlock(8)` 和 `mheld(8)` 工具跟踪内核互斥锁的延迟和持有时间,并以直方图的形式显示内核级堆栈。`mlock(8)` 用于识别锁争用问题,而 `mheld(8)` 可以显示问题的原因:哪个代码路径导致了锁的占用。首先使用 `mlock(8)`:


输出包含了许多堆栈跟踪和锁,这里只包含了其中一个。它显示了锁的地址(0xffff9d015738c6e0)、到 `mutex_lock()` 的堆栈跟踪、进程名称(“chrome”),以及 `mutex_lock()` 的延迟。尽管该锁在跟踪期间被获取了数千次,但通常很快。例如,直方图显示有 8303 次锁定时间在 1024 到 2048 纳秒之间(大约一到两个微秒)。现在运行 `mheld(8)`:

这表明相同的进程和堆栈跟踪是该锁的持有者。这些工具通过跟踪 `mutex_lock()`、`mutex_lock_interruptible()` 和 `mutex_trylock()` 内核函数来工作,因为目前还不存在 mutex 跟踪点。由于这些操作可能很频繁,因此在繁忙的工作负载下,跟踪过程中的开销可能会变得显著。

这会计算 `mutex_lock()` 的持续时间,同时也会计算 `mutex_lock_interruptible()` 的时间,仅在其成功返回时。`mutex_trylock()` 不被跟踪,因为它被认为没有延迟。可以为 `mlock(8)` 提供一个可选参数来指定要跟踪的进程 ID;如果不指定,则会跟踪整个系统。


14.4.6 Spin Locks
与之前跟踪的互斥锁一样,目前还没有用于跟踪自旋锁的跟踪点。请注意,自旋锁有几种类型,包括 `spin_lock_bh()`、`spin_lock()`、`spin_lock_irq()` 和 `spin_lock_irqsave()`。它们在 `include/linux/spinlock.h` 中定义如下:

`funccount(8)` 使用 kprobes 对这些函数的入口进行探测。这些函数的返回无法使用 kretprobes 进行跟踪,因此不能直接从这些函数中测量它们的持续时间。可以查看堆栈中更高的函数,以找到可以被跟踪的函数,例如,通过在 kprobe 上使用 `stackcount(8)` 来查看调用堆栈。
我通常使用 CPU 性能分析和火焰图来调试自旋锁性能问题,因为它们会作为消耗 CPU 的函数出现。
14.4.7 kmem
`kmem(8)` 是一个 bpftrace 工具,用于通过堆栈跟踪来跟踪内核内存分配,并打印分配次数、平均分配大小和总分配字节数的统计信息。例如:

这个输出被截断,只显示了最后两个堆栈。第一个堆栈显示了一个 `open(2)` 系统调用,该调用在 Xorg 进程的 `getname_flags()` 过程中导致了一个 slab 分配 (`kmem_cache_alloc()`)。在跟踪期间,这种分配发生了 44 次,平均分配了 4096 字节,总共分配了 180,224 字节。
这通过跟踪 `kmem` 跟踪点来实现。由于内存分配可能很频繁,因此在繁忙的系统上,跟踪开销可能变得可测量。
`kmem(8)` 的源代码在以下位置:

14.4.8 kpages

输出已被截断,只显示了一个堆栈;这个堆栈显示了 Chrome 进程在页面错误期间分配了 11,733 页。此工具通过跟踪 `kmem` 跟踪点来工作。由于内存分配可能非常频繁,因此在繁忙的系统上,跟踪开销可能变得可测量。

14.4.9 memleak
`memleak(8)` 在第七章中介绍:它是一个 BCC 工具,用于显示在跟踪过程中未被释放的内存分配,这可以帮助识别内存增长或泄漏。默认情况下,它跟踪内核内存分配,例如:

这里只包含了一个堆栈,显示了通过 ext4 写入进行的内存分配。有关 `memleak(8)` 的更多信息,请参见第七章。
14.4.10 slabratetop
`slabratetop(8)` 是一个 BCC 和 bpftrace 工具,显示按 slab 缓存名称分类的内核 slab 分配速率,通过直接跟踪 `kmem_cache_alloc()` 实现。这是 `slabtop(1)` 的补充工具,后者显示 slab 缓存的容量(通过 `/proc/slabinfo`)。例如,从一个 48-CPU 的生产实例中:

该输出显示,在这个输出时间间隔内,`kmalloc-4096` 缓存分配了最多的字节。与 `slabtop(1)` 一样,当排查意外的内存压力时,这个工具也很有用。
它通过使用 kprobes 跟踪 `kmem_cache_alloc()` 内核函数来工作。由于这个函数可能被调用得相当频繁,因此在非常繁忙的系统上,这个工具的开销可能会变得显著。
**BCC**
命令行使用方法:
```bash
slabratetop [选项] [时间间隔 [计数]]
```
选项:
- `-C`:不清除屏幕
**bpftrace**
此版本仅按缓存名称计数分配,每秒打印一次带时间戳的输出。

14.4.11 numamove
`numamove(8)` 追踪类型为“NUMA misplaced”的页面迁移。这些页面被移动到不同的 NUMA 节点,以改善内存局部性和整体系统性能。我遇到过生产问题,其中多达 40% 的 CPU 时间用于进行这样的 NUMA 页面迁移;这种性能损失超过了 NUMA 页面平衡的好处。这个工具帮助我监控 NUMA 页面迁移,以防问题再次出现。示例输出:


该输出记录了在 22:48:47 时段内发生的一次 NUMA 页面迁移的高峰:208 次迁移,总共耗时 29 毫秒。列显示了每秒迁移的速率和执行迁移所花费的时间(以毫秒为单位)。请注意,必须启用 NUMA 平衡(`sysctl kernel.numa_balancing=1`)才能进行此活动。
`numamove(8)` 的源代码是:

14.4.12 workq

该输出显示,`kcryptd_crypt()` 工作队列函数被频繁调用,通常耗时在 4 到 32 微秒之间。
这通过追踪 `workqueue:workqueue_execute_start` 和 `workqueue:workqueue_execute_end` 追踪点来实现。
`workq(8)` 的源代码是:


14.4.13 Tasklets
2009 年,Anton Blanchard 提出了一个补丁,添加任务链追踪点,但这些追踪点至今尚未被合并到内核中 [164]。任务链函数,在 `tasklet_init()` 中初始化,可以使用 kprobes 进行追踪。例如,在 `net/ipv4/tcp_output.c` 中:


14.4.14 Other Tools
值得提及的其他内核分析工具包括:
- **runqlat(8)**: 汇总 CPU 运行队列延迟(第 6 章)。
- **syscount(8)**: 按类型和进程汇总系统调用(第 6 章)。
- **hardirq(8)**: 汇总硬中断时间(第 6 章)。
- **softirq(8)**: 汇总软中断时间(第 6 章)。
- **xcalls(8)**: 计时 CPU 跨调用(第 6 章)。
- **vmscan(8)**: 测量 VM 扫描器的缩减和回收时间(第 7 章)。
- **vfsstat(8)**: 统计常见 VFS 操作(第 8 章)。
- **cachestat(8)**: 显示页面缓存统计信息(第 8 章)。
- **biostacks(8)**: 显示带有延迟的块 I/O 初始化堆栈(第 9 章)。
- **skblife(8)**: 测量 `sk_buff` 生命周期(第 10 章)。
- **inject(8)**: 使用 `bpf_override_return()` 修改内核函数以返回错误,用于测试错误路径。是 BCC 工具。
- **criticalstat(8)**: 测量内核中的原子关键区,显示持续时间和堆栈跟踪。默认显示持续时间超过 100 微秒的 IRQ 禁用路径。这是一个 BCC 工具,有助于定位内核中的延迟源。需要 `CONFIG_DEBUG_PREEMPT` 和 `CONFIG_PREEMPTIRQ_EVENTS`。
内核分析通常涉及除工具之外的自定义仪器,而一行代码可以帮助开始开发自定义程序。


14.5 BPF One-Liners

这些部分展示了 BCC 和 bpftrace 的一行命令。在可能的情况下,相同的一行命令会使用 BCC 和 bpftrace 实现。
14.5.1 BCC
按进程计数系统调用:
`syscount -P`
按系统调用名称计数系统调用:
`syscount`
计时内核函数 `vfs_read()` 并以直方图汇总:
`funclatency vfs_read`
频率计数内核函数 "func1" 的第一个整数参数:
`argdist -C 'p::func1(int a):int:a'`
频率计数内核函数 "func1" 的返回值:
`argdist -C 'r::func1():int:$retval'`
将第一个参数强制转换为 `sk_buff` 并频率计数 `len` 成员:
`argdist -C 'p::func1(struct sk_buff *skb):unsigned int:skb->len'`
以 99 赫兹的频率采样内核级堆栈:
`profile -K -F99`
计数上下文切换堆栈跟踪:
`stackcount -p 123 t:sched:sched_switch`
14.5.2 bpftrace
按进程计数系统调用:
`bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[pid, comm] = count(); }'`
按系统调用探针名称计数系统调用:
`bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'`
按系统调用函数计数系统调用:
`bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[ksym(*(kaddr("sys_call_table") + args->id * 8))] = count(); }'`
计数以 "attach" 开头的内核函数调用:
`bpftrace -e 'kprobe:attach* { @[probe] = count(); }'`
计时内核函数 `vfs_read()` 并以直方图汇总:
`bpftrace -e 'k:vfs_read { @ts[tid] = nsecs; } kr:vfs_read /@ts[tid]/ { @ = hist(nsecs - @ts[tid]); delete(@ts[tid]); }'`
频率计数内核函数 "func1" 的第一个整数参数:
`bpftrace -e 'kprobe:func1 { @[arg0] = count(); }'`
频率计数内核函数 "func1" 的返回值:
`bpftrace -e 'kretprobe:func1 { @[retval] = count(); }'`
以 99 赫兹的频率采样内核级堆栈,排除空闲:
`bpftrace -e 'profile:hz:99 /pid/ { @[kstack] = count(); }'`
以 99 赫兹的频率采样 CPU 上的内核函数:
`bpftrace -e 'profile:hz:99 { @[kstack(1)] = count(); }'`
计数上下文切换堆栈跟踪:
`bpftrace -e 't:sched:sched_switch { @[kstack, ustack, comm] = count(); }'`
按内核函数计数工作队列请求:
`bpftrace -e 't:workqueue:workqueue_execute_start { @[ksym(args->function)] = count() }'`
按内核函数计数高精度定时器启动:
`bpftrace -e 't:timer:hrtimer_start { @[ksym(args->function)] = count(); }'`


14.6 BPF One-Liners Examples

包括一些示例输出,就像为每个工具所做的那样,对于说明一行命令的效果也很有帮助。
14.6.1 Counting System Calls by Syscall Function


这个输出显示,在追踪过程中,sys_recvmsg() 函数(可能对应于 recvmsg(2) 系统调用)被调用了最多,共 51,683 次。这条一行命令使用了单一的 `raw_syscalls:sys_enter` tracepoint,而不是匹配所有 `syscalls:sys_enter_*` tracepoints,从而使初始化和终止速度更快。然而,`raw_syscall` tracepoint 仅提供了系统调用的 ID 号;这条命令通过查找内核 `sys_call_table` 中的条目来将其转换为系统调用函数。
14.6.2 Counting hrtimer Starts by Kernel Function

这显示了正在使用的定时器函数;输出捕捉到了 `perf_swevent_hrtimer()`,因为 `perf(1)` 正在进行基于软件的 CPU 性能分析。我编写了这条一行命令来检查正在使用的 CPU 性能分析模式(`cpu-clock` 与 `cycles` 事件),因为软件版本使用了定时器。


14.7 Challenges

在追踪内核函数时遇到的一些挑战:
■ 一些内核函数被编译器内联,这可能使它们对 BPF 追踪不可见。一种解决方法是追踪一个不被内联的父函数或子函数,以完成相同的任务(可能需要使用过滤器)。另一种方法是使用 kprobe 指令偏移追踪。
■ 一些内核函数在特殊模式下运行(例如中断被禁用),或者是追踪框架的一部分,这些函数是不安全的进行追踪。内核将它们列入黑名单,以使其无法被追踪。
■ 任何基于 kprobe 的工具都需要维护以适应内核的变化。已有多个 BCC 工具因适应较新的内核而出现故障并需要修复。长期解决方案是尽可能使用 tracepoints。


14.8 Summary

本章专注于内核分析,作为对之前资源导向章节的补充材料。总结了包括 Ftrace 在内的传统工具,然后更详细地探讨了使用 BPF 的离 CPU 分析,以及内核内存分配、唤醒和工作队列请求。

15 Containers

容器已成为在 Linux 上部署服务的一种常用方法,提供了安全隔离、应用启动时间、资源控制和部署简便性。本章介绍了如何在容器环境中使用 BPF 工具,并涵盖了针对容器的分析工具和方法的一些差异。
学习目标:
■ 了解容器的构成及其追踪目标
■ 理解特权、容器 ID 和 FaaS 相关的挑战
■ 量化容器之间的 CPU 共享
■ 测量 blk cgroup I/O 限制
■ 测量 overlay FS 的性能
本章首先介绍了容器分析的必要背景,然后描述了 BPF 的能力。接着介绍了各种 BPF 工具和一行命令。
分析容器中应用性能所需的知识和工具大多在之前的章节中已经涵盖:对于容器来说,CPU 仍然是 CPU,文件系统仍然是文件系统,磁盘仍然是磁盘。本章专注于容器特有的差异,例如命名空间和 cgroups。


15.1 Background

容器允许在单个主机上执行多个操作系统实例。容器的实现主要有两种方式:
■ 操作系统虚拟化:这涉及使用 Linux 上的命名空间来划分系统,通常与 cgroups 结合进行资源控制。所有容器共享一个内核。这是 Docker、Kubernetes 和其他容器环境采用的方法。
■ 硬件虚拟化:这涉及运行轻量级虚拟机,每个虚拟机都有自己的内核。Intel Clear Containers(现为 Kata Containers)和 AWS 的 Firecracker 使用这种方法。
第 16 章提供了对硬件虚拟化容器分析的一些见解。本章涵盖了操作系统虚拟化容器。典型的 Linux 容器实现如图 15-1 所示。

命名空间限制了系统的视图。命名空间包括 cgroup、ipc、mnt、net、pid、user 和 uts。pid 命名空间限制了容器进程对 /proc 的视图,使其只能看到容器自己的进程;mnt 命名空间限制了可以看到的文件系统挂载;uts 命名空间隔离了 uname(2) 系统调用返回的详细信息,等等。
控制组(cgroup)限制了资源的使用。Linux 内核中有两个版本的 cgroups:v1 和 v2;许多项目(如 Kubernetes)仍在使用 v1。v1 的 cgroups 包括 blkio、cpu、cpuacct、cpuset、devices、hugetlb、memory、net_cls、net_prio、pids 和 rmda。这些可以配置以限制容器之间的资源争用,例如通过对 CPU 和内存使用设置硬限制,或对 CPU 和磁盘使用设置较软的限制(基于共享)。cgroups 也可以形成层次结构,包括容器之间共享的系统 cgroups,如图 15-1 所示。
cgroups v2 解决了 v1 的各种不足,预计容器技术将在未来几年迁移到 v2,而 v1 将最终被弃用。
容器性能分析中的一个常见问题是可能存在“吵闹的邻居”:那些过度消耗资源并导致其他容器访问争用的容器租户。由于这些容器进程都在一个内核下,并可以从主机上同时进行分析,这与传统的多应用程序在时间共享系统上的性能分析类似。主要区别在于,cgroups 可能会在达到硬件限制之前施加额外的软件限制。未更新以支持容器的监控工具可能无法识别这些软限制及其引起的性能问题。
15.1.1 BPF Capabilities
容器分析工具通常基于指标,显示容器、cgroups 和命名空间的存在、设置和大小。BPF 跟踪工具可以提供更多详细信息,回答以下问题:
■ 每个容器的运行队列延迟是多少?
■ 调度器是否在同一 CPU 上切换容器?
■ 是否遇到 CPU 或磁盘的软限制?
通过对调度器事件进行 tracepoints 仪器化和对内核函数进行 kprobes,可以使用 BPF 解答这些问题。如前面章节所讨论的,这些事件(如调度)可能非常频繁,更适合临时分析而非持续监控。
有用于 cgroup 事件的 tracepoints,包括 cgroup:cgroup_setup_root、cgroup:cgroup_attach_task 等。这些高层次事件有助于调试容器启动。
网络数据包程序还可以通过 BPF_PROG_TYPE_CGROUP_SKB 程序类型附加到 cgroup 的 ingress 和 egress(本章未展示)。
15.1.2 Challenges
使用 BPF 跟踪容器时面临的一些挑战包括:
**BPF 权限**  
目前,BPF 跟踪需要 root 权限,这意味着在大多数容器环境中,BPF 跟踪工具只能从主机上执行,而不能在容器内运行。这种情况预计会改变;目前正在讨论非特权 BPF 访问,以解决容器问题。这也在第 11 章第 11.1.2 节中进行了总结。
**容器 ID**  
Kubernetes 和 Docker 等技术使用的容器 ID 由用户空间软件管理。例如(加粗部分):

在内核中,容器是由一组 cgroups 和 namespaces 组成的,但内核空间中没有将这些元素关联起来的标识符。虽然有人建议将容器 ID 添加到内核中,但迄今为止尚未实现。
这在从主机上运行 BPF 跟踪工具时可能会成为问题(如第 15.1.2 节中的“BPF 权限”小节所述)。从主机上,BPF 跟踪工具捕获所有容器的事件,可能希望能够过滤出特定容器的事件或按容器拆分事件。然而,内核中没有可用于过滤或拆分的容器 ID。
幸运的是,虽然每种解决方案取决于特定容器的配置,但仍然有一些变通办法。容器使用了某种组合的 namespaces,可以从内核中的 `nsproxy` 结构体中读取其详细信息。具体来说,可以参考 `linux/nsproxy.h` 头文件中的内容:

这将 `$pidns` 设置为 PID 命名空间 ID(整数),可以打印或过滤。它将与 `/proc/PID/ns/pid_for_children` 符号链接中看到的 PID 命名空间匹配。
如果容器运行时使用了 UTS 命名空间并将 nodename 设置为容器名称(如 Kubernetes 和 Docker 通常所做的那样),则可以从 BPF 程序中获取 nodename,以识别
输出中的容器。例如,使用 bpftrace 语法:

pidnss(8) 工具(详见第 15.3.2 节)执行了这一操作。网络命名空间可以作为分析 Kubernetes pod 的有用标识符,因为 pod 中的容器可能会共享相同的网络命名空间。你可以将这些命名空间标识符添加到前面章节中涵盖的工具中,使其能够识别容器,包括 PID 命名空间 ID 或 UTS nodename 字符串,以及 PID。注意,这仅在仪器处于进程上下文时有效,这样 curtask 才是有效的。
**编排**
在多个容器主机上运行 BPF 工具面临类似于跨多个 VM 的云部署的问题。你的公司可能已经有了用于管理此类任务的编排软件,可以在多个主机上运行给定命令并收集输出。还有一些专门的解决方案,如 kubectl-trace。
kubectl-trace 是一个 Kubernetes 调度器,用于在 Kubernetes 集群中运行 bpftrace 程序。它还提供了一个 `$container_pid` 变量,用于 bpftrace 程序中,指代根进程的 PID。例如,这个命令:
```
kubectl trace run -e 'k:vfs* /pid == $container_pid/ { @[probe] = count() }' mypod -a
```
会计算 mypod 容器应用程序的 kernel vfs*() 调用次数,直到你按 Ctrl-C。程序可以像这个示例一样作为单行命令指定,也可以通过 -f 从文件中读取。kubectl-trace 在第 17 章中有进一步介绍。
**功能即服务(FaaS)**
一种新的计算模型涉及定义服务提供商运行的应用程序函数,这些函数可能运行在容器中。最终用户只定义函数,可能没有 SSH 访问权限来访问运行这些函数的系统。这种环境不支持最终用户运行 BPF 跟踪工具(也不能运行其他工具)。当内核支持非特权 BPF 跟踪时,应用程序函数可能直接进行 BPF 内核调用,但这会带来许多挑战。FaaS 的 BPF 分析可能仅在主机上进行,由具有主机访问权限的用户或接口执行。
15.1.3 Strategy
如果你刚开始进行容器分析,可能会很难确定从哪里入手—选择哪个目标进行分析以及使用哪个工具。以下是一个整体的建议策略,你可以按照这个策略进行。接下来的章节将更详细地解释涉及的工具。
1. 检查系统是否存在硬件资源瓶颈和其他问题(如第6章、第7章等所涵盖的内容)。特别是,为运行中的应用程序创建CPU火焰图。
2. 检查是否遇到cgroup软件限制。
3. 浏览并执行第6章到第14章中列出的BPF工具。
我遇到的大多数容器问题都是由应用程序或硬件问题引起的,而不是容器配置问题。CPU火焰图通常会显示出与容器运行无关的应用程序级别问题。请检查这些问题,同时调查容器限制。


15.2 Traditional Tools

容器可以通过前面章节中介绍的众多性能工具进行分析。这里总结了从主机和容器内部使用传统工具分析容器特定问题的方法。
15.2.1 From the Host

15.2.2 From the Container
传统工具也可以在容器内部使用,但要注意,有些指标可能会涉及整个主机,而不仅仅是容器。表15-2列出了常用工具的状态,基于Linux 4.8内核。

“容器感知”一词用来描述那些在容器中运行时仅显示容器进程和资源的工具。表中的这些工具都不是完全容器感知的。随着内核和工具的更新,这种情况可能会改变。目前,这仍然是容器内性能分析中的一个已知问题。
15.2.3 systemd-cgtop

该输出显示,一个名为“/docker/dcf3a...”的cgroup在此更新间隔内(跨多个CPU)消耗了610.5%的总CPU,并使用了24GB的主内存,且有200个正在运行的任务。输出还显示了由systemd为系统服务(/system.slice)和用户会话(/user.slice)创建的多个cgroup。
15.2.4 kubectl top
Kubernetes容器编排系统提供了一种使用`kubectl top`检查基本资源使用情况的方法。检查主机(“节点”)时:
```
# kubectl top nodes
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
bgregg-i-03cb3a7e46298b38e 1781m 10% 2880Mi 9%
```
“CPU(cores)”时间显示累计的CPU时间毫秒数,“CPU%”显示节点的当前使用情况。检查容器(“pods”)时:
```
# kubectl top pods
NAME CPU(cores) MEMORY(bytes)
kubernetes-b94cb9bff-p7jsp 73m 9Mi
```
这显示了累计的CPU时间和当前的内存大小。这些命令需要运行一个metrics server,根据Kubernetes的初始化方式,它可能默认会被添加。其他监控工具,如cAdvisor、Sysdig和Google Cloud Monitoring,也可以以GUI的形式显示这些指标。
15.2.5 docker stats

这显示了一个UUID为“353426a09db1”的容器在此更新间隔内总共消耗了527%的CPU,并使用了4GB的主内存,而内存限制为8.5GB。在此间隔内,没有网络I/O,仅有少量(以MB为单位)磁盘I/O。
15.2.6 /sys/fs/cgroups

`cpuacct.usage` 文件显示了该 cgroup 的 CPU 使用情况,以总纳秒数表示。`cpu.stat` 文件则显示了该 cgroup 被 CPU 限制(throttled)的次数(`nr_throttled`),以及总的限制时间(以纳秒为单位)。在这个例子中,该 cgroup 在507个时间段中被限制了74次,总共限制了3.8秒。
此外,还有一个 `cpuacct.usage_percpu` 文件,下面是一个显示 Kubernetes cgroup 的示例:

15.2.7 perf
perf(1) 工具在第六章中介绍,可以从主机上运行,并通过 `--cgroup`(或 `-G`)对 cgroup 进行过滤。这可以用于 CPU 性能分析,例如,通过 `perf record` 子命令:
```bash
perf record -F 99 -e cpu-clock --cgroup=docker/1d567... -a -- sleep 30
```
这个事件可以是任何在进程上下文中发生的事件,包括系统调用。这个选项在 `perf stat` 子命令中也可用,因此可以收集事件的计数,而不是将事件写入 `perf.data` 文件。例如,计数读相关的系统调用并显示不同格式的 cgroup 规范(省略了标识符):
```bash
perf stat -e syscalls:sys_enter_read* --cgroup /containers.slice/5aad.../...
```
可以指定多个 cgroup。`perf(1)` 可以跟踪与 BPF 相同的事件,但不具备 BCC 和 bpftrace 提供的编程能力。`perf(1)` 确实有自己的 BPF 接口,示例见附录 D。有关 `perf` 的其他使用方法,可以参考我的 `perf` 示例页面 [73]。


15.3 BPF Tools

这一部分介绍了你可以用于容器性能分析和故障排除的 BPF 工具。这些工具来自 BCC(BPF Compiler Collection)或是为本书专门创建的。表 15-3 列出了这些工具的来源。

15.3.1 runqlat
`runqlat(8)` 在第六章中介绍:它以直方图的形式显示运行队列延迟,有助于识别 CPU 饱和问题。它支持 `--pidnss` 选项以显示 PID 命名空间。例如,在生产容器系统中:

这显示了一个 PID 命名空间(4026532382)比另一个命名空间的运行队列延迟高得多。该工具不会打印容器名称,因为命名空间与容器的映射取决于所使用的容器技术。至少,可以使用 `ls(1)` 命令作为 root 用户来确定给定 PID 的命名空间。例如:
```bash
# ls -lh /proc/181/ns/pid
lrwxrwxrwx 1 root root 0 May 6 13:50 /proc/181/ns/pid -> 'pid:[4026531836]'
```
这显示 PID 181 正在 PID 命名空间 4026531836 中运行。
15.3.2 pidnss
`pidnss(8)` 计算 CPU 在运行一个容器和另一个容器之间切换的次数,通过检测调度程序上下文切换期间的 PID 命名空间切换来实现。这个工具可以用来确认或排除多个容器争用单个 CPU 的问题。例如:

输出显示了两个字段和一个切换计数。字段分别是 PID 命名空间 ID 和节点名称(如果存在)。这个输出显示了一个 PID 命名空间,节点名称为 "bgregg-i-03cb3a7e46298b38e"(主机),在跟踪期间切换到了另一个命名空间 28 次,而另一个命名空间的节点名称为 "6280172ea7b9"(一个 Docker 容器),切换了 27 次。这些详细信息可以从主机上确认:

这通过使用 kprobes 跟踪内核上下文切换路径来实现。对于繁忙的 I/O 工作负载,开销预计会变得显著。以下是另一个示例,这次是在设置 Kubernetes 集群时:

这也是一个提取命名空间标识符的示例。其他命名空间的标识符可以以类似的方式获取。如果需要更多容器特定的细节,超出内核命名空间和 cgroup 信息,这个工具可以移植到 BCC,以便包含直接从 Kubernetes、Docker 等获取详细信息的代码。
15.3.3 blkthrot

在跟踪过程中,我看到 ID 为 1 的 blk cgroup 被限制了 31 次,而不是 506 次。这通过跟踪内核的 `blk_throtl_bio()` 函数来实现。由于块 I/O 通常是相对低频的事件,因此开销应该很小。

这也是一个提取 cgroup ID 的示例,它位于 `cgroup_subsys_state` 结构体中,在这种情况下作为 blkcg 中的 `css`。如果需要,还可以采用不同的方法:在块完成时检查 `bio` 结构体上的 `BIO_THROTTLED` 标志。
15.3.4 overlayfs
`overlayfs(8)7` 跟踪 overlay 文件系统的读写延迟。Overlay FS 常用于容器,因此这个工具提供了容器文件系统性能的视图。例如:

这显示了读写的延迟分布,在 21:21:06 时间间隔内,读取通常需要 16 到 64 微秒。它通过跟踪 overlayfs 文件操作内核函数来实现,开销与这些函数的调用频率相关,对于许多工作负载来说应该是微不足道的。`overlayfs(8)` 的源代码是:


`ovl_read_iter()` 和 `ovl_write_iter()` 函数是在 Linux 4.19 中新增的。这个工具接受 PID 命名空间 ID 作为参数:它是为 Docker 开发的,通过以下 wrapper (`overlayfs.sh`) 运行,该 wrapper 接受 Docker 容器 ID 作为参数。

你可以根据使用的容器技术进行调整。需要这一步的原因在第 15.1.2 节中讨论:内核中没有容器 ID;它是用户空间的构造。这是一个用户空间的 wrapper,用于将容器 ID 转换为内核可以匹配的 PID 命名空间。


15.4 BPF One-Liners

本节展示了 bpftrace 的一行命令示例。
以 99 Hertz 的频率统计 cgroup ID:
`bpftrace -e 'profile:hz:99 { @[cgroup] = count(); }'`
跟踪 cgroup v2 名为 "container1" 的打开文件名:
`bpftrace -e 't:syscalls:sys_enter_openat /cgroup == cgroupid("/sys/fs/cgroup/unified/container1")/ { printf("%s\n", str(args->filename)); }'`


15.5 Optional Exercises

如果未指定,可以使用 bpftrace 或 BCC 完成以下操作:
1. 修改第 6 章中的 `runqlat(8)`,以包括 UTS 命名空间的节点名称(参见 `pidnss(8)`)。
2. 修改第 8 章中的 `opensnoop(8)`,以包括 UTS 命名空间的节点名称。
3. 开发一个工具,以显示哪些容器因内存 cgroup 而发生交换(参见 `mem_cgroup_swapout()` 内核函数)。


15.6 Summary

本章总结了 Linux 容器,并展示了如何通过 BPF 跟踪揭示容器 CPU 竞争和 cgroup 限制的持续时间,以及 overlay FS 的延迟。

16 Hypervisors

本章讨论了在硬件虚拟化中使用 BPF 工具的情况,其中 Xen 和 KVM 是流行的示例。上一章已经讨论了与操作系统级虚拟化(即容器)相关的 BPF 工具。
学习目标:
- 理解虚拟机监控程序(hypervisor)的配置和 BPF 跟踪能力
- 尽可能跟踪来宾(guest)超调用和退出
- 总结被盗取的 CPU 时间
本章首先介绍了硬件虚拟化分析所需的背景知识,描述了针对不同虚拟机监控程序情况的 BPF 能力和策略,并包括了一些示例 BPF 工具。


16.1 Background

一种常见的虚拟机监控程序分类将其分为类型 1 和类型 2。然而,随着这些技术的发展,这些类型不再具有实际的区分意义,因为类型 2 已通过使用内核模块变得类似于类型 1。以下描述了图 16-1 中显示的两种常见配置:
- 配置 A:这种配置被称为原生虚拟机监控程序或裸金属虚拟机监控程序。虚拟机监控程序软件直接在处理器上运行,为来宾虚拟机创建域,并将虚拟来宾 CPU 调度到真实 CPU 上。一个特权域(图 16-1 中的第 0 号)可以管理其他域。一个流行的例子是 Xen 虚拟机监控程序。
- 配置 B:虚拟机监控程序软件由主机操作系统内核执行,可能包括内核级模块和用户级进程。主机操作系统具有管理虚拟机监控程序的特权,其内核将虚拟机 CPU 与主机上的其他进程一起调度。通过使用内核模块,这种配置也提供对硬件的直接访问。一个流行的例子是 KVM 虚拟机监控程序。
这两种配置可能涉及在域 0(Xen)或主机操作系统(KVM)中运行 I/O 代理(例如 QEMU 软件)来处理来宾 I/O。这增加了 I/O 的开销,多年来通过添加共享内存传输和其他技术进行了优化。
原始硬件虚拟机监控程序由 VMware 于 1998 年首创,使用二进制翻译来实现完整的硬件虚拟化。此后已经通过以下方式改进:
- 处理器虚拟化支持:AMD-V 和 Intel VT-x 扩展在 2005-2006 年引入,以提供处理器对虚拟机操作的更快硬件支持。
- 半虚拟化(paravirt 或 PV):与运行未修改的操作系统不同,半虚拟化使操作系统能够意识到它正在硬件虚拟机上运行,并向虚拟机监控程序发出特殊调用(超调用),以更高效地处理某些操作。为提高效率,Xen 将这些超调用批处理成一个多调用。
- 设备硬件支持:为了进一步优化虚拟机性能,处理器以外的硬件设备也添加了虚拟机支持,包括网络和存储设备的 SR-IOV 以及用于使用它们的特殊驱动程序:ixgbe、ena 和 nvme。
多年来,Xen 已经演变并提高了其性能。现代 Xen 虚拟机通常以硬件虚拟机模式(HVM)启动,然后使用带有 HVM 支持的 PV 驱动程序,以实现两者的最佳效果:一种称为 PVHVM 的配置。通过完全依赖硬件虚拟化来改进某些驱动程序(如网络和存储设备的 SR-IOV)可以进一步提升性能。
2017 年,AWS 推出了 Nitro 虚拟机监控程序,部分基于 KVM,并为所有主要资源(处理器、网络、存储、中断和计时器)提供硬件支持。不使用 QEMU 代理。
16.1.1 BPF Capabilities
由于硬件虚拟机运行自己的内核,它们可以使用来自来宾的 BPF 工具。BPF 可以帮助回答以下问题:
- 虚拟化硬件资源的性能如何?可以使用前几章描述的工具来回答。
- 如果使用了半虚拟化,那么超调用延迟是多少,以衡量虚拟机监控程序的性能?
- 被窃取的 CPU 时间的频率和持续时间是多少?
- 虚拟机监控程序的中断回调是否干扰了应用程序?
如果从主机运行,BPF 可以回答更多问题(主机访问对云计算提供商开放,但对终端用户不可用):
- 如果使用 QEMU,来宾应用了什么工作负载?结果性能如何?
- 对于配置 B 虚拟机监控程序,来宾为什么会退出到虚拟机监控程序?
硬件虚拟机监控程序的 BPF 分析是另一个可能有未来发展的领域,增加了更多功能和可能性。未来的工作在后续工具部分中提到。
AWS EC2 客户机
随着虚拟机监控程序通过从仿真到半虚拟化再到硬件支持来优化性能,从来宾的追踪目标减少了,因为事件已转移到硬件。这在 AWS EC2 实例及其可以追踪的虚拟机监控程序目标的演变中变得明显,列表如下:
- PV:超调用(多调用)、虚拟机监控程序回调、驱动程序调用、被窃取的时间
- PVHVM:虚拟机监控程序回调、驱动程序调用、被窃取的时间
- PVHVM+SR-IOV 驱动程序:虚拟机监控程序回调、被窃取的时间
- KVM(Nitro):被窃取的时间
最新的虚拟机监控程序 Nitro 设计上在来宾中运行的代码几乎没有虚拟机监控程序特有的部分。这是有意为之:它通过将虚拟机监控程序功能移到硬件上来提高性能。
16.1.2 Suggested Strategies
首先确定使用的是哪种硬件虚拟机监控程序配置。是否使用了超调用或特殊设备驱动程序?
对于来宾:
1. 对超调用进行监控(如果在使用),检查是否存在过多操作。
2. 检查 CPU 被窃取的时间。
3. 使用前几章中的工具进行资源分析,考虑到这些是虚拟资源。它们的性能可能受限于虚拟机监控程序或外部硬件施加的资源控制,也可能与其他来宾的访问发生争用。
对于主机:
1. 监控虚拟机退出情况,检查是否存在过多操作。
2. 如果使用了 I/O 代理(如 QEMU),监控其工作负载和延迟。
3. 使用前几章中的工具进行资源分析。
随着虚拟机监控程序将功能转移到硬件上(如 Nitro),需要使用前几章中的工具进行更多分析,而不是专门针对虚拟机监控程序的工具。


16.2 Traditional Tools

用于虚拟机监控程序性能分析和故障排除的工具不多。从来宾系统来看,在某些情况下,有针对超调用的跟踪点,如第16.3.1节所示。
从主机系统来看,Xen 提供了自己的工具,包括 `xl top` 和 `xentrace`,用于检查来宾资源使用情况。对于 KVM,Linux 的 `perf(1)` 工具具有 `kvm` 子命令。示例输出:

这显示了虚拟机退出的原因以及每种原因的统计数据。在这个示例输出中,持续时间最长的退出原因是 HLT(停止),因为虚拟 CPU 进入了空闲状态。
KVM 事件(包括退出)有跟踪点,可以与 BPF 一起使用,创建更详细的工具。


16.3 Guest BPF Tools

本节介绍了用于来宾虚拟机性能分析和故障排除的 BPF 工具。这些工具来自第4章和第5章中介绍的 BCC 和 bpftrace 仓库,或者是为本书创建的工具。
16.3.1 Xen Hypercalls
如果来宾系统使用了半虚拟化(paravirt)并发出了超调用,可以使用现有工具进行仪器化,例如 `funccount(8)`、`trace(8)`、`argdist(8)` 和 `stackcount(8)`。甚至可以使用 Xen 的跟踪点。测量超调用延迟需要自定义工具。
Xen PV
例如,这个系统已经启动到半虚拟化(PV)模式:
```shell
# dmesg | grep Hypervisor
[ 0.000000] Hypervisor detected: Xen PV
```
使用 BCC 的 `funccount(8)` 来计算可用的 Xen 跟踪点:

`xen_mc` 跟踪点用于多重调用(multicalls):批量超调用。这些调用以 `xen:xen_mc_batch` 开始,然后对于每个超调用依次有 `xen:xen_mc_entry` 调用,最后以 `xen:xen_mc_issue` 结束。实际的超调用只在一个刷新操作中发生,这由 `xen:xen_mc_flush` 跟踪。作为性能优化,有两种“懒惰”半虚拟化模式,其中会忽略问题,允许多重调用进行缓冲并在之后刷新:一种用于 MMU 更新,一种用于上下文切换。
各种内核代码路径被 `xen_mc_batch` 和 `xen_mc_issue` 包围,以将可能的 `xen_mc_calls` 分组。但是如果没有进行 `xen_mc_calls`,则 `issue` 和 `flush` 针对零个超调用。
接下来的 `xenhyper(8)` 工具是使用这些跟踪点的一个示例。尽管有很多跟踪点可用,但由于 Xen PV 客户端变得不那么常用,逐渐被 HVM 客户端(PVHVM)取代,因此更多类似的工具可能会被编写,但不幸的是,我只包括了一个工具作为演示,还有以下的一些单行命令。
Xen PV:超调用计数
可以通过 `xen:xen_mc_flush` 跟踪点以及其 `mcidx` 参数来计算已发出的超调用数量,该参数显示了进行了多少次超调用。例如,使用 BCC 的 `argdist(8)`:

这个频率统计每次刷新时发出的超调用数量。如果计数为零,则没有发出超调用。上述输出显示每秒大约 130 个超调用,并且在跟踪过程中没有出现每批次超过一个超调用的批量情况。
Xen PV:超调用堆栈
每个 Xen 跟踪点可以使用 `stackcount(8)` 进行跟踪,以揭示触发它们的代码路径。例如,跟踪多重调用被发出时的情况:


过多的多重调用(超调用)可能会成为性能问题,这些输出有助于揭示其原因。超调用跟踪的开销取决于它们的频率,对于繁忙的系统,这种频率可能很高,从而导致明显的开销。
Xen PV:超调用延迟
真正的超调用只在刷新操作期间发生,并且没有跟踪点用于标记其开始和结束。您可以切换到 kprobes 来跟踪包含实际超调用的 `xen_mc_flush()` 内核函数。使用 BCC 的 `funclatency(8)`:


这可以成为从客体系统测量虚拟机监控程序性能的重要指标。可以编写 BCC 工具来记录哪些超调用被批量处理,以便按超调用操作类型细分超调用延迟。
另一种确定超调用延迟问题的方法是尝试 CPU 性能分析,如第六章所述,并检查在超调用中花费的 CPU 时间,这可能在 `hypercall_page()` 函数(实际上是一个超调用函数表)或 `xen_hypercall*()` 函数中找到。示例如图 16-2 所示。

这显示了一个 TCP 接收代码路径以 `hypercall_page()` 结束。请注意,这种 CPU 性能分析方法可能会产生误导,因为可能无法从客体系统中采样某些超调用代码路径。这是因为 PV 客体通常无法访问基于 PMC 的性能分析,而会默认使用基于软件的分析,这种方法无法在禁用 IRQ 的代码路径中进行采样,这些代码路径可能包括超调用。此问题在第六章的 6.2.4 节中描述。
Xen HVM
对于 HVM 客体,通常不会触发 xen 跟踪点:

这是因为这些代码路径不再进行超调用,而是进行本地调用,这些调用被 HVM 虚拟机监控程序捕获并处理。这使得检查虚拟机监控程序性能变得更加困难:必须使用前面章节中介绍的常规资源导向工具进行检查,注意这些资源是通过虚拟机监控程序访问的,因此观察到的延迟包括资源延迟和虚拟机监控程序延迟。
16.3.2 xenhyper
`xenhyper(8)` 是一个 bpftrace 工具,用于通过 `xen:xen_mc_entry` 跟踪点统计超调用,并打印超调用名称的计数。这仅对以 paravirt 模式启动并使用超调用的 Xen 客体有用。示例输出:


这使用一个基于内核源代码映射的转换表来将超调用操作编号转换为名称。由于这些映射会随时间变化,因此需要根据你的内核版本进行更新。`xenhyper(8)` 可以通过修改 @map 键来定制,包括如进程名称或用户栈跟踪等超调用的详细信息。
16.3.3 Xen Callbacks
这些调用发生在 Xen 调用客体时,例如 IRQ 通知,而不是客体向虚拟机监控程序发起超调用。对于这些调用,`/proc/interrupts` 中有每个 CPU 的计数。

每个数字表示一个 CPU 的计数(这是一个八核系统)。这些调用也可以通过 BPF 进行跟踪,方法是对内核函数 `xen_evtchn_do_upcall()` 使用 kprobe。例如,可以使用 bpftrace 统计哪个进程被中断:

输出显示大多数时间 CPU 空闲线程(“swapper/*”)被 Xen 回调中断。这些中断的延迟也可以测量,例如,使用 BCC 的 `funclatency(8)` 工具。


这表明大多数情况下,处理时间在 1 到 32 微秒之间。有关中断类型的更多信息可以通过跟踪 `xen_evtchn_do_upcall()` 的子函数来获取。
16.3.4 cpustolen
`cpustolen(8)` 是一个 bpftrace 工具,用于显示被偷取的 CPU 时间的分布,展示时间是被短时间还是长时间偷取的。这是指客体无法使用的 CPU 时间,因为这些时间被其他客体使用(在某些虚拟机监控程序配置中,这也可以包括由其他域的 I/O 代理为该客体使用的 CPU 时间,因此“偷取”这个术语可能会误导)。示例输出:

输出显示,大多数时间没有被偷取的 CPU 时间(即 “[0]” 桶),但有四次被偷取的时间在 8 到 16 微秒范围内。包括 “[0]” 桶在输出中是为了计算偷取时间与总时间的比例:在这种情况下为 0.1%(32 / 30416)。这个工具通过使用 Xen 和 KVM 版本的 kprobes 跟踪 `stolen_clock` paravirt ops 调用:`xen_stolen_clock()` 和 `kvm_stolen_clock()`。它在许多频繁事件(如上下文切换和中断)中被调用,因此根据工作负载的不同,这个工具的开销可能会很明显。`cpustolen(8)` 的源代码是:

这将需要针对 Xen 和 KVM 以外的虚拟机监控程序进行更新。其他虚拟机监控程序可能会有类似的 `steal_clock` 函数,以满足 paravirt ops (pv_ops) 表。请注意,还有一个更高级的函数 `paravirt_steal_clock()`,它听起来更适合进行跟踪,因为它不是绑定到特定的虚拟机监控程序类型。然而,它不适合进行跟踪(可能已被内联)。
16.3.5 HVM Exit Tracing
随着从 PV 到 HVM 客户机的迁移,我们失去了对显式 hypercalls 的仪器化能力,但客户机仍然会因为资源访问而向虚拟机监控程序发出退出请求,我们希望跟踪这些请求。当前的方法是使用前几章中的所有现有工具分析资源延迟,同时考虑到这些延迟中可能有一部分与虚拟机监控程序相关,而我们无法直接测量这一点。我们可能通过比较裸金属机器上的延迟测量来推断这一点。一项有趣的研究原型是 hyperupcalls [Amit 18],它提供了一种安全的方式让客户机请求虚拟机监控程序运行一个小程序;其示例用例包括从客户机进行虚拟机监控程序跟踪。它们通过在虚拟机监控程序中扩展 BPF VM 实现,客户机编译 BPF 字节码以运行。目前没有云服务提供商提供这一技术(可能永远不会),但它是另一个使用 BPF 的有趣项目。


16.4 Host BPF Tools

这一部分涵盖了您可以用于主机端虚拟机性能分析和故障排除的 BPF 工具。这些工具要么来自于第4章和第5章中介绍的 BCC 和 bpftrace 仓库,要么是为本书专门创建的。
16.4.1 kvmexits

此输出显示了按类型分布的退出情况,包括退出代码编号和退出原因字符串(如果已知)。最长的退出时间达到一秒钟,发生在 HLT(暂停)指令上,这是正常行为:这是 CPU 空闲线程。输出还显示了 IO_INSTRUCTIONS 占用了最多八毫秒。此功能通过跟踪 kvm:kvm_exit 和 kvm:kvm_entry 追踪点实现,这些追踪点仅在内核 KVM 模块用于加速性能时才会使用。kvmexit(8) 的源代码是:

一些 KVM 配置不使用内核 KVM 模块,因此所需的追踪点不会触发,这样工具就无法测量来宾退出情况。在这种情况下,可以直接使用 uprobes 对 qemu 进程进行仪器化,以读取退出原因。(添加 USDT 追踪点会更好。)
16.4.2 Future Work
在 KVM 和类似的虚拟机监控程序中,来宾 CPU 会作为进程出现,这些进程会显示在包括 `top(1)` 在内的工具中。这使我想知道是否可以回答以下问题:
- 来宾在 CPU 上正在做什么?可以读取函数或堆栈跟踪吗?
- 为什么来宾会进行 I/O 操作?
主机可以采样 CPU 上的指令指针,并且在 I/O 操作发生时,可以根据其退出到虚拟机监控程序来读取它。例如,可以使用 bpftrace 显示 I/O 指令上的指令指针:

然而,主机缺乏符号表来将这些指令指针转换为函数名称,也没有进程上下文来确定使用哪个地址空间或甚至哪个进程正在运行。关于这些问题的可能解决方案已经讨论了多年,包括在我最近的一本书 [Gregg 13b] 中。这些解决方案包括读取 CR3 寄存器以获取当前页表的根,以尝试确定哪个进程正在运行,并使用来宾提供的符号表。
这些问题目前可以通过来自来宾的仪器化来回答,但主机无法做到这一点。


16.5 Summary

本章总结了硬件虚拟机监控程序,并展示了如何使用 BPF 追踪来揭示来自来宾和主机的详细信息,包括超调用、被盗 CPU 时间以及来宾退出。

17 Other BPF Performance Tools

本章介绍了基于 BPF 的其他可观察性工具,这些工具都是开源的,可以在网上免费获取。(感谢我的同事 Jason Koch,他在 Netflix 性能工程团队中开发了本章的大部分内容。)
虽然本书包含了几十种命令行 BPF 工具,但预计大多数人将最终通过图形用户界面(GUI)使用 BPF 追踪。这对于由成千上万甚至数十万个实例组成的云计算环境尤其如此,这些环境通常通过 GUI 管理。研究前几章中涉及的 BPF 工具应有助于您使用和理解这些基于 BPF 的 GUI,这些 GUI 是相同工具的前端。
本章讨论的 GUI 和工具包括:
- Vector 和 Performance Co-Pilot (PCP):用于远程 BPF 监控
- Grafana 与 PCP:用于远程 BPF 监控
- eBPF Exporter:用于 BPF 与 Prometheus 和 Grafana 的集成
- kubectl-trace:用于追踪 Kubernetes pods 和节点
本章的作用是展示一些基于 BPF 的 GUI 和自动化工具的可能性,以这些工具为例进行说明。本章包含每个工具的部分,总结工具的功能、内部结构和使用方法,并提供进一步的参考。请注意,这些工具在写作时正在快速开发中,功能可能会不断增长。


17.1 Vector and Performance Co-Pilot (PCP)

Netflix Vector 是一个开源的主机级性能监控工具,可以近实时地可视化高分辨率的系统和应用指标。它作为一个 Web 应用程序实现,利用了经过实战检验的开源系统监控框架 Performance Co-Pilot (PCP),并在其上构建了一个灵活且用户友好的界面。该界面每秒或更长时间轮询一次指标,将数据呈现在完全可配置的仪表板上,从而简化了跨指标的关联和分析。

图 17-1 显示了 Vector 如何在本地 Web 浏览器中运行,从 Web 服务器获取应用程序代码,然后直接连接到目标主机和 PCP 以执行 BPF 程序。请注意,内部 PCP 组件在未来版本中可能会发生变化。
Vector 的特点包括:
- 提供高级仪表板,显示运行实例在多个资源(CPU、磁盘、网络、内存)上的利用情况。
- 提供超过 2000 个指标用于更深入的分析。可以通过修改性能指标域代理(PMDAs)的配置来添加或移除指标。
- 实时数据可视化,精确到秒级。
- 可以同时比较不同指标和不同主机的数据,包括容器与主机的指标比较。例如,可以同时比较容器和主机的资源利用情况,查看它们的相关性。
Vector 现在除了使用其他数据源外,还支持基于 BPF 的指标。这得益于为访问 BPF 的 BCC 前端添加了一个 PCP 代理。BCC 在第 4 章中介绍。
17.1.1 Visualizations
Vector 可以以多种格式向用户展示数据。时间序列数据可以通过折线图进行可视化,如图 17-2 所示。
Vector 还支持其他图表类型,这些图表更适合可视化每秒 BPF 直方图和每事件日志生成的数据,具体包括热图和表格数据。

17.1.2 Visualization: Heat Maps
热图可以用来显示随时间变化的直方图,非常适合可视化每秒 BPF 延迟直方图汇总。延迟热图的两个轴都表示时间,由桶组成,每个桶显示特定时间和延迟范围内的计数 [Gregg 10]。轴的定义如下:
■ x 轴:时间的流逝,每列代表一秒(或一个间隔)
■ y 轴:延迟
■ z 轴(颜色饱和度):显示落入该时间和延迟范围内的 I/O 数量
虽然可以使用散点图来可视化时间和延迟,但当 I/O 数量达到数千或数百万时,点会重叠在一起,细节丢失。热图通过根据需要调整其颜色范围来解决这个问题。
在 Vector 中,热图通常可用于相关的 BCC 工具。目前包括 block I/O 延迟的 biolatency(8)、CPU 运行队列延迟的 runqlat(8) 以及用于监控文件系统延迟的 ext4、xfs 和 zfs-dist 工具。通过配置 BCC PMDA(在 17.1.5 节中解释)并在 Vector 中启动适当的 BCC 图表,可以看到这些输出的可视化结果。图 17-3 显示了在主机上收集的两秒样本的 block I/O 延迟,运行一些简单的 fio(1) 任务。

你可以看到,最常见的块延迟在 256 微秒到 511 微秒范围内,而在光标位置,工具提示显示该桶中有 805 个样本。
作为对比,以下是命令行工具 `biolatency(8)` 捕获类似时间段的结果:

在汇总图中可以看到相同的延迟,但热图更容易观察随时间变化的情况。同时,可以明显看出 128 毫秒到 256 毫秒范围内的 I/O 延迟是持续的,而不是短时间的突发现象。
许多 BPF 工具不仅生成延迟直方图,还包括字节大小、运行队列长度和其他指标:这些都可以通过 Vector 热图进行可视化。
17.1.3 Visualization: Tabular Data
除了可视化数据,查看原始数据表格也很有帮助。这对某些 BCC 工具尤其有用,因为表格可以提供额外的背景信息,或帮助理解一系列值。
例如,可以监控 `execsnoop(8)` 输出,显示最近启动的进程列表。如图 17-4 所示,Tomcat(catalina)进程正在被启动。表格适合用于可视化这些事件细节。

例如,你可以使用 `tcplife(8)` 监控 TCP 套接字,显示主机地址和端口详情、传输的字节数以及会话持续时间。如图 17-5 所示。(`tcplife(8)` 在第 10 章中介绍。)

在这种情况下,你可以看到 `amazon-ssm-agent`,它似乎正在进行 20 秒的长轮询,并且执行了一个 `wget(1)` 命令,该命令在 41.595 秒内接收了 2 GB 的数据。
17.1.4 BCC Provided Metrics
bcc-tools 包中的大多数工具目前都可以通过 PCP PMDA 使用。
Vector 为以下 BCC 工具预配置了图表:
- `biolatency(8)` 和 `biotop(8)`
- `ext4dist(8)`、`xfsdist(8)` 和 `zfsdist(8)`
- `tcplife(8)`、`tcptop(8)` 和 `tcpretrans(8)`
- `runqlat(8)`
- `execsnoop(8)`
这些工具中的许多支持在主机上提供的配置选项。还可以将其他 BCC 工具添加到 Vector 中,并使用自定义图表、表格或热图来可视化数据。
Vector 还支持为 tracepoints、uprobe 和 USDT 事件添加自定义事件指标。
17.1.5 Internals
Vector 本身是一个完全在用户浏览器内运行的 web 应用程序。它是使用 React 构建的,并利用 D3.js 进行图表绘制。指标数据通过 Performance Co-Pilot [175] 收集并提供,该工具包用于从多个操作系统中收集、归档和处理性能指标。一个典型的 Linux PCP 安装默认提供超过 1000 个指标,并且可以通过自定义插件或 PMDA 扩展。
要理解 Vector 如何可视化 BPF 指标,首先需要了解 PCP 如何收集这些指标(见图 17-6):

- **PMCD**(性能指标收集守护进程)是 PCP 的核心组件。它通常在目标主机上运行,并协调从多个代理处收集指标。
- **PMDA**(性能指标域代理)是指由 PCP 托管的代理。许多 PMDA 可用,每个 PMDA 可以暴露不同的指标。例如,有用于收集内核数据的代理、用于不同文件系统的代理、用于 NVIDIA GPU 的代理等。要在 PCP 中使用 BCC 指标,必须安装 BCC PMDA。
- **Vector** 是一个单页面 web 应用,可以部署到服务器或本地执行,并允许连接到目标 pmwebd 实例。
- **pmwebd** 作为一个 REST 网关,与目标主机上的 pmcd 实例进行交互。Vector 连接到暴露的 REST 端口,并通过此端口与 pmcd 进行交互。
PCP 的无状态模型使其轻量且稳健。其对主机的开销几乎可以忽略不计,因为客户端负责跟踪状态、采样率和计算。此外,指标不会在主机间汇总或在用户浏览器会话之外持久化,从而保持了框架的轻量。
17.1.6 Installing PCP and Vector
要尝试 PCP 和 Vector,您可以在单台主机上同时运行它们进行本地监控。在实际的生产部署中,您可能会将 Vector 部署在与 PCP 代理和 PMDA 不同的主机上。有关详细信息,请参阅最新的项目文档。
安装 Vector 的步骤已在网上记录并更新 [176][177]。目前,这些步骤包括安装 `pcp` 和 `pcp-webapi` 包,并从 Docker 容器中运行 Vector UI。请遵循以下附加说明以确保启用 BCC PMDA:

17.1.7 Connecting and Viewing Data
浏览到 http://localhost/(如果在本地机器上测试)或 Vector 安装的适当地址。在弹出的对话框中输入目标系统的主机名,如图 17-7 所示。

连接区域将显示一个新连接。如图 17-8 所示,图标应很快变为绿色 (1),并且大按钮将变为可用。这个示例将使用特定的图表,而不是预设的仪表板,因此切换到 Custom 标签 (2),然后选择 runqlat (3)。任何在服务器上不可用的模块将被变暗并不可用。点击启用的模块,然后点击仪表板上的 ^ (4) 箭头以关闭仪表板。

在连接对话框中,切换到 Custom 标签并查看 BCC/BPF 选项,您可以看到可用的 BCC/BPF 指标。在这种情况下,许多 BPF 程序会显得灰暗,因为它们在 PMDA 中没有启用。当您选择 runqlat 并关闭仪表板面板时,将显示一个运行队列延迟热图,该图每秒实时更新,如图 17-9 所示。这使用了 runqlat(8) BCC 工具。

17.1.8 Configuring the BCC PMDA
如前所述,许多 BCC PMDA 功能在未专门配置的情况下不可用。BCC PMDA 的手册页(pmdabcc(1))详细描述了配置文件的格式。以下步骤展示了如何配置 tcpretrans BCC 模块,以便在 Vector 中使其可用,从而查看 TCP 会话统计信息。


17.1.9 Future Work
在 Vector 和 PCP 之间仍需进一步工作,以提升与 BCC 工具全集的集成。多年来,Vector 一直为 Netflix 提供了详细的主机指标解决方案。Netflix 目前正在调查 Grafana 是否也能提供这种能力,这将允许更多的开发重点放在主机和指标上。Grafana 相关内容请参见第 17.2 节。
17.1.10 Further Reading
For more information on Vector and PCP, see:
■ https://getvector.io/
■ https://pcp.io/


17.2 Grafana and Performance Co-Pilot (PCP)

Grafana 是一个流行的开源图表和可视化工具,支持连接和显示存储在许多后端数据源中的数据。通过使用 Performance Co-Pilot (PCP) 作为数据源,您可以可视化 PCP 中暴露的任何指标。PCP 的详细内容请参见第 17.1 节。
有两种方法可以配置 PCP 以支持在 Grafana 中展示指标:一种是展示历史数据,另一种是展示实时指标数据。每种方法有稍微不同的使用场景和配置。
17.2.1 Installation and Configuration
在 Grafana 中展示 PCP 数据有两种选项:
- **Grafana PCP 实时数据源**:使用 `grafana-pcp-live` 插件。此插件会轮询 PCP 实例以获取最新的指标数据,并在浏览器中保留短时间的历史记录(几分钟)。数据不会长期保存。其优点是当您不在查看时,监控系统不会有负担,非常适合对主机上各种实时指标进行深入查看。
- **Grafana PCP 存档数据源**:使用 `grafana-pcp-redis` 插件。此插件从数据源中提取数据,使用 PCP 的 pmseries 数据存储,并将数据汇总到 Redis 实例中。这依赖于已配置的 pmseries 实例,意味着 PCP 会轮询并存储数据。此方法更适合收集较大的时间序列数据,适用于跨多个主机查看。
假设您已经完成了第 17.1 节中描述的 PCP 配置步骤。
对于这两种选项,项目正在不断变化,因此安装的最佳方法是参阅第 17.2.4 节中的链接,并查看每个插件的安装说明。
17.2.2 Connecting and Viewing Data
`grafana-pcp-live` 插件正在积极开发中。撰写时,连接后端的方法依赖于 PCP 客户端所需的变量设置。由于此插件没有存储功能,因此仪表板可以动态重新配置,以连接到多个不同的主机。这些变量包括 `_proto`、`_host` 和 `_port`。
请按以下步骤操作:
1. 创建一个新的仪表板。
2. 进入仪表板设置。
3. 为仪表板创建变量,并根据要求配置这些变量。
4. 根据需要设置变量配置。
你可以在图 17-10 中看到结果(在“主机”字段中填入适当的主机)。


还需要配置合适的可视化(参见图 17-12)。在这种情况下,选择“热力图”可视化,将格式设置为“时间序列桶”,单位设置为“微秒(μs)”。桶边界应设置为“上限”(参见图 17-13)。

17.2.3 Future Work
Grafana 和 PCP 之间的集成仍需改进,以支持完整的 bcc-tools 套件。未来的更新中,希望能支持可视化自定义 bpftrace 程序。此外,`grafana-pcp-live` 插件还需要进行显著的改进,才能被认为是经过严酷测试的。
17.2.4 Further Reading
The following links are quite likely to change as the projects mature:
■ grafana-pcp-live data source:
https://github.com/Netflix-Skunkworks/grafana-pcp-live/
■ grafana-pcp-redis data source:
https://github.com/performancecopilot/grafana-pcp-redis/


17.3 Cloudflare eBPF Prometheus Exporter (with Grafana)

Cloudflare eBPF 导出器是一个开源工具,它集成了定义明确的 Prometheus 监控格式。Prometheus 因其提供简单且广为人知的协议而在度量数据的收集、存储和查询中变得特别受欢迎。这使得从任何语言中进行集成变得容易,并且提供了多种简单的语言绑定。Prometheus 还提供了警报功能,并且与动态环境如 Kubernetes 集成良好。
尽管 Prometheus 仅提供了基本的用户界面,但许多图表工具——包括 Grafana——也建立在其基础上,以提供一致的仪表板体验。
Prometheus 还可以集成到现有的应用操作工具中。在 Prometheus 中,负责收集和暴露度量数据的工具被称为导出器。现有的官方和第三方导出器可以用于收集 Linux 主机统计数据、Java 应用程序的 JMX 导出器以及用于各种应用程序(如 Web 服务器、存储层、硬件和数据库服务)的导出器。Cloudflare 已开源了一个用于 BPF 度量数据的导出器,允许通过 Prometheus 曝露和可视化这些度量数据,并进一步将其展示在 Grafana 中。
17.3.1 Build and Run the ebpf Exporter

17.3.2 Configure Prometheus to Monitor the ebpf_exporter Instance
这取决于你在环境中监控目标的方式。假设实例正在端口 9435 上运行 `ebpf_exporter`,你可以找到一个示例目标配置,如下所示:

17.3.3 Set Up a Query in Grafana

17.3.4 Further Reading
For more information on Grafana and Prometheus, see:
■ https://grafana.com/
■ https://github.com/prometheus/prometheus
For more information on the Cloudflare eBPF exporter, see:
■ https://github.com/cloudflare/ebpf_exporter
■ https://blog.cloudflare.com/introducing-ebpf_exporter/


17.4 kubectl-trace

`kubectl-trace` 是一个 Kubernetes 命令行前端,用于在 Kubernetes 集群中的节点上运行 bpftrace。它由 Lorenzo Fontana 创建,并托管在 IO Visor 项目中(参见 https://github.com/iovisor/kubectl-trace)。要跟随这些示例,你需要下载和安装 `kubectl-trace`,同时需要安装 Kubernetes(这超出了本书的范围)。

17.4.1 Tracing Nodes
`kubectl` 是 Kubernetes 的命令行前端。`kubectl-trace` 支持在集群节点上运行 bpftrace 命令。虽然对整个节点进行跟踪是最简单的选项,但要注意 BPF 插件的开销:高开销的 bpftrace 调用会影响整个集群节点。
例如,可以使用 `vfsstat.bt` 来捕获集群中某个 Kubernetes 节点的 bpftrace 输出:


这个输出显示了整个节点的所有 VFS 统计数据,而不仅仅是某个 pod。由于 bpftrace 是从主机上执行的,`kubectl-trace` 也在主机的上下文中运行,因此它会跟踪该节点上所有运行的应用程序。这在某些情况下对系统管理员可能有帮助,但对于许多用例,重点关注容器内运行的进程可能更为重要。
17.4.2 Tracing Pods and Containers
`bpftrace` 和 `kubectl-trace` 通过匹配内核数据结构间接支持容器。`kubectl-trace` 为 pods 提供了两种帮助:首先,当指定 pod 名称时,它会自动定位并在正确的节点上部署 bpftrace 程序;其次,`kubectl-trace` 在脚本中引入了一个额外的变量 `$container_pid`,该变量设置为容器根进程的 PID(使用主机 PID 命名空间)。这使得你可以执行过滤或其他操作,仅针对你感兴趣的 pod。
例如,确保 PID 是容器内唯一运行的 PID。在更复杂的场景中,如运行 init 进程或有 fork 服务器时,你需要在此工具之上构建,以将 PIDs 映射到其父 PID。
创建一个新的部署,使用以下规范。请注意,命令指定了 Docker 入口点,以确保节点进程是容器内唯一的进程,并且 `vfsstat-pod.bt` 包含对 PID 的额外过滤:


创建一个名为 `vfsstat-pod.bt` 的 `vfsstat.bt` 副本,如下所示,然后启动跟踪器实现(这些步骤展示了如何启动跟踪和查看跟踪输出):

你会注意到,在 Pod 级别的 VFS 操作明显少于在节点级别的操作,这对于一个大部分时间处于空闲状态的 Web 服务器来说是预期中的情况。
17.4.3 Further Reading
■ https://github.com/iovisor/kubectl-trace


17.5 Other Tools

一些其他基于 BPF 的工具包括:
- **Cilium**:在容器化环境中使用 BPF 应用网络和应用安全策略。
- **Sysdig**:利用 BPF 扩展容器可观测性。
- **Android eBPF**:监控和管理 Android 设备上的网络使用情况。
- **osquery eBPF**:暴露操作系统信息用于分析和监控,现在支持使用 BPF 监控 kprobes。
- **ply**:一个基于 BPF 的 CLI 跟踪工具,类似于 bpftrace,但依赖性较少,适用于包括嵌入式目标在内的环境。ply 由 Tobias Waldekranz 创建。
随着 BPF 使用的增长,未来可能会开发出更多基于 BPF 的 GUI 工具。


17.6 Summary

BPF 工具领域正在迅速扩展,更多的工具和功能将不断开发。本章介绍了四个当前可用的基于 BPF 的工具。Vector/PCP、Grafana 和 Cloudflare 的 eBPF 导出器是图形化工具,能够可视化展示大量复杂数据,包括时间序列 BPF 输出。最后一个工具,kubectl-trace,允许在 Kubernetes 集群中轻松执行 bpftrace 脚本。此外,还提供了一些其他 BPF 工具的简要列表。

18 Tips,Tricks,and Common Problems

本章分享了成功进行 BPF 跟踪的技巧和窍门,以及可能遇到的常见问题及其解决方法。
**技巧和窍门:**
1. 典型事件频率和开销
2. 以 49 或 99 Hertz 进行采样
3. 黄猪和灰鼠
4. 编写目标软件
5. 学习系统调用
6. 保持简单
**常见问题:**
1. 遗漏事件
2. 遗漏堆栈跟踪
3. 打印时缺失符号(函数名)
4. 跟踪时缺失函数
5. 反馈循环
6. 丢失事件


18.1 Typical Event Frequency and Overhead

一个跟踪程序的 CPU 开销由三个主要因素决定:
- 被跟踪事件的频率。
- 跟踪过程中执行的操作。
- 系统中的 CPU 数量。
应用程序的开销是按 CPU 计算的,使用以下关系:
开销 = (事件频率 × 执行的操作) / CPU 数量
在单 CPU 系统上每秒跟踪一百万个事件可能会使应用程序变得非常缓慢,而在 128 个 CPU 的系统上,这种影响可能几乎不可察觉。因此,必须考虑 CPU 数量。
CPU 数量和所执行工作的开销都可能有一个数量级的差异。然而,事件频率可能会有几个数量级的变化,这使得它成为估算开销时最大的变量。
18.1.1 Frequency
了解典型事件速率的直观理解非常有帮助,因此我创建了表 18-1.1。表中包括一列,将最大速率转换为人们易于理解的术语:例如,从每秒一次转换为每年一次。可以想象一下,你订阅了一个邮件列表,以这种缩放后的速率向你发送电子邮件。


在本书中,我描述了 BPF 工具的开销,有时附有测量值,但通常使用了“微不足道”、“可测量”、“显著”和“昂贵”等词汇。我选择这些术语既故意模糊又足够描述性。使用具体数字可能会误导,因为具体指标取决于工作负载和系统。鉴于这一点,以下是这些术语的大致指南:
- 微不足道:<0.1%
- 可测量:约 1%
- 显著:>5%
- 昂贵:>30%
- 极端:>300%
在表 18-1 中,我从事件频率推测了这些开销描述,假设使用最低的跟踪操作:内核中的计数,并且针对当前典型的系统规模。下一节将展示不同操作可能会带来的更高成本。
18.1.1.2 Action Performed
以下测量描述了 BPF 的开销,表示每个事件的绝对成本,说明不同操作如何可能更昂贵。这些测量是通过对执行超过每秒一百万次读取的 dd(1) 工作负载进行读取插桩计算的:前一节关于频率的内容应该已经表明,在如此高的速率下,BPF 跟踪会增加昂贵的开销。工作负载是:
```
dd if=/dev/zero of=/dev/null bs=1 count=10000k
```
这会使用不同的 bpftrace 单行命令执行,例如:
```
bpftrace -e 'kprobe:vfs_read { @ = count(); }'
```
根据运行时间差异和已知的事件计数,可以计算每事件的 CPU 成本(忽略 dd(1) 进程的启动和终止成本)。这些数据展示在表 18-2 中。

这表明 kprobes(在此系统上)运行速度较快,每次调用仅增加 76 纳秒,当使用带键的映射时增加到约 200 纳秒。由于需要插桩函数入口并插入跳板处理程序来处理返回,kretprobes 显著更慢。Uprobes 和 uretprobes 产生的开销最大,每个事件超过 1 微秒:这是一个已知问题,我们希望在未来的 Linux 版本中进行改进。这些都是简短的 BPF 程序。编写较长的 BPF 程序可能会花费更多时间,以微秒为单位测量。这些测量在启用了 BPF JIT 的 Linux 4.15 上进行,使用了 Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz CPU,通过 taskset(1) 仅绑定到一个 CPU 以确保一致性,并取 10 次运行中最快的一次(最小扰动原理),同时检查标准偏差以确保一致性。请注意,这些数据可能会因系统的速度和架构、运行的工作负载以及 BPF 的未来更改而有所不同。
18.1.3 Test Yourself
如果能够准确测量应用程序的性能,可以分别在运行和不运行 BPF 跟踪工具的情况下进行测量,并比较差异。如果系统在 CPU 饱和(100%)状态下运行,那么 BPF 将从应用程序中占用 CPU 周期,这种差异可能会表现为请求率的下降。如果系统处于 CPU 空闲状态,则差异可能表现为可用 CPU 空闲时间的减少。


18.2 Sample at 49 or 99 Hertz

在这些看似不寻常的采样率下进行采样的目的是为了避免锁步采样。我们通过定时采样来描绘目标软件的大致情况。每秒 100 次采样(100 赫兹)或每秒 50 次采样通常足以提供解决大大小小性能问题的细节。
以 100 赫兹为例。这意味着每 10 毫秒采样一次。假设有一个应用线程每 10 毫秒醒来进行 2 毫秒的工作。它消耗了一个 CPU 的 20%。如果我们以 100 赫兹的频率进行采样,并且恰巧在刚好合适的时间运行我们的分析工具,每次采样都会与这 2 毫秒的工作窗口重合,因此我们的分析结果将显示该线程 100% 的时间在 CPU 上。或者,如果我们在其他时间点采样,每次采样都会错过,显示该线程 0% 的时间在 CPU 上。这两种结果都极具误导性,属于别名错误的例子。
通过使用 99 赫兹而不是 100 赫兹,采样的时间偏移将不再总是与应用程序的工作窗口重合。经过足够长的时间,它会显示应用程序在 CPU 上的时间占比为 20%。此外,99 赫兹的采样频率足够接近 100 赫兹,让我们可以将其视作 100 赫兹来进行推理。对于一个 8 核 CPU 的系统,一秒钟大约会有 800 次采样。我在检查结果时经常进行这样的计算。
如果我们选择例如 73 赫兹,这也可以避免锁步采样,但我们就不能如此快速地在脑中进行计算。对于四个 CPU 的系统,8 秒钟的 73 赫兹采样?需要计算器!
99 赫兹策略之所以有效,是因为应用程序开发人员通常会选择圆整的数字进行定时活动:每秒一次、每秒 10 次、每 20 毫秒一次,等等。如果应用程序开发人员开始选择每秒 99 次进行定时活动,我们就会再次遇到锁步问题。
我们称 99 赫兹为“分析器数字”。不要将其用于除分析之外的其他目的!


18.3 Yellow Pigs and Gray Rats

在数学中,数字 17 被认为很特别,并被昵称为“黄色猪”数字;甚至还有一个黄色猪日,即 7 月 17 日。它也是跟踪分析中的一个有用数字,尽管我个人更倾向于 23。
你经常会遇到需要分析的未知系统,而不知道从哪些事件开始跟踪。如果你能够注入已知的工作负载,那么频率计数事件可能会揭示哪些事件与你的工作负载相关。
为了说明这如何运作,假设你想了解 ext4 文件系统如何执行写入 I/O,但你不知道应该跟踪哪些事件。我们可以使用 dd(1) 创建一个已知的工作负载,执行 23 次写入,或者更好的是,执行 230,000 次写入,以使它们从其他活动中脱颖而出。


注意到这些函数中有 15 个被调用了略多于 230,000 次:这些函数很可能与我们的已知工作负载有关。在 509 个被跟踪的 ext4 函数中,利用这个技巧我们将其缩小到了 15 个候选函数。我喜欢使用 23(或者 230、2300 等),因为它不太可能与其他无关的事件计数重合。在 10 秒的跟踪过程中,还有什么会发生 230,000 次呢?
23 和 17 是质数,这些数字在计算中出现的自然频率通常低于其他数字,例如 2 或 10 的幂。我更喜欢 23,因为它距离其他幂数(如 2 和 10)更远,而 17 则没有。我会称 23 为“灰色老鼠”数字。有关更多信息,请参见第 12 章第 12.4 节,该节也使用了这种技巧来发现函数。


18.4 Write Target Software

首先编写负载生成软件,然后编写跟踪工具来测量它,可以为你节省时间和精力。
假设你想跟踪 DNS 请求,并展示延迟和请求详情。你该从哪里开始,如何确认你的程序是否正常工作?如果你首先编写一个简单的 DNS 请求生成器,你将了解到哪些函数需要跟踪,请求详情是如何存储在结构体中的,以及请求函数的返回值。你很可能会迅速了解这些,因为通常有大量的文档和代码示例可以通过互联网搜索找到。
在这种情况下,getaddrinfo(3) 解析函数的手册页中包含了可以直接使用的完整程序:

从这里开始,你将得到一个生成已知请求的工具。你甚至可以修改它,使其发出 23 次请求(或 2300 次),以帮助你找到堆栈中其他相关的函数(参见第 18.3 节)。


18.5 Learn Syscalls

系统调用是跟踪的丰富目标。
它们在手册页中有详细记录,具有跟踪点,并且提供了关于应用程序资源使用的有用见解。例如,你使用 BCC 的 syscount(8) 工具,发现 setitimer(2) 的调用频率很高。这是什么呢?

手册页解释了 setitimer(2) 的功能,以及它的输入参数和返回值。所有这些都可以通过跟踪点 `syscalls:sys_enter_setitimer` 和 `syscalls:sys_exit_setitimer` 来检查。


18.6 Keep It Simple

避免编写长且复杂的跟踪程序。BPF 跟踪功能强大,可以跟踪所有内容,但容易陷入将越来越多事件添加到跟踪程序中的误区,从而失去解决原始问题的重点。这有以下缺点:
- **不必要的开销**:原始问题可能只需要跟踪少量事件就能解决,但工具现在跟踪了更多事件,这对常见用例贡献不大,却增加了所有用户的开销。
- **维护负担**:尤其是 kprobes 和 uprobes,因为它们是一个不稳定的接口,可能在软件版本之间发生变化。在 Linux 4.x 系列中,我们已经遇到过若干次内核更改破坏了 BCC 工具。解决办法是为每个内核版本包含代码(通常通过检查函数的存在来选择,因为内核版本号由于回移而不可靠),或者简单地复制工具,将旧内核的副本保存在 tools/old 目录中。最好的情况是:添加了跟踪点,使这种破坏停止发生(例如,tcp 工具中的 `sock:inet_sock_set_state`)。
修复 BCC 工具并不困难,因为每个工具通常只跟踪少数几个事件或事件类型(因为我设计时就是这样)。如果每个工具跟踪数十个事件,破坏会更频繁,修复也会更复杂。同时,需要的测试也会增加:测试所有工具专用代码支持的内核版本中的所有事件类型。
我在 15 年前开发名为 tcpsnoop(1m) 的工具时深刻体会到这一点。我的目标是展示哪些进程导致了 TCP I/O,但我通过编写一个工具来跟踪所有包类型(包括 TCP 握手、端口拒绝包、UDP、ICMP 等),以匹配网络嗅探器的输出来解决这个问题。这涉及跟踪许多不稳定的内核细节,工具由于内核更新而多次损坏。我失去了原始问题的重点,开发出了一个难以维护的工具。(有关这一经验的更多细节,请参见第十章中的 tcpsnoop。)
我在本书中开发并包含的 bpftrace 工具是 15 年经验的结果:我故意限制它们只跟踪解决特定问题所需的最少事件。若有可能,我建议你也这样做。


18.7 Missing Events

这是一个常见的问题:一个事件可能被成功地加以仪表化,但似乎没有触发,或者工具没有输出。(如果事件根本无法被加以仪表化,请参见第18.10节。)使用Linux perf(1)工具来仪表化这些事件可以帮助确定问题是出在BPF跟踪上还是事件本身。以下演示了如何使用perf(1)来检查`block:block_rq_insert`和`block:block_rq_requeue`跟踪点是否发生。

在这个例子中,`block:block_rq_insert`跟踪点触发了41次,而`block:block_rq_requeue`跟踪点没有触发任何次数。如果一个BPF工具在同一时间跟踪`block:block_rq_insert`并且没有看到任何事件,这表明BPF工具可能存在问题。如果BPF工具和perf(1)都显示零事件,则表明事件本身存在问题:它没有发生。接下来是一个使用kprobes检查`vfs_read()`内核函数是否被调用的示例:

perf(1)接口需要分别的命令来创建和删除kprobe,uprobes也是如此。这个例子显示了在跟踪期间`vfs_read()`被调用了3029次。缺失事件有时发生在软件更改后,之前被仪表化的事件不再被调用。一个常见的情况是,从共享库位置跟踪库函数,但目标应用程序是静态编译的,该函数从应用程序二进制文件中调用。


18.8 Missing Stacks Traces

这通常是打印的堆栈跟踪看起来不完整或完全缺失的情况。也可能涉及缺失符号(参见第18.9节),使得帧显示为“[unknown]”。以下是一个示例输出,使用BCC trace(8)打印execve()跟踪点(新进程执行)的用户级堆栈跟踪:

这是另一个在深入BCC/BPF调试之前使用perf(1)进行交叉检查的机会。使用perf(1)重新执行这个任务:

这显示了类似的堆栈问题。存在三个问题:
1. **堆栈不完整**:这些堆栈跟踪显示的是bash(1) shell调用新程序的情况:根据以往的经验,堆栈应该有几个帧的深度,但上面仅显示了两个帧(行)。如果你的堆栈跟踪只有一两行且没有以初始帧(例如“main”或“start_thread”)结尾,那么合理的假设是它们也可能是不完整的。
2. **最后一行是[unknown]**:即使perf(1)也无法解析符号。这可能是bash(1)中的符号问题,或者libc的`__GI___execve()`可能覆盖了帧指针,导致进一步的堆栈遍历失败。
3. **libc的`__GI___execve()`调用被perf(1)检测到但未出现在BCC的输出中**:这指向了BCC的trace(8)工具的另一个问题,需要修复。
18.8.1 How to Fix Broken Stack Traces
不完整的堆栈跟踪很常见,通常由两个因素共同造成:(1)观察工具使用基于帧指针的方法读取堆栈跟踪;(2)目标二进制文件没有为帧指针保留寄存器(x86_64上的RBP),而是将其作为通用寄存器重用,作为编译器性能优化。观察工具读取这个寄存器时期望它是一个帧指针,但实际上它现在可能包含任意内容:数字、对象地址、字符串指针。观察工具尝试在符号表中解析这个数字,如果运气好,它找不到符号并打印"[unknown]"。如果运气不好,这个随机数字解析为一个无关的符号,打印的堆栈跟踪就会有错误的函数名,混淆最终用户。
最简单的解决方法通常是修复帧指针寄存器:
- 对于C/C++软件和其他用gcc或LLVM编译的软件:使用`-fno-omit-frame-pointer`重新编译软件。
- 对于Java:使用`-XX:+PreserveFramePointer`运行java(1)。
这可能会有性能成本,但通常低于1%;使用堆栈跟踪找到性能提升的好处通常远远超过这个成本。这些问题也在第12章讨论过。
另一种方法是切换到非基于帧指针的堆栈遍历技术。perf(1)支持基于DWARF的堆栈遍历、ORC和最后分支记录(LBR)。在撰写本文时,DWARF-based和LBR堆栈遍历不在BPF中提供,ORC还未提供给用户级软件。有关更多信息,请参见第2章第2.4节。


18.9 Missing Symbols (Function Names) When Printing

这就是符号在堆栈跟踪或符号查找函数中未正确打印的情况:它们显示为十六进制数字或字符串"[unknown]",而不是函数名。一个原因是断裂的堆栈,如前一节所述。另一个原因是短命的进程在BPF工具能够读取其地址空间并查找符号表之前就退出了。第三个原因是符号表信息不可用。修复方法在JIT运行时和ELF二进制文件之间有所不同。
18.9.1 How to Fix Missing Symbols: JIT Runtimes (Java, Node.js, ...)
缺失符号常见于即时编译(JIT)运行时环境,如Java和Node.js。在这些情况下,JIT编译器有其自己的符号表,这些符号表在运行时发生变化,并且不是二进制文件中预编译的符号表的一部分。常见的解决方法是使用运行时生成的补充符号表,这些符号表存放在`/tmp/perf-<PID>.map`文件中,并由`perf(1)`和BCC读取。有关这种方法、一些注意事项和未来工作,详见第12章第12.3节。
18.9.2 How to Fix Missing Symbols: ELF binaries (C, C++, ...)
符号可能在已编译的二进制文件中缺失,特别是那些已打包和分发的文件,因为它们经过了`strip(1)`处理以减小文件大小。解决方法之一是调整构建过程,避免去除符号;另一个方法是使用其他符号信息来源,如`debuginfo`或BTF。BCC和bpftrace支持`debuginfo`符号。这些方法、注意事项和未来工作在第12章第12.2节中进行了讨论。


18.10 Missing Functions When Tracing

这是指在使用`uprobes`、`uretprobes`、`kprobes`或`kretprobes`时,某个已知函数无法被跟踪的情况:它似乎缺失或者没有被触发。这个问题可能与缺失的符号有关(之前已讨论)。也可能由于编译器优化或其他原因:
- **内联优化**:在内联优化中,函数指令已被包含在调用函数中。这通常发生在指令较少的函数中,以避免执行调用、返回和函数前言指令。函数符号可能完全消失,或者虽然存在但在该代码路径上没有触发。
- **尾调用优化**:当代码流为`A()->B()->C()`,且`C()`在`B()`中最后被调用时,编译器可能会优化为`C()`直接返回到`A()`。这意味着`uretprobe`或`kretprobe`对该函数不会触发。
- **静态和动态链接**:这是指`uprobe`定义的函数原本位于库中,但目标软件从动态链接切换到静态链接,函数位置发生了变化:现在它在二进制文件中。同样,也有可能相反,`uprobe`定义的函数原本在二进制文件中,但现在已移动到共享库中。
应对这些问题可能需要跟踪不同的事件:父函数、子函数或邻近函数。`kprobes`和`uprobes`还支持指令偏移量跟踪(`bpftrace`未来应该也会支持),因此如果你知道内联函数的偏移量,可以对其位置进行探测。


18.11 Feedback Loops

如果你跟踪自己正在进行的跟踪操作,可能会创建一个反馈循环。
以下是一些需要避免的例子:

前两个会通过创建另一个 `printf()` 事件来意外地跟踪 `bpftrace printf()` 事件,这个事件又被跟踪并创建了另一个事件。事件速率会爆炸,导致性能问题,直到你能终止 `bpftrace`。第三个情况类似,因为 `bpftrace` 触发了 `ext4` 的写操作以保存输出,这会导致生成更多的输出并保存,依此类推。你可以通过使用过滤器来排除跟踪自己的 BPF 工具,或者只跟踪感兴趣的目标进程,来避免这种情况。
18.12 Dropped Events
注意丢失的事件会使工具输出不完整。BPF 工具可能发出输出速度太快,导致 perf 输出缓冲区溢出,或者尝试保存过多的堆栈 ID 以至于溢出 BPF 堆栈映射等。例如:

工具应该会告诉你何时发生了事件丢失,如上面的输出所示。这些丢失通常可以通过调整设置来修复。例如,`profile(8)` 提供了 `-stack-storage-size` 选项,可以增加堆栈映射的大小,默认情况下可以存储 16,384 个唯一的堆栈跟踪。如果调整设置变得很常见,工具的默认值应该进行更新,以免用户需要手动更改。


网站公告

今日签到

点亮在社区的每一天
去签到