docs: improve tutorials 50 through 54

This commit is contained in:
yuxi4096
2026-07-21 18:05:31 -07:00
parent a4ffc7fd17
commit 4ce52b24df
10 changed files with 1186 additions and 1018 deletions

View File

@@ -1,53 +1,53 @@
# eBPF Tutorial by Example 50: Composable Traffic Control with TCX Links
Ever tried attaching multiple BPF programs to the TC ingress path and got frustrated managing qdisc handles, filter priorities, and the `tc` CLI? Or needed one application's TC program to coexist safely with another's without accidentally overwriting it? Traditional `cls_bpf` attachment through `tc` works, but it inherits decades of queueing discipline plumbing that was never designed for the BPF-centric world. What if you could attach, order, and manage TC programs using the same link-based API that XDP and cgroup programs already enjoy?
Imagine you need to run two BPF programs on every inbound packet: one that collects statistics, and another that decides whether to accept the packet. With classic TC attachment, you would create a qdisc, assign each program a numeric priority, and hope no other application on the system picks the same priority numbers. If your stats-gathering application crashes, its filter stays attached—possibly interfering with the classifier. If a third-party CNI plugin runs `tc filter del` to clean up its own filters, it might accidentally remove yours too.
This is what **TCX** (Traffic Control eXtension) solves. Introduced by Daniel Borkmann and merged in Linux 6.6, TCX provides a lightweight, fd-based multi-program attach infrastructure for the TC ingress and egress data path. Programs get BPF link semantics (safe ownership, auto-detachment on close, and explicit ordering through `BPF_F_BEFORE` / `BPF_F_AFTER` flags) without touching a single qdisc or filter priority.
**TCX** (Traffic Control eXtension) solves these problems. Introduced by Daniel Borkmann and merged in Linux 6.6, TCX provides a link-based attachment model for the TC ingress and egress hooks. Programs gain automatic cleanup on file descriptor close, explicit ordering through `BPF_F_BEFORE` and `BPF_F_AFTER` flags, and immunity from accidental deletion by other processes—all without touching qdiscs or filter priorities.
In this tutorial, we'll attach two TCX ingress programs to the loopback interface, place one before the other, query the kernel's live chain state, and generate traffic to verify execution order.
In this tutorial, we build a minimal demonstration: two TCX ingress programs on the loopback interface, with controlled execution order and observable counters.
> The complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/50-tcx>
> Complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/50-tcx>
## Introduction to TCX: Why Classic TC Attachment Needed a Rethink
## Background: Why Classic TC Attachment Falls Short
### The Problem: Qdisc Plumbing and Unsafe Ownership
### The Problem: Shared Namespace, No Ownership
Classic `tc` BPF attachment (`cls_bpf`) was bolted onto the existing Traffic Control framework. To attach a BPF program, you first needed a `clsact` qdisc on the interface, then added a filter with a handle and priority. This worked fine for a single operator, but created real problems in cloud-native environments where multiple applications need to attach TC programs to the same interface:
Classic `tc` BPF attachment (`cls_bpf`) was designed when a single operator controlled the entire Traffic Control pipeline. To attach a BPF program, you create a `clsact` qdisc, then add filters with handles and priorities. This model breaks down when multiple independent applications need to coexist:
1. **No ownership model**: A `tc filter del` from one application can accidentally remove another application's program. There's no protection against this because classic tc filters are identified by handle/priority, not by the process that created them.
1. **No ownership model.** A filter is identified by its handle and priority, not by the process that created it. Any process with sufficient privilege can delete any filter—there is no protection against one application removing another's program.
2. **Priority conflicts**: Two applications might pick the same priority number. The second attachment silently replaces the first.
2. **Priority collisions.** Two applications might independently choose priority 100. The second attachment silently replaces the first, with no error and no warning.
3. **Permanent attachment by default**: Classic tc filters persist until explicitly removed. If the application that attached a filter crashes without cleanup, the filter remains, potentially with stale program logic.
3. **Persistence after crash.** Classic filters survive until explicitly removed. If an application crashes without cleanup, its filter persists with potentially stale logic.
4. **CLI dependency**: Even with libbpf, the attachment model was tied to netlink, the same mechanism the `tc` CLI uses. This meant your BPF application was sharing a control plane with every other tc user on the system.
4. **Shared control plane.** Both the `tc` CLI and libbpf-based programs use netlink to manage filters. Your BPF application competes with every other tc user on the system for this shared namespace.
These issues became acute in projects like Cilium, where the BPF dataplane needs to coexist with third-party CNI plugins, observability agents, and security tools that all want to hook into TC.
These problems became critical in projects like Cilium, where BPF datapaths must coexist with third-party CNI plugins, observability agents, and security tools—all attaching to the same interfaces.
### The Solution: Link-Based Multi-Program Management
### The Solution: Link-Based Attachment
TCX takes a fundamentally different approach. Instead of piggybacking on qdisc infrastructure, it provides a dedicated, qdisc-less extension point for BPF programs at the TC ingress and egress hooks. The key design principles:
TCX takes a fundamentally different approach. Instead of layering onto qdisc infrastructure, it provides a dedicated attachment point for BPF programs at the TC hooks. The design principles:
**BPF Link Semantics**: `bpf_program__attach_tcx()` creates a `BPF_LINK_TYPE_TCX` link. Like XDP links and cgroup links, TCX links give you safe ownership: the link is pinned to the file descriptor, auto-detaches when the fd is closed, and cannot be accidentally overridden by another application.
**BPF Link Semantics.** `bpf_program__attach_tcx()` creates a `BPF_LINK_TYPE_TCX` link. Like XDP and cgroup links, TCX links provide safe ownership: the link is tied to a file descriptor, auto-detaches when that fd closes, and cannot be removed by other applications.
**Explicit Ordering**: Instead of implicit priority numbers, you place programs relative to each other using `BPF_F_BEFORE` and `BPF_F_AFTER`. You can also use `BPF_F_REPLACE` to atomically swap a specific program. All operations support an `expected_revision` field that prevents race conditions during concurrent modifications.
**Explicit Ordering.** Rather than hoping two numeric priorities sort correctly, you specify order directly with `BPF_F_BEFORE` and `BPF_F_AFTER`. You can also use `BPF_F_REPLACE` to atomically swap a program. All operations accept an `expected_revision` field to prevent races during concurrent modifications.
**Chain Return Codes**: TCX defines simplified return codes that make multi-program composition explicit:
**Chain Return Codes.** TCX defines return codes that make multi-program composition explicit:
| Return Code | Value | Meaning |
|-------------|-------|---------|
| `TCX_NEXT` | -1 | Non-terminating; pass the packet to the next program in the chain |
| `TCX_PASS` | 0 | Accept the packet and terminate the chain |
| `TCX_DROP` | 2 | Drop the packet and terminate the chain |
| `TCX_REDIRECT` | 7 | Redirect the packet and terminate the chain |
| Return Code | Value | Behavior |
|-------------|-------|----------|
| `TCX_NEXT` | -1 | Continue to the next program in the chain |
| `TCX_PASS` | 0 | Accept the packet; terminate the chain |
| `TCX_DROP` | 2 | Drop the packet; terminate the chain |
| `TCX_REDIRECT` | 7 | Redirect the packet; terminate the chain |
Unknown return codes are mapped to `TCX_NEXT` for forward compatibility.
Unknown return codes map to `TCX_NEXT` for forward compatibility.
**Coexistence with Classic TC**: TCX links can coexist with traditional `cls_bpf` filters on the same interface. The kernel runs TCX programs first, then falls through to classic `tcf_classify()` if present. This allows gradual migration from classic tc to TCX without a disruptive cutover.
**Coexistence with Classic TC.** TCX links can coexist with traditional `cls_bpf` filters on the same interface. The kernel runs TCX programs first, then falls through to classic `tcf_classify()` if any classic filters are present. This allows gradual migration from classic tc to TCX.
## Writing the eBPF Program
## The eBPF Program
Our BPF object contains two programs that demonstrate chain composition. Here is the complete source:
Our BPF object contains two programs that demonstrate chained execution. Here is the complete kernel-side code:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -89,30 +89,38 @@ int tcx_classifier(struct __sk_buff *skb)
}
```
Let's walk through this step by step.
### Section Name: `SEC("tcx/ingress")`
### Section Names: `SEC("tcx/ingress")`
The `SEC("tcx/ingress")` annotation tells libbpf this program attaches to the TCX ingress hook. Libbpf maps this section name to `BPF_PROG_TYPE_SCHED_CLS` with the TCX-specific attach type. The egress equivalent is `SEC("tcx/egress")`.
The `SEC("tcx/ingress")` annotation tells libbpf that this program should be attached to the TCX ingress hook rather than the classic TC classifier. This is not just a naming convention; libbpf maps this section name to `BPF_PROG_TYPE_SCHED_CLS` with the appropriate attach type for TCX. The corresponding egress variant is `SEC("tcx/egress")`.
The older section names `SEC("tc")`, `SEC("classifier")`, and `SEC("action")` still work but are deprecated in favor of the `tcx/*` variants.
Note that `SEC("tc")`, `SEC("classifier")`, and `SEC("action")` are now considered deprecated by libbpf in favor of the `tcx/*` section names.
### The `__sk_buff` Context
Both programs receive a `struct __sk_buff *skb` parameter. This is the same socket buffer abstraction used by classic TC programs, XDP, and other networking BPF program types. It provides access to packet metadata:
- `skb->len`: packet length in bytes
- `skb->protocol`: EtherType in network byte order (hence `bpf_ntohs()`)
- `skb->ifindex`: the interface index where the packet arrived
For packet data access, you would use `bpf_skb_load_bytes()` or direct packet pointers—but this demo focuses on attachment mechanics rather than packet parsing.
### Global Variables as Counters
Instead of using a BPF map for counters, we use global variables (`stats_hits`, `classifier_hits`, `last_len`, etc.). The libbpf skeleton exposes these through `skel->bss->stats_hits`, which makes the user-space code simpler. This is fine for a single-CPU demo; for production use, you would want per-CPU maps to avoid data races.
We use global variables (`stats_hits`, `classifier_hits`, etc.) rather than BPF maps. The libbpf skeleton exposes these at `skel->bss->stats_hits`, simplifying user-space access. This approach works for single-CPU demonstrations; production code should use per-CPU arrays to avoid concurrent update races.
### Return Codes: `TCX_NEXT` vs `TCX_PASS`
This is the heart of TCX composition:
The return code determines whether the chain continues:
- `tcx_stats` returns `TCX_NEXT`, which means "I've done my work, now pass the packet to the next program in the chain." The chain continues executing.
- `tcx_classifier` returns `TCX_PASS`, which is a terminal verdict: the packet is accepted and no further programs in the chain run.
- `tcx_stats` returns `TCX_NEXT`: "I have finished my work; pass the packet to the next program." Execution continues down the chain.
- `tcx_classifier` returns `TCX_PASS`: "Accept this packet." The chain terminates; no subsequent programs run.
If we had placed `tcx_classifier` *before* `tcx_stats` in the chain, `tcx_stats` would never execute because `TCX_PASS` terminates the chain. Ordering matters, and TCX makes it explicit.
Order matters. If `tcx_classifier` ran first, it would return `TCX_PASS` and `tcx_stats` would never execute. TCX makes you specify this order explicitly.
## User-Space Loader: Attaching and Querying the Chain
## User-Space Loader
The user-space code demonstrates three key TCX operations: attaching programs, ordering them relative to each other, and querying the live chain.
The user-space code demonstrates the three key TCX operations: attaching programs, controlling their order, and querying the live chain state.
### Step 1: Attach the First Program
@@ -121,9 +129,9 @@ classifier_link = bpf_program__attach_tcx(skel->progs.tcx_classifier,
ifindex, NULL);
```
This attaches `tcx_classifier` to the TCX ingress hook on the specified interface. Passing `NULL` for options means "use defaults", so the program gets appended to the chain. At this point, the chain has one program.
This attaches `tcx_classifier` to the TCX ingress hook on the specified interface. Passing `NULL` for options uses defaults—the program appends to the chain. The chain now contains one program.
### Step 2: Insert the Second Program *Before* the First
### Step 2: Insert the Second Program Before the First
```c
LIBBPF_OPTS(bpf_tcx_opts, before_opts,
@@ -134,9 +142,9 @@ stats_link = bpf_program__attach_tcx(skel->progs.tcx_stats,
ifindex, &before_opts);
```
The `bpf_tcx_opts` structure tells the kernel to insert `tcx_stats` *before* `tcx_classifier` in the chain. The `.relative_fd` field identifies the reference point, which is the fd of the already-attached classifier program. After this, the chain is: `tcx_stats` → `tcx_classifier`.
The `bpf_tcx_opts` structure specifies that `tcx_stats` should be inserted *before* `tcx_classifier`. The `.relative_fd` field identifies the reference point—the file descriptor of the already-attached classifier. After this call, the chain is: `tcx_stats` → `tcx_classifier`.
You could equivalently use `BPF_F_AFTER` with a different reference to achieve the same ordering. The important point is that you express the desired order directly, rather than hoping that two numeric priorities sort correctly.
You could achieve the same ordering with `BPF_F_AFTER` and a different reference point. The key insight is that you express order directly, not through numeric priorities that might collide.
### Step 3: Query the Chain
@@ -150,17 +158,17 @@ query.link_ids = link_ids;
err = bpf_prog_query_opts(ifindex, BPF_TCX_INGRESS, &query);
```
After attachment, the loader queries the kernel for the live chain state. The returned data includes:
After attachment, the loader queries the kernel for the live chain state. The result includes:
- **`revision`**: A monotonically increasing counter that changes on every chain modification. This is the value you would pass as `expected_revision` if you wanted to perform atomic updates.
- **`prog_ids[]`**: The BPF program IDs in chain order.
- **`link_ids[]`**: The corresponding BPF link IDs.
- **`revision`**: A monotonically increasing counter that increments on each chain modification. You can pass this as `expected_revision` when attaching to ensure the chain hasn't changed since you last observed it—useful for atomic multi-step updates.
- **`prog_ids[]`**: BPF program IDs in execution order.
- **`link_ids[]`**: Corresponding BPF link IDs.
This allows any observer to determine exactly which programs are attached and in what order, which is invaluable for debugging multi-program pipelines.
This introspection capability is valuable for debugging multi-program pipelines and for tools that need to understand the current attachment state.
### Step 4: Generate Traffic and Read Counters
### Step 4: Generate Traffic and Verify
The loader sends a UDP packet to `127.0.0.1` (port 9, discard) to trigger the chain, waits briefly, then reads the global variables to verify both programs executed:
The loader sends a UDP packet to `127.0.0.1:9` (the discard service) to trigger the chain, waits briefly, then reads the global variables:
```c
printf(" tcx_stats hits : %llu\n",
@@ -169,15 +177,27 @@ printf(" tcx_classifier hits : %llu\n",
(unsigned long long)skel->bss->classifier_hits);
```
If both counters are 1, the chain worked as expected: `tcx_stats` ran first (recording metadata and returning `TCX_NEXT`), then `tcx_classifier` ran second (counting the packet and returning `TCX_PASS`).
If both counters show 1, the chain executed as intended: `tcx_stats` ran first (recording metadata, returning `TCX_NEXT`), then `tcx_classifier` ran (counting the hit, returning `TCX_PASS`).
## Compilation and Execution
## Building and Running
This example requires Linux 6.6+ with TCX support and a recent libbpf.
### Requirements
- Linux 6.6 or later
- Kernel configuration: `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_NET_XGRESS=y`
- Root privileges (for BPF and network interface access)
- Recent libbpf (1.2+ recommended)
### Build
```bash
cd bpf-developer-tutorial/src/50-tcx
make
```
### Run
```bash
sudo ./tcx_demo -i lo
```
@@ -185,7 +205,7 @@ Expected output:
```text
Attached TCX programs to lo (ifindex=1)
TCX ingress chain revision: 3
TCX ingress chain revision: 2
slot 0: prog_id=812 link_id=901
slot 1: prog_id=811 link_id=900
@@ -197,38 +217,41 @@ Counters:
last length : 46
```
The revision is 3 because the chain was modified twice: once when `tcx_classifier` was attached (revision went from 0 to 1), and once when `tcx_stats` was inserted before it (revision went to 2). The query itself increments the revision to 3.
**Reading the output:**
If you want to inspect the attach behavior without traffic, add `-n`:
- **Revision 2**: The chain was modified twice—once when `tcx_classifier` attached (revision 1), and once when `tcx_stats` was inserted before it (revision 2).
- **Slot ordering**: Slot 0 is `tcx_stats` (the program we inserted with `BPF_F_BEFORE`); slot 1 is `tcx_classifier`.
- **Protocol 0x0800**: IPv4 (the generated UDP packet).
- **Length 46**: The 20-byte payload "tcx tutorial packet" plus headers.
```bash
sudo ./tcx_demo -i lo -n
```
### Options
Use `-v` to enable libbpf debug output, which is helpful for seeing the low-level BPF syscall sequence.
- `-i IFACE`: Attach to a different interface (default: `lo`)
- `-n`: Skip traffic generation (useful for examining attach/query behavior only)
- `-v`: Enable libbpf debug output to see the underlying BPF syscall sequence
## How This Differs from Lesson 20 (Classic TC)
## Comparison with Lesson 20 (Classic TC)
[Lesson 20-tc](../20-tc/README.md) teaches the classic TC data path: creating a `clsact` qdisc, attaching a `SEC("tc")` program as a filter, and using `__sk_buff` for packet inspection. That lesson is still valuable because the **packet processing model** is identical: TCX programs receive the same `__sk_buff` context and use the same helpers for packet parsing.
[Lesson 20](../20-tc/README.md) teaches the classic TC path: creating a `clsact` qdisc, attaching a `SEC("tc")` program as a filter, and inspecting packets with `__sk_buff`. That lesson remains valuable because the **data-plane model** is identical—TCX programs use the same context structure and the same helpers for packet access.
What TCX replaces is the **control plane**:
TCX replaces the **control plane**:
| Aspect | Classic TC (Lesson 20) | TCX (Lesson 50) |
|--------|----------------------|-----------------|
|--------|------------------------|-----------------|
| Attach mechanism | Netlink / `tc` CLI | `bpf_program__attach_tcx()` |
| Ownership | None; anyone can `tc filter del` | BPF link; auto-detaches on fd close |
| Ordering | Implicit priority numbers | Explicit `BPF_F_BEFORE` / `BPF_F_AFTER` |
| Ownership | None; any process can delete any filter | BPF link tied to fd; auto-detaches on close |
| Ordering | Implicit numeric priorities | Explicit `BPF_F_BEFORE` / `BPF_F_AFTER` |
| Multi-program | Manual priority management | Built-in chain with revision tracking |
| Section name | `SEC("tc")` | `SEC("tcx/ingress")` / `SEC("tcx/egress")` |
| Kernel requirement | Any modern kernel | Linux 6.6+ |
| Kernel requirement | 4.1+ | 6.6+ |
If you are building new libbpf-based networking tools, TCX is the recommended interface. Cilium has already migrated from classic tc to TCX for its dataplane.
For new libbpf-based networking tools, TCX is the recommended attachment method. Cilium has already migrated its dataplane from classic tc to TCX.
## Summary
In this tutorial, we learned how TCX modernizes TC program attachment by replacing qdisc-based plumbing with BPF link semantics. We attached two ingress programs, controlled their execution order with `BPF_F_BEFORE`, queried the live chain with `bpf_prog_query_opts()`, and verified that both programs executed in the correct order. TCX provides safe ownership, explicit ordering, revision-aware updates, and coexistence with classic TC, making it the foundation for composable, multi-program traffic control in modern eBPF applications.
TCX modernizes TC program attachment by replacing qdisc-based plumbing with BPF link semantics. In this tutorial, we attached two ingress programs, controlled their execution order with `BPF_F_BEFORE`, queried the live chain state, and verified correct execution by observing counters. TCX provides safe ownership, explicit ordering, revision-aware updates, and backward compatibility with classic TC—making it the foundation for composable, multi-program traffic control in modern eBPF applications.
If you'd like to learn more about eBPF, visit our tutorial code repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or website <https://eunomia.dev/tutorials/> for more examples and complete tutorials.
For more eBPF tutorials, visit our repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or <https://eunomia.dev/tutorials/>.
## References
@@ -237,4 +260,4 @@ If you'd like to learn more about eBPF, visit our tutorial code repository at <h
- [bpf_program__attach_tcx libbpf API](https://docs.ebpf.io/ebpf-library/libbpf/userspace/bpf_program__attach_tcx/)
- [Cilium TCX & Netkit update (BPFConf 2024)](https://bpfconf.ebpf.io/bpfconf2024/bpfconf2024_material/tcx_netkit_update_and_global_sk_iter.pdf)
- [Generic multi-prog API, tcx links and meta device (BPFConf 2023)](http://oldvger.kernel.org/bpfconf2023_material/tcx_meta_netdev_borkmann.pdf)
- <https://docs.kernel.org/bpf/>
- [Kernel BPF documentation](https://docs.kernel.org/bpf/)

View File

@@ -1,53 +1,53 @@
# eBPF 入门实践教程第五十篇:使用 TCX Link 实现可组合的流量控制
你是否试过在 TC ingress 路径上挂载多个 BPF 程序,却被 qdisc handle、filter priority 和 `tc` CLI 搞得焦头烂额?或者一个应用的 TC 程序被另一个应用不小心覆盖掉?传统的 `cls_bpf` 挂载方式确实能工作,但它继承了几十年的 queueing discipline 管道,而这套体系根本不是为 BPF 优先的世界设计的。如果你能用和 XDP、cgroup 相同的 link 模型来管理 TC 程序,会怎样?
假设你需要在每个入站数据包上运行两个 BPF 程序:一个收集统计信息,另一个决定是否接受该数据包。使用经典 TC 挂载方式,你需要创建 qdisc,为每个程序分配一个数字优先级,然后祈祷系统上没有其他应用选择相同的优先级。如果你的统计收集应用崩溃了,它的 filter 会继续挂在那里——可能会干扰分类器。如果某个第三方 CNI 插件执行 `tc filter del` 清理自己的 filter,可能会顺带把你的也删掉。
这就是 **TCX**(Traffic Control eXtension)要解决的问题。TCX 由 Daniel Borkmann 开发,于 Linux 6.6 合入内核,它为 TC ingress 和 egress 数据路径提供了一套轻量级的、基于 fd 的多程序挂载基础设施。程序获得 BPF link 语义(安全的所有权、fd 关闭时自动卸载、通过 `BPF_F_BEFORE` / `BPF_F_AFTER` 显式排序),完全不需要碰任何 qdisc 或 filter priority。
**TCX**(Traffic Control eXtension)解决了这些问题。它由 Daniel Borkmann 开发,于 Linux 6.6 合入内核,为 TC ingress 和 egress 挂载点提供了基于 link 的挂载模型。程序获得了文件描述符关闭时自动清理的能力、通过 `BPF_F_BEFORE` 和 `BPF_F_AFTER` 标志的显式排序,以及不被其他进程意外删除的保护——所有这些都不需要接触 qdisc 或 filter 优先级。
本教程将在 loopback 接口上挂载两个 TCX ingress 程序,把一个插到另一个前面,查询内核的实时链状态,并发送流量来验证执行顺序。
本教程构建一个最小化的演示:在 loopback 接口上挂载两个 TCX ingress 程序,控制它们的执行顺序,并通过计数器观察执行情况。
> 完整源代码: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/50-tcx>
> 完整源代码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/50-tcx>
## 背景:为什么经典 TC 挂载需要被重新思考
## 背景:为什么经典 TC 挂载不够用
### 问题:qdisc 管道和不安全的所有权
### 问题:共享命名空间,无所有权
经典的 `tc` BPF 挂载(`cls_bpf`)是嫁接在已有 Traffic Control 框架之上的。要挂载一个 BPF 程序,你首先需要在接口上创建一个 `clsact` qdisc,然后添加一个带有 handle 和 priority 的 filter。这在单一操作者的场景下没问题,但在云原生环境中,多个应用需要在同一个接口上挂载 TC 程序,就出了大问题:
经典的 `tc` BPF 挂载(`cls_bpf`)是在单一操作者控制整个 Traffic Control 流水线的时代设计的。要挂载一个 BPF 程序,你需要创建 `clsact` qdisc,然后添加带有 handle 和 priority 的 filter。当多个独立应用需要共存时,这种模型就崩溃了:
1. **没有所有权模型**:一个应用的 `tc filter del` 可以意外删除另一个应用的程序。因为经典 tc filter 是通过 handle/priority 标识的,而不是通过创建它的进程。
1. **没有所有权模型。** Filter 通过 handle 和 priority 标识,而不是创建它的进程。任何有足够权限的进程都能删除任何 filter——一个应用删除另一个应用的程序毫无保护可言。
2. **Priority 冲突**:两个应用可能选了相同的 priority 值。第二次挂载会默默覆盖第一次的。
2. **优先级冲突。** 两个应用可能独立地选择优先级 100。第二次挂载会悄无声息地替换第一次,没有错误,没有警告。
3. **默认永久挂载**:经典 tc filter 会一直存在直到被显式删除。如果挂载 filter 的应用崩溃了且没有清理,filter 会一直留在那里,可能带着过时的程序逻辑。
3. **崩溃后残留。** 经典 filter 会一直存在直到被显式删除。如果应用崩溃且没有清理,它的 filter 会带着可能过时的逻辑继续存在。
4. **CLI 依赖**:即使用 libbpf,挂载模型也绑定在 netlink 上,和 `tc` CLI 使用的是同一套机制。这意味着你的 BPF 应用和系统上所有其他 tc 用户共享同一个控制面。
4. **共享控制面。** `tc` CLI 和基于 libbpf 的程序都使用 netlink 来管理 filter。你的 BPF 应用和系统上所有其他 tc 用户竞争这个共享命名空间。
这些问题在 Cilium 等项目中变得尤为突出。BPF 数据面需要和第三方 CNI 插件、可观测性 agent 以及安全工具和平共处,而它们都想挂到 TC 上。
这些问题在 Cilium 等项目中变得非常严重,BPF 数据面必须与第三方 CNI 插件、可观测性 agent 和安全工具共存——它们都在同一个接口上挂载。
### 解决方案:基于 Link 的多程序管理
### 解决方案:基于 Link 的挂载
TCX 采取了完全不同的思路。它不是在 qdisc 基础设施上打补丁,而是在 TC ingress 和 egress 挂载点上提供了一个专用的、无 qdisc 的扩展入口。核心设计原则:
TCX 采取了完全不同的思路。它不是在 qdisc 基础设施上叠加,而是在 TC 挂载点上提供了专用的 BPF 程序入口。核心设计原则:
**BPF Link 语义**:`bpf_program__attach_tcx()` 创建 `BPF_LINK_TYPE_TCX` link。和 XDP link、cgroup link 一样,TCX link 赋予你安全的所有权:link 绑定到 fd 上,fd 关闭时自动卸载,不会被其他应用意外覆盖。
**BPF Link 语义。** `bpf_program__attach_tcx()` 创建 `BPF_LINK_TYPE_TCX` link。与 XDP 和 cgroup link 一样,TCX link 提供安全的所有权:link 绑定到文件描述符,fd 关闭时自动卸载,不会被其他应用删除。
**显式排序**:不再依赖隐式的 priority 数字,而是通过 `BPF_F_BEFORE` 和 `BPF_F_AFTER` 将程序相对于彼此放置。还可以用 `BPF_F_REPLACE` 原子替换特定程序。所有操作都支持 `expected_revision` 字段来防止并发修改时的竞争条件。
**显式排序。** 不再期望两个数字优先级碰巧排对,而是直接用 `BPF_F_BEFORE` 和 `BPF_F_AFTER` 指定顺序。还可以用 `BPF_F_REPLACE` 原子替换程序。所有操作都接受 `expected_revision` 字段来防止并发修改时的竞争。
**链返回码**:TCX 定义了简化的返回码,使多程序组合变得显式:
**链返回码。** TCX 定义了让多程序组合变得显式的返回码:
| 返回码 | 值 | 含义 |
| 返回码 | 值 | 行为 |
|--------|-----|------|
| `TCX_NEXT` | -1 | 非终止;把数据包传给链中的下一个程序 |
| `TCX_PASS` | 0 | 接受数据包并终止链 |
| `TCX_DROP` | 2 | 丢弃数据包并终止链 |
| `TCX_REDIRECT` | 7 | 重定向数据包并终止链 |
| `TCX_NEXT` | -1 | 继续执行链中的下一个程序 |
| `TCX_PASS` | 0 | 接受数据包;终止链 |
| `TCX_DROP` | 2 | 丢弃数据包;终止链 |
| `TCX_REDIRECT` | 7 | 重定向数据包;终止链 |
未知的返回码会被映射为 `TCX_NEXT`,以保证前向兼容。
未知的返回码映射为 `TCX_NEXT` 以保证前向兼容。
**和经典 TC 共存**:TCX link 可以和同一接口上的传统 `cls_bpf` filter 共存。内核先执行 TCX 程序,如果存在经典 filter,再降级到 `tcf_classify()`。这允许从经典 tc 到 TCX 的渐进迁移,不需要一次性切换。
**与经典 TC 共存。** TCX link 可以和同一接口上的传统 `cls_bpf` filter 共存。内核先执行 TCX 程序,如果存在经典 filter 再降级到 `tcf_classify()`。这允许从经典 tc 到 TCX 的渐进迁移。
## 编写 eBPF 程序
## eBPF 程序
我们的 BPF 对象包含两个程序,用来演示链的组合。以下是完整源代码:
我们的 BPF 对象包含两个程序,用于演示链式执行。以下是完整的内核侧代码:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -89,30 +89,38 @@ int tcx_classifier(struct __sk_buff *skb)
}
```
我们逐步分析。
### Section 名:`SEC("tcx/ingress")`
`SEC("tcx/ingress")` 注解告诉 libbpf 这个程序应该挂载到 TCX ingress 挂载点,而非经典的 TC classifier。这不仅仅是一个命名约定;libbpf 会把这个 section 名映射到 `BPF_PROG_TYPE_SCHED_CLS` 并设置 TCX 对应的 attach type。egress 的对应写法是 `SEC("tcx/egress")`。
`SEC("tcx/ingress")` 注解告诉 libbpf 这个程序挂载到 TCX ingress 挂载点。libbpf 将这个 section 名映射到 `BPF_PROG_TYPE_SCHED_CLS` 并设置 TCX 特定的 attach type。egress 对应的写法是 `SEC("tcx/egress")`。
注意,`SEC("tc")`、`SEC("classifier")` 和 `SEC("action")` 已经被 libbpf 视为废弃,推荐改用 `tcx/*` section 名。
旧的 section 名 `SEC("tc")`、`SEC("classifier")` 和 `SEC("action")` 仍然可用,但已被废弃,推荐使用 `tcx/*` 变体。
### `__sk_buff` 上下文
两个程序都接收 `struct __sk_buff *skb` 参数。这是经典 TC 程序、XDP 和其他网络 BPF 程序类型使用的同一个 socket buffer 抽象。它提供对数据包元数据的访问:
- `skb->len`:数据包长度(字节)
- `skb->protocol`:网络字节序的 EtherType(因此需要 `bpf_ntohs()`)
- `skb->ifindex`:数据包到达的接口索引
要访问数据包内容,可以使用 `bpf_skb_load_bytes()` 或直接数据包指针——但本演示聚焦于挂载机制而非数据包解析。
### 全局变量作为计数器
我们用全局变量(`stats_hits`、`classifier_hits`、`last_len` 等)而不是 BPF map 作为计数器。libbpf skeleton 会通过 `skel->bss->stats_hits` 暴露这些变量,使用户态代码更简洁。这在单 CPU demo 中没有问题;生产环境中应使用 per-CPU map 来避免数据竞争。
我们使用全局变量(`stats_hits`、`classifier_hits` 等)而非 BPF map。libbpf skeleton 在 `skel->bss->stats_hits` 暴露这些变量,简化了用户态访问。这种方式适用于单 CPU 演示;生产代码应使用 per-CPU 数组来避免并发更新竞争。
### 返回码:`TCX_NEXT` vs `TCX_PASS`
### 返回码:`TCX_NEXT` 与 `TCX_PASS`
这是 TCX 组合的核心:
返回码决定链是否继续:
- `tcx_stats` 返回 `TCX_NEXT`,意思是"我的工作做完了,把数据包传给链中的下一个程序"。链继续执行。
- `tcx_classifier` 返回 `TCX_PASS`,这是一个终止性判定:数据包被接受,链中后续的程序不会再执行。
- `tcx_stats` 返回 `TCX_NEXT`:"我的工作做完了;把数据包传给下一个程序。"链继续执行。
- `tcx_classifier` 返回 `TCX_PASS`:"接受这个数据包。"链终止;后续程序不再执行。
如果我们把 `tcx_classifier` 放在 `tcx_stats` *前面*,`tcx_stats` 就永远不会执行,因为 `TCX_PASS` 会终止链。顺序很重要,而 TCX 让这件事变得显式。
顺序很重要。如果 `tcx_classifier` 先执行,它会返回 `TCX_PASS`,`tcx_stats` 就永远不会执行。TCX 让你必须显式指定这个顺序。
## 用户态加载器:挂载和查询链
## 用户态加载器
用户态代码演示了三个关键的 TCX 操作:挂载程序、相对排序、查询实时链。
用户态代码演示了三个关键的 TCX 操作:挂载程序、控制顺序、查询实时链状态。
### 第一步:挂载第一个程序
@@ -121,9 +129,9 @@ classifier_link = bpf_program__attach_tcx(skel->progs.tcx_classifier,
ifindex, NULL);
```
把 `tcx_classifier` 挂到指定接口的 TCX ingress 挂载点上。`NULL` 选项表示"使用默认值",程序被追加到链的末尾。此时链中有一个程序。
这将 `tcx_classifier` 挂到指定接口的 TCX ingress 挂载点。`NULL` 选项表示使用默认值——程序追加到链末尾。此时链中有一个程序。
### 第二步:把第二个程序插到第一个*前面*
### 第二步:把第二个程序插到第一个前面
```c
LIBBPF_OPTS(bpf_tcx_opts, before_opts,
@@ -134,9 +142,9 @@ stats_link = bpf_program__attach_tcx(skel->progs.tcx_stats,
ifindex, &before_opts);
```
`bpf_tcx_opts` 结构体告诉内核把 `tcx_stats` 插到 `tcx_classifier` *前面*。`.relative_fd` 字段标识参考点,即已挂载的 classifier 程序的 fd。操作完成后,链的顺序是:`tcx_stats` → `tcx_classifier`。
`bpf_tcx_opts` 结构体指定 `tcx_stats` 应该插入到 `tcx_classifier` *前面*。`.relative_fd` 字段标识参考点——已挂载的 classifier 的文件描述符。这个调用之后,链的顺序是:`tcx_stats` → `tcx_classifier`。
你也可以用 `BPF_F_AFTER` 配合不同的参考点来达到同样的排序效果。重点是你可以直接表达想要的顺序,而不需要期望两个数字 priority 碰巧排对。
你可以用 `BPF_F_AFTER` 配合不同的参考点达到同样的排序效果。关键是你直接表达顺序,而不是通过可能冲突的数字优先级。
### 第三步:查询链
@@ -150,17 +158,17 @@ query.link_ids = link_ids;
err = bpf_prog_query_opts(ifindex, BPF_TCX_INGRESS, &query);
```
挂载完成后,加载器查询内核中链的实时状态。返回的数据包括:
挂载完成后,加载器查询内核中链的实时状态。结果包括:
- **`revision`**:一个单调递增的计数器,每次链被修改时都会变化。如果你想执行原子更新,可以把这个值作为 `expected_revision` 传入。
- **`prog_ids[]`**:按链顺序排列的 BPF 程序 ID。
- **`revision`**:每次链被修改时递增的单调计数器。挂载时可以把这个值作为 `expected_revision` 传入,确保链在你上次观察之后没有变化——这对原子多步更新很有用。
- **`prog_ids[]`**:按执行顺序排列的 BPF 程序 ID。
- **`link_ids[]`**:对应的 BPF link ID。
这让任何观察者都能精确判断哪些程序被挂载了、顺序是什么,这对调试多程序流水线非常有价值。
这种内省能力对调试多程序流水线很有价值,也适用于需要了解当前挂载状态的工具。
### 第四步:发送流量并读取计数器
### 第四步:发送流量并验证
加载器向 `127.0.0.1`(端口 9,discard 服务)发送一个 UDP 包来触发链,短暂等待后读取全局变量来验证两个程序都执行了:
加载器向 `127.0.0.1:9`(discard 服务)发送一个 UDP 包来触发链,短暂等待后读取全局变量:
```c
printf(" tcx_stats hits : %llu\n",
@@ -169,15 +177,27 @@ printf(" tcx_classifier hits : %llu\n",
(unsigned long long)skel->bss->classifier_hits);
```
如果两个计数器都是 1,链就按预期工作了:`tcx_stats` 先执行(记录元信息并返回 `TCX_NEXT`),然后 `tcx_classifier` 执行(计数并返回 `TCX_PASS`)。
如果两个计数器都显示 1,说明链按预期执行了:`tcx_stats` 先运行(记录元数据,返回 `TCX_NEXT`),然后 `tcx_classifier` 运行(计数,返回 `TCX_PASS`)。
## 编译和运行
本示例需要 Linux 6.6+ 且支持 TCX,以及较新版本的 libbpf。
### 环境要求
- Linux 6.6 或更高版本
- 内核配置:`CONFIG_BPF=y`、`CONFIG_BPF_SYSCALL=y`、`CONFIG_NET_XGRESS=y`
- root 权限(用于 BPF 和网络接口访问)
- 较新版本的 libbpf(推荐 1.2+)
### 编译
```bash
cd bpf-developer-tutorial/src/50-tcx
make
```
### 运行
```bash
sudo ./tcx_demo -i lo
```
@@ -185,7 +205,7 @@ sudo ./tcx_demo -i lo
```text
Attached TCX programs to lo (ifindex=1)
TCX ingress chain revision: 3
TCX ingress chain revision: 2
slot 0: prog_id=812 link_id=901
slot 1: prog_id=811 link_id=900
@@ -197,38 +217,41 @@ Counters:
last length : 46
```
revision 是 3,因为链被修改了两次:`tcx_classifier` 挂载时(revision 从 0 到 1),`tcx_stats` 插入到它前面时(revision 到 2)。查询本身使 revision 递增到 3。
**输出解读:**
如果只想看挂载行为而不发流量,加 `-n`:
- **Revision 2**:链被修改了两次——`tcx_classifier` 挂载时(revision 1),`tcx_stats` 插入到它前面时(revision 2)。
- **Slot 顺序**:Slot 0 是 `tcx_stats`(用 `BPF_F_BEFORE` 插入的程序);slot 1 是 `tcx_classifier`。
- **Protocol 0x0800**:IPv4(生成的 UDP 包)。
- **Length 46**:20 字节的载荷 "tcx tutorial packet" 加上报头。
```bash
sudo ./tcx_demo -i lo -n
```
### 选项
加 `-v` 开启 libbpf 调试输出,可以看到底层 BPF syscall 的执行序列。
- `-i IFACE`:挂载到不同的接口(默认:`lo`)
- `-n`:跳过流量生成(只查看挂载/查询行为)
- `-v`:启用 libbpf 调试输出,查看底层 BPF syscall 序列
## 它和第 20 课(经典 TC)的区别
## 与第 20 课(经典 TC)的对比
[第 20 课-tc](../20-tc/README.zh.md) 讲的是经典 TC 数据路径:创建 `clsact` qdisc,挂载 `SEC("tc")` 程序作为 filter,使用 `__sk_buff` 进行包检查。那一课仍然有价值,因为**数据包处理模型**是完全相同的:TCX 程序收到的是相同的 `__sk_buff` context,使用相同的 helper 来解析数据包。
[第 20 课](../20-tc/README.zh.md)讲的是经典 TC 路径:创建 `clsact` qdisc,挂载 `SEC("tc")` 程序作为 filter,用 `__sk_buff` 检查数据包。那一课仍然有价值,因为**数据面模型**是相同的——TCX 程序使用相同的上下文结构和相同的 helper 来访问数据包。
TCX 替换的是**控制面**:
| 方面 | 经典 TC(第 20 课) | TCX(第 50 课) |
|------|---------------------|-----------------|
| 挂载方式 | Netlink / `tc` CLI | `bpf_program__attach_tcx()` |
| 所有权 | 无;任何人可以 `tc filter del` | BPF link;fd 关闭时自动卸载 |
| 排序 | 隐式 priority 数字 | 显式 `BPF_F_BEFORE` / `BPF_F_AFTER` |
| 多程序 | 手动 priority 管理 | 内建链 + revision 追踪 |
| 所有权 | 无;任何进程都能删除任何 filter | BPF link 绑定到 fd;关闭时自动卸载 |
| 排序 | 隐式数字优先级 | 显式 `BPF_F_BEFORE` / `BPF_F_AFTER` |
| 多程序 | 手动优先级管理 | 内建链 + revision 追踪 |
| Section 名 | `SEC("tc")` | `SEC("tcx/ingress")` / `SEC("tcx/egress")` |
| 内核要求 | 任意现代内核 | Linux 6.6+ |
| 内核要求 | 4.1+ | 6.6+ |
如果你正在构建新的 libbpf 网络工具,TCX 是推荐的接口。Cilium 已经将其数据面从经典 tc 迁移到了 TCX。
对于新的基于 libbpf 的网络工具,TCX 是推荐的挂载方式。Cilium 已经将其数据面从经典 tc 迁移到了 TCX。
## 总结
本教程介绍了 TCX 如何用 BPF link 语义取代基于 qdisc 的 TC 程序管理。我们挂载了两个 ingress 程序,用 `BPF_F_BEFORE` 控制了它们的执行顺序,用 `bpf_prog_query_opts()` 查询了实时链状态,并验证了两个程序按正确顺序执行。TCX 提供了安全的所有权、显式排序、revision 感知的更新以及和经典 TC 的共存能力,使其成为现代 eBPF 应用中可组合、多程序流量控制的基石。
TCX 用 BPF link 语义取代了基于 qdisc 的管道,使 TC 程序挂载现代化。本教程中,我们挂载了两个 ingress 程序,用 `BPF_F_BEFORE` 控制了它们的执行顺序,查询了实时链状态,并通过观察计数器验证了正确执行。TCX 提供了安全的所有权、显式排序、revision 感知的更新以及与经典 TC 的向后兼容——使其成为现代 eBPF 应用中可组合、多程序流量控制的基石。
如果你想了解更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或网站 <https://eunomia.dev/tutorials/> 获取更多示例和完整教程。
更多 eBPF 教程请访问我们的仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或 <https://eunomia.dev/tutorials/>。
## 参考
@@ -237,4 +260,4 @@ TCX 替换的是**控制面**:
- [bpf_program__attach_tcx libbpf API](https://docs.ebpf.io/ebpf-library/libbpf/userspace/bpf_program__attach_tcx/)
- [Cilium TCX & Netkit 更新(BPFConf 2024)](https://bpfconf.ebpf.io/bpfconf2024/bpfconf2024_material/tcx_netkit_update_and_global_sk_iter.pdf)
- [Generic multi-prog API, tcx links and meta device(BPFConf 2023)](http://oldvger.kernel.org/bpfconf2023_material/tcx_meta_netdev_borkmann.pdf)
- <https://docs.kernel.org/bpf/>
- [内核 BPF 文档](https://docs.kernel.org/bpf/)

View File

@@ -1,38 +1,61 @@
# eBPF Tutorial: Precisely Isolating Established TCP Connections
Suppose an outbound destination address has just been added to a threat-intelligence blocklist. The firewall has updated its rules to block future connections. However, `netstat` shows that a server already has an active TCP session to that address -- you need to close that exact connection while preserving the process and all unrelated traffic.
## The Problem
This tutorial demonstrates how to achieve this using a BPF iterator and the `bpf_sock_destroy` kfunc. The tool traverses the kernel's TCP socket table, finds established connections that exactly match an IPv4 destination address and port, reports match counts in dry-run mode, and destroys the selected sockets in `--apply` mode.
An outbound IP address has just been added to a threat-intelligence blocklist. Your firewall now rejects new connections to that address. But when you check `netstat`, you find a server already has an active TCP session to the same destination. You need to close that exact connection immediately—without killing the process, disrupting unrelated traffic, or waiting for the session to time out.
> Complete source: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/51-tcp-quarantine>
This seemingly simple requirement is surprisingly difficult to achieve with traditional tools:
## Why Kernel-Level Connection Isolation Is Needed
- **Killing the process** disrupts all its connections, not just the problematic one. Worse, the process may immediately reconnect to the same destination after restarting.
Traditional approaches to terminating connections all have significant limitations.
- **Firewall rules** block new connections but have no effect on sessions that are already established. Even `iptables -m state --state ESTABLISHED` can only match existing connections for filtering future packets—it cannot tear down an active session.
**Killing the process** is the most direct approach, but a single process often maintains multiple connections. Killing it disrupts all business traffic, and the process may immediately reconnect to the same destination after restart.
- **User-space tools** like `ss --kill` or `tcpkill` work by reading `/proc/net/tcp` and then injecting TCP RST packets. This approach suffers from a race condition: the socket state can change between reading the list and sending the RST. RST injection also requires guessing the correct sequence number and may fail against encrypted tunnels or certain network configurations.
**Firewall rules** can block new connections but are powerless against already-established ones. The `-m state --state ESTABLISHED` rule in `iptables` or `nftables` can only match state -- it cannot actively sever existing sessions.
What we need is a kernel-level mechanism that can identify and destroy specific sockets atomically, with no race window. This is exactly what Linux 6.5 introduced with the `bpf_sock_destroy` kernel function (kfunc).
**User-space tools** such as `ss --kill` or `tcpkill` rely on traversing `/proc/net/tcp` and injecting RST packets. This approach has a race condition: between reading the socket list and sending the RST, the socket state may have already changed. Furthermore, injecting RST requires the correct sequence number and may be ineffective against encrypted connections or certain protocol stack configurations.
This tutorial builds a command-line tool that uses a BPF iterator to walk the kernel's TCP socket table, find established connections matching a specific IPv4 address and port, and destroy them on demand. You will learn how BPF iterators provide safe, locked traversal of kernel data structures, and how kfuncs like `bpf_sock_destroy` expose kernel operations to BPF programs.
**A kernel-level approach** is needed to truly solve this problem. BPF iterators can traverse the kernel's socket table while holding appropriate locks. The `bpf_sock_destroy` kfunc directly invokes the kernel's socket destruction path. The entire process is atomic with no race window. This is the significance of Linux 6.5 introducing `bpf_sock_destroy`: it allows BPF programs to forcibly close sockets from an iterator context without relying on user-space RST injection.
> Complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/51-tcp-quarantine>
## BPF Iterator and bpf_sock_destroy
## Background: BPF Iterators and Kfuncs
A BPF TCP iterator is a special type of BPF program that drives typed callbacks for each TCP socket in the loading process's network namespace. User space triggers the traversal by reading the iterator file descriptor, and the kernel invokes the BPF callback once per socket during traversal.
Before diving into the code, let's understand the two kernel features that make this tool possible.
The `bpf_sock_destroy` kfunc was introduced in Linux 6.5 (commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`), allowing BPF programs to forcibly close sockets from an iterator context. The kernel executes the protocol-specific destroy path. This is a synchronous operation -- the socket is destroyed by the time the call returns.
### BPF Iterators
The `tcp_quarantine` tool combines these two capabilities. The user specifies a target IPv4 address and port, and the BPF program traverses the socket table, filtering for `ESTABLISHED` connections with an exact destination match. Dry-run mode only counts matches; apply mode calls `bpf_sock_destroy` to destroy matching sockets. The entire process completes in kernel space. User space only needs to read the iterator file descriptor to drive the traversal, then read the statistics counters from the BSS section afterward.
A BPF iterator is a special type of BPF program that the kernel invokes repeatedly—once for each element in some kernel data structure. Unlike tracepoints or kprobes that fire when specific events occur, iterators let you actively scan kernel state on demand.
## Code Implementation
The TCP iterator (`SEC("iter/tcp")`) iterates over all TCP sockets in the network namespace of the process that triggers it. User space initiates a scan by reading from an iterator file descriptor. Each `read()` call causes the kernel to invoke your BPF callback for a batch of sockets. When `read()` returns zero (EOF), the traversal is complete.
This tool consists of three files: a shared header defining the statistics structure, a BPF program that traverses the socket table and performs destruction, and a user-space loader that manages the lifecycle and prints results.
Because the kernel controls the iteration and holds appropriate locks while invoking your callback, there's no race condition between reading socket state and acting on it.
### Shared Header
### Kfuncs
`tcp_quarantine.h` defines the statistics structure shared between BPF and user space, residing in the BSS section as a result channel carrying five counters.
Kfuncs (kernel functions) are a mechanism for BPF programs to call specific kernel functions directly. Unlike the older BPF helper functions which have a fixed ABI, kfuncs are regular kernel functions that are explicitly marked as callable from BPF. They can do things that helpers cannot, including operations that modify kernel state in complex ways.
The `bpf_sock_destroy` kfunc, introduced in Linux 6.5 (commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`), allows a BPF program running in an iterator context to forcibly close a socket. The kernel executes the full protocol-specific teardown: removing the socket from hash tables, sending FIN/RST as appropriate, and releasing resources. This happens synchronously—by the time the call returns, the socket is destroyed.
## How the Tool Works
The `tcp_quarantine` tool combines these two features into a practical workflow:
1. User space specifies a target IPv4 address and port, plus whether to actually destroy matches or just count them (dry-run mode).
2. The BPF program is loaded with these parameters baked into its read-only data section.
3. User space opens an iterator file descriptor and reads from it, triggering the kernel to invoke the BPF callback for each TCP socket.
4. For each socket, the BPF callback checks: Is this an IPv4 socket? Is it in the ESTABLISHED state? Does its destination match the target?
5. Matching sockets are counted. In apply mode, `bpf_sock_destroy` is called to tear them down.
6. After traversal completes, user space reads statistics from the BPF program's BSS section and reports results.
The entire matching-and-destroy sequence happens inside the kernel, with no window where socket state could change between inspection and action.
## Code Walkthrough
The implementation has three source files: a shared header defining the statistics structure, the BPF program that performs the actual iteration and destruction, and a user-space loader that orchestrates everything.
### Statistics Structure
`tcp_quarantine.h` defines counters that the BPF program updates during iteration and user space reads afterward:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -50,11 +73,11 @@ struct quarantine_stats {
#endif /* __TCP_QUARANTINE_H */
```
The five counters record: total sockets scanned, IPv4 sockets in `ESTABLISHED` state, sockets with exact destination address and port matches, sockets successfully destroyed in apply mode, and `bpf_sock_destroy` calls that returned nonzero.
These five counters track: total sockets examined, how many were IPv4 and ESTABLISHED, how many matched the target destination, how many were successfully destroyed (apply mode only), and how many `bpf_sock_destroy` calls failed.
### BPF Program
### BPF Iterator Program
`tcp_quarantine.bpf.c` uses `SEC("iter/tcp")` to declare itself as a TCP iterator type. When user space reads the iterator file descriptor, the kernel calls the callback once for each TCP socket in the loading process's network namespace.
`tcp_quarantine.bpf.c` is the kernel-side code. The `SEC("iter/tcp")` annotation tells the loader this is a TCP iterator program:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -116,17 +139,21 @@ int quarantine_tcp(struct bpf_iter__tcp *ctx)
}
```
The program structure is straightforward. Three `const volatile` variables reside in the `.rodata` section. User space writes the target address, port, and apply flag after `open()` but before `load()`, and the verifier treats them as compile-time constants. `bpf_sock_destroy` is declared via `extern ... __ksym` as a strong kfunc symbol, which the kernel resolves at load time.
Let's break down the key elements:
The callback logic uses three-level filtering. First, for each non-null `sk`, it increments the `scanned` counter, then selects sockets that are `AF_INET` and in `TCP_ESTABLISHED` state for the `established` count. The `BPF_CORE_READ` macro uses BTF information to read `sock_common` structure fields. CO-RE relocations ensure the same compiled artifact runs across different kernel versions and field layouts.
**Configuration variables**: The three `const volatile` variables (`target_addr`, `target_port`, `apply`) live in the BPF program's read-only data section (`.rodata`). User space writes to them after opening the skeleton but before loading the program. The `const volatile` pattern tells the BPF verifier these are compile-time constants while still allowing user space to set them. This enables the verifier to optimize code paths—for example, when `apply` is false, the entire destruction branch can be eliminated.
The second filter checks destination address and port. The destination address is compared directly in network byte order (the result stored by `inet_pton` is already in network byte order). Port comparison uses `bpf_htons(target_port)` because `skc_dport` is stored in network byte order.
**Kfunc declaration**: The line `extern int bpf_sock_destroy(struct sock_common *sock) __ksym;` declares `bpf_sock_destroy` as an external kernel symbol. The `__ksym` annotation tells the loader to resolve this at load time by looking up the kernel function.
Matching sockets increment `matched`. Dry-run mode returns at this point. Apply mode calls `bpf_sock_destroy(sk)`, incrementing `destroyed` on success or `failed` on nonzero return.
**CO-RE field access**: `BPF_CORE_READ(sk, skc_family)` reads the `skc_family` field from the socket structure using BTF (BPF Type Format) information. This is part of CO-RE (Compile Once, Run Everywhere): the compiled BPF program contains relocation records that the loader patches based on the running kernel's BTF data. A program compiled on one kernel version will work on others even if structure field offsets differ.
**Byte order handling**: The destination address is stored in network byte order (big-endian), which matches what `inet_pton` produces, so we compare directly. The port is also stored in network byte order, so we convert our host-byte-order target port with `bpf_htons()` before comparing.
**Three-stage filtering**: The callback applies progressively tighter filters. First it counts all sockets (`scanned`), then narrows to IPv4 ESTABLISHED sockets (`established`), then to those matching the exact destination (`matched`). This funnel structure lets you see at a glance whether the tool is scanning the right population.
### User-Space Loader
`tcp_quarantine.c` parses command-line arguments, configures BPF constants, runs the iterator, and prints results.
`tcp_quarantine.c` handles argument parsing, BPF lifecycle management, and result reporting:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -319,32 +346,46 @@ cleanup:
}
```
The loader follows a standard flow. After parsing command-line arguments, it validates the IPv4 address with `inet_pton` and converts it to network byte order. After opening the skeleton, it writes three parameters to the `.rodata` section, then calls `load` to complete BPF program loading. The load failure diagnostic provides both the kernel errno and a prerequisite checklist to help users troubleshoot.
The key workflow:
The `run_iterator` function is the core driver for traversal. It first attaches the BPF program with `bpf_program__attach_iter`, then creates an iterator file descriptor with `bpf_iter_create`. The loop reads that fd until EOF, with each `read` driving the kernel to invoke the BPF callback for a batch of TCP sockets. The content of the read buffer is irrelevant in this scenario -- it only serves to drive traversal. The actual results are conveyed through the BSS section counters. After iteration completes, the loader reads counters from `skel->bss->stats` and prints a single-line summary.
1. **Open the skeleton**: `tcp_quarantine_bpf__open()` parses the embedded BPF object but doesn't load it yet.
## Network Namespaces
2. **Configure parameters**: We write to `skel->rodata->*` to set the target address, port, and apply flag. This must happen before `load()`.
The TCP BPF iterator scans the network namespace of the process that loads the BPF program. This is an important boundary: the tool can only see and operate on connections within its own network namespace.
3. **Load the program**: `tcp_quarantine_bpf__load()` loads the BPF program into the kernel. The verifier runs, BTF relocations are applied, and kfuncs are resolved.
To isolate connections inside a container or another network namespace, first enter the target namespace with `nsenter`:
4. **Run the iterator**: `run_iterator()` attaches the program, creates an iterator file descriptor, and reads from it until EOF. The read loop drives the kernel to invoke our BPF callback for every TCP socket.
5. **Read results**: After iteration, we read statistics from `skel->bss->stats`. The BSS section is automatically memory-mapped, so these reads just access shared memory.
The iterator's `read()` loop discards the buffer contents because this tool doesn't produce output through the iterator interface—it only uses the traversal to drive the BPF callback. Other iterator programs might write data to the iterator (e.g., formatted socket information), but ours communicates exclusively through the BSS statistics.
## Network Namespace Boundary
The TCP BPF iterator scans sockets in the network namespace of the process that triggers it. This is a critical boundary to understand: the tool can only see and operate on connections within its own network namespace.
If you need to close a connection inside a container or another namespace, first enter that namespace using `nsenter`:
```bash
sudo nsenter --net=/proc/<target-pid>/ns/net -- ./tcp_quarantine \
--destination 203.0.113.99 --port 443 --apply
```
`<target-pid>` is the PID of any process in the target network namespace. `nsenter --net` changes only the network namespace; the caller's mount namespace is retained, so the binary and working directory remain accessible.
Here `<target-pid>` is the PID of any process in the target network namespace. The `--net` flag changes only the network namespace while keeping the mount namespace intact, so the tool binary remains accessible.
## bpf_sock_destroy Semantics
## What Applications See
`bpf_sock_destroy` directly invokes the kernel's socket destruction path, which is fundamentally different from user-space RST packet injection. The kernel executes protocol-specific cleanup logic, removes the socket from hash tables, and releases associated resources.
When `bpf_sock_destroy` tears down a socket, the kernel executes the protocol's shutdown path. The specific error an application sees depends on which end of the connection is destroyed and what operation it next attempts:
The error seen by the application depends on which endpoint is destroyed and what operation it attempts next. The upstream TCP selftest expects `ECONNABORTED` on the client side and `ECONNRESET` on the server side. This tutorial's test also accepts `EPIPE` on the send side, as the exact error code may vary depending on operation order and kernel version.
- The upstream TCP selftest expects `ECONNABORTED` for the client and `ECONNRESET` for the server.
- Writes may produce `EPIPE` with a `SIGPIPE` signal.
- The exact behavior varies by kernel version and the timing of operations.
## Compilation and Execution
The test included with this tutorial accepts any of these as valid indicators that the connection was torn down.
Build from source:
## Building and Running
Build the tool:
```bash
cd src/51-tcp-quarantine
@@ -352,19 +393,19 @@ make clean
make -j2
```
Dry-run mode reports matches; matching connections remain established:
**Dry-run mode** (the default) scans for matches but doesn't destroy anything:
```bash
sudo ./tcp_quarantine --destination 127.0.0.1 --port 42063
```
Apply mode destroys matching sockets:
**Apply mode** destroys matching sockets:
```bash
sudo ./tcp_quarantine --destination 127.0.0.1 --port 42063 --apply
```
Command-line options:
Command-line reference:
```text
Usage: ./tcp_quarantine --destination IPv4 --port PORT [--apply] [--verbose]
@@ -377,7 +418,9 @@ Options:
-h, --help show this help
```
The following output was captured on an x86_64 environment running kernel `7.0.0-rc2+`. The automated test creates two loopback TCP connection pairs: one target port and one unrelated control port. It performs request/response round trips before running the tool to verify behavior:
### Example Session
The following output was captured on x86_64 running kernel `7.0.0-rc2+`. The automated test creates two loopback TCP connections: a target (which we'll destroy) and a control (which should survive). It verifies that traffic flows on both connections before running the tool:
```console
mode=dry-run destination=127.0.0.1:42063 scanned=6 established=4 matched=1 destroyed=0 failed=0
@@ -385,32 +428,39 @@ mode=apply destination=127.0.0.1:42063 scanned=6 established=4 matched=1 destroy
PASS: dry-run preserved both connections; apply destroyed only the target
```
Socket table counts and ephemeral port numbers vary with each run. The test assertions focus on: match count is 1, dry-run preserves connections, apply destroys the target, destroy failures are 0, and the control connection survives.
The socket counts and port numbers vary between runs. What matters is: exactly one socket matched, dry-run left it intact, apply destroyed it successfully, and the control connection continued working.
Run the test:
Run the automated test:
```bash
sudo make test
```
### Environment Requirements
### Requirements
| Requirement | Details |
|---|---|
| Kernel | Linux 6.5+ (when `bpf_sock_destroy` was first introduced) |
| Kernel | Linux 6.5+ (when `bpf_sock_destroy` was introduced) |
| BTF | Required (`CONFIG_DEBUG_INFO_BTF=y`) |
| Kernel configs | `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_BPF_EVENTS=y`, `CONFIG_DEBUG_INFO_BTF=y`, `CONFIG_INET=y`, `CONFIG_PROC_FS=y` |
| BPF JIT | Must be enabled at runtime. Programs calling kfuncs use the JIT path; `CONFIG_BPF_JIT_ALWAYS_ON` kernels require no sysctl setting. |
| BPF JIT | Must be enabled. Kfuncs require the JIT. Kernels built with `CONFIG_BPF_JIT_ALWAYS_ON` require no runtime configuration. |
| Architecture | Tested on x86_64 |
| Privileges | root |
| Privileges | Root required |
If loading fails, the tool prints both the kernel error and a checklist of prerequisites to help you diagnose the problem.
## Summary
This tutorial demonstrated how to use a BPF iterator and the `bpf_sock_destroy` kfunc to implement precise TCP connection isolation. Compared to killing processes, firewall rules, or user-space RST injection, this kernel-level approach is atomic with no race window and no dependency on correct sequence numbers.
This tutorial demonstrated how to use BPF iterators and the `bpf_sock_destroy` kfunc to surgically terminate specific TCP connections. Unlike process killing, firewall rules, or user-space RST injection, this approach:
The tool is designed for single-pass traversal with exact matching on one IPv4 destination tuple. Future extensions could add IPv6 support, continuous policy input, process/cgroup attribution, multi-namespace orchestration, authorization controls, and audit logging.
- Works atomically with no race window between inspection and action
- Does not require guessing TCP sequence numbers
- Does not disrupt unrelated connections on the same process
- Is not affected by encryption or unusual network configurations
> To learn more about eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>.
The tool as written handles single-pass traversal with exact matching on one IPv4 destination. Possible extensions include IPv6 support, wildcard matching, continuous monitoring mode, process/cgroup attribution, multi-namespace orchestration, and integration with threat-intelligence feeds.
> To learn more about eBPF, visit our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or our website at <https://eunomia.dev/tutorials/>.
## References

View File

@@ -1,38 +1,61 @@
# eBPF 教程:精准隔离已建立的 TCP 连接
假设某个出站目标地址刚被添加到威胁情报黑名单,防火墙已更新规则阻止后续连接。然而 `netstat` 显示某台服务器上已经存在一条到该地址的活跃 TCP 会话——你需要精准关闭这条连接,同时保留进程和其他无关流量。
## 问题场景
本教程展示如何使用 eBPF 迭代器和 `bpf_sock_destroy` kfunc 实现这一目标。工具遍历内核的 TCP 套接字表,找出精确匹配 IPv4 目标地址和端口的已建立连接,dry-run 模式统计匹配数量,`--apply` 模式销毁选中的套接字。
某个出站 IP 地址刚刚被加入威胁情报黑名单,防火墙已经更新规则阻止新连接。但当你用 `netstat` 检查时,发现服务器上已经存在一条到该地址的活跃 TCP 会话。你需要立即关闭这条连接——但不能杀掉进程,不能影响其他流量,也不能等待会话超时。
这个看似简单的需求,用传统工具却很难实现:
- **杀进程**会断开该进程的所有连接,而不只是有问题的那条。更糟糕的是,进程重启后可能立刻重连到同一目标。
- **防火墙规则**只能阻止新连接,对已经建立的会话无能为力。即使是 `iptables -m state --state ESTABLISHED` 也只能匹配已有连接用于后续报文过滤,无法主动拆除会话。
- **用户态工具**如 `ss --kill` 或 `tcpkill` 的工作方式是读取 `/proc/net/tcp`,然后注入 TCP RST 报文。这种方式存在竞态条件:在读取套接字列表和发送 RST 之间,套接字状态可能已经改变。RST 注入还需要猜测正确的序列号,对加密隧道或某些网络配置可能失效。
我们需要的是一种内核级机制,能够原子地识别并销毁特定套接字,不存在竞态窗口。这正是 Linux 6.5 引入 `bpf_sock_destroy` 内核函数(kfunc)所提供的能力。
本教程将构建一个命令行工具,使用 BPF 迭代器遍历内核的 TCP 套接字表,找出与指定 IPv4 地址和端口匹配的已建立连接,并按需销毁。你将学习 BPF 迭代器如何在持有锁的情况下安全遍历内核数据结构,以及 `bpf_sock_destroy` 这样的 kfunc 如何将内核操作暴露给 BPF 程序。
> 完整源码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/51-tcp-quarantine>
## 为什么需要内核级别的连接隔离
## 背景知识:BPF 迭代器与 Kfunc
传统的连接终止方案都有明显的局限性。
在深入代码之前,我们先理解使这个工具成为可能的两个内核特性。
**杀掉进程**是最直接的想法,但一个进程往往维护着多条连接,杀进程会中断所有业务流量,而且进程重启后可能立刻重建到同一目标的连接。
### BPF 迭代器
**防火墙规则**可以阻止新连接,但对已建立的连接无能为力。`iptables` 或 `nftables` 的 `-m state --state ESTABLISHED` 规则只能匹配状态,不能主动断开现有会话。
BPF 迭代器是一种特殊的 BPF 程序类型,内核会为某个数据结构中的每个元素重复调用它。与 tracepoint 或 kprobe 那种在特定事件发生时触发不同,迭代器让你可以按需主动扫描内核状态。
**用户态工具**如 `ss --kill` 或 `tcpkill` 依赖 `/proc/net/tcp` 遍历和注入 RST 报文,但这种方式有竞态问题:在你读取套接字列表和发送 RST 之间,套接字状态可能已经变化。而且注入 RST 需要正确的序列号,对加密连接或某些协议栈配置可能无效。
TCP 迭代器(`SEC("iter/tcp")`)会遍历触发它的进程所在网络命名空间中的所有 TCP 套接字。用户态程序通过从迭代器文件描述符读取来启动扫描,每次 `read()` 调用会促使内核为一批套接字调用你的 BPF 回调。当 `read()` 返回零(EOF)时,遍历完成。
**内核态方案**才能真正解决这个问题。BPF 迭代器可以在持有适当锁的情况下遍历内核的套接字表,`bpf_sock_destroy` kfunc 直接调用内核的套接字销毁路径,整个过程是原子的,不存在竞态窗口。这就是 Linux 6.5 引入 `bpf_sock_destroy` 的意义:让 BPF 程序能够在迭代器上下文中强制关闭套接字,无需依赖用户态的 RST 注入。
由于内核控制着迭代过程,并在调用回调时持有适当的锁,因此读取套接字状态和对其操作之间不存在竞态条件。
## BPF 迭代器与 bpf_sock_destroy
### Kfunc
BPF TCP 迭代器是一种特殊的 BPF 程序类型,它为加载进程所在网络命名空间内的每个 TCP 套接字驱动类型化回调。用户态程序通过读取迭代器文件描述符来触发遍历,内核在遍历过程中为每个套接字调用一次 BPF 回调。
Kfunc(内核函数)是 BPF 程序直接调用特定内核函数的一种机制。与老式的 BPF helper 函数(有固定 ABI)不同,kfunc 是普通的内核函数,只是被显式标记为可从 BPF 调用。它们可以执行 helper 无法完成的操作,包括以复杂方式修改内核状态。
`bpf_sock_destroy` kfunc 由 Linux 6.5 引入(commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`),允许 BPF 程序在迭代器上下文中强制关闭套接字。内核执行协议特定的销毁路径,这是一个同步操作,调用返回时套接字已经被销毁。
`bpf_sock_destroy` kfunc 在 Linux 6.5 引入(commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`),允许运行在迭代器上下文中的 BPF 程序强制关闭套接字。内核会执行完整的协议特定拆除过程:从哈希表移除套接字、根据需要发送 FIN/RST、释放资源。这是同步操作——调用返回时,套接字已被销毁。
`tcp_quarantine` 工具把这两个能力组合起来。用户指定目标 IPv4 地址和端口,BPF 程序遍历套接字表,筛选出 `ESTABLISHED` 状态且目标地址完全匹配的连接。dry-run 模式只统计匹配数量,apply 模式调用 `bpf_sock_destroy` 销毁匹配的套接字。整个过程在内核中完成,用户态只需要读取迭代器文件描述符来驱动遍历,最后从 BSS 段读取统计计数器。
## 工具工作原理
## 代码实现
`tcp_quarantine` 工具将这两个特性组合成一个实用的工作流程:
本工具由三个文件组成:共享头文件定义统计结构、BPF 程序遍历套接字表并执行销毁、用户空间加载器管理生命周期并打印结果。
1. 用户态指定目标 IPv4 地址和端口,以及是真正销毁匹配项还是仅计数(dry-run 模式)。
2. BPF 程序加载时,这些参数被写入其只读数据段。
3. 用户态打开迭代器文件描述符并从中读取,触发内核为每个 TCP 套接字调用 BPF 回调。
4. 对于每个套接字,BPF 回调检查:这是 IPv4 套接字吗?处于 ESTABLISHED 状态吗?目标地址匹配吗?
5. 匹配的套接字被计数。在 apply 模式下,调用 `bpf_sock_destroy` 拆除它们。
6. 遍历完成后,用户态从 BPF 程序的 BSS 段读取统计数据并报告结果。
### 共享头文件
整个匹配和销毁过程都在内核中进行,检查和操作之间不存在套接字状态变化的窗口。
`tcp_quarantine.h` 定义了 BPF 和用户空间共享的统计结构,位于 BSS 段作为结果通道传递五个计数器。
## 代码详解
实现包含三个源文件:定义统计结构的共享头文件、执行实际迭代和销毁的 BPF 程序、以及协调一切的用户态加载器。
### 统计结构
`tcp_quarantine.h` 定义了 BPF 程序在迭代过程中更新、用户态之后读取的计数器:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -50,11 +73,11 @@ struct quarantine_stats {
#endif /* __TCP_QUARANTINE_H */
```
五个计数器分别记录:扫描的套接字总数、处于 `ESTABLISHED` 状态的 IPv4 套接字数、目标地址和端口完全匹配的数量、apply 模式下成功销毁的数量、以及 `bpf_sock_destroy` 返回非零的数量。
五个计数器分别记录:检查的套接字总数、IPv4 且 ESTABLISHED 状态的数量、与目标地址匹配的数量、成功销毁的数量(仅 apply 模式)、`bpf_sock_destroy` 调用失败的数量。
### BPF 程序
### BPF 迭代器程序
`tcp_quarantine.bpf.c` 使用 `SEC("iter/tcp")` 声明为 TCP 迭代器类型。当用户态读取迭代器文件描述符时,内核会为加载进程所在网络命名空间中的每个 TCP 套接字调用一次回调。
`tcp_quarantine.bpf.c` 是内核侧代码。`SEC("iter/tcp")` 注解告诉加载器这是一个 TCP 迭代器程序:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -116,17 +139,21 @@ int quarantine_tcp(struct bpf_iter__tcp *ctx)
}
```
程序结构很直接。三个 `const volatile` 变量位于 `.rodata` 段,用户态在 `open()` 之后、`load()` 之前写入目标地址、端口和 apply 标志,验证器将它们视为编译期常量。`bpf_sock_destroy` 通过 `extern ... __ksym` 声明为强 kfunc 符号,内核在加载时解析该符号。
关键要素解读:
回调的处理逻辑分为三层过滤。首先对每个非空 `sk` 递增 `scanned` 计数器,然后选出 `AF_INET` 且状态为 `TCP_ESTABLISHED` 的套接字计入 `established`。`BPF_CORE_READ` 宏利用 BTF 信息读取 `sock_common` 结构体的字段,CO-RE 重定位保证同一编译产物可以在不同内核版本和字段布局上运行。
**配置变量**:三个 `const volatile` 变量(`target_addr`、`target_port`、`apply`)位于 BPF 程序的只读数据段(`.rodata`)。用户态在打开 skeleton 之后、加载程序之前写入它们。`const volatile` 模式告诉 BPF 验证器这些是编译期常量,同时仍允许用户态设置。这使验证器能够优化代码路径——例如当 `apply` 为 false 时,整个销毁分支可以被消除。
第二层过滤检查目标地址和端口。目标地址直接比较网络字节序的值(`inet_pton` 存储的结果已经是网络字节序),端口比较使用 `bpf_htons(target_port)` 因为 `skc_dport` 以网络字节序存储。
**Kfunc 声明**:`extern int bpf_sock_destroy(struct sock_common *sock) __ksym;` 这行将 `bpf_sock_destroy` 声明为外部内核符号。`__ksym` 注解告诉加载器在加载时通过查找内核函数来解析它。
匹配的套接字递增 `matched`,dry-run 模式到此返回。apply 模式调用 `bpf_sock_destroy(sk)`,成功时递增 `destroyed`,返回非零时递增 `failed`。
**CO-RE 字段访问**:`BPF_CORE_READ(sk, skc_family)` 使用 BTF(BPF Type Format)信息读取套接字结构的 `skc_family` 字段。这是 CO-RE(Compile Once, Run Everywhere,一次编译到处运行)的一部分:编译后的 BPF 程序包含重定位记录,加载器会根据运行中内核的 BTF 数据进行修补。在一个内核版本上编译的程序可以在其他版本上运行,即使结构体字段偏移不同。
### 用户空间加载器
**字节序处理**:目标地址以网络字节序(大端)存储,与 `inet_pton` 产生的结果一致,所以直接比较。端口也以网络字节序存储,所以比较前用 `bpf_htons()` 把主机字节序的目标端口转换一下。
`tcp_quarantine.c` 解析命令行参数、配置 BPF 常量、运行迭代器并打印结果。
**三级过滤**:回调采用逐级收紧的过滤器。首先计数所有套接字(`scanned`),然后筛选出 IPv4 且 ESTABLISHED 状态的(`established`),最后筛选与目标地址精确匹配的(`matched`)。这种漏斗结构让你一眼就能看出工具是否在扫描正确的群体。
### 用户态加载器
`tcp_quarantine.c` 处理参数解析、BPF 生命周期管理和结果报告:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -319,32 +346,46 @@ cleanup:
}
```
加载器的流程比较标准。解析命令行后,用 `inet_pton` 验证 IPv4 地址并转换为网络字节序。打开 skeleton 后把三个参数写入 `.rodata` 段,然后调用 `load` 完成 BPF 程序加载。加载失败时的诊断同时给出内核 errno 和前置条件列表,方便用户排查问题。
核心工作流程:
`run_iterator` 函数是驱动遍历的核心。它先用 `bpf_program__attach_iter` 挂载 BPF 程序,然后用 `bpf_iter_create` 创建迭代器文件描述符。循环读取该 fd 直到 EOF,每次 `read` 驱动内核为若干 TCP 套接字调用 BPF 回调。读取缓冲区的内容在这个场景下不重要,它只是用于驱动遍历,真正的结果通过 BSS 段的计数器传递。迭代完成后,加载器从 `skel->bss->stats` 读取计数器并打印单行汇总。
1. **打开 skeleton**:`tcp_quarantine_bpf__open()` 解析嵌入的 BPF 对象但不加载。
## 网络命名空间
2. **配置参数**:我们写入 `skel->rodata->*` 来设置目标地址、端口和 apply 标志。这必须在 `load()` 之前完成。
TCP BPF 迭代器扫描的是加载 BPF 程序的进程所在的网络命名空间。这是一个重要的边界:工具只能看到和操作自己网络命名空间中的连接。
3. **加载程序**:`tcp_quarantine_bpf__load()` 将 BPF 程序加载到内核。验证器运行,BTF 重定位被应用,kfunc 被解析。
要隔离容器或其他网络命名空间中的连接,先用 `nsenter` 进入目标命名空间:
4. **运行迭代器**:`run_iterator()` 挂载程序,创建迭代器文件描述符,然后从中读取直到 EOF。读取循环驱动内核为每个 TCP 套接字调用我们的 BPF 回调。
5. **读取结果**:迭代完成后,我们从 `skel->bss->stats` 读取统计数据。BSS 段是自动内存映射的,所以这些读取只是访问共享内存。
迭代器的 `read()` 循环丢弃缓冲区内容,因为这个工具不通过迭代器接口产生输出——它只是利用遍历来驱动 BPF 回调。其他迭代器程序可能会向迭代器写入数据(例如格式化的套接字信息),但我们的工具完全通过 BSS 统计数据进行通信。
## 网络命名空间边界
TCP BPF 迭代器扫描的是触发它的进程所在网络命名空间中的套接字。这是一个关键边界:工具只能看到和操作自己网络命名空间中的连接。
如果需要关闭容器或其他命名空间中的连接,先用 `nsenter` 进入该命名空间:
```bash
sudo nsenter --net=/proc/<target-pid>/ns/net -- ./tcp_quarantine \
--destination 203.0.113.99 --port 443 --apply
```
`<target-pid>` 是目标网络命名空间中任意进程的 PID。`nsenter --net` 只改变网络命名空间,调用者的 mount 命名空间保持不变,因此二进制文件和当前工作目录仍可访问。
这里 `<target-pid>` 是目标网络命名空间中任意进程的 PID。`--net` 标志只改变网络命名空间而保持 mount 命名空间不变,所以工具二进制文件仍然可访问。
## bpf_sock_destroy 语义
## 应用层看到的错误
`bpf_sock_destroy` 直接调用内核的套接字销毁路径,这与用户态注入 RST 报文有本质区别。内核执行协议特定的清理逻辑,套接字从哈希表中移除,相关资源被释放。
当 `bpf_sock_destroy` 拆除套接字时,内核执行协议的关闭路径。应用看到的具体错误取决于连接的哪一端被销毁以及它下一步尝试什么操作:
应用层看到的错误取决于被销毁的端点和下一个操作。上游 TCP selftest 预期客户端收到 `ECONNABORTED`,服务端收到 `ECONNRESET`。本教程的测试还接受发送端的 `EPIPE`,因为具体错误码可能因操作顺序和内核版本而异。
- 上游 TCP selftest 预期客户端收到 `ECONNABORTED`,服务端收到 `ECONNRESET`。
- 写操作可能产生 `EPIPE` 并伴随 `SIGPIPE` 信号。
- 具体行为因内核版本和操作时机而异。
本教程附带的测试接受以上任何一种作为连接被拆除的有效指示。
## 编译与运行
从源码构建:
构建工具:
```bash
cd src/51-tcp-quarantine
@@ -352,19 +393,19 @@ make clean
make -j2
```
Dry-run 模式报告匹配结果,匹配的连接保持已建立状态:
**Dry-run 模式**(默认)扫描匹配项但不销毁:
```bash
sudo ./tcp_quarantine --destination 127.0.0.1 --port 42063
```
Apply 模式销毁匹配的套接字:
**Apply 模式**销毁匹配的套接字:
```bash
sudo ./tcp_quarantine --destination 127.0.0.1 --port 42063 --apply
```
命令行参数:
命令行参考:
```text
Usage: ./tcp_quarantine --destination IPv4 --port PORT [--apply] [--verbose]
@@ -377,7 +418,9 @@ Options:
-h, --help 显示帮助
```
以下输出在运行内核 `7.0.0-rc2+` 的 x86_64 环境中采集。自动化测试创建两组回环 TCP 连接对:一个目标端口和一个无关的控制端口,先执行请求/响应往返,再运行工具验证行为:
### 示例会话
以下输出在运行内核 `7.0.0-rc2+` 的 x86_64 环境上采集。自动化测试创建两条回环 TCP 连接:一个目标(将被销毁)和一个控制(应该存活)。它在运行工具前验证两条连接上的流量都正常:
```console
mode=dry-run destination=127.0.0.1:42063 scanned=6 established=4 matched=1 destroyed=0 failed=0
@@ -385,9 +428,9 @@ mode=apply destination=127.0.0.1:42063 scanned=6 established=4 matched=1 destroy
PASS: dry-run preserved both connections; apply destroyed only the target
```
套接字表计数和临时端口号随每次运行而变化,测试断言关注的是:匹配数为 1、dry-run 保留连接、apply 销毁目标、销毁失败数为 0、控制连接存活。
套接字计数和端口号每次运行都会变化。重要的是:恰好一个套接字匹配,dry-run 让它保持完好,apply 成功销毁了它,控制连接继续正常工作。
运行测试:
运行自动化测试:
```bash
sudo make test
@@ -397,20 +440,27 @@ sudo make test
| 要求 | 详情 |
|---|---|
| 内核版本 | Linux 6.5+(`bpf_sock_destroy` 首次引入) |
| BTF | 必须启用 (`CONFIG_DEBUG_INFO_BTF=y`) |
| 内核配置 | `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_BPF_EVENTS=y`, `CONFIG_DEBUG_INFO_BTF=y`, `CONFIG_INET=y`, `CONFIG_PROC_FS=y` |
| BPF JIT | 运行时必须启用。调用 kfunc 的程序使用 JIT 路径;`CONFIG_BPF_JIT_ALWAYS_ON` 内核无需 sysctl 设置。 |
| 内核版本 | Linux 6.5+(`bpf_sock_destroy` 引入时) |
| BTF | 必需(`CONFIG_DEBUG_INFO_BTF=y`) |
| 内核配置 | `CONFIG_BPF=y`、`CONFIG_BPF_SYSCALL=y`、`CONFIG_BPF_JIT=y`、`CONFIG_BPF_EVENTS=y`、`CONFIG_DEBUG_INFO_BTF=y`、`CONFIG_INET=y`、`CONFIG_PROC_FS=y` |
| BPF JIT | 必须启用。Kfunc 需要 JIT。使用 `CONFIG_BPF_JIT_ALWAYS_ON` 构建的内核无需运行时配置。 |
| 架构 | 已在 x86_64 上测试 |
| 权限 | root |
| 权限 | 需要 root |
如果加载失败,工具会打印内核错误和前置条件清单,帮助你诊断问题。
## 总结
本教程展示了如何使用 BPF 迭代器和 `bpf_sock_destroy` kfunc 实现精确的 TCP 连接隔离。相比杀进程、防火墙规则或用户态 RST 注入,这种内核态方案是原子的,不存在竞态窗口,也不依赖正确的序列号。
本教程展示了如何使用 BPF 迭代器和 `bpf_sock_destroy` kfunc 精确终止特定 TCP 连接。与杀进程、防火墙规则或用户态 RST 注入相比,这种方法:
工具的设计是单次遍历、精确匹配一个 IPv4 目标元组。后续扩展可以添加 IPv6 支持、持续策略输入、进程/cgroup 归属、多命名空间编排、授权控制和审计记录。
- 原子操作,检查和动作之间不存在竞态窗口
- 不需要猜测 TCP 序列号
- 不影响同一进程上的其他连接
- 不受加密或特殊网络配置的影响
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
工具目前实现的是单次遍历、精确匹配一个 IPv4 目标地址。可能的扩展包括 IPv6 支持、通配符匹配、持续监控模式、进程/cgroup 归属、多命名空间编排,以及与威胁情报源集成。
> 如果你想深入了解 eBPF,请访问我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或网站 <https://eunomia.dev/tutorials/>。
## 参考资料

View File

@@ -1,42 +1,101 @@
# eBPF Tutorial: Tracing Slow vfs_read Calls with fsession
Suppose a file-backed service shows read-latency spikes. Application-level timing tells you that requests slowed down, but it cannot distinguish whether the kernel read path blocked or user-space logic took too long. The useful questions are: which thread issued the read, how many bytes did it request, what did the call return, and how long did that single `vfs_read` invocation take?
When a file-backed service shows read-latency spikes, application-level timing tells you that requests slowed down, but it cannot distinguish whether the kernel blocked on I/O or user-space logic took too long. The useful questions are: which thread issued the read, how many bytes did it request, what did the call return, and how long did that single `vfs_read` invocation take?
This tutorial demonstrates how to measure `vfs_read` call latency using the fsession mechanism introduced in Linux 7.0. The tool timestamps function entry, computes latency at return, filters by TGID and threshold, and reports slow-read events.
This tutorial demonstrates how to measure `vfs_read` call latency using the **fsession** mechanism introduced in Linux 7.0. fsession is a new eBPF program type that runs once at function entry and once at return, with built-in per-invocation storage for correlating the two phases. The tool we build timestamps function entry, computes latency at return, filters by process and threshold, and reports slow-read events through a ring buffer.
> Complete source: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/52-fsession-latency>
## Why fsession Is Needed
## The Problem: Correlating Function Entry and Return
Traditional approaches to measuring function latency all have notable limitations.
To measure how long a kernel function takes, you need to record a timestamp when it starts and compute the difference when it returns. This sounds simple, but the traditional approaches in eBPF all have drawbacks.
**Separate fentry and fexit programs** are the most common approach. You write two BPF programs: one attached to function entry, one to function return. The two programs pass timestamps through a per-thread hash map: entry stores a mapping from `bpf_get_current_pid_tgid()` to timestamp, and return looks up and deletes the entry. This pattern has several problems. First, you must maintain an extra hash map, incurring lookup and deletion overhead on every call. Second, if a thread dies between entry and return (for example, if it is killed), the hash-map entry leaks. Third, the two programs have no intrinsic relationship; state passing depends entirely on an external data structure.
### Traditional Approach: Two Programs with a Hash Map
**kprobe and kretprobe** also require two separate programs and an external hash map. Compared to fentry/fexit, kprobes have higher overhead because they work through a breakpoint mechanism, whereas fentry hooks directly into the function prologue using JIT-optimized call sequences on x86_64.
The most common pattern uses two separate BPF programs - one attached to function entry (fentry), one to function return (fexit). The entry program records the timestamp in a hash map keyed by thread ID; the return program looks up the timestamp, computes latency, and deletes the entry:
**User-space sampling** such as `perf record` or periodic stack sampling with `bpftrace` provides only a statistical profile and cannot precisely measure the latency of each call. For scenarios that need to capture tail-latency events, sampling may completely miss critical slow calls.
```
Entry program: Return program:
1. Get thread ID 1. Get thread ID
2. Get timestamp 2. Look up timestamp from map
3. Store timestamp in hash map 3. Compute latency
4. Delete entry from map
5. Report event
```
**The fsession mechanism** solves this problem fundamentally. Linux 7.0 introduced fsession, which executes the same BPF program once at function entry and once at return, providing an 8-byte per-invocation scratch area through `bpf_session_cookie(ctx)`. This cookie persists from entry to return, completely replacing external hash maps. The program uses `bpf_session_is_return(ctx)` to distinguish the two phases and handles all logic within a single program.
This works, but has several problems:
## How fsession Works
- **Map overhead**: Every function call requires a hash map insertion at entry and a lookup plus deletion at return. For high-frequency functions like `vfs_read`, this overhead adds up.
- **State leaks**: If a thread is killed between entry and return (e.g., `kill -9`), the hash map entry is never deleted and leaks memory.
- **No intrinsic relationship**: The two programs are completely independent. The only thing connecting them is the external hash map - there's no guarantee they're actually tracking the same function invocation.
fsession is a special ftracing program type. When you declare a program with `SEC("fsession/vfs_read")`, the kernel calls your BPF program once at `vfs_read` entry and once at return.
### kprobe/kretprobe: Same Pattern, Higher Overhead
`bpf_session_is_return(ctx)` returns false during the entry phase and true during the return phase. The program uses this helper to determine which phase it is in and execute the appropriate logic.
The kprobe mechanism has the same two-program structure and requires the same external hash map for correlation. Additionally, kprobes work through a software breakpoint mechanism (replacing the first instruction with an interrupt), which has higher overhead than fentry hooks that use the kernel's ftrace infrastructure and JIT-optimized call sequences.
`bpf_session_cookie(ctx)` returns a pointer to an 8-byte scratch area. This scratch area is unique to each invocation and persists from entry to return. You can write data (such as a timestamp) during entry and read it during return.
### User-Space Sampling: Statistical, Not Precise
The return-phase context includes not only the original function arguments but also the function's return value. For `vfs_read`, you can see the `file`, `buf`, `count`, and `pos` arguments at entry; at return you can also see the `ret` return value. This means you do not need to store arguments in a map - the return phase can access them directly.
Tools like `perf record` sample stack traces periodically and can build statistical profiles of where time is spent. However, sampling cannot measure the latency of individual function calls. If you need to capture tail-latency events - the occasional 100ms read that causes a timeout - statistical sampling may miss them entirely.
The `fsession_latency` tool leverages these capabilities for latency measurement. At entry, it checks whether the TGID matches the target process; matching calls store a `bpf_ktime_get_ns()` timestamp in the cookie. At return, it reads the timestamp from the cookie to compute latency, increments aggregate counters, and reports detailed events through a ring buffer for calls exceeding the threshold.
## The fsession Solution
Linux 7.0 introduced **fsession**, which solves the correlation problem at the kernel level. When you declare a BPF program with `SEC("fsession/vfs_read")`, the kernel:
1. Calls your program once when `vfs_read` enters
2. Allocates an 8-byte scratch area (the "session cookie") tied to this specific invocation
3. Calls your program again when `vfs_read` returns
4. Deallocates the session cookie
The key insight is that the session cookie is automatically managed and scoped to exactly one function invocation. Your program distinguishes entry from return using `bpf_session_is_return(ctx)`, and reads or writes the cookie using `bpf_session_cookie(ctx)`.
### What the Session Cookie Replaces
In the traditional approach, you'd use a hash map like this:
```c
// Traditional: hash map keyed by thread ID
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u64); // pid_tgid
__type(value, u64); // timestamp
} start SEC(".maps");
```
With fsession, this entire map disappears. The timestamp lives in the session cookie:
```c
// fsession: per-invocation cookie, no map needed
__u64 *started = bpf_session_cookie(ctx);
*started = bpf_ktime_get_ns();
```
The cookie persists from entry to return and is automatically cleaned up afterward - no leaks possible.
### Return-Phase Context Includes Function Arguments
Another fsession advantage: the return-phase context includes both the original function arguments and the return value. In the traditional approach, if you need access to function arguments at return time (e.g., to include the requested byte count in your event), you must store them in the hash map at entry. With fsession, arguments are directly available:
```c
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
loff_t *pos, ssize_t ret)
{
// At return, 'count' and 'ret' are both available
// No need to store 'count' anywhere at entry
}
```
## Code Implementation
This tool consists of three files: a shared header defining event and statistics structures, a BPF program that measures latency and submits events, and a user-space loader that manages the lifecycle and prints results.
This tool consists of three files:
- `fsession_latency.h`: Shared data structures for BPF and user space
- `fsession_latency.bpf.c`: The BPF program that measures latency
- `fsession_latency.c`: User-space loader that manages the BPF lifecycle and prints results
### Shared Header
`fsession_latency.h` defines the event and statistics structures shared between BPF and user space. `latency_event` contains a 32-bit PID and TGID, 64-bit requested bytes, a signed 64-bit return value, nanosecond-precision latency, and the process name. `latency_stats` contains four counters for aggregate statistics.
`fsession_latency.h` defines the event structure sent through the ring buffer and the aggregate statistics structure:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -64,11 +123,17 @@ struct latency_stats {
#endif /* __FSESSION_LATENCY_H */
```
The four counters record: `calls` for total invocations, `slow` for calls reaching the threshold, `errors` for calls returning negative values, and `dropped` for ring-buffer reservation failures. `FSESSION_COMM_LEN` is set to 16 to match the kernel's `TASK_COMM_LEN`.
The four counters track:
- `calls`: Total `vfs_read` invocations observed
- `slow`: Calls that met or exceeded the latency threshold
- `errors`: Calls where `vfs_read` returned a negative error code
- `dropped`: Events that couldn't be submitted because the ring buffer was full
`FSESSION_COMM_LEN` is 16 to match the kernel's `TASK_COMM_LEN`.
### BPF Program
`fsession_latency.bpf.c` uses `SEC("fsession/vfs_read")` to declare itself as an fsession type. The kernel calls this program once at `vfs_read` entry and once at return.
`fsession_latency.bpf.c` is the core of the tool. Let's walk through it section by section.
```c
// SPDX-License-Identifier: GPL-2.0
@@ -82,7 +147,11 @@ The four counters record: `calls` for total invocations, `slow` for calls reachi
#include "fsession_latency.h"
char LICENSE[] SEC("license") = "GPL";
```
The macro dance at the top is a compatibility workaround. The repository's `vmlinux.h` snapshot was generated before Linux 7.0 added a `ctx` argument to `bpf_session_is_return` and `bpf_session_cookie`. The macros rename the old declarations during the include, then we provide the correct signatures below. A `vmlinux.h` regenerated from kernel 7.0+ wouldn't need this.
```c
const volatile __u64 threshold_ns;
const volatile __u32 target_tgid;
@@ -92,7 +161,15 @@ struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
```
`const volatile` variables in BPF programs have special semantics. They're placed in the `.rodata` section and can be set by user space after opening the skeleton but before loading. Once loaded, the verifier treats them as compile-time constants, enabling optimizations like dead-code elimination when `target_tgid` is 0.
`stats` is a global variable in the `.bss` section, directly readable from user space after the program runs.
The ring buffer (`events`) is sized at 256 KB - enough for thousands of events before overflow.
```c
/*
* The repository vmlinux.h snapshot predates the ctx argument on these
* kfunc prototypes. Rename those stale declarations while including the
@@ -100,7 +177,11 @@ struct {
*/
extern bool bpf_session_is_return(void *ctx) __ksym;
extern __u64 *bpf_session_cookie(void *ctx) __ksym;
```
These are **kfunc** declarations - kernel functions exported for BPF programs to call. The `__ksym` attribute tells the verifier to resolve these symbols from the running kernel at load time, rather than expecting them to be defined in the BPF object.
```c
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
loff_t *pos, ssize_t ret)
@@ -109,7 +190,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
__u64 *started = bpf_session_cookie(ctx);
struct latency_event *event;
__u64 latency;
```
The `SEC("fsession/vfs_read")` tells the kernel this is an fsession program attached to `vfs_read`. The `BPF_PROG` macro expands to set up the standard tracing context; `ctx` is implicitly available for passing to kfuncs.
The function signature lists `vfs_read`'s parameters followed by its return value. At entry, `ret` is undefined; at return, all parameters and the return value are valid.
```c
if (!bpf_session_is_return(ctx)) {
if (target_tgid && pid_tgid >> 32 != target_tgid) {
*started = 0;
@@ -118,7 +205,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
*started = bpf_ktime_get_ns();
return 0;
}
```
**Entry phase**: First, check if we should filter this call. If `target_tgid` is set (non-zero) and the current process's TGID doesn't match, write 0 to the cookie to mark this invocation as "skip" and return. Otherwise, write the current monotonic timestamp to the cookie.
The TGID is in the upper 32 bits of `bpf_get_current_pid_tgid()`'s return value; the lower 32 bits are the thread ID (PID in kernel terms).
```c
if (!*started)
return 0;
@@ -128,7 +221,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
__sync_fetch_and_add(&stats.errors, 1);
if (latency < threshold_ns)
return 0;
```
**Return phase**: If the cookie is 0, the entry phase filtered this call - return immediately. Otherwise, compute latency and update the aggregate counters. The `__sync_fetch_and_add` provides atomic updates since multiple CPUs may execute this program concurrently.
If the latency is below the threshold, we're done - the call is counted but no event is emitted. This keeps the ring buffer focused on slow calls.
```c
__sync_fetch_and_add(&stats.slow, 1);
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event) {
@@ -147,160 +246,28 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
}
```
The program is structured into entry and return phases. The two `const volatile` variables reside in the `.rodata` section; user space writes the threshold and target TGID after `open()` but before `load()`, and the verifier treats them as compile-time constants. `bpf_session_is_return` and `bpf_session_cookie` are declared as kfunc symbols via `extern ... __ksym`, resolved by the kernel at load time.
For slow calls, increment the `slow` counter and try to reserve space in the ring buffer. If the reservation fails (buffer full), increment `dropped` so the user knows events were lost. On success, fill in the event fields and submit.
At entry, the program calls `bpf_get_current_pid_tgid()` to obtain the caller's PID and TGID. If `target_tgid` is set and the call comes from a different process, the program writes 0 to the cookie to indicate this invocation should be skipped. Matching calls store the monotonic timestamp returned by `bpf_ktime_get_ns()`.
At return, the program first checks whether the cookie is 0. A zero cookie indicates the entry phase already filtered this call, so it returns immediately. Otherwise it computes latency as the current timestamp minus the saved entry timestamp, then increments the `calls` counter. If `ret < 0`, indicating the read operation returned an error, it increments the `errors` counter. Calls with latency below `threshold_ns` stop here, updating only aggregate counters without reporting events.
Calls reaching the threshold increment the `slow` counter, then attempt to reserve a fixed-size record in the ring buffer. A failed reservation increments the `dropped` counter, allowing the final statistics to expose pressure in the event path. On successful reservation, the record is filled with PID, TGID, requested bytes, return value, latency, and process name, then submitted to the ring buffer.
The macros at the beginning of the file provide a local compatibility bridge. The repository's `vmlinux.h` snapshot has `bpf_session_is_return` and `bpf_session_cookie` declarations that predate the `ctx` argument added in Linux 7.0. The macros rename the old declarations, include the snapshot, undef, then redeclare with the correct Linux 7.0 signatures. A freshly regenerated `vmlinux.h` from kernel 7.0 or later can be used directly without this bridge.
Note that `count` (the requested byte count) is available directly - no need to have stored it at entry. This is the fsession advantage: function arguments persist to the return phase.
### User-Space Loader
`fsession_latency.c` parses the command line, configures BPF constants, manages the observation window, and prints results.
`fsession_latency.c` handles command-line parsing, BPF lifecycle management, and event consumption. The key sections:
**Configuration via read-only data**:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <errno.h>
#include <getopt.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <bpf/libbpf.h>
#include "fsession_latency.h"
#include "fsession_latency.skel.h"
skel->rodata->threshold_ns = env.threshold_us * 1000;
skel->rodata->target_tgid = env.pid;
```
static volatile sig_atomic_t exiting;
static struct env {
unsigned long long threshold_us;
unsigned int duration;
unsigned int pid;
bool verbose;
} env = {
.threshold_us = 1000,
.duration = 10,
};
static unsigned long long events_printed;
static void handle_signal(int signal)
{
(void)signal;
exiting = 1;
}
static int libbpf_print_fn(enum libbpf_print_level level, const char *format,
va_list args)
{
if (level == LIBBPF_DEBUG && !env.verbose)
return 0;
return vfprintf(stderr, format, args);
}
static void usage(const char *program)
{
fprintf(stderr,
"Usage: %s [--threshold-us USEC] [--duration SEC] [--pid TGID] "
"[--verbose]\n\n"
"Report vfs_read calls at or above a latency threshold.\n\n"
"Options:\n"
" -t, --threshold-us USEC slow-read threshold (default: 1000)\n"
" -d, --duration SEC trace duration, 1-86400 (default: 10)\n"
" -p, --pid TGID trace one process ID (default: all)\n"
" -v, --verbose print libbpf diagnostics\n"
" -h, --help show this help\n",
program);
}
static int parse_u64(const char *value, unsigned long long maximum,
unsigned long long *result)
{
char *end = NULL;
unsigned long long parsed;
errno = 0;
parsed = strtoull(value, &end, 10);
if (errno || end == value || *end || parsed > maximum)
return -EINVAL;
*result = parsed;
return 0;
}
static int parse_args(int argc, char **argv)
{
static const struct option options[] = {
{ "threshold-us", required_argument, NULL, 't' },
{ "duration", required_argument, NULL, 'd' },
{ "pid", required_argument, NULL, 'p' },
{ "verbose", no_argument, NULL, 'v' },
{ "help", no_argument, NULL, 'h' },
{},
};
unsigned long long parsed;
int option;
while ((option = getopt_long(argc, argv, "t:d:p:vh", options, NULL)) != -1) {
switch (option) {
case 't':
if (parse_u64(optarg, UINT64_MAX / 1000, &env.threshold_us)) {
fprintf(stderr, "invalid threshold in microseconds: %s\n", optarg);
return -EINVAL;
}
break;
case 'd':
if (parse_u64(optarg, 86400, &parsed) || parsed == 0) {
fprintf(stderr, "invalid duration in seconds: %s\n", optarg);
return -EINVAL;
}
env.duration = parsed;
break;
case 'p':
if (parse_u64(optarg, UINT32_MAX, &parsed) || parsed == 0) {
fprintf(stderr, "invalid process ID: %s\n", optarg);
return -EINVAL;
}
env.pid = parsed;
break;
case 'v':
env.verbose = true;
break;
case 'h':
usage(argv[0]);
exit(0);
default:
return -EINVAL;
}
}
if (optind != argc)
return -EINVAL;
return 0;
}
static long long monotonic_milliseconds(void)
{
struct timespec timestamp;
if (clock_gettime(CLOCK_MONOTONIC, &timestamp))
return -errno;
return timestamp.tv_sec * 1000LL + timestamp.tv_nsec / 1000000;
}
After opening the skeleton but before loading, user space writes the threshold (converted from microseconds to nanoseconds) and target TGID to the `.rodata` section. These become constants in the BPF program.
**Ring buffer consumption**:
```c
static int handle_event(void *context, void *data, size_t size)
{
const struct latency_event *event = data;
(void)context;
if (size < sizeof(*event))
return 0;
printf("EVENT comm=%-16s tgid=%u pid=%u requested=%llu result=%lld "
"latency_us=%llu\n",
event->comm, event->tgid, event->pid, event->requested,
@@ -308,124 +275,28 @@ static int handle_event(void *context, void *data, size_t size)
events_printed++;
return 0;
}
static int poll_until_deadline(struct ring_buffer *ring, long long deadline)
{
while (!exiting) {
long long now = monotonic_milliseconds();
int consumed;
if (now < 0)
return (int)now;
if (now >= deadline)
break;
consumed = ring_buffer__poll(ring,
deadline - now > 100 ? 100 : deadline - now);
if (consumed == -EINTR)
continue;
if (consumed < 0) {
fprintf(stderr, "ring buffer poll failed: %s\n",
strerror(-consumed));
return consumed;
}
}
return 0;
}
int main(int argc, char **argv)
{
struct fsession_latency_bpf *skel = NULL;
struct ring_buffer *ring = NULL;
long long deadline, now;
int err;
err = parse_args(argc, argv);
if (err) {
usage(argv[0]);
return 1;
}
libbpf_set_print(libbpf_print_fn);
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
skel = fsession_latency_bpf__open();
if (!skel) {
fprintf(stderr, "failed to open BPF skeleton\n");
return 1;
}
skel->rodata->threshold_ns = env.threshold_us * 1000;
skel->rodata->target_tgid = env.pid;
err = fsession_latency_bpf__load(skel);
if (err) {
fprintf(stderr,
"failed to load fsession program: %s\n"
"This tool requires Linux 7.0+, BTF, BPF JIT, and x86_64 "
"fsession support.\n",
strerror(-err));
goto cleanup;
}
err = fsession_latency_bpf__attach(skel);
if (err) {
fprintf(stderr, "failed to attach to vfs_read: %s\n", strerror(-err));
goto cleanup;
}
ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, NULL, NULL);
err = libbpf_get_error(ring);
if (err) {
ring = NULL;
fprintf(stderr, "failed to create ring buffer: %s\n", strerror(-err));
goto cleanup;
}
printf("Tracing vfs_read for %u seconds; threshold=%llu us; pid=%s\n",
env.duration, env.threshold_us, env.pid ? "selected" : "all");
fflush(stdout);
now = monotonic_milliseconds();
if (now < 0) {
err = now;
goto cleanup;
}
deadline = now + env.duration * 1000LL;
err = poll_until_deadline(ring, deadline);
if (err)
goto cleanup;
fsession_latency_bpf__detach(skel);
err = ring_buffer__consume(ring);
if (err < 0) {
fprintf(stderr, "ring buffer drain failed: %s\n", strerror(-err));
goto cleanup;
}
err = 0;
printf("SUMMARY calls=%llu slow=%llu errors=%llu dropped=%llu events=%llu\n",
skel->bss->stats.calls, skel->bss->stats.slow,
skel->bss->stats.errors, skel->bss->stats.dropped, events_printed);
cleanup:
ring_buffer__free(ring);
fsession_latency_bpf__destroy(skel);
return err != 0;
}
```
The loader follows a standard flow. After parsing the command line, it opens the skeleton and writes `threshold_ns` and `target_tgid` to the read-only BPF configuration. The user-provided threshold is in microseconds; it is multiplied by 1000 to convert to nanoseconds before writing. Calling `load` completes BPF program loading, then attaches to `vfs_read`. The failure diagnostic provides both the kernel errno and a prerequisite list to help users troubleshoot.
Each event is printed with the process name, IDs, requested bytes, return value, and latency in microseconds.
`ring_buffer__new` creates the ring-buffer consumer and registers the `handle_event` callback to process each event. The callback prints comm, TGID, PID, requested bytes, result, and latency in microseconds, incrementing `events_printed`.
**Clean shutdown sequence**:
```c
fsession_latency_bpf__detach(skel);
The `poll_until_deadline` function drives the event consumption loop. It calculates a monotonic deadline and polls for up to 100 ms per iteration until the deadline or SIGINT/SIGTERM. A `-EINTR` return continues the loop; other negative values indicate errors.
err = ring_buffer__consume(ring);
// ... error handling ...
After polling ends, the loader first calls `fsession_latency_bpf__detach` to detach the fsession program, stopping changes to aggregate counters. It then calls `ring_buffer__consume` to deliver records submitted before detach but not yet consumed. This ordering is part of observation-window boundary correctness: stop new event production first, drain existing events, then read stable counters. On exit, it frees the ring buffer and destroys the skeleton.
printf("SUMMARY calls=%llu slow=%llu errors=%llu dropped=%llu events=%llu\n",
skel->bss->stats.calls, skel->bss->stats.slow,
skel->bss->stats.errors, skel->bss->stats.dropped, events_printed);
```
The shutdown order matters for correctness:
1. Detach the BPF program (stops new events from being generated)
2. Drain remaining events from the ring buffer
3. Read the final counter values (now stable since the program is detached)
This ensures `events_printed` matches the events actually delivered through the ring buffer.
## Compilation and Execution
@@ -444,9 +315,9 @@ SERVICE_PID=$(pgrep -n my-service)
sudo ./fsession_latency --pid "$SERVICE_PID" --threshold-us 10000 --duration 30
```
`--pid` compares against the TGID returned by `bpf_get_current_pid_tgid()`, which is the TGID in the host (initial) PID namespace. The `pgrep` example above works for processes running directly on the host; targets inside containers or nested PID namespaces need their host-visible TGID resolved and passed in.
**Note about PID namespaces**: The `--pid` option compares against the TGID in the host (initial) PID namespace. If your target runs in a container with its own PID namespace, you need to find its host-visible TGID. Inside the container the process might be PID 1, but from the host it could be PID 12345. Use `pgrep` on the host or inspect `/proc/<pid>/status` for the `NSpid` line.
Command-line arguments:
### Command-Line Options
```text
Usage: ./fsession_latency [--threshold-us USEC] [--duration SEC] [--pid TGID] [--verbose]
@@ -459,9 +330,9 @@ Options:
-h, --help show this help
```
Defaults are 1000 microsecond threshold, 10 second duration, and all processes. `--duration` accepts 1 through 86400 seconds, `--pid` accepts 1 through 2^32-1, and `--threshold-us` accepts 0 through `UINT64_MAX / 1000` microseconds (user space converts to nanoseconds before load). Empty values and trailing characters are rejected by `parse_u64`.
### Example Output
The following output was captured on x86_64 with kernel `7.0.0-rc2+`. The integration test performs 64 ordinary file reads plus one pipe read delayed by 50 ms; the pipe read exceeds the 10 ms threshold and appears in the event stream:
The following output was captured on x86_64 with kernel `7.0.0-rc2+`. The integration test performs 64 ordinary file reads plus one pipe read delayed by 50 ms. The pipe read exceeds the 10 ms threshold and appears in the event stream:
```console
Tracing vfs_read for 1 seconds; threshold=10000 us; pid=selected
@@ -471,17 +342,26 @@ TEST-SUMMARY miss_calls=0 high_threshold_calls=66 high_threshold_slow=0 threshol
PASS: PID filtering, threshold miss, and slow-read reporting behaved as expected
```
Exact call counts, PID values, and latency numbers vary by run.
The `SUMMARY` line shows:
- 66 total `vfs_read` calls were observed
- 1 was slow (met the threshold)
- 0 returned errors
- 0 events were dropped
- 1 event was printed
Running the tests:
### Running the Tests
```bash
sudo make test
```
The integration test validates four runtime paths: target TGID of `UINT32_MAX` produces zero calls and events; a 10,000,000 microsecond threshold causes ordinary reads to produce zero slow events; 64 ordinary file reads plus one 50 ms delayed pipe read under a 10,000 microsecond threshold produces a slow event; and threshold 0 verifies `slow == events + dropped`, proving every threshold-matching call is either delivered or accounted as a ring-buffer reservation failure. CLI tests cover invalid duration, empty duration, empty threshold, and empty PID.
The integration test validates:
- **PID filtering**: Targeting a nonexistent TGID produces zero calls
- **Threshold filtering**: A very high threshold (10 seconds) produces zero slow events from fast reads
- **Event reporting**: A deliberately slow pipe read (50ms) triggers an event under a 10ms threshold
- **Accounting invariant**: With threshold=0, `slow == events + dropped` (every threshold-matching call is either delivered or counted as dropped)
### Environment Requirements
## Environment Requirements
| Requirement | Details |
|---|---|
@@ -494,15 +374,28 @@ The integration test validates four runtime paths: target TGID of `UINT32_MAX` p
Runtime behavior was tested on x86_64 with kernel `7.0.0-rc2+`. The upstream merge commit is `f17b474e36647c23801ef8fdaf2255ab66dd2973`.
## Observation Scope
## Extending This Tool
This tool measures time inside `vfs_read`, covering read paths that reach that function and filtering by exact host-visible TGID. A more comprehensive tracer could add cgroup, path, inode, mount, stack, or long-running metrics dimensions while keeping the same fsession correlation pattern; the existing `dropped` counter remains the event-loss signal under load.
The fsession pattern demonstrated here applies to any kernel function where you need to correlate entry and return. Some directions for extension:
- **Additional functions**: Attach to `vfs_write`, `vfs_fsync`, or other VFS operations
- **File path filtering**: Use `file->f_path` to filter by specific files or mount points
- **Stack traces**: Add `bpf_get_stackid()` to capture kernel and/or user stack traces for slow calls
- **Histograms**: Replace per-event reporting with latency histograms using `BPF_MAP_TYPE_ARRAY`
- **cgroup filtering**: Add cgroup ID checks for container-aware tracing
The `dropped` counter remains the signal for ring buffer pressure - if it's growing, either increase the buffer size or raise the threshold to reduce event volume.
## Summary
This tutorial demonstrated how to measure kernel function latency using the fsession mechanism in Linux 7.0. Compared to the traditional approach of two fentry/fexit programs with a hash map, fsession handles both entry and return in a single program. The 8-byte per-invocation session cookie replaces the external correlation map for carrying timestamps, resulting in cleaner code, no state-leak risk, and lower performance overhead.
This tutorial demonstrated how to measure kernel function latency using the fsession mechanism in Linux 7.0. The key advantages over the traditional fentry/fexit with hash map approach:
The tool's design demonstrates the core fsession pattern: use `bpf_session_is_return` to distinguish phases, use `bpf_session_cookie` to pass per-invocation state, and access function arguments and return values directly in the return phase. This pattern applies to any scenario requiring correlation between function entry and return.
1. **Single program**: One BPF program handles both entry and return, with `bpf_session_is_return()` distinguishing the phases
2. **Built-in correlation**: The 8-byte session cookie replaces the external hash map for passing data between phases
3. **No state leaks**: The kernel manages cookie lifecycle - no cleanup required, no leaks possible
4. **Arguments available at return**: Function parameters persist to the return phase without explicit storage
The pattern is simple: check `bpf_session_is_return()`, use `bpf_session_cookie()` for per-invocation state, and access arguments/return value directly. This applies wherever you need to correlate function entry and exit.
> To learn more about eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit <https://eunomia.dev/tutorials/>.

View File

@@ -1,42 +1,101 @@
# eBPF 教程:使用 fsession 追踪慢速 vfs_read 调用
假设一个文件服务出现了读延迟尖峰,应用层计时只能告诉你请求变慢了,却无法区分是内核读路径阻塞还是用户态逻辑耗时。有用的问题是:哪个线程发起了读操作、请求了多少字节、调用返回了什么、这一次 `vfs_read` 花了多长时间。
当一个文件服务出现读延迟尖峰时,应用层计时只能告诉你请求变慢了,却无法区分是内核在 I/O 上阻塞还是用户态逻辑耗时。真正有用的问题是:哪个线程发起了读操作、请求了多少字节、调用返回了什么、这一次 `vfs_read` 花了多长时间?
本教程展示如何使用 Linux 7.0 引入的 fsession 机制测量 `vfs_read` 调用延迟。工具在函数进入时记录时间戳、返回时计算延迟,按 TGID 和阈值过滤后上报慢读事件。
本教程展示如何使用 Linux 7.0 引入的 **fsession** 机制测量 `vfs_read` 调用延迟。fsession 是一种新的 eBPF 程序类型,它在函数进入时执行一次、返回时再执行一次,并提供内置的调用级存储来关联这两个阶段。我们构建的工具会在函数进入时记录时间戳、返回时计算延迟,按进程和阈值过滤后,通过 ring buffer 上报慢读事件。
> 完整源码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/52-fsession-latency>
## 为什么需要 fsession
## 问题:如何关联函数的进入和返回
传统的函数延迟测量方案都有明显的局限性。
要测量一个内核函数的执行时间,需要在它开始时记录时间戳,在它返回时计算差值。这听起来很简单,但 eBPF 中的传统方案都有各自的缺陷。
**独立的 fentry 和 fexit 程序**是最常见的做法。你需要编写两个 BPF 程序,一个附加到函数入口,一个附加到函数返回。两个程序之间需要通过一个按线程索引的哈希表来传递时间戳:进入时存入 `bpf_get_current_pid_tgid()` 到时间戳的映射,返回时查找并删除。这个模式有几个问题:首先需要维护额外的哈希表,每次调用都有查找和删除的开销;其次如果进入后没有正常返回(比如线程被杀死),哈希表中的条目会泄漏;最后两个程序之间没有天然的关联,状态传递完全依赖外部数据结构。
### 传统方案:两个程序配合哈希表
**kprobe 和 kretprobe**同样需要两个独立的程序和外部哈希表。相比 fentry/fexit,kprobe 的开销更大,因为它通过断点机制工作,而 fentry 直接挂钩到函数序言,在 x86_64 上使用 JIT 优化的调用序列。
最常见的做法是使用两个独立的 BPF 程序——一个附加到函数进入(fentry),一个附加到函数返回(fexit)。进入程序把时间戳存入一个以线程 ID 为键的哈希表;返回程序查找时间戳、计算延迟、删除条目:
**用户态采样**如 `perf record` 或 `bpftrace` 的周期性堆栈采样只能提供统计概貌,无法精确测量每次调用的延迟。对于需要捕获尾部延迟事件的场景,采样可能完全错过关键的慢调用。
```
进入程序: 返回程序:
1. 获取线程 ID 1. 获取线程 ID
2. 获取时间戳 2. 从哈希表查找时间戳
3. 存入哈希表 3. 计算延迟
4. 删除哈希表条目
5. 上报事件
```
**fsession 机制**从根本上解决了这个问题。Linux 7.0 引入了 fsession,它让同一个 BPF 程序在被追踪函数的进入和返回阶段各执行一次,并通过 `bpf_session_cookie(ctx)` 提供一个 8 字节的调用级暂存区。这个 cookie 从进入到返回期间持续有效,完全替代了外部哈希表。程序用 `bpf_session_is_return(ctx)` 区分两个阶段,在同一个程序内处理所有逻辑。
这个方案能工作,但有几个问题:
## fsession 的工作原理
- **哈希表开销**:每次函数调用都需要在进入时插入、在返回时查找并删除。对于像 `vfs_read` 这样的高频函数,开销会累积。
- **状态泄漏**:如果线程在进入后、返回前被杀死(比如 `kill -9`),哈希表条目永远不会被删除,造成内存泄漏。
- **没有内在关联**:两个程序完全独立,唯一的联系是外部哈希表——没有任何保证它们追踪的是同一次函数调用。
fsession 是一种特殊的 ftracing 程序类型。当你用 `SEC("fsession/vfs_read")` 声明一个程序时,内核会在 `vfs_read` 的进入和返回阶段各调用一次你的 BPF 程序。
### kprobe/kretprobe:相同模式,更高开销
`bpf_session_is_return(ctx)` 返回 false 表示进入阶段,返回 true 表示返回阶段。程序可以用这个 helper 函数区分当前处于哪个阶段,执行相应的逻辑。
kprobe 机制也是两个程序的结构,同样需要外部哈希表来关联。此外,kprobe 通过软件断点机制工作(用中断指令替换第一条指令),开销比 fentry 更大——后者使用内核的 ftrace 基础设施和 JIT 优化的调用序列。
`bpf_session_cookie(ctx)` 返回一个指向 8 字节暂存区的指针。这个暂存区是每次调用独立的,从进入阶段到返回阶段持续有效。你可以在进入阶段写入数据(比如时间戳),在返回阶段读取并使用它。
### 用户态采样:统计性的,不是精确的
返回阶段的上下文不仅包含原始函数参数,还包含函数的返回值。对于 `vfs_read`,进入时能看到 `file`、`buf`、`count`、`pos` 四个参数,返回时还能看到 `ret` 返回值。这意味着你不需要把参数存到 map 里,返回阶段可以直接访问。
像 `perf record` 这样的工具周期性采样堆栈,可以构建时间花在哪里的统计概貌。然而,采样无法测量单次函数调用的延迟。如果你需要捕获尾部延迟事件——偶尔出现的 100ms 读操作导致超时——统计采样可能完全错过它们。
`fsession_latency` 工具利用这些能力实现延迟测量。进入阶段,它检查 TGID 是否匹配目标进程,匹配的调用在 cookie 中存入 `bpf_ktime_get_ns()` 时间戳。返回阶段,它从 cookie 读取时间戳计算延迟,递增聚合计数器,超过阈值的调用通过 ring buffer 上报详细事件。
## fsession 的解决方案
Linux 7.0 引入了 **fsession**,在内核层面解决了关联问题。当你用 `SEC("fsession/vfs_read")` 声明一个 BPF 程序时,内核会:
1. 在 `vfs_read` 进入时调用你的程序
2. 分配一个 8 字节的暂存区("session cookie"),绑定到这次特定的调用
3. 在 `vfs_read` 返回时再次调用你的程序
4. 释放 session cookie
关键在于 session cookie 是自动管理的,作用域精确到一次函数调用。程序用 `bpf_session_is_return(ctx)` 区分进入和返回,用 `bpf_session_cookie(ctx)` 读写 cookie。
### Session Cookie 替代了什么
在传统方案中,你需要一个这样的哈希表:
```c
// 传统方案:以线程 ID 为键的哈希表
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u64); // pid_tgid
__type(value, u64); // timestamp
} start SEC(".maps");
```
用了 fsession,整个哈希表都不需要了。时间戳直接存在 session cookie 里:
```c
// fsession:调用级 cookie,不需要 map
__u64 *started = bpf_session_cookie(ctx);
*started = bpf_ktime_get_ns();
```
cookie 从进入到返回一直存在,之后自动清理——不可能泄漏。
### 返回阶段可以访问函数参数
fsession 的另一个优势:返回阶段的上下文同时包含原始函数参数和返回值。在传统方案中,如果你需要在返回时访问函数参数(比如在事件中包含请求的字节数),必须在进入时把它们存到哈希表里。用 fsession,参数直接可用:
```c
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
loff_t *pos, ssize_t ret)
{
// 返回时,'count' 和 'ret' 都可以直接访问
// 不需要在进入时存储 'count'
}
```
## 代码实现
本工具由三个文件组成:共享头文件定义事件和统计结构、BPF 程序测量延迟并提交事件、用户空间加载器管理生命周期并打印结果。
本工具由三个文件组成:
- `fsession_latency.h`:BPF 和用户空间共享的数据结构
- `fsession_latency.bpf.c`:测量延迟的 BPF 程序
- `fsession_latency.c`:管理 BPF 生命周期并打印结果的用户空间加载器
### 共享头文件
`fsession_latency.h` 定义了 BPF 和用户空间共享的事件与统计结构。`latency_event` 包含 32 位 PID 和 TGID、64 位请求字节数、有符号 64 位返回值、纳秒精度延迟和进程名。`latency_stats` 包含四个计数器用于聚合统计。
`fsession_latency.h` 定义了通过 ring buffer 发送的事件结构和聚合统计结构:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -64,11 +123,17 @@ struct latency_stats {
#endif /* __FSESSION_LATENCY_H */
```
四个计数器分别记录:`calls` 是总调用数、`slow` 是达到阈值的调用数、`errors` 是返回负值的调用数、`dropped` 是 ring buffer 预留失败的计数。`FSESSION_COMM_LEN` 设为 16,与内核 `TASK_COMM_LEN` 一致。
四个计数器分别记录:
- `calls`:观察到的 `vfs_read` 调用总数
- `slow`:达到或超过延迟阈值的调用数
- `errors`:`vfs_read` 返回负数错误码的调用数
- `dropped`:因 ring buffer 满而无法提交的事件数
`FSESSION_COMM_LEN` 设为 16,与内核的 `TASK_COMM_LEN` 一致。
### BPF 程序
`fsession_latency.bpf.c` 使用 `SEC("fsession/vfs_read")` 声明为 fsession 类型。内核会在 `vfs_read` 的进入和返回阶段各调用一次这个程序。
`fsession_latency.bpf.c` 是工具的核心。我们逐段分析。
```c
// SPDX-License-Identifier: GPL-2.0
@@ -82,7 +147,11 @@ struct latency_stats {
#include "fsession_latency.h"
char LICENSE[] SEC("license") = "GPL";
```
开头的宏处理是一个兼容性变通。仓库的 `vmlinux.h` 快照是在 Linux 7.0 给 `bpf_session_is_return` 和 `bpf_session_cookie` 加上 `ctx` 参数之前生成的。宏在 include 时重命名旧声明,然后我们在下面提供正确的签名。从 7.0 以上内核重新生成的 `vmlinux.h` 不需要这个处理。
```c
const volatile __u64 threshold_ns;
const volatile __u32 target_tgid;
@@ -92,7 +161,15 @@ struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
```
BPF 程序中的 `const volatile` 变量有特殊语义。它们被放在 `.rodata` 段,用户空间可以在打开 skeleton 之后、加载之前设置。一旦加载,验证器把它们当作编译期常量,可以做死代码消除等优化(比如当 `target_tgid` 为 0 时)。
`stats` 是 `.bss` 段的全局变量,程序运行后用户空间可以直接读取。
ring buffer(`events`)大小是 256 KB——足够容纳数千个事件才会溢出。
```c
/*
* The repository vmlinux.h snapshot predates the ctx argument on these
* kfunc prototypes. Rename those stale declarations while including the
@@ -100,7 +177,11 @@ struct {
*/
extern bool bpf_session_is_return(void *ctx) __ksym;
extern __u64 *bpf_session_cookie(void *ctx) __ksym;
```
这是 **kfunc** 声明——导出给 BPF 程序调用的内核函数。`__ksym` 属性告诉验证器在加载时从运行中的内核解析这些符号,而不是期望它们在 BPF 对象中定义。
```c
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
loff_t *pos, ssize_t ret)
@@ -109,7 +190,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
__u64 *started = bpf_session_cookie(ctx);
struct latency_event *event;
__u64 latency;
```
`SEC("fsession/vfs_read")` 告诉内核这是一个附加到 `vfs_read` 的 fsession 程序。`BPF_PROG` 宏展开后设置标准的追踪上下文;`ctx` 隐式可用,可以传给 kfunc。
函数签名列出 `vfs_read` 的参数,最后是返回值。进入时,`ret` 是未定义的;返回时,所有参数和返回值都有效。
```c
if (!bpf_session_is_return(ctx)) {
if (target_tgid && pid_tgid >> 32 != target_tgid) {
*started = 0;
@@ -118,7 +205,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
*started = bpf_ktime_get_ns();
return 0;
}
```
**进入阶段**:首先检查是否应该过滤这次调用。如果设置了 `target_tgid`(非零)且当前进程的 TGID 不匹配,在 cookie 中写入 0 表示"跳过",然后返回。否则,把当前单调时间戳写入 cookie。
TGID 在 `bpf_get_current_pid_tgid()` 返回值的高 32 位;低 32 位是线程 ID(内核术语中的 PID)。
```c
if (!*started)
return 0;
@@ -128,7 +221,13 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
__sync_fetch_and_add(&stats.errors, 1);
if (latency < threshold_ns)
return 0;
```
**返回阶段**:如果 cookie 是 0,说明进入阶段已经过滤了这次调用——直接返回。否则计算延迟并更新聚合计数器。`__sync_fetch_and_add` 提供原子更新,因为多个 CPU 可能同时执行这个程序。
如果延迟低于阈值,到此为止——调用被计数但不产生事件。这让 ring buffer 只关注慢调用。
```c
__sync_fetch_and_add(&stats.slow, 1);
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event) {
@@ -147,160 +246,28 @@ int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
}
```
程序结构分为进入阶段和返回阶段两部分。两个 `const volatile` 变量位于 `.rodata` 段,用户态在 `open()` 之后、`load()` 之前写入阈值和目标 TGID,验证器将它们视为编译期常量。`bpf_session_is_return` 和 `bpf_session_cookie` 通过 `extern ... __ksym` 声明为 kfunc 符号,内核在加载时解析。
对于慢调用,递增 `slow` 计数器并尝试在 ring buffer 中预留空间。如果预留失败(缓冲区满),递增 `dropped` 让用户知道有事件丢失。成功后填入各字段并提交。
进入阶段,程序调用 `bpf_get_current_pid_tgid()` 获取调用者的 PID 和 TGID。如果设置了 `target_tgid` 且调用来自其他进程,程序在 cookie 中写入 0 表示这次调用应该被跳过。匹配的调用则存入 `bpf_ktime_get_ns()` 返回的单调时间戳。
返回阶段,程序首先检查 cookie 是否为 0。cookie 为 0 表示进入阶段已经过滤了这次调用,直接返回。否则计算延迟为当前时间戳减去保存的进入时间戳,然后递增 `calls` 计数器。如果 `ret < 0` 表示读操作返回错误,递增 `errors` 计数器。延迟低于 `threshold_ns` 的调用到此为止,只更新聚合计数器不上报事件。
达到阈值的调用递增 `slow` 计数器,然后尝试在 ring buffer 中预留固定大小的记录。预留失败时递增 `dropped` 计数器,这让最终统计能够暴露事件路径的压力。预留成功后填入 PID、TGID、请求字节数、返回值、延迟和进程名,最后提交到 ring buffer。
文件开头的宏处理是一个本地兼容桥接。仓库的 `vmlinux.h` 快照中 `bpf_session_is_return` 和 `bpf_session_cookie` 的声明早于 Linux 7.0 添加的 `ctx` 参数。宏先把旧声明重命名,include 之后再 undef,然后用正确的 Linux 7.0 签名重新声明。从 7.0 以上内核重新生成的 `vmlinux.h` 可以直接使用,不需要这个桥接。
注意 `count`(请求的字节数)可以直接访问——不需要在进入时存储。这就是 fsession 的优势:函数参数在返回阶段仍然可用。
### 用户空间加载器
`fsession_latency.c` 解析命令行、配置 BPF 常量、管理观察窗口并打印结果。
`fsession_latency.c` 处理命令行解析、BPF 生命周期管理和事件消费。关键部分:
**通过只读数据配置**:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <errno.h>
#include <getopt.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <bpf/libbpf.h>
#include "fsession_latency.h"
#include "fsession_latency.skel.h"
skel->rodata->threshold_ns = env.threshold_us * 1000;
skel->rodata->target_tgid = env.pid;
```
static volatile sig_atomic_t exiting;
static struct env {
unsigned long long threshold_us;
unsigned int duration;
unsigned int pid;
bool verbose;
} env = {
.threshold_us = 1000,
.duration = 10,
};
static unsigned long long events_printed;
static void handle_signal(int signal)
{
(void)signal;
exiting = 1;
}
static int libbpf_print_fn(enum libbpf_print_level level, const char *format,
va_list args)
{
if (level == LIBBPF_DEBUG && !env.verbose)
return 0;
return vfprintf(stderr, format, args);
}
static void usage(const char *program)
{
fprintf(stderr,
"Usage: %s [--threshold-us USEC] [--duration SEC] [--pid TGID] "
"[--verbose]\n\n"
"Report vfs_read calls at or above a latency threshold.\n\n"
"Options:\n"
" -t, --threshold-us USEC slow-read threshold (default: 1000)\n"
" -d, --duration SEC trace duration, 1-86400 (default: 10)\n"
" -p, --pid TGID trace one process ID (default: all)\n"
" -v, --verbose print libbpf diagnostics\n"
" -h, --help show this help\n",
program);
}
static int parse_u64(const char *value, unsigned long long maximum,
unsigned long long *result)
{
char *end = NULL;
unsigned long long parsed;
errno = 0;
parsed = strtoull(value, &end, 10);
if (errno || end == value || *end || parsed > maximum)
return -EINVAL;
*result = parsed;
return 0;
}
static int parse_args(int argc, char **argv)
{
static const struct option options[] = {
{ "threshold-us", required_argument, NULL, 't' },
{ "duration", required_argument, NULL, 'd' },
{ "pid", required_argument, NULL, 'p' },
{ "verbose", no_argument, NULL, 'v' },
{ "help", no_argument, NULL, 'h' },
{},
};
unsigned long long parsed;
int option;
while ((option = getopt_long(argc, argv, "t:d:p:vh", options, NULL)) != -1) {
switch (option) {
case 't':
if (parse_u64(optarg, UINT64_MAX / 1000, &env.threshold_us)) {
fprintf(stderr, "invalid threshold in microseconds: %s\n", optarg);
return -EINVAL;
}
break;
case 'd':
if (parse_u64(optarg, 86400, &parsed) || parsed == 0) {
fprintf(stderr, "invalid duration in seconds: %s\n", optarg);
return -EINVAL;
}
env.duration = parsed;
break;
case 'p':
if (parse_u64(optarg, UINT32_MAX, &parsed) || parsed == 0) {
fprintf(stderr, "invalid process ID: %s\n", optarg);
return -EINVAL;
}
env.pid = parsed;
break;
case 'v':
env.verbose = true;
break;
case 'h':
usage(argv[0]);
exit(0);
default:
return -EINVAL;
}
}
if (optind != argc)
return -EINVAL;
return 0;
}
static long long monotonic_milliseconds(void)
{
struct timespec timestamp;
if (clock_gettime(CLOCK_MONOTONIC, &timestamp))
return -errno;
return timestamp.tv_sec * 1000LL + timestamp.tv_nsec / 1000000;
}
打开 skeleton 之后、加载之前,用户空间把阈值(从微秒转换为纳秒)和目标 TGID 写入 `.rodata` 段。这些在 BPF 程序中成为常量。
**Ring buffer 消费**:
```c
static int handle_event(void *context, void *data, size_t size)
{
const struct latency_event *event = data;
(void)context;
if (size < sizeof(*event))
return 0;
printf("EVENT comm=%-16s tgid=%u pid=%u requested=%llu result=%lld "
"latency_us=%llu\n",
event->comm, event->tgid, event->pid, event->requested,
@@ -308,124 +275,28 @@ static int handle_event(void *context, void *data, size_t size)
events_printed++;
return 0;
}
static int poll_until_deadline(struct ring_buffer *ring, long long deadline)
{
while (!exiting) {
long long now = monotonic_milliseconds();
int consumed;
if (now < 0)
return (int)now;
if (now >= deadline)
break;
consumed = ring_buffer__poll(ring,
deadline - now > 100 ? 100 : deadline - now);
if (consumed == -EINTR)
continue;
if (consumed < 0) {
fprintf(stderr, "ring buffer poll failed: %s\n",
strerror(-consumed));
return consumed;
}
}
return 0;
}
int main(int argc, char **argv)
{
struct fsession_latency_bpf *skel = NULL;
struct ring_buffer *ring = NULL;
long long deadline, now;
int err;
err = parse_args(argc, argv);
if (err) {
usage(argv[0]);
return 1;
}
libbpf_set_print(libbpf_print_fn);
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
skel = fsession_latency_bpf__open();
if (!skel) {
fprintf(stderr, "failed to open BPF skeleton\n");
return 1;
}
skel->rodata->threshold_ns = env.threshold_us * 1000;
skel->rodata->target_tgid = env.pid;
err = fsession_latency_bpf__load(skel);
if (err) {
fprintf(stderr,
"failed to load fsession program: %s\n"
"This tool requires Linux 7.0+, BTF, BPF JIT, and x86_64 "
"fsession support.\n",
strerror(-err));
goto cleanup;
}
err = fsession_latency_bpf__attach(skel);
if (err) {
fprintf(stderr, "failed to attach to vfs_read: %s\n", strerror(-err));
goto cleanup;
}
ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, NULL, NULL);
err = libbpf_get_error(ring);
if (err) {
ring = NULL;
fprintf(stderr, "failed to create ring buffer: %s\n", strerror(-err));
goto cleanup;
}
printf("Tracing vfs_read for %u seconds; threshold=%llu us; pid=%s\n",
env.duration, env.threshold_us, env.pid ? "selected" : "all");
fflush(stdout);
now = monotonic_milliseconds();
if (now < 0) {
err = now;
goto cleanup;
}
deadline = now + env.duration * 1000LL;
err = poll_until_deadline(ring, deadline);
if (err)
goto cleanup;
fsession_latency_bpf__detach(skel);
err = ring_buffer__consume(ring);
if (err < 0) {
fprintf(stderr, "ring buffer drain failed: %s\n", strerror(-err));
goto cleanup;
}
err = 0;
printf("SUMMARY calls=%llu slow=%llu errors=%llu dropped=%llu events=%llu\n",
skel->bss->stats.calls, skel->bss->stats.slow,
skel->bss->stats.errors, skel->bss->stats.dropped, events_printed);
cleanup:
ring_buffer__free(ring);
fsession_latency_bpf__destroy(skel);
return err != 0;
}
```
加载器的流程比较标准。解析命令行后,打开 skeleton 并把 `threshold_ns` 和 `target_tgid` 写入只读 BPF 配置。用户输入的阈值是微秒单位,乘以 1000 转换为纳秒后写入。调用 `load` 完成 BPF 程序加载,然后附加到 `vfs_read`。加载失败时的诊断同时给出内核 errno 和前置条件列表,方便用户排查问题。
每个事件打印进程名、ID、请求字节数、返回值和延迟(微秒)。
`ring_buffer__new` 创建 ring buffer 消费者,注册 `handle_event` 回调处理每个事件。回调打印 comm、TGID、PID、请求字节数、结果和延迟微秒数,并递增 `events_printed` 计数。
**干净关闭序列**:
```c
fsession_latency_bpf__detach(skel);
`poll_until_deadline` 函数驱动事件消费循环。它计算单调时间截止点,每次迭代最多轮询 100 ms 直到截止或收到 SIGINT/SIGTERM。轮询返回 `-EINTR` 时继续循环,其他负值表示错误。
err = ring_buffer__consume(ring);
// ... 错误处理 ...
轮询结束后,加载器先调用 `fsession_latency_bpf__detach` 解除 fsession 程序的附加,使聚合计数器停止变化。然后调用 `ring_buffer__consume` 投递解除附加前已提交但尚未消费的记录。这个顺序是观察窗口边界正确性的一部分:先停止新事件产生,再排空已有事件,最后读取稳定的计数器。退出时释放 ring buffer 并销毁 skeleton。
printf("SUMMARY calls=%llu slow=%llu errors=%llu dropped=%llu events=%llu\n",
skel->bss->stats.calls, skel->bss->stats.slow,
skel->bss->stats.errors, skel->bss->stats.dropped, events_printed);
```
关闭顺序对正确性很重要:
1. 解除 BPF 程序的附加(停止产生新事件)
2. 排空 ring buffer 中剩余的事件
3. 读取最终的计数器值(现在稳定了,因为程序已解除附加)
这确保 `events_printed` 与实际通过 ring buffer 投递的事件数一致。
## 编译与运行
@@ -444,14 +315,14 @@ SERVICE_PID=$(pgrep -n my-service)
sudo ./fsession_latency --pid "$SERVICE_PID" --threshold-us 10000 --duration 30
```
`--pid` 比较的是 `bpf_get_current_pid_tgid()` 返回的 TGID,即宿主机(初始)PID 命名空间中的 TGID。上面的 `pgrep` 适用于直接在宿主机上运行的进程,容器或嵌套 PID 命名空间内的目标需要解析并传入其宿主机可见的 TGID。
**关于 PID 命名空间**:`--pid` 选项比较的是宿主机(初始)PID 命名空间中的 TGID。如果目标运行在有自己 PID 命名空间的容器中,你需要找到它在宿主机可见的 TGID。在容器内进程可能是 PID 1,但从宿主机看可能是 PID 12345。在宿主机上使用 `pgrep` 或检查 `/proc/<pid>/status` 中的 `NSpid` 行。
命令行参数:
### 命令行选项
```text
Usage: ./fsession_latency [--threshold-us USEC] [--duration SEC] [--pid TGID] [--verbose]
Options:
选项:
-t, --threshold-us USEC 慢读阈值(默认:1000 微秒)
-d, --duration SEC 追踪时长,1-86400(默认:10 秒)
-p, --pid TGID 追踪指定进程 ID(默认:所有进程)
@@ -459,9 +330,9 @@ Options:
-h, --help 显示帮助
```
默认阈值 1000 微秒、持续 10 秒、追踪所有进程。`--duration` 接受 1 到 86400 秒,`--pid` 接受 1 到 2^32-1,`--threshold-us` 接受 0 到 `UINT64_MAX / 1000` 微秒(用户空间在加载前转换为纳秒)。空值和尾随字符都会被 `parse_u64` 拒绝。
### 输出示例
以下输出在运行内核 `7.0.0-rc2+` 的 x86_64 环境中采集。集成测试执行 64 次普通文件读取加一次延迟 50 ms 的管道读取,管道读超过 10 ms 阈值后出现在事件流中:
以下输出在运行内核 `7.0.0-rc2+` 的 x86_64 环境中采集。集成测试执行 64 次普通文件读取加一次延迟 50 ms 的管道读取。管道读超过 10 ms 阈值后出现在事件流中:
```console
Tracing vfs_read for 1 seconds; threshold=10000 us; pid=selected
@@ -471,22 +342,31 @@ TEST-SUMMARY miss_calls=0 high_threshold_calls=66 high_threshold_slow=0 threshol
PASS: PID filtering, threshold miss, and slow-read reporting behaved as expected
```
具体调用次数、PID 值和延迟数值因运行环境而异。
`SUMMARY` 行显示:
- 观察到 66 次 `vfs_read` 调用
- 1 次是慢的(达到阈值)
- 0 次返回错误
- 0 个事件被丢弃
- 1 个事件被打印
运行测试:
### 运行测试
```bash
sudo make test
```
集成测试验证四个运行时路径:目标 TGID 为 `UINT32_MAX` 产生零调用和事件、10,000,000 微秒阈值使普通读产生零慢事件、64 次普通文件读加一次延迟 50 ms 的管道读在 10,000 微秒阈值下产生慢事件、阈值为 0 时 `slow == events + dropped` 证明每个阈值匹配调用要么被投递要么被计入 ring buffer 预留失败。CLI 测试覆盖无效 duration、空 duration、空 threshold 和空 PID。
集成测试验证:
- **PID 过滤**:针对不存在的 TGID 产生零调用
- **阈值过滤**:很高的阈值(10 秒)使快速读取产生零慢事件
- **事件上报**:故意延迟的管道读取(50ms)在 10ms 阈值下触发事件
- **计数不变式**:阈值为 0 时,`slow == events + dropped`(每个达到阈值的调用要么被投递要么被计入丢弃)
### 环境要求
## 环境要求
| 要求 | 详情 |
|---|---|
| 内核版本 | Linux 7.0+(fsession 首次引入) |
| BTF | 必须启用 (`CONFIG_DEBUG_INFO_BTF=y`) |
| BTF | 必须启用(`CONFIG_DEBUG_INFO_BTF=y`) |
| 内核配置 | `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_BPF_EVENTS=y`, `CONFIG_DEBUG_INFO_BTF=y`, `CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y` |
| BPF JIT | 运行时必须启用 |
| 架构 | 已在 x86_64 上测试 |
@@ -494,15 +374,28 @@ sudo make test
运行时行为在 x86_64 内核 `7.0.0-rc2+` 上测试。上游合并提交为 `f17b474e36647c23801ef8fdaf2255ab66dd2973`。
## 观察范围
## 扩展方向
本工具测量 `vfs_read` 内部的时间,覆盖到达该函数的读路径并按精确的宿主机可见 TGID 筛选。更完整的追踪器可以在保持相同 fsession 关联模式的基础上加入 cgroup、路径、inode、挂载点、栈或长时运行指标维度,现有的 `dropped` 计数器在负载下仍是事件丢失信号。
这里展示的 fsession 模式适用于任何需要关联进入和返回的内核函数。一些扩展方向:
- **其他函数**:附加到 `vfs_write`、`vfs_fsync` 或其他 VFS 操作
- **文件路径过滤**:使用 `file->f_path` 按特定文件或挂载点过滤
- **堆栈追踪**:添加 `bpf_get_stackid()` 捕获慢调用的内核和/或用户态堆栈
- **直方图**:用 `BPF_MAP_TYPE_ARRAY` 的延迟直方图替代逐事件上报
- **cgroup 过滤**:添加 cgroup ID 检查实现容器感知的追踪
`dropped` 计数器仍然是 ring buffer 压力的信号——如果它在增长,要么增大缓冲区大小,要么提高阈值减少事件量。
## 总结
本教程展示了如何使用 Linux 7.0 的 fsession 机制测量内核函数延迟。相比传统的 fentry/fexit 两程序配合哈希表的方案,fsession 用一个程序同时处理进入和返回,8 字节的调用级 session cookie 替代外部关联 map 携带时间戳,代码更简洁、无状态泄漏风险、性能开销更低。
本教程展示了如何使用 Linux 7.0 的 fsession 机制测量内核函数延迟。相比传统的 fentry/fexit 配合哈希表的方案,主要优势:
工具的设计演示了 fsession 的核心模式:用 `bpf_session_is_return` 区分阶段,用 `bpf_session_cookie` 传递调用级状态,返回阶段直接访问函数参数和返回值。这个模式适用于任何需要关联函数进入和返回的场景。
1. **单个程序**:一个 BPF 程序处理进入和返回,用 `bpf_session_is_return()` 区分阶段
2. **内置关联**:8 字节的 session cookie 替代外部哈希表在两个阶段间传递数据
3. **无状态泄漏**:内核管理 cookie 生命周期——不需要清理,不可能泄漏
4. **返回时可访问参数**:函数参数在返回阶段仍然可用,无需显式存储
模式很简单:检查 `bpf_session_is_return()`,用 `bpf_session_cookie()` 管理调用级状态,直接访问参数和返回值。这个模式适用于任何需要关联函数进入和退出的场景。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。

View File

@@ -1,48 +1,54 @@
# eBPF Tutorial: Building an Egress Pacer with BPF Qdisc
Imagine you need to verify how your application behaves under low-bandwidth conditions in a test environment. You create a veth pair to simulate a network link and want to limit egress to 64 Kbit/s while observing packet drops when the queue fills up. Traditional rate limiting requires complex tc configurations or user-space proxies, but Linux 6.16 introduced BPF qdisc, which lets you implement a complete queuing discipline directly in eBPF.
## The Problem: Testing Under Bandwidth Constraints
This tutorial shows how to use `struct_ops` to build a FIFO rate limiter. The program registers as a root qdisc, manages skb enqueue and dequeue, computes transmission time from packet length, uses the watchdog to schedule the next dequeue, and cleans up queued packets when the qdisc is removed. This is a complete qdisc lifecycle example, suitable for validating skb ownership, transmission timing, and resource cleanup on controlled interfaces like veth, TAP, or IFB.
Suppose you need to test how your application behaves when bandwidth is limited. You create a veth pair to simulate a network link and want to cap egress at 64 Kbit/s while observing what happens when the queue fills up and packets start dropping. Traditional approaches—complex tc configurations or user-space proxies—work but are heavyweight for a quick validation.
Linux 6.16 introduced a new option: BPF qdisc. Instead of configuring existing schedulers, you can implement a complete queuing discipline directly in eBPF. This tutorial builds a FIFO rate limiter that demonstrates the full qdisc lifecycle: registering as the root qdisc, managing packet ownership through enqueue and dequeue, computing transmission times, using the watchdog timer for scheduling, and cleaning up when removed.
This example is designed for controlled interfaces like veth, TAP, or IFB—environments where you have full control and can safely experiment with packet scheduling.
> Complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/53-egress-pacer>
## Why BPF Qdisc Is Needed
## Why BPF Qdisc Exists
Traditional traffic shaping approaches have clear limitations.
To see what BPF qdisc changes, compare it with three common approaches.
tc provides rich qdisc options such as TBF (Token Bucket Filter) and HTB (Hierarchical Token Bucket), but these are predefined algorithms in the kernel. If you need a custom scheduling policy, such as prioritizing by application type or dynamically adjusting rates based on real-time metrics, you must either modify kernel code or use complex tc classifier combinations.
**Built-in schedulers are fixed algorithms.** tc provides TBF (Token Bucket Filter), HTB (Hierarchical Token Bucket), and others, but these are predefined behaviors in the kernel. If you need something custom—prioritizing by application type, adjusting rates based on real-time metrics—you must either patch the kernel or chain multiple tc classifiers together.
Ordinary TC BPF programs can inspect and process packets, but queueing and transmission timing remain under the original qdisc's control. [Lesson 20](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc) demonstrates this pattern: BPF programs decide whether to pass or drop packets, but cannot control when packets are sent. If you want custom queueing policies, TC BPF is not enough.
**TC BPF programs cannot control timing.** Regular TC BPF programs can inspect packets and decide whether to pass or drop them, but the underlying qdisc still controls when packets actually transmit. [Lesson 20](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc) shows this pattern: BPF decides pass/drop, but not "send this packet at time T."
User-space rate limiting approaches like tc-netem with external tools or DPDK can implement complex traffic shaping, but they introduce additional context switches and deployment complexity. In a test environment, you may just want to quickly validate an idea rather than set up a full user-space network stack.
**User-space solutions add overhead.** Tools like tc-netem with external helpers or DPDK can shape traffic, but they introduce context switches and deployment complexity. For testing, you often want something lightweight.
Linux 6.16 introduced BPF implementations of `Qdisc_ops` callbacks through `struct_ops`. Programs implement `enqueue`, `dequeue`, `init`, `reset`, and `destroy`, and can dynamically register as a qdisc. This means you can use BPF programs to fully control packet queueing and transmission timing while retaining the performance advantages of the kernel network stack. The `egress_pacer` in this lesson is a FIFO rate limiter built exactly this way.
BPF qdisc addresses these gaps. Starting with Linux 6.16, you can implement `Qdisc_ops` callbacks (`enqueue`, `dequeue`, `init`, `reset`, `destroy`) in eBPF and register them as a qdisc type. Your BPF program fully controls packet queuing and timing while staying in kernel space. The `egress_pacer` in this tutorial is a FIFO rate limiter built this way.
## How BPF Qdisc Works
Before diving into the code, let's understand the overall flow of a BPF qdisc. Unlike ordinary TC BPF programs, a qdisc BPF program registers as the root qdisc and completely takes over packet queueing and transmission.
Unlike regular TC BPF programs that run within an existing qdisc, a BPF qdisc *is* the qdisc. When you attach it to an interface's root position, your BPF code handles every egress packet.
The user-space program first sets the rate and queue limit, loads the BPF program and registers the `struct_ops` implementation, then attaches it to the `TC_H_ROOT` position on the target interface. From then on, all egress packets through that interface are handled by the BPF program.
Here is the lifecycle:
When the kernel needs to send a packet, it calls the `enqueue` callback. The BPF program creates a node to hold the skb, computes the earliest transmission time based on packet length, then adds the node to the FIFO queue. If the queue is full or node allocation fails, the program drops the packet and updates statistics.
1. **Setup**: User space sets the rate and queue limit, loads the BPF program, registers the `struct_ops` implementation, and attaches it to `TC_H_ROOT` on the target interface.
When the kernel tries to retrieve a packet for transmission, it calls the `dequeue` callback. The BPF program checks the transmission time of the head node. If the current time has reached or passed that time, the program retrieves the skb and returns it to the kernel; if the transmission time hasn't arrived yet, the program uses `bpf_qdisc_watchdog_schedule` to set a timer and returns NULL. When the timer expires, the kernel calls `dequeue` again.
2. **Enqueue**: When the kernel needs to queue a packet for transmission, it calls your `enqueue` callback. You receive an `skb`, create a node to hold it, compute when it should transmit based on packet length and rate, and add the node to your queue. If the queue is full or allocation fails, you drop the packet.
When the user-space program removes the qdisc, the kernel calls the `reset` callback. The BPF program iterates through all unsent packets in the queue, releases their resources, and zeros out counters.
3. **Dequeue**: When the kernel wants to transmit, it calls your `dequeue` callback. You check if the front packet's transmission time has arrived. If yes, return the `skb`; if not, schedule a watchdog timer for the correct time and return NULL. The kernel will call `dequeue` again when the timer fires.
Throughout this lifecycle, the BPF program has complete ownership of the skb. From receiving the skb in `enqueue`, to returning it in `dequeue` or releasing it in `reset`, packets are held in BPF-managed data structures.
4. **Reset**: When the qdisc is removed, the kernel calls `reset`. You iterate through any remaining packets, release their resources, and zero the counters.
Throughout this lifecycle, your BPF program owns the packets. From receiving an `skb` in `enqueue`, through holding it in your data structure, to returning it in `dequeue` or freeing it in `reset`—the BPF program is responsible.
![egress_pacer data flow: from configuration, attachment, enqueue, BPF FIFO, dequeue to transmit path, including policy-drop branch, watchdog loop, and reset lifecycle](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/53-egress-pacer/egress-pacer-flow.png)
The solid lines in the diagram start from configuration and attachment, then trace an skb from enqueue into the BPF FIFO and out through dequeue to the transmit path. When the queue is full or node allocation returns null, packets take the policy-drop branch. An early dequeue pushes the node back to the front, schedules the watchdog, and returns NULL; when the watchdog fires, it re-enters dequeue. The dashed lines show the lifecycle path: removing the root qdisc triggers reset, which releases skbs in the queue and zeros qlen and backlog.
The solid lines trace a packet's normal path: configuration and attachment, then enqueue into the FIFO, then dequeue to the transmit path. When the queue is full or allocation fails, packets take the policy-drop branch. If dequeue is called before a packet's transmission time, the node is pushed back to the front, a watchdog is scheduled, and NULL is returned; the watchdog later re-enters dequeue. The dashed lines show the lifecycle path: removing the qdisc triggers reset, which frees queued packets and zeros qlen and backlog.
## Code Implementation
This tool consists of four files: a shared header defining the statistics structure, a compatibility header providing graph-object kfunc declarations, the BPF program implementing qdisc callbacks, and the user-space loader managing the lifecycle and printing results.
The implementation consists of four files: a shared header defining statistics, a compatibility header providing BPF graph-object declarations, the BPF program implementing qdisc callbacks, and a user-space loader that manages the lifecycle.
### Shared Header
`egress_pacer.h` defines the statistics structure shared between BPF and user space. These six fields categorize packet outcomes throughout their lifetime, residing in the BSS section as the result channel.
`egress_pacer.h` defines statistics shared between BPF and user space. These six counters categorize every packet outcome:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -61,11 +67,15 @@ struct pacer_stats {
#endif /* __EGRESS_PACER_H */
```
Packets that successfully enter and leave the FIFO are counted in `enqueued` and `dequeued` respectively. When the queue is full or node allocation fails, `policy_dropped` increases. Packets still in the queue during reset are counted in `cleanup_dropped`. `bytes_dequeued` accumulates the bytes actually returned to the transmit path, and `max_qlen` records the peak queue depth.
- `enqueued` / `dequeued`: Packets that successfully entered and left the FIFO.
- `policy_dropped`: Packets dropped at enqueue because the queue was full or allocation failed.
- `cleanup_dropped`: Packets still in the queue when reset runs—these were queued but never transmitted.
- `bytes_dequeued`: Total bytes actually transmitted.
- `max_qlen`: Peak queue depth observed.
### Compatibility Header
This example requires graph-object kfuncs, so a local compatibility header provides the BPF qdisc declarations. These declarations exist only in this lesson's directory and are independent from the shared headers used across the repository.
BPF qdisc uses "graph-object" kfuncs—kernel functions for managing BPF-owned linked lists and objects. This local header provides the necessary declarations:
```c
/* SPDX-License-Identifier: GPL-2.0 */
@@ -102,11 +112,16 @@ bpf_list_pop_front(struct bpf_list_head *head) __ksym;
#endif /* __EGRESS_PACER_BPF_EXPERIMENTAL_H */
```
This file provides the graph-object helpers needed to build the queue. `bpf_obj_new` creates a packet node, and `bpf_obj_drop` releases it when done. `bpf_list_push_back` and `bpf_list_pop_front` arrange these nodes into a FIFO. The `__contains` macro generates a BTF tag that tells the verifier which node type belongs inside the list head, enabling safe graph tracking.
These helpers let BPF code manage kernel objects safely:
- `bpf_obj_new` allocates a new object of the given type.
- `bpf_obj_drop` frees it.
- `bpf_list_push_back` / `bpf_list_push_front` / `bpf_list_pop_front` implement a doubly-linked list.
- The `__contains` macro generates a BTF tag telling the verifier what node type the list head contains, enabling safe ownership tracking.
### BPF Program
`egress_pacer.bpf.c` uses `SEC(".struct_ops")` to declare the `Qdisc_ops` implementation. The kernel registers these callbacks at load time, making the BPF program a complete qdisc.
`egress_pacer.bpf.c` implements the qdisc. The `SEC(".struct_ops")` declaration registers this as a `Qdisc_ops` implementation:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -308,19 +323,59 @@ struct Qdisc_ops pacer = {
};
```
The program structure centers on three core qdisc phases. The two `const volatile` variables `rate_kbps` and `queue_limit` reside in the `.rodata` section; user space writes the rate and queue limit after `open()` but before `load()`, and the verifier treats them as compile-time constants. The `packet_node` structure defines nodes in the queue, containing transmission time, packet length, skb pointer, and list node. The `__kptr` annotation tells the verifier this field owns a kernel pointer.
#### Key data structures
`egress_pacer_enqueue` is the enqueue callback. It first retrieves the packet length from `qdisc_skb_cb(skb)->pkt_len`, then checks if the queue has space. If the queue is full or node creation fails, it calls `bpf_qdisc_skb_drop` to drop the packet and increments `policy_dropped`. The actual ownership transfer happens via `bpf_kptr_xchg`: after success, the skb is placed in the node's `__kptr` field, and the node becomes responsible for it. The program then computes transmission time while holding `queue_lock`, adds the node to the FIFO tail, and updates qlen and backlog. The transmission interval is calculated as `packet_len * 8 * 1000000 / rate_kbps` in nanoseconds. After the queue has been idle for a while, `next_departure_ns` will be earlier than the current time, allowing the first arriving packet to leave immediately.
The `const volatile` variables `rate_kbps` and `queue_limit` live in `.rodata`. User space writes them after `open()` but before `load()`, and the verifier treats them as compile-time constants thereafter.
`egress_pacer_dequeue` is the dequeue callback. It retrieves the node from the front and compares `eligible_ns` with the current time. If the packet is ready to send, it uses `bpf_kptr_xchg` to retrieve the skb from the node, decrements qlen and backlog, releases the node, then calls `bpf_qdisc_bstats_update` to update qdisc statistics and returns the skb. If transmission time hasn't arrived, it pushes the node back to the front, calls `bpf_qdisc_watchdog_schedule` to set a timer, and returns NULL. The kernel will call dequeue again at the specified time.
The `packet_node` structure holds each queued packet:
- `eligible_ns`: The earliest time this packet may transmit (in nanoseconds).
- `packet_len`: The packet's length for backlog accounting.
- `skb`: A `__kptr` field that owns the kernel socket buffer. The `__kptr` annotation tells the verifier this field holds an owned kernel pointer.
- `node`: A `bpf_list_node` for linking into the FIFO.
`egress_pacer_reset` handles cleanup. When the qdisc is removed, there may still be unsent packets in the queue. The program uses `bpf_for` to retrieve nodes one by one, calls `bpf_kfree_skb` to release the skb, then destroys the node object. Cleared packets are counted in `cleanup_dropped`, separate from enqueue-stage `policy_dropped`. After draining the list, the program zeros `next_departure_ns`, qlen, and backlog together.
The `private(A)` macro places the lock, list head, and departure timestamp in a private data section with proper alignment.
The `SEC(".struct_ops")` block at the end registers all callbacks as a `Qdisc_ops` structure. The `id` field specifies the qdisc name as `bpf_pacer`, which user space uses to create qdisc instances.
#### The enqueue callback
`egress_pacer_enqueue` receives a packet from the kernel:
1. Get the packet length from `qdisc_skb_cb(skb)->pkt_len`.
2. If the queue is at capacity, drop the packet.
3. Allocate a `packet_node`. If allocation fails, drop.
4. Compute when this packet may transmit: `interval_ns = packet_len * 8 * 1000000 / rate_kbps` (bits divided by kilobits/sec gives nanoseconds).
5. Transfer ownership of the `skb` to the node using `bpf_kptr_xchg`. After this call, the node owns the `skb`.
6. Under the spin lock, set the packet's eligible time. If the queue was idle (`next_departure_ns` is in the past), this packet can leave immediately; otherwise it waits behind the previous departure.
7. Push the node onto the FIFO, update counters, unlock, and return success.
The `bpf_qdisc_skb_drop` call handles dropped packets—it adds the `skb` to the kernel's free list and updates qdisc statistics.
#### The dequeue callback
`egress_pacer_dequeue` is called when the kernel wants a packet to transmit:
1. Pop the front node from the FIFO.
2. If the queue is empty, return NULL.
3. Check if the current time has reached `eligible_ns`. If not, push the node back to the front, schedule a watchdog timer for `eligible_ns`, and return NULL. The kernel will call dequeue again when the timer fires.
4. If ready, extract the `skb` from the node using `bpf_kptr_xchg`, decrement counters, free the node, update statistics, and return the `skb`.
The watchdog mechanism (`bpf_qdisc_watchdog_schedule`) is how BPF qdisc implements transmission timing—you tell the kernel when to wake you, and it calls dequeue at that time.
#### The reset callback
`egress_pacer_reset` runs when the qdisc is removed. Any packets still in the queue must be freed:
1. Loop through all nodes using `bpf_for` (a BPF helper for bounded iteration).
2. Pop each node, extract its `skb`, and free it with `bpf_kfree_skb`.
3. Count freed packets in `cleanup_dropped`.
4. Zero the departure timestamp and qdisc counters.
#### Registration
The `SEC(".struct_ops")` block at the end registers the callbacks as a `Qdisc_ops` structure. The `id` field (`"bpf_pacer"`) is the name user space uses to instantiate this qdisc.
### User-Space Loader
`egress_pacer.c` parses command-line arguments, configures BPF constants, creates the qdisc, waits for the specified duration, then cleans up.
`egress_pacer.c` handles command-line arguments, configures the BPF program, attaches the qdisc, waits for the specified duration, and cleans up:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -621,11 +676,17 @@ cleanup:
}
```
The loader follows a standard flow. After parsing command-line arguments, it uses `if_nametoindex` to verify the interface exists. After opening the skeleton, it writes the rate and queue limit to the `.rodata` section, then calls `load` to complete BPF program loading. After successful loading, it calls `attach` to register the `struct_ops` implementation, making `bpf_pacer` an available qdisc type.
The loader follows a standard libbpf pattern:
The subsequent `bpf_tc_hook_create` creates a root qdisc named `bpf_pacer` at the `TC_H_ROOT` position on the target interface. This call has exclusive semantics: if the target interface already has a root qdisc, the kernel returns `-EEXIST` and the program stops immediately, preserving the existing configuration. This exclusive approach limits the tool's scope, making it suitable for use on dedicated test interfaces.
1. Parse arguments and verify the interface exists with `if_nametoindex`.
2. Open the skeleton and write rate/queue limit to `.rodata`.
3. Load the BPF program (this verifies and JIT-compiles it).
4. Attach the `struct_ops` implementation, making `bpf_pacer` available as a qdisc type.
5. Create the root qdisc on the target interface with `bpf_tc_hook_create`.
After successful installation, the program prints the `READY` line, then checks the runtime and `exiting` flag every 100 ms. SIGINT, SIGTERM, or timeout all exit the wait loop. The cleanup order ensures `SUMMARY` includes packets cleared during reset: first call `bpf_tc_hook_destroy` to trigger reset, then read `skel->bss->stats`, and finally destroy the skeleton to unregister the `struct_ops` link.
The `bpf_tc_hook_create` call has exclusive semantics: if the interface already has a root qdisc, the kernel returns `-EEXIST` and the program exits without changing anything. This safety measure means the tool is best suited for dedicated test interfaces where you control the configuration.
After successful attachment, the program prints `READY`, then polls every 100ms until duration expires or a signal arrives. Cleanup order matters: first `bpf_tc_hook_destroy` (which triggers reset and frees queued packets), then read final statistics, then destroy the skeleton.
## Compilation and Execution
@@ -637,13 +698,13 @@ make clean
make -j2
```
For the first run, you can use the integration test directly. It creates a disposable `epac_tx`/`epac_rx` veth pair and deletes it after testing. Loading BPF and modifying qdisc require root:
The easiest way to test is with the included integration test, which creates a temporary veth pair and cleans it up afterward. Root is required for loading BPF and modifying qdiscs:
```bash
sudo python3 tests/test_egress_pacer.py ./egress_pacer
```
Here is a completed run. The environment was x86_64, with kernel `7.0.0-rc2+` built from commit `a03114efd0720dff230388f7e160e427e54ea31b`:
Here is output from a successful run on x86_64 with kernel `7.0.0-rc2+` (commit `a03114efd0720dff230388f7e160e427e54ea31b`):
```console
READY interface=epac_tx rate_kbps=64 queue_limit=8 duration=2
@@ -652,43 +713,44 @@ TEST-SUMMARY attempts=40 received=9 send_errors=31 span_ms=1024 observed_kbps=64
PASS: conflict refusal, bounded drops, pacing, accounting, normal/signal cleanup, and SIGKILL recovery succeeded
```
This output demonstrates functional correctness of the main paths: queue limits, rate limiting schedule, and counter consistency. The test sends 40 1024-byte raw EtherType `0x88B5` frames continuously into a 64 Kbit/s queue that can hold 8 packets. In this run, the receiver saw 9 packets with 1024 ms between first and last; another 31 sends went to `policy_dropped`. The exact numbers will vary depending on the system and timing.
What happened: the test sent 40 raw Ethernet frames (1024 bytes each, EtherType `0x88B5`) into a 64 Kbit/s queue limited to 8 packets. Nine packets made it through the queue, spaced over 1024ms—matching the expected rate. The other 31 sends hit the queue limit and went to `policy_dropped`. Exact numbers vary by system timing.
The test also covers conflict detection, signal cleanup, and abnormal recovery. It pre-installs a conflicting `pfifo` to confirm the loader preserves existing configuration; creates a backlog then sends SIGTERM, checking that `cleanup_dropped` is greater than zero and verifying `enqueued = dequeued + cleanup_dropped`. The final SIGKILL is handled by the test program: it uses `tc` to delete the residual qdisc, confirms the interface recovers normally, and cleans up the veth pair in a Python `finally` block.
The test also exercises edge cases:
- Pre-installs a `pfifo` to confirm the loader refuses to replace existing qdiscs.
- Sends SIGTERM with packets queued, verifying `cleanup_dropped > 0` and that `enqueued = dequeued + cleanup_dropped`.
- Sends SIGKILL (which skips cleanup), then uses `tc` to manually remove the residual qdisc and confirm the interface recovers.
For manual runs, choose an interface you fully control and first check its current root qdisc:
For manual testing, pick an interface you control and check its current qdisc:
```bash
tc qdisc show dev veth-service
```
After confirming the interface is safe to use, run the pacer for 30 seconds:
If it shows the default `noqueue` or `pfifo_fast`, you can attach the pacer:
```bash
sudo ./egress_pacer --interface veth-service --rate-kbps 64000 --queue-limit 256 --duration 30
```
Command-line arguments:
Command-line options:
```text
Usage: ./egress_pacer --interface IFACE [--rate-kbps KBPS] [--queue-limit PACKETS] [--duration SEC] [--verbose]
Options:
-i, --interface IFACE target interface (required)
-r, --rate-kbps KBPS egress rate, 8-100000000 (default: 1024)
-q, --queue-limit PACKETS queue capacity, 1-65535 packets (default: 256)
-d, --duration SEC control duration, 1-86400 seconds (default: 10)
-v, --verbose print libbpf diagnostics
-r, --rate-kbps KBPS egress rate in Kbit/s, 8-100000000 (default: 1024)
-q, --queue-limit PACKETS queue capacity in packets, 1-65535 (default: 256)
-d, --duration SEC how long to run, 1-86400 seconds (default: 10)
-v, --verbose print libbpf debug output
-h, --help show help
```
Seeing `READY` means the qdisc is installed. After the run completes and removal succeeds, the program prints `SUMMARY`.
When you see `READY`, the qdisc is installed and pacing traffic. When the duration ends (or you press Ctrl+C), the program removes the qdisc and prints `SUMMARY`.
### Environment Requirements
This feature is available starting with Linux 6.16. The kernel must have BTF and BPF JIT enabled, with at least `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_DEBUG_INFO_BTF=y`, `CONFIG_NET_SCHED=y`, and `CONFIG_NET_SCH_BPF=y`.
BPF qdisc TC hook requires libbpf 1.6.0 or newer. The repository includes libbpf and bpftool in `src/third_party`. Root privileges are required at runtime. The integration test additionally requires Python 3, raw packet sockets, and `ip` and `tc` from `iproute2`. A controlled veth pair is sufficient; the tested architecture is x86_64.
BPF qdisc requires Linux 6.16 or later. Your kernel must have BTF and BPF JIT enabled:
| Requirement | Minimum Version / Config |
| --- | --- |
@@ -702,11 +764,19 @@ BPF qdisc TC hook requires libbpf 1.6.0 or newer. The repository includes libbpf
| libbpf | 1.6.0+ |
| Privileges | root |
The repository includes libbpf and bpftool in `src/third_party`. The integration test additionally requires Python 3, raw socket capability, and `ip`/`tc` from iproute2. A veth pair is sufficient for testing; verified on x86_64.
## Summary
This tutorial demonstrated how to implement a complete BPF qdisc using `struct_ops`. `enqueue` takes ownership of the skb and schedules transmission time based on packet length, `dequeue` uses the watchdog to wake up at the right moment and return the skb, and `reset` releases packets still in the queue during removal. These three ownership and timing stages form a reusable qdisc pattern.
This tutorial demonstrated BPF qdisc through a complete FIFO rate limiter:
This scheduler implements a single aggregate FIFO, with queue limits counted by packet count and transmission intervals derived from qdisc packet length. Rate and queue limit are fixed at load time, making it suitable for validating functional correctness on controlled interfaces. A more complete scheduler could add fairness, dynamic policies, and persistent recovery on top of this foundation.
- **enqueue** takes ownership of packets and schedules transmission times based on packet length and rate.
- **dequeue** uses the watchdog timer to wake at the right moment and returns packets to the kernel.
- **reset** frees any packets still queued when the qdisc is removed.
These three phases—receiving packets, timing their release, and cleaning up—form a reusable pattern for custom scheduling disciplines.
This particular scheduler implements a single aggregate FIFO with fixed rate and queue limit set at load time. It is intentionally simple, suited for validating correctness on controlled interfaces. A production scheduler could build on this foundation to add fairness, dynamic policies, or state persistence across restarts.
> To learn more about eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>.

View File

@@ -1,48 +1,54 @@
# eBPF 教程:用 BPF Qdisc 实现出口限速
假设你需要在测试环境中验证应用程序在低带宽条件下的行为。你创建了一对 veth 接口模拟网络链路,现在想把出口速率限制到 64 Kbit/s,同时观察队列深度达到上限时的丢包行为。传统的限速方案需要复杂的 tc 配置或用户态代理,而 Linux 6.16 引入的 BPF qdisc 允许你用 eBPF 程序直接实现完整的排队规则。
## 问题背景:带宽受限场景下的测试
本教程展示如何使用 `struct_ops` 实现一个 FIFO 限速器。程序注册为 root qdisc,自己管理 skb 入队和出队,按报文长度计算发送时间,使用 watchdog 调度下一次出队,并在 qdisc 移除时清理队列中的报文。这是一个完整的 qdisc 生命周期示例,适合在 veth、TAP 或 IFB 这类受控接口上验证 skb 所有权、发送时序和资源清理。
假设你需要测试应用程序在低带宽条件下的行为。你创建了一对 veth 接口模拟网络链路,想把出口速率限制到 64 Kbit/s,同时观察队列满时的丢包情况。传统方案——复杂的 tc 配置或用户态代理——虽然可行,但对于快速验证来说过于笨重。
Linux 6.16 提供了一种新选择:BPF qdisc。你不再需要配置现有的调度器,而是可以用 eBPF 直接实现一个完整的排队规则。本教程构建一个 FIFO 限速器,演示 qdisc 的完整生命周期:注册为 root qdisc、在入队和出队过程中管理报文所有权、计算发送时间、使用 watchdog 定时器调度下次出队、以及在移除时清理资源。
本示例适用于 veth、TAP 或 IFB 这类受控接口——你拥有完全控制权,可以安心进行报文调度实验。
> 完整源码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/53-egress-pacer>
## 为什么需要 BPF Qdisc
传统的流量整形方案有明显的局限性。
要理解 BPF qdisc 改变了什么,可以先和三种常见做法对照。
tc 提供了丰富的 qdisc 选项,如 TBF(Token Bucket Filter)和 HTB(Hierarchical Token Bucket),但这些都是内核中预定义的算法。如果你需要一种特殊的调度策略,比如按应用程序类型区分优先级,或者根据实时指标动态调整速率,就必须修改内核代码或使用复杂的 tc 分类器组合。
**内置调度器是固定算法。** tc 提供了 TBF(令牌桶过滤器)、HTB(分层令牌桶)等多种选项,但这些都是内核中预定义的行为。如果你需要自定义的调度策略——比如按应用程序类型区分优先级,或者根据实时指标动态调整速率——就必须修改内核代码或串联多个 tc 分类器。
普通的 TC BPF 程序可以检查和处理报文,但排队和发送时间仍由原有的 qdisc 管理。[第 20 课](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc) 展示的就是这种模式:BPF 程序决定放行或丢弃,但无法控制报文何时发送。如果你想实现自定义的排队策略,TC BPF 的能力是不够的。
**TC BPF 程序无法控制发送时机。** 普通的 TC BPF 程序可以检查报文并决定放行或丢弃,但底层的 qdisc 仍然控制着报文的实际发送时间。[第 20 课](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc)展示的正是这种模式:BPF 决定放行还是丢弃,但无法做到"在时刻 T 发送这个报文"。
用户态限速方案如 tc-netem 配合外部工具或 DPDK 可以实现复杂的流量整形,但引入了额外的上下文切换和复杂的部署依赖。在测试环境中,你可能只想快速验证一个想法,而不是搭建完整的用户态网络栈。
**用户态方案会引入额外开销。** tc-netem 配合外部工具或 DPDK 可以实现流量整形,但会带来上下文切换和部署复杂度。在测试场景中,你往往需要轻量级的方案。
Linux 6.16 通过 `struct_ops` 引入了 BPF 实现的 `Qdisc_ops` 回调。程序实现 `enqueue`、`dequeue`、`init`、`reset` 和 `destroy`,可以动态注册为一个 qdisc。这意味着你可以用 BPF 程序完全控制报文的排队和发送时序,同时保持内核网络栈的性能优势。本课的 `egress_pacer` 就是用这种方式构建的 FIFO 限速器。
BPF qdisc 解决了这些问题。从 Linux 6.16 开始,你可以用 eBPF 实现 `Qdisc_ops` 的回调函数(`enqueue`、`dequeue`、`init`、`reset`、`destroy`),并将其注册为一种 qdisc 类型。你的 BPF 程序可以完全控制报文的排队和发送时序,同时保持在内核态运行。本教程的 `egress_pacer` 就是用这种方式构建的 FIFO 限速器。
## BPF Qdisc 的工作原理
在深入代码之前,先理解 BPF qdisc 的整体流程。与普通 TC BPF 程序不同,qdisc BPF 程序注册为 root qdisc,完全接管报文的排队和发送。
与在现有 qdisc 内部运行的普通 TC BPF 程序不同,BPF qdisc 本身**就是** qdisc。当你把它挂载到接口的 root 位置后,你的 BPF 代码会处理每一个出口报文。
用户态程序首先设置速率和队列上限,加载 BPF 程序并注册 `struct_ops` 实现,然后把它挂载到指定接口的 `TC_H_ROOT` 位置。此后所有经过该接口的出口报文都由 BPF 程序处理。
整个生命周期如下:
当内核需要发送报文时,它调用 `enqueue` 回调。BPF 程序创建一个节点来保存 skb,根据报文长度计算最早可以发送的时间,然后把节点加入 FIFO 队列。如果队列已满或节点分配失败,程序丢弃报文并更新统计。
1. **初始化**:用户态程序设置速率和队列上限,加载 BPF 程序,注册 `struct_ops` 实现,然后把它挂载到目标接口的 `TC_H_ROOT` 位置。
当内核尝试取出报文发送时,它调用 `dequeue` 回调。BPF 程序检查队首节点的发送时间。如果当前时间已经到达或超过该时间,程序取出 skb 返回给内核;如果还没到发送时间,程序用 `bpf_qdisc_watchdog_schedule` 设置一个定时器,然后返回 NULL。定时器到期后,内核会再次调用 `dequeue`。
2. **入队**:当内核需要将报文排队发送时,它调用你的 `enqueue` 回调。你接收到一个 `skb`,创建一个节点来保存它,根据报文长度和速率计算发送时间,然后把节点加入队列。如果队列已满或分配失败,则丢弃报文。
当用户态程序移除 qdisc 时,内核调用 `reset` 回调。BPF 程序遍历队列中所有未发送的报文,释放它们占用的资源,并清零计数器。
3. **出队**:当内核想要发送报文时,它调用你的 `dequeue` 回调。你检查队首报文的发送时间是否已到。如果是,返回 `skb`;如果否,为正确的时间安排一个 watchdog 定时器并返回 NULL。定时器触发后,内核会再次调用 `dequeue`。
整个生命周期中,BPF 程序完全控制 skb 的所有权。从 `enqueue` 接收 skb 开始,到 `dequeue` 返回 skb 或 `reset` 释放 skb 为止,报文都由 BPF 管理的数据结构保存。
4. **重置**:当 qdisc 被移除时,内核调用 `reset`。你需要遍历所有剩余的报文,释放它们占用的资源,并将计数器清零。
在整个生命周期中,你的 BPF 程序拥有报文的所有权。从 `enqueue` 接收 `skb`,到在你的数据结构中持有它,再到在 `dequeue` 中返回它或在 `reset` 中释放它——BPF 程序始终是责任方。
![egress_pacer 数据流:从配置、挂载、入队、BPF FIFO、出队到发送路径,包含 policy-drop 分支、watchdog 循环和 reset 生命周期](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/53-egress-pacer/egress-pacer-flow.png)
图中实线从配置和挂载开始,随后跟踪一个 skb 从 enqueue 进入 BPF FIFO,再由 dequeue 交还给发送路径。队列已满或节点分配返回空时走 policy-drop 分支。早到的 dequeue 把节点推回队首,安排 watchdog 并返回 NULL,等 watchdog 唤醒后重新进入 dequeue。虚线表示生命周期路径:移除 root qdisc 后 reset 释放队列中的 skb 并清零 qlen 和 backlog。
图中实线描绘了报文的正常路径:配置和挂载,然后入队进入 FIFO,再出队到发送路径。当队列已满或分配失败时,报文走 policy-drop 分支。如果在报文发送时间到来之前调用了 dequeue,节点会被推回队首,安排 watchdog,然后返回 NULL;watchdog 随后会重新进入 dequeue。虚线表示生命周期路径:移除 qdisc 会触发 reset,释放队列中的报文并清零 qlen 和 backlog。
## 代码实现
本工具由四个文件组成:共享头文件定义统计结构、兼容头文件提供 graph-object kfunc 声明、BPF 程序实现 qdisc 回调、用户空间加载器管理生命周期并打印结果。
本实现由四个文件组成:定义统计结构的共享头文件、提供 BPF graph-object 声明的兼容头文件、实现 qdisc 回调的 BPF 程序,以及管理生命周期的用户态加载器。
### 共享头文件
`egress_pacer.h` 定义了 BPF 和用户空间共享的统计结构。这六个字段把报文的一生分成几种结果,位于 BSS 段作为结果通道。
`egress_pacer.h` 定义了 BPF 和用户态之间共享的统计结构。这六个计数器把每个报文的最终结果分门别类:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -61,11 +67,15 @@ struct pacer_stats {
#endif /* __EGRESS_PACER_H */
```
成功进入和离开 FIFO 的报文分别计入 `enqueued` 和 `dequeued`。队列已满或节点分配失败时,`policy_dropped` 增加。reset 时仍在队列中的报文计入 `cleanup_dropped`。`bytes_dequeued` 累计实际交还给发送路径的字节数,`max_qlen` 记录队列的峰值深度。
- `enqueued` / `dequeued`:成功进入和离开 FIFO 的报文数。
- `policy_dropped`:在入队阶段因队列已满或分配失败而丢弃的报文数。
- `cleanup_dropped`:reset 运行时仍在队列中的报文数——这些报文已入队但从未发送。
- `bytes_dequeued`:实际发送的总字节数。
- `max_qlen`:观察到的队列深度峰值。
### 兼容头文件
这个示例需要 graph-object kfunc,因此用一个本地兼容头文件补齐 BPF qdisc 声明。这些声明仅存在于本课目录,与仓库统一使用的头文件相互独立。
BPF qdisc 使用"graph-object"kfunc——用于管理 BPF 所持有的链表和对象的内核函数。这个本地头文件提供了必要的声明:
```c
/* SPDX-License-Identifier: GPL-2.0 */
@@ -102,11 +112,16 @@ bpf_list_pop_front(struct bpf_list_head *head) __ksym;
#endif /* __EGRESS_PACER_BPF_EXPERIMENTAL_H */
```
这个文件提供构建队列所需的 graph-object 辅助函数。`bpf_obj_new` 创建报文节点,`bpf_obj_drop` 在节点用完后释放它。`bpf_list_push_back` 和 `bpf_list_pop_front` 让这些节点组成 FIFO。`__contains` 生成 BTF tag,告诉 verifier 这个 list head 装的是哪一种节点,使 verifier 可以安全地跟踪链表对象。
这些辅助函数让 BPF 代码可以安全地管理内核对象:
- `bpf_obj_new` 分配给定类型的新对象。
- `bpf_obj_drop` 释放对象。
- `bpf_list_push_back` / `bpf_list_push_front` / `bpf_list_pop_front` 实现双向链表操作。
- `__contains` 宏生成 BTF 标签,告诉验证器 list head 包含的是哪种节点类型,从而实现安全的所有权追踪。
### BPF 程序
`egress_pacer.bpf.c` 使用 `SEC(".struct_ops")` 声明 `Qdisc_ops` 实现。内核在加载时注册这些回调,使 BPF 程序成为一个完整的 qdisc。
`egress_pacer.bpf.c` 实现了 qdisc。`SEC(".struct_ops")` 声明将其注册为 `Qdisc_ops` 实现:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -308,19 +323,59 @@ struct Qdisc_ops pacer = {
};
```
程序结构围绕 qdisc 的三个核心阶段展开。两个 `const volatile` 变量 `rate_kbps` 和 `queue_limit` 位于 `.rodata` 段,用户态在 `open()` 之后、`load()` 之前写入速率和队列上限,验证器将它们视为编译期常量。`packet_node` 结构体定义了队列中的节点,包含发送时间、报文长度、skb 指针和链表节点。`__kptr` 标记告诉 verifier 这个字段持有内核指针的所有权。
#### 关键数据结构
`egress_pacer_enqueue` 是入队回调。它首先从 `qdisc_skb_cb(skb)->pkt_len` 取得 qdisc 看到的报文长度,然后检查队列是否还有空间。如果队列已满或节点创建失败,程序调用 `bpf_qdisc_skb_drop` 丢弃报文并递增 `policy_dropped`。真正的所有权转移发生在 `bpf_kptr_xchg`:调用成功后,skb 被放进节点的 `__kptr` 字段,节点从此负责保管它。程序随后在持有 `queue_lock` 的情况下计算发送时间、把节点加入 FIFO 尾部、更新 qlen 和 backlog。发送间隔按 `packet_len * 8 * 1000000 / rate_kbps` 计算,单位是纳秒。队列空闲一段时间后,`next_departure_ns` 会早于当前时间,新来的第一个报文可以立刻离开。
`const volatile` 变量 `rate_kbps` 和 `queue_limit` 位于 `.rodata` 段。用户态在 `open()` 之后、`load()` 之前写入它们,之后验证器将其视为编译期常量。
`egress_pacer_dequeue` 是出队回调。它从队首取出节点,比较 `eligible_ns` 与当前时间。如果报文已经可以发送,程序用 `bpf_kptr_xchg` 把 skb 从节点取回,扣减 qlen 和 backlog,释放节点,然后调用 `bpf_qdisc_bstats_update` 更新 qdisc 统计并返回 skb。如果还没到发送时间,程序把节点放回队首,调用 `bpf_qdisc_watchdog_schedule` 设置定时器,然后返回 NULL。内核会在指定时间再次调用 dequeue。
`packet_node` 结构体保存每个排队的报文:
- `eligible_ns`:该报文最早可以发送的时间(纳秒)。
- `packet_len`:报文长度,用于 backlog 统计。
- `skb`:一个 `__kptr` 字段,拥有内核套接字缓冲区的所有权。`__kptr` 标注告诉验证器这个字段持有一个被拥有的内核指针。
- `node`:一个 `bpf_list_node`,用于链入 FIFO。
`egress_pacer_reset` 负责清理。当 qdisc 被移除时,队列中可能还有未发送的报文。程序用 `bpf_for` 逐个取出节点,调用 `bpf_kfree_skb` 释放 skb,然后销毁节点对象。清掉的报文计入 `cleanup_dropped`,与 enqueue 阶段的 `policy_dropped` 分开。链表排空后,程序把 `next_departure_ns`、qlen 和 backlog 一起归零。
`private(A)` 宏将锁、链表头和出发时间戳放入一个私有数据段,并确保正确对齐。
程序末尾的 `SEC(".struct_ops")` 块把所有回调注册为 `Qdisc_ops` 结构体。`id` 字段指定 qdisc 名称为 `bpf_pacer`,用户态通过这个名称创建 qdisc 实例。
#### 入队回调
### 用户空间加载器
`egress_pacer_enqueue` 从内核接收报文:
`egress_pacer.c` 解析命令行参数、配置 BPF 常量、创建 qdisc 并等待指定时间后清理。
1. 从 `qdisc_skb_cb(skb)->pkt_len` 获取报文长度。
2. 如果队列已满,丢弃报文。
3. 分配一个 `packet_node`。如果分配失败,丢弃。
4. 计算该报文何时可以发送:`interval_ns = packet_len * 8 * 1000000 / rate_kbps`(比特数除以千比特每秒得到纳秒)。
5. 用 `bpf_kptr_xchg` 将 `skb` 的所有权转移给节点。调用完成后,节点拥有这个 `skb`。
6. 在自旋锁保护下设置报文的可发送时间。如果队列之前是空闲的(`next_departure_ns` 在过去),这个报文可以立即发送;否则它需要排在上一个报文之后。
7. 把节点推入 FIFO 尾部,更新计数器,解锁,返回成功。
`bpf_qdisc_skb_drop` 调用处理被丢弃的报文——它把 `skb` 加入内核的释放链表并更新 qdisc 统计。
#### 出队回调
当内核想要发送报文时,会调用 `egress_pacer_dequeue`:
1. 从 FIFO 弹出队首节点。
2. 如果队列为空,返回 NULL。
3. 检查当前时间是否已到达 `eligible_ns`。如果未到,把节点推回队首,为 `eligible_ns` 安排一个 watchdog 定时器,然后返回 NULL。定时器触发后,内核会再次调用 dequeue。
4. 如果已经可以发送,用 `bpf_kptr_xchg` 从节点取出 `skb`,递减计数器,释放节点,更新统计,返回 `skb`。
watchdog 机制(`bpf_qdisc_watchdog_schedule`)是 BPF qdisc 实现发送时序的关键——你告诉内核什么时候唤醒你,它在那个时间调用 dequeue。
#### 重置回调
`egress_pacer_reset` 在 qdisc 被移除时运行。队列中可能还有未发送的报文,必须释放它们:
1. 用 `bpf_for`(一个用于有界迭代的 BPF 辅助函数)遍历所有节点。
2. 弹出每个节点,取出其 `skb`,用 `bpf_kfree_skb` 释放。
3. 释放的报文计入 `cleanup_dropped`。
4. 将出发时间戳和 qdisc 计数器清零。
#### 注册
末尾的 `SEC(".struct_ops")` 块将回调函数注册为 `Qdisc_ops` 结构体。`id` 字段(`"bpf_pacer"`)是用户态用于创建这个 qdisc 实例的名称。
### 用户态加载器
`egress_pacer.c` 处理命令行参数,配置 BPF 程序,挂载 qdisc,等待指定时长,然后清理:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -621,11 +676,17 @@ cleanup:
}
```
加载器的流程比较标准。解析命令行后,用 `if_nametoindex` 验证接口存在。打开 skeleton 后把速率和队列上限写入 `.rodata` 段,然后调用 `load` 完成 BPF 程序加载。加载成功后调用 `attach` 注册 `struct_ops` 实现,使 `bpf_pacer` 成为可用的 qdisc 类型。
加载器遵循标准的 libbpf 模式:
接下来的 `bpf_tc_hook_create` 在目标接口的 `TC_H_ROOT` 位置创建名为 `bpf_pacer` 的 root qdisc。这个调用带着独占语义:目标接口已经有 root qdisc 时,内核返回 `-EEXIST`,程序立即停止,保留现有配置。这种独占做法限制了工具的作用范围,适合把它用在专门创建的测试接口上。
1. 解析参数,用 `if_nametoindex` 验证接口存在。
2. 打开 skeleton,将速率和队列上限写入 `.rodata`。
3. 加载 BPF 程序(这一步会进行验证和 JIT 编译)。
4. 挂载 `struct_ops` 实现,使 `bpf_pacer` 成为可用的 qdisc 类型。
5. 用 `bpf_tc_hook_create` 在目标接口上创建 root qdisc。
安装成功后,程序打印 `READY` 行,然后每隔 100 ms 检查运行时间和 `exiting` 标志。SIGINT、SIGTERM 或时间到期都会退出等待循环。清理顺序保证 `SUMMARY` 包含 reset 清掉的报文:先调用 `bpf_tc_hook_destroy` 触发 reset,再读取 `skel->bss->stats`,最后销毁 skeleton 并注销 `struct_ops` link。
`bpf_tc_hook_create` 调用具有独占语义:如果接口已经有 root qdisc,内核返回 `-EEXIST`,程序直接退出而不做任何修改。这一安全措施意味着该工具最适合用于你完全控制配置的专用测试接口。
挂载成功后,程序打印 `READY`,然后每隔 100ms 检查一次,直到时长到期或收到信号。清理顺序很重要:先调用 `bpf_tc_hook_destroy`(触发 reset 并释放排队的报文),然后读取最终统计,最后销毁 skeleton。
## 编译与运行
@@ -637,13 +698,13 @@ make clean
make -j2
```
第一次运行可以直接使用集成测试程序,它会创建一次性的 `epac_tx`/`epac_rx` veth 对,完成测试后删除。加载 BPF 和修改 qdisc 需要 root:
最简单的测试方式是使用附带的集成测试,它会创建一个临时的 veth 对并在测试后清理。加载 BPF 和修改 qdisc 需要 root 权限:
```bash
sudo python3 tests/test_egress_pacer.py ./egress_pacer
```
下面是一次已经完成的运行。环境为 x86_64,内核是从 commit `a03114efd0720dff230388f7e160e427e54ea31b` 构建的 `7.0.0-rc2+`:
下面是一次成功运行的输出,环境为 x86_64,内核版本 `7.0.0-rc2+`(commit `a03114efd0720dff230388f7e160e427e54ea31b`):
```console
READY interface=epac_tx rate_kbps=64 queue_limit=8 duration=2
@@ -652,49 +713,70 @@ TEST-SUMMARY attempts=40 received=9 send_errors=31 span_ms=1024 observed_kbps=64
PASS: conflict refusal, bounded drops, pacing, accounting, normal/signal cleanup, and SIGKILL recovery succeeded
```
这段输出展示主要路径的功能正确性:队列上限、限速调度和计数一致。测试向一个 64 Kbit/s、最多容纳 8 个报文的队列连续发送 40 个 1024 字节 EtherType `0x88B5` 原始帧。本次运行中,接收端看到 9 个报文,首尾相隔 1024 ms;另外 31 次发送走到 `policy_dropped`。换一个系统或时序,这些具体数字会有所不同。
发生了什么:测试向一个 64 Kbit/s、最多容纳 8 个报文的队列发送了 40 个原始以太网帧(每个 1024 字节,EtherType `0x88B5`)。9 个报文成功通过队列,分布在 1024ms 内——与预期速率吻合。其他 31 次发送触发了队列上限,计入 `policy_dropped`。具体数字因系统时序而异。
测试同时覆盖冲突检测、信号清理和异常恢复。它会预先安装一个冲突的 `pfifo`,确认加载器保留现有配置;制造 backlog 后发送 SIGTERM,检查 `cleanup_dropped` 大于零,并验证 `enqueued = dequeued + cleanup_dropped`。最后的 SIGKILL 由测试程序接管:用 `tc` 删除残留的 qdisc,确认接口恢复正常,并在 Python 的 `finally` 块中清掉 veth 对。
测试还覆盖了边界情况:
- 预先安装一个 `pfifo`,确认加载器拒绝替换现有的 qdisc。
- 在队列中有报文时发送 SIGTERM,验证 `cleanup_dropped > 0` 且 `enqueued = dequeued + cleanup_dropped`。
- 发送 SIGKILL(跳过清理),然后用 `tc` 手动删除残留的 qdisc,确认接口恢复正常。
手动运行时,选择一个完全由你控制的接口,先查看它当前使用的 root qdisc:
手动测试时,选择一个你控制的接口,先查看其当前 qdisc:
```bash
tc qdisc show dev veth-service
```
确认接口可以安全使用后,让 pacer 持续 30 秒:
如果显示的是默认的 `noqueue` 或 `pfifo_fast`,就可以挂载 pacer:
```bash
sudo ./egress_pacer --interface veth-service --rate-kbps 64000 --queue-limit 256 --duration 30
```
命令行参数:
命令行选项:
```text
Usage: ./egress_pacer --interface IFACE [--rate-kbps KBPS] [--queue-limit PACKETS] [--duration SEC] [--verbose]
Options:
-i, --interface IFACE 目标接口(必选)
-r, --rate-kbps KBPS 出口速率,8-100000000(默认:1024)
-q, --queue-limit PACKETS 队列容量,1-65535 个报文(默认:256)
-d, --duration SEC 控制时长,1-86400 秒(默认:10)
-v, --verbose 打印 libbpf 诊断信息
-r, --rate-kbps KBPS 出口速率,单位 Kbit/s,范围 8-100000000(默认:1024)
-q, --queue-limit PACKETS 队列容量,单位报文数,范围 1-65535(默认:256)
-d, --duration SEC 运行时长,单位秒,范围 1-86400(默认:10)
-v, --verbose 打印 libbpf 调试输出
-h, --help 显示帮助
```
看到 `READY` 就表示 qdisc 已经安装。运行结束并成功移除后,程序打印 `SUMMARY`。
看到 `READY` 就表示 qdisc 已安装并正在限速。时长结束(或按 Ctrl+C)后,程序移除 qdisc 并打印 `SUMMARY`。
### 环境要求
这个功能从 Linux 6.16 开始可用。内核需要启用 BTF 和 BPF JIT,并至少包含 `CONFIG_BPF=y`、`CONFIG_BPF_SYSCALL=y`、`CONFIG_BPF_JIT=y`、`CONFIG_DEBUG_INFO_BTF=y`、`CONFIG_NET_SCHED=y` 与 `CONFIG_NET_SCH_BPF=y`。
BPF qdisc 需要 Linux 6.16 或更高版本。内核必须启用 BTF 和 BPF JIT:
BPF qdisc TC hook 需要 libbpf 1.6.0 或更新版本。仓库已经在 `src/third_party` 中集成 libbpf 和 bpftool。运行时需要 root 权限。集成测试另外依赖 Python 3、原始报文套接字,以及 `iproute2` 中的 `ip` 和 `tc`。一个受控的 veth 对就足够了;本课验证的架构是 x86_64。
| 要求 | 最低版本 / 配置 |
| --- | --- |
| Linux 内核 | 6.16+ |
| `CONFIG_BPF` | y |
| `CONFIG_BPF_SYSCALL` | y |
| `CONFIG_BPF_JIT` | y |
| `CONFIG_DEBUG_INFO_BTF` | y |
| `CONFIG_NET_SCHED` | y |
| `CONFIG_NET_SCH_BPF` | y |
| libbpf | 1.6.0+ |
| 权限 | root |
仓库已在 `src/third_party` 中包含 libbpf 和 bpftool。集成测试另外依赖 Python 3、原始套接字能力,以及 iproute2 中的 `ip` 和 `tc`。一对 veth 接口即可满足测试需求;已在 x86_64 上验证。
## 总结
本教程展示了如何使用 `struct_ops` 实现一个完整的 BPF qdisc。`enqueue` 接管 skb 并按报文长度安排发送时间,`dequeue` 用 watchdog 在适当时刻被唤醒并返回 skb,`reset` 释放移除时仍在队列里的报文。这三段所有权和时序处理构成可复用的 qdisc 模式。
本教程通过一个完整的 FIFO 限速器演示了 BPF qdisc:
这个调度器实现一条聚合 FIFO,队列上限按报文个数计算,发送间隔使用 qdisc 报文长度推导。速率和队列上限在加载时固定,适合在受控接口上验证功能正确性。更完整的调度器可以在此基础上加入公平性、动态策略和持久化恢复。
- **enqueue** 接管报文所有权,根据报文长度和速率安排发送时间。
- **dequeue** 使用 watchdog 定时器在正确的时刻被唤醒,并将报文返回给内核。
- **reset** 释放 qdisc 移除时仍在队列中的所有报文。
这三个阶段——接收报文、控制释放时机、清理资源——构成了自定义调度器的可复用模式。
这个调度器实现了一条聚合 FIFO,速率和队列上限在加载时固定。它有意保持简单,适合在受控接口上验证功能正确性。生产级调度器可以在此基础上添加公平性、动态策略或跨重启的状态持久化。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。

View File

@@ -1,54 +1,91 @@
# eBPF Tutorial: Inspecting the Executable Image After exec
Imagine a container running a series of nested wrapper scripts. What you see from `ps` or `/proc` is `/usr/bin/wrapper.sh --config /etc/app.conf`, but the actual business logic might be a Python interpreter or some dynamically linked binary three layers deep. When troubleshooting, you need to know which executable image the kernel finally installed, what architecture it targets, and what byte order it uses - not just the command-line arguments.
When a process calls `execve`, the kernel replaces its memory image with a new executable. But which executable, exactly? If the command line says `/usr/bin/wrapper.sh --config /etc/app.conf`, the actual running code might be a Python interpreter or a compiled binary launched three layers deep through wrapper scripts. Security tools, container runtimes, and troubleshooting utilities all need to know what the kernel *actually* installed—not just what the user typed.
This tutorial shows how to achieve this using BPF task work and file-backed dynptr. The tool attaches an LSM hook to observe the `bprm_committed_creds` moment, schedules a task work callback for the target process after credentials are committed, and that callback runs in a sleepable context to reacquire the installed executable file, resolve its path, read the ELF header, and send the results to user space through a ring buffer.
This tutorial builds a tool that captures that information at the kernel level. It hooks the moment credentials are committed after exec, schedules a deferred callback, then reads the installed executable's ELF header and reports the architecture, byte order, and file type. Along the way, it demonstrates two recent kernel features—BPF task work and file dynptr—that together solve a problem traditional eBPF approaches cannot.
> Complete source: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/54-exec-image-inspector>
## Why BPF task work and file dynptr are needed
## The problem: reading file content from eBPF
Traditional approaches to inspecting executable files all have significant limitations.
Why would a security tool need to read the executable's content rather than just its path? Because the path alone does not identify what code will run. A hash of the executable's content can verify it matches a known-good binary. Embedded signatures or certificates can prove provenance. Specific byte patterns at known offsets can identify packing, obfuscation, or tampering. None of this information is available from the path—you must read the file's bytes. Doing so from eBPF, at the exact moment of exec, eliminates the race window that plagues user-space approaches.
The most straightforward idea is to read the `/proc/<pid>/exe` symlink, but this requires the process to still be alive. Short-lived processes may have already exited before you can read it. Furthermore, `/proc` access happens in user space, creating a race window with the kernel's exec event - the same process might have already executed `execve` multiple times.
The most direct approach to inspecting an executable is reading `/proc/<pid>/exe`, but this only works if the process is still alive. Short-lived processes exit before you can read them. Even if you catch them in time, the `/proc` filesystem is accessed from user space, creating a race window: by the time you read the symlink, the process might have called `execve` again.
Using a tracepoint or kprobe to observe `sched_process_exec` can capture exec events, but these hooks run in a non-sleepable context. You can obtain the file path, but if you need to read file contents to verify the ELF header or check embedded metadata, you run into a problem: the file-backed dynptr's `bpf_dynptr_read` triggers a page fault when target bytes are on a cold page, and page faults require sleep to complete. BPF programs in a non-sleepable context cannot wait for I/O.
Tracepoints and kprobes can hook `sched_process_exec` to observe exec events synchronously, but these hooks run in what the kernel calls a **non-sleepable context**. This matters because of how Linux manages file data in memory.
BPF task work solves this problem. Linux 6.18 introduced the `bpf_task_work_schedule_signal` kfunc, which allows a BPF program to schedule a callback for a target task. The kernel executes this callback in a safe sleepable context. Linux 6.19 introduced file-backed dynptr, providing verifier-tracked file data access. Combining these two features, you can identify the target process in an LSM hook, record a timestamp, and schedule task work. The callback then runs in a sleepable context where it can reacquire the task's installed executable file, read content from any offset through the dynptr (including cold pages), and finally send the complete inspection results to user space.
When you read from a file, the kernel first checks whether the requested bytes are already in the **page cache**—a memory region that caches recently-accessed file data. If they are, the read completes immediately. If they are not (a **cold page**), the kernel must issue I/O to the storage device, and the calling context must **sleep** while waiting for that I/O to complete.
This is the design philosophy behind `exec_image_inspector`: the LSM hook selects the child process and schedules task work, while the sleepable callback performs the actual file reading and parsing.
BPF programs attached to tracepoints and kprobes cannot sleep. They run with interrupts potentially disabled and locks held; sleeping would deadlock the system. If a BPF program tries to read file content and encounters a cold page, the read fails with `-EFAULT` instead of waiting for I/O.
## How BPF task work and file dynptr work
This creates a fundamental limitation: you can observe exec events, but you cannot reliably read the executable's content to verify its ELF header or check embedded metadata.
BPF task work is a deferred execution mechanism. When you call `bpf_task_work_schedule_signal(task, work, map, callback)` in a BPF program, the kernel associates the callback with the specified task. The callback does not execute immediately - it runs at a safe point before that task returns to user space, where the context allows sleeping.
## The solution: BPF task work and file dynptr
`struct bpf_task_work` is an opaque 64-byte structure. The BPF program only needs to allocate storage for it; the kernel manages its internal state. This tool uses a single-element ARRAY map to store `struct exec_work`, which contains the `bpf_task_work` storage, a scheduling timestamp, and direct probe results.
Linux 6.18 introduced **BPF task work**, a mechanism that lets a BPF program schedule a callback to run later in a safe, sleepable context. The callback executes before the target task returns to user space, at a point where the kernel permits sleeping.
File-backed dynptr provides a verifier-tracked file data access interface. `bpf_dynptr_from_file(file, flags, dynptr)` creates a dynptr from a file, and `bpf_dynptr_read(dst, len, dynptr, offset, flags)` reads content at the specified offset. The dynptr holds internal state, so every path that creates a dynptr must call `bpf_dynptr_file_discard` to release it, including failure branches.
Linux 6.19 introduced **file dynptr**, which provides verifier-tracked access to file data. A dynptr (dynamic pointer) is a BPF abstraction that tracks a pointer's bounds at verification time; the file variant wraps file I/O operations so the verifier can ensure memory safety.
In a non-sleepable context, the dynptr can only read content already in the page cache; accessing a cold page returns `-EFAULT`. In a sleepable context, the dynptr can trigger page fault operations and wait for I/O to complete. This is precisely the value of the task work callback.
Combining these features, the design becomes:
## Overall tool flow
1. Attach an LSM hook to `bprm_committed_creds`, which fires after exec installs the new credentials
2. In the hook (non-sleepable), identify the target process and schedule a task work callback
3. The callback runs in a sleepable context, where it can access the installed executable, read content from any offset (including cold pages), and send results to user space
After user space starts, it forks the target command, but the child process blocks waiting for a release signal on a pipe. This way the parent knows the child's PID (which is also the TGID) before the child executes `execvp`, and can write this value into the BPF program's read-only data section as a filter condition. Blocking the child eliminates the attach race with short-lived commands and keeps the example self-contained.
This separation—identify the target in a non-sleepable hook, do the heavy lifting in a sleepable callback—is the key insight.
The parent opens the BPF skeleton, sets `target_tgid` and the optional `probe_offset`, loads and attaches the LSM program, creates a ring buffer reader, then writes a release byte to the pipe to let the child execute `execvp`.
## How BPF task work operates
After the child executes exec, the `lsm/bprm_committed_creds` hook triggers when credentials are committed. The BPF program compares the current TGID with `target_tgid` and returns immediately if they do not match. After matching the target, the program records a scheduling timestamp, optionally attempts a direct read in the current context if `--probe-offset` is set (to verify the cold page scenario), then calls `bpf_task_work_schedule_signal` to schedule the callback.
When you call `bpf_task_work_schedule_signal(task, work, map, callback)`, the kernel associates your callback with the specified task. The callback does not run immediately; it runs later, at a safe point before that task returns to user space.
The callback `inspect_executable` runs in that task's sleepable context. It calls `bpf_get_task_exe_file` to obtain the task's currently installed executable file (returning a referenced `struct file` that must be released with `bpf_put_file`), resolves the path with `bpf_path_d_path`, and reads the 64-byte ELF header plus the optional marker location using the file dynptr. After a successful read, it parses the ELF magic, class, data, type, and machine fields. Finally, it packages all the information into a ring buffer event and sends it to user space.
The `struct bpf_task_work` is an opaque structure that the kernel uses to track the scheduled callback. Your BPF program allocates storage for it but does not interpret its contents. This tool uses a single-element ARRAY map to hold `struct exec_work`, which contains the `bpf_task_work` storage plus fields for timestamps and intermediate results.
User space alternates between polling the ring buffer and checking child process status with `waitpid(WNOHANG)`. The loop ends when the child is reaped and at least one event is received, or when the timeout is reached. The same child might install multiple images in succession (for example, `/bin/sh -c 'exec /bin/true'`), so after the child is reaped, `drain_events` performs a zero-timeout poll to collect any remaining events in the ring buffer.
The callback signature is `int callback(struct bpf_map *map, void *key, void *value)`. The `value` parameter points to the map element containing your `bpf_task_work`, so you can pass data from the scheduling hook to the callback through surrounding fields.
## How file dynptr operates
A dynptr wraps a pointer with bounds information that the BPF verifier can track. For file dynptrs:
- `bpf_dynptr_from_file(file, flags, dynptr)` creates a dynptr from a file
- `bpf_dynptr_read(dst, len, dynptr, offset, flags)` reads content at the specified offset
- `bpf_dynptr_file_discard(dynptr)` releases the dynptr's internal state
Every path that creates a dynptr—including error paths—must call `bpf_dynptr_file_discard` to release it. Failing to do so leaks internal resources.
In a non-sleepable context, `bpf_dynptr_read` only succeeds if the target bytes are already in the page cache. Accessing a cold page returns `-EFAULT`. In a sleepable context, the same call can trigger page fault handling and wait for I/O, so it succeeds even for cold pages. This difference is why task work matters: the callback runs in a sleepable context where file reads work reliably.
## Tool architecture
The user-space program forks a child process, but holds the child blocked on a pipe before it calls `execvp`. This gives the parent time to:
1. Note the child's PID (which becomes its TGID—thread group ID)
2. Open the BPF skeleton and write the target TGID into read-only data
3. Load and attach the BPF program
4. Create a ring buffer reader
Only then does the parent release the child by writing to the pipe. This handshake eliminates a race condition: without it, a fast-exiting command might finish before the BPF program attaches.
When the child calls `execvp`, the `lsm/bprm_committed_creds` hook fires. The BPF program compares the current TGID with the configured target; if they match, it records a timestamp and schedules a task work callback.
The callback—`inspect_executable`—runs in the child's sleepable context. It:
1. Calls `bpf_get_task_exe_file` to get the installed executable (returning a referenced `struct file` that must be released with `bpf_put_file`)
2. Resolves the path with `bpf_path_d_path`
3. Creates a file dynptr and reads the 64-byte ELF header
4. Parses ELF fields: magic number, class (32/64-bit), data (endianness), type (executable vs shared object), and machine (architecture)
5. Sends an event through the ring buffer
User space polls the ring buffer while also checking whether the child has exited. A single child might exec multiple times (for example, `/bin/sh -c 'exec /bin/true'` first execs the shell, then execs `/bin/true`), so after the child exits, the tool drains any remaining events from the ring buffer.
![Exec image inspector data flow](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/54-exec-image-inspector/exec-image-flow.png)
## Code implementation
## Code walkthrough
This tool consists of four files: a shared header defining event and statistics structures, a compatibility header declaring new kernel kfuncs, a BPF program implementing the hook and callback, and a user-space loader managing the lifecycle.
The implementation spans four files: a shared header, a compatibility header for new kernel interfaces, the BPF program, and the user-space loader.
### Shared header
`exec_image_inspector.h` defines the ring buffer event structure and statistics structure shared between BPF and user space.
`exec_image_inspector.h` defines structures shared between BPF and user space:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -96,11 +133,11 @@ struct inspector_stats {
#endif /* __EXEC_IMAGE_INSPECTOR_H */
```
`exec_event` contains all the information needed to inspect one exec: PID, TGID, process name, path, ELF parsing results, probe results, callback latency, and various error codes. `inspector_stats` accumulates match, schedule, callback, error, and drop counts; user space reads the BSS section at the end and reports the statistics.
`exec_event` carries everything needed to report one exec: process identifiers, the resolved path, ELF metadata, and error codes. `inspector_stats` accumulates counters that user space reads from the BSS section at exit to report success and failure rates.
### Compatibility declarations
### Compatibility header
Linux 6.18 introduced BPF task work, and 6.19 introduced file-backed dynptr. The repository's currently generated UAPI and BTF headers predate these features, so this tool places the missing declarations in a local header `bpf_experimental.h`.
The repository's vendored vmlinux headers predate Linux 6.18/6.19, so `bpf_experimental.h` declares the new interfaces locally:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -134,11 +171,11 @@ extern int bpf_dynptr_file_discard(struct bpf_dynptr *dynptr) __ksym;
#endif /* __EXEC_IMAGE_INSPECTOR_BPF_EXPERIMENTAL_H */
```
This file declares kfuncs including `bpf_task_work_schedule_signal`, `bpf_get_task_exe_file`, `bpf_put_file`, `bpf_path_d_path`, `bpf_dynptr_from_file`, and `bpf_dynptr_file_discard`. This file can be removed once the repository's integrated vmlinux headers include these declarations.
These declarations use `__ksym` to mark them as kernel symbols resolved at load time. Once the repository's vmlinux headers are regenerated from a 6.19+ kernel, this file can be removed.
### BPF program
`exec_image_inspector.bpf.c` implements the LSM hook and task work callback.
`exec_image_inspector.bpf.c` implements the LSM hook and the task work callback:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -341,23 +378,23 @@ void BPF_PROG(schedule_exec_inspection, struct linux_binprm *bprm)
}
```
The program entry point is the `schedule_exec_inspection` function declared with `SEC("lsm/bprm_committed_creds")`. The `bprm_committed_creds` hook runs after the new executable's credentials have been committed, at which point `bprm->file` points to the image involved in this exec. This hook itself is non-sleepable, but we can identify the target process here and schedule task work.
The entry point is `schedule_exec_inspection`, declared with `SEC("lsm/bprm_committed_creds")`. This LSM hook fires after the new executable's credentials have been installed, so `bprm->file` points to the file being executed. The hook itself is non-sleepable, but it can identify the target and schedule the deferred work.
The two `const volatile` variables `target_tgid` and `probe_offset` are in the `.rodata` section. User space writes values after `open()` but before `load()`, and the verifier treats them as compile-time constants. The program first compares the current TGID with `target_tgid`, returning immediately if they do not match to avoid observing unrelated process execs.
The two `const volatile` variables—`target_tgid` and `probe_offset`—live in the `.rodata` section. User space writes values between `open()` and `load()`, and the verifier treats them as compile-time constants for optimization.
After matching the target, the program looks up key 0 from the single-element `pending` ARRAY map to obtain `struct exec_work`. This structure stores the scheduling timestamp, direct probe result, and `struct bpf_task_work` storage. This tool observes only one child process per invocation, so one slot is sufficient. When `--probe-offset` is set, `probe_file_without_sleep` attempts to read 8 bytes from the specified location in `bprm->file` in the current non-sleepable context. The test program places the marker on a cold page to verify that direct reads return `-EFAULT` in this scenario.
When the target matches, the program looks up `struct exec_work` from the single-element `pending` ARRAY map. This structure holds the `bpf_task_work` storage plus a timestamp and optional direct-probe result. A single slot suffices because this tool observes one child process per invocation.
After the hook saves the timestamp and direct read result, it calls `bpf_task_work_schedule_signal`, passing the current task, work storage, map pointer, and the `inspect_executable` callback function pointer. The kernel holds the references needed for callback execution, and the callback will run in a sleepable context before that task returns to user space.
The optional `--probe-offset` flag makes the tool attempt a direct read in the non-sleepable hook via `probe_file_without_sleep`. The test suite uses this to verify that reading a cold page fails with `-EFAULT` in the non-sleepable context but succeeds in the sleepable callback.
`inspect_executable` is the task work callback function, with signature `int callback(struct bpf_map *map, void *key, void *value)`. It first calculates callback latency (current time minus scheduling timestamp) for diagnostics, then fills PID, TGID, process name, and direct probe result into the event structure. It then calls `bpf_get_task_exe_file` to obtain the task's installed executable file. This kfunc returns a referenced `struct file`; every successful acquisition must be released with `bpf_put_file`.
After saving the timestamp and direct-probe result, the hook calls `bpf_task_work_schedule_signal`. The kernel holds the references needed to execute the callback later.
The callback first resolves the file path with `bpf_path_d_path`, then creates a dynptr with `bpf_dynptr_from_file`. Since we are now in a sleepable context, `bpf_dynptr_read` can trigger page fault operations and wait for I/O to complete. The callback only reads a 64-byte ELF header and an optional 8-byte marker. After a successful header read, the program checks the 4-byte ELF magic `\x7fELF`, then parses `EI_CLASS` (32-bit or 64-bit), `EI_DATA` (little-endian or big-endian), `e_type` (executable or shared object), and `e_machine` (architecture). The `read_elf_u16` helper function correctly parses 16-bit fields according to byte order.
The callback `inspect_executable` calculates latency for diagnostics, then acquires the executable file with `bpf_get_task_exe_file`. This returns a referenced `struct file` that must be released with `bpf_put_file`. The callback resolves the path, creates a file dynptr, reads the 64-byte ELF header, and parses it. The `read_elf_u16` helper handles endianness: ELF files declare their byte order in the header, and multi-byte fields must be read accordingly.
Every path that creates a dynptr must call `bpf_dynptr_file_discard` to release its internal state, including branches where the helper fails. Finally, `bpf_ringbuf_output` sends the event to user space; the `dropped` counter is incremented if sending fails.
Every path that creates a dynptr—success or failure—must call `bpf_dynptr_file_discard`. Finally, `bpf_ringbuf_output` sends the event to user space.
### User-space loader
`exec_image_inspector.c` handles command-line parsing, coordinates the blocked child, loads BPF, formats events, and cleans up resources.
`exec_image_inspector.c` coordinates the child process, loads BPF, receives events, and reports results:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -898,17 +935,15 @@ cleanup:
}
```
The loader's flow centers on the blocked child handshake. `start_blocked_child` forks before BPF setup, but the child blocks on a pipe read. The parent already knows the child PID at this point and can write it to `rodata->target_tgid` after opening the skeleton. After attachment, `release_child` writes one byte to the pipe to let the child continue with `execvp`. This handshake eliminates the attach race with short-lived commands.
The core technique is the blocked-child handshake. `start_blocked_child` forks before BPF setup, but the child blocks on a pipe read. The parent notes the child's PID, opens the skeleton, writes `target_tgid`, loads and attaches, creates the ring buffer, and only then calls `release_child` to write to the pipe and let the child proceed to `execvp`.
The `setup_inspector` function opens the skeleton, sets read-only data, loads and attaches the BPF program, and creates the ring buffer reader. Error messages on load failure indicate possible causes to help users troubleshoot kernel version or configuration issues.
`wait_for_command` is the main loop. It prints a `READY` line, releases the child, then alternates between polling the ring buffer and checking whether the child has exited. The loop ends when the child is reaped and at least one event has arrived, or when the timeout expires. After timeout, `reap_timed_out_child` sends SIGKILL and waits.
`wait_for_command` is the main loop. It first prints a `READY` line indicating the tool is ready, then releases the child, sets a timeout deadline, and enters the poll loop. The loop alternates between calling `ring_buffer__poll` to receive events and `waitpid(WNOHANG)` to check child status. The loop exits when the child is reaped and at least one event is received, or when the timeout is reached. After timeout, `reap_timed_out_child` sends SIGKILL to the child and waits for it to exit.
A single exec might trigger multiple events if the command itself execs (for example, `/bin/sh -c 'exec /bin/true'`). After the child exits, `drain_events` does a zero-timeout poll to collect any remaining events.
The same child may install multiple images in succession. For example, running `/bin/sh -c 'exec /bin/true'` causes the shell to exec itself first, then exec `/bin/true`. After the child is reaped, `drain_events` uses zero-timeout poll to collect any remaining events in the ring buffer, ensuring the last image event also reaches user space.
`handle_event` formats the output, translating numeric ELF values to readable names while preserving the raw values for scripting.
`handle_event` parses ring buffer events and prints formatted output. It converts ELF numeric values to readable names like `ELF64`, `LSB`, `ET_DYN`, `EM_X86_64`, while preserving original values. When `--probe-offset` is set, it also prints a `PROBE` line showing probe results.
## Compilation and execution
## Building and running
Build from source:
@@ -918,22 +953,22 @@ make -C src/54-exec-image-inspector clean
make -C src/54-exec-image-inspector -j2
```
The integration test requires Linux 6.19 or newer. Before running, confirm that `bpf` appears in the active LSM list:
The test suite requires Linux 6.19 or newer with the BPF LSM active. Check that `bpf` appears in the LSM list:
```bash
cat /sys/kernel/security/lsm
```
To add `bpf`, change `lsm=<existing-list>` to `lsm=<existing-list>,bpf` in the kernel boot parameters.
If `bpf` is missing, add it to the kernel command line: change `lsm=<existing-list>` to `lsm=<existing-list>,bpf` in your bootloader configuration.
Run tests:
Run the tests:
```bash
cd src/54-exec-image-inspector
sudo make test
```
Repository CI only compiles this lesson. Runtime behavior was functionally tested on x86_64 with kernel `7.0.0-rc2+`. The following session output shows the test harness and tool output:
The repository CI only compiles this lesson. Runtime behavior was functionally tested on x86_64 with kernel `7.0.0-rc2+`. Sample output:
```text
TEST-MISSING matched=0 events=0 command_exit=127
@@ -947,11 +982,15 @@ SUMMARY matched=1 scheduled=1 schedule_errors=0 callbacks=1 header_errors=0 path
PASS: missing-command, timeout cleanup, re-exec drain, ELF decode, and deferred file read succeeded
```
The first three test records cover important boundaries: a missing command exits with status 127, at which point the committed-exec hook has not yet triggered, so `matched=0` and `events=0`; the timeout case produces an exec event then exceeds the test deadline, is terminated by SIGKILL, and reaped with status 137; the successive exec case drains two image events and confirms `/usr/bin/true` appears last.
The first three test lines cover boundary conditions:
The `EXEC` line shows the installed image and ELF header parsing results. The test program flushes and evicts the marker page before exec, so the direct read in the hook returns `-EFAULT` (`-14`), while the task-work callback successfully returns `454950524f424521` (the hexadecimal bytes for `EIPROBE!`). This verifies that non-sleepable context cannot read cold pages, while sleepable context can.
- **TEST-MISSING**: A nonexistent command exits with status 127. The LSM hook never fires because exec fails before credentials are committed, so `matched=0` and `events=0`.
- **TEST-TIMEOUT**: The command runs too long, gets SIGKILL, and is reaped with status 137 (128 + 9). One exec event was observed.
- **TEST-REEXEC**: A shell command that uses `exec` internally produces two events, and the final path is `/usr/bin/true`.
To inspect other commands, omit the test-program-specific probe offset:
The `EXEC` line shows the installed image and parsed ELF fields. The `PROBE` line demonstrates the sleepable-context difference: the test writes a marker (`EIPROBE!`) to a page, flushes and evicts it from the page cache, then execs. The direct read in the non-sleepable hook returns `-EFAULT` (`-14`) because the page is cold. The deferred read in the sleepable callback succeeds, returning the marker bytes as hex (`454950524f424521`).
To inspect a simple command:
```bash
sudo ./exec_image_inspector --timeout-ms 3000 -- /bin/true
@@ -963,29 +1002,32 @@ Command-line format:
exec_image_inspector [--probe-offset BYTES] [--timeout-ms MS] [--verbose] -- COMMAND [ARG...]
```
`--timeout-ms` accepts 100 to 60000 milliseconds, default is 5000 milliseconds; the loader terminates and reaps the command after the deadline. `--probe-offset` compares 8-byte direct and deferred reads at the specified location, used to verify cold page scenarios. `--verbose` outputs libbpf diagnostics for troubleshooting load or attach issues. `--` explicitly ends inspector option parsing; all subsequent arguments belong to the observed command.
- `--timeout-ms`: 100 to 60000 milliseconds (default 5000). The tool kills and reaps the command after this deadline.
- `--probe-offset`: Read 8 bytes at this offset both in the hook (direct) and in the callback (deferred), to verify the cold-page difference.
- `--verbose`: Print libbpf diagnostic messages.
- `--`: Separates inspector options from the command to run.
### Environment requirements
### Requirements
| Requirement | Details |
|---|---|
| Kernel version | Linux 6.19+ (BPF task work introduced in 6.18, file-backed dynptr introduced in 6.19) |
| Kernel version | Linux 6.19+ (BPF task work introduced in 6.18, file dynptr in 6.19) |
| Kernel configuration | `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_BPF_LSM=y`, `CONFIG_SECURITY=y`, `CONFIG_DEBUG_INFO_BTF=y` |
| Active LSM | `/sys/kernel/security/lsm` contains `bpf` |
| Active LSM | `/sys/kernel/security/lsm` must contain `bpf` |
| Architecture | Tested on x86_64 |
| Privilege | root |
| Privileges | root |
## Scope
## Limitations and extensions
This tool observes one direct child that it creates, and a single map slot is sufficient for this CLI scenario. To extend it to concurrent services, you could allocate per-task state, set admission limits, and reclaim pending callbacks. Timeout cleanup sends SIGKILL to the child; callers can add process-group management and external signal handling to cover more complex scenarios.
This tool observes one direct child per invocation, using a single map slot. For concurrent services, you would allocate per-task state, implement admission limits, and handle callback reclamation. Timeout cleanup sends SIGKILL to the child only; callers needing process-group management or external signal handling would add those features.
## Summary
This tutorial demonstrates how to use BPF task work and file-backed dynptr to inspect the executable image actually installed after exec. Compared to reading `/proc/<pid>/exe` or obtaining paths in tracepoints, this approach can read file contents in a sleepable context, even when target bytes are on cold pages.
This tutorial demonstrates how to combine BPF task work and file dynptr to inspect the executable image actually installed by exec. The key insight is separating two moments: identifying the target (in a non-sleepable LSM hook) and reading file content (in a sleepable task work callback). This lets eBPF programs read file data reliably, even when the target bytes are not in the page cache.
The core idea of this tool is to separate the moment of identifying the target process (non-sleepable LSM hook) from the moment of reading file contents (sleepable task work callback). The blocked-child handshake eliminates attach races, bounded command execution and final drain ensure all events reach user space, and explicit resource release guarantees complete cleanup. These kernel features combine into a reproducible single-command tool while leaving room for extension to concurrent scenarios.
The blocked-child handshake eliminates attach races, bounded execution ensures cleanup, and the final drain captures all events. Together, these techniques produce a reproducible single-command tool while leaving room for extension.
> If you want to learn more about eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>.
> To learn more about eBPF, visit our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or <https://eunomia.dev/tutorials/>.
## References

View File

@@ -1,54 +1,91 @@
# eBPF 教程:检查 exec 后真正安装的可执行镜像
# eBPF 教程:检查 exec 后实际安装的可执行镜像
假设某个容器中运行着一系列嵌套的包装脚本,你从 `ps` 或 `/proc` 看到的命令行是 `/usr/bin/wrapper.sh --config /etc/app.conf`,但实际执行业务逻辑的可能是三层深处的 Python 解释器或某个动态链接的二进制。排查故障时你需要知道内核最终安装的是哪个可执行镜像、它是什么架构、用什么字节序,而不只是命令行参数。
当进程调用 `execve` 时,内核会用新的可执行镜像替换它的内存映像。但具体是哪个可执行文件?如果命令行显示 `/usr/bin/wrapper.sh --config /etc/app.conf`,实际运行的代码可能是三层包装脚本深处的 Python 解释器或编译好的二进制。安全工具、容器运行时、故障排查工具都需要知道内核 *实际* 安装的是什么,而不仅仅是用户输入的命令行。
本教程展示如何使用 BPF task work 和 file-backed dynptr 实现这一目标。工具挂载 LSM hook 观察 `bprm_committed_creds` 时刻,在凭据提交后为目标进程安排 task work 回调,回调在可睡眠上下文中重新获取已安装的可执行文件、解析路径、读取 ELF 头部,并把结果通过 ring buffer 发送给用户态。
本教程构建一个在内核层面捕获这些信息的工具。它在 exec 提交凭据后挂载钩子,安排一个延迟回调,然后读取已安装可执行文件的 ELF 头部,报告其架构、字节序和文件类型。过程中会演示两个较新的内核特性——BPF task work 和 file dynptr——它们组合起来可以解决传统 eBPF 方案无法处理的问题。
> 完整源码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/54-exec-image-inspector>
## 为什么需要 BPF task work 和 file dynptr
## 问题:从 eBPF 读取文件内容的困难
传统的可执行文件检查方案都有明显的局限性。
为什么安全工具需要读取可执行文件的内容,而不仅仅是路径?因为路径本身并不能确定将要运行什么代码。对可执行文件内容的哈希可以验证它是否与已知的可信二进制匹配。嵌入的签名或证书可以证明来源。特定偏移处的特定字节模式可以识别加壳、混淆或篡改。这些信息都无法从路径获取——必须读取文件的字节。在 eBPF 中、在 exec 的精确时刻读取,消除了困扰用户态方案的竞态窗口。
最直接的想法是读取 `/proc/<pid>/exe` 符号链接,但这需要在进程存活期间完成,短命进程可能在你读取之前就已经退出。而且 `/proc` 访问发生在用户态,与内核的 exec 事件之间存在竞态窗口,同一进程可能已经执行了多次 `execve`。
最直接的可执行文件检查方法是读取 `/proc/<pid>/exe`,但这要求进程仍然存活。短命进程在你读取之前就已退出。即使抓住了时机,`/proc` 文件系统是从用户态访问的,与内核的 exec 事件之间存在竞态窗口:等你读到符号链接时,进程可能已经再次调用了 `execve`。
使用 tracepoint 或 kprobe 观察 `sched_process_exec` 可以捕获 exec 事件,但这些 hook 运行在不可睡眠上下文中。你可以获取文件路径,但如果需要读取文件内容来验证 ELF 头部或检查嵌入的元数据,问题就来了:file-backed dynptr 的 `bpf_dynptr_read` 在目标字节位于冷页时会触发缺页操作,而缺页需要睡眠才能完成。不可睡眠上下文中的 BPF 程序无法等待 I/O。
Tracepoint 和 kprobe 可以钩住 `sched_process_exec` 同步观察 exec 事件,但这些钩子运行在内核所谓的**不可睡眠上下文**中。为什么这很重要?这涉及到 Linux 如何管理内存中的文件数据。
BPF task work 才能真正解决这个问题。Linux 6.18 引入了 `bpf_task_work_schedule_signal` kfunc,允许 BPF 程序为目标 task 安排回调,内核会在安全的可睡眠上下文中执行这个回调。Linux 6.19 引入了 file-backed dynptr,提供验证器跟踪的文件数据访问方式。把这两个功能组合起来,你可以在 LSM hook 中识别目标进程、记录时间戳,然后安排 task work;回调在可睡眠上下文中执行,可以重新获取 task 已安装的可执行文件、通过 dynptr 读取任意位置的内容(包括冷页),最后把完整的检查结果发送给用户态。
当你读取文件时,内核首先检查请求的字节是否已经在**页缓存**(page cache)中——这是一块缓存最近访问过的文件数据的内存区域。如果在,读取立即完成。如果不在(即**冷页**),内核必须向存储设备发起 I/O 请求,调用上下文必须**睡眠**等待 I/O 完成。
这就是 `exec_image_inspector` 的设计思路:LSM hook 选定子进程并调度 task work,可睡眠回调完成实际的文件读取和解析工作。
挂载在 tracepoint 和 kprobe 上的 BPF 程序不能睡眠。它们运行时可能中断被禁用、持有锁;如果睡眠会导致系统死锁。当 BPF 程序尝试读取文件内容遇到冷页时,读取会以 `-EFAULT` 失败,而不是等待 I/O。
## BPF task work 与 file dynptr 的工作原理
这就形成了根本性的限制:你可以观察 exec 事件,但无法可靠地读取可执行文件的内容来验证其 ELF 头部或检查嵌入的元数据。
BPF task work 是一种延迟执行机制。当你在 BPF 程序中调用 `bpf_task_work_schedule_signal(task, work, map, callback)` 时,内核会把回调关联到指定的 task。回调不会立即执行,而是在该 task 返回用户态前的某个安全点执行,此时上下文允许睡眠。
## 解决方案:BPF task work 与 file dynptr
`struct bpf_task_work` 是一个不透明的 64 字节结构,BPF 程序只需要为它分配存储空间,内核负责管理其内部状态。本工具使用单元素 ARRAY map 存储 `struct exec_work`,其中包含 `bpf_task_work` 存储、调度时间戳和直接探测结果。
Linux 6.18 引入了 **BPF task work**,这是一种让 BPF 程序安排回调稍后在安全的可睡眠上下文中执行的机制。回调会在目标任务返回用户态之前、内核允许睡眠的时刻执行。
File-backed dynptr 则提供了验证器跟踪的文件数据访问接口。`bpf_dynptr_from_file(file, flags, dynptr)` 从文件创建 dynptr,`bpf_dynptr_read(dst, len, dynptr, offset, flags)` 读取指定偏移处的内容。dynptr 持有内部状态,因此所有创建 dynptr 的路径都必须调用 `bpf_dynptr_file_discard` 来释放,包括失败分支。
Linux 6.19 引入了 **file dynptr**,提供验证器跟踪的文件数据访问。dynptr(动态指针)是一种 BPF 抽象,在验证时追踪指针的边界;file 变体封装了文件 I/O 操作,让验证器能确保内存安全。
在不可睡眠上下文中,dynptr 只能读取已经在页缓存中的内容,访问冷页会返回 `-EFAULT`。在可睡眠上下文中,dynptr 可以触发缺页操作并等待 I/O 完成。这正是 task work 回调的价值所在。
结合这两个特性,设计变成:
## 工具的整体流程
1. 在 `bprm_committed_creds` 挂载 LSM 钩子,它在 exec 安装新凭据后触发
2. 在钩子中(不可睡眠)识别目标进程并安排 task work 回调
3. 回调在可睡眠上下文中执行,可以访问已安装的可执行文件、读取任意偏移的内容(包括冷页)、然后把结果发送给用户态
用户态启动后先 fork 目标命令,但子进程会在 pipe 上阻塞等待释放信号。这样父进程就能在子进程执行 `execvp` 之前知道它的 PID(也是 TGID),可以把这个值写入 BPF 程序的只读数据段作为过滤条件。阻塞子进程消除了短命命令带来的 attach 竞态,让整个示例保持自包含。
这种分离——在不可睡眠的钩子中识别目标,在可睡眠的回调中完成重活——是关键思路。
父进程打开 BPF skeleton、设置 `target_tgid` 和可选的 `probe_offset`、加载并挂载 LSM 程序、创建 ring buffer reader,随后向 pipe 写入释放字节让子进程执行 `execvp`。
## BPF task work 的工作机制
子进程执行 exec 后,凭据提交时 `lsm/bprm_committed_creds` hook 触发。BPF 程序比较当前 TGID 与 `target_tgid`,不匹配则直接返回。命中目标后程序记录调度时间戳,如果设置了 `--probe-offset` 则先尝试在当前上下文直接读取(用于验证冷页场景),然后调用 `bpf_task_work_schedule_signal` 安排回调。
调用 `bpf_task_work_schedule_signal(task, work, map, callback)` 时,内核把你的回调关联到指定的任务。回调不会立即执行;它会稍后在该任务返回用户态之前的某个安全点执行。
回调 `inspect_executable` 在该 task 的可睡眠上下文中执行。它调用 `bpf_get_task_exe_file` 获取 task 当前安装的可执行文件(返回带引用的 `struct file`,必须用 `bpf_put_file` 释放),用 `bpf_path_d_path` 解析路径,用 file dynptr 读取 64 字节 ELF 头部和可选的标记位置。读取成功后解析 ELF 魔数、class、data、type 和 machine 字段。最后把所有信息打包成 ring buffer 事件发送给用户态。
`struct bpf_task_work` 是一个不透明结构,内核用它来追踪已安排的回调。BPF 程序只需为它分配存储空间,不解释其内容。本工具使用单元素 ARRAY map 保存 `struct exec_work`,其中包含 `bpf_task_work` 存储以及时间戳和中间结果字段。
用户态边轮询 ring buffer 边用 `waitpid(WNOHANG)` 检查子进程状态。子进程回收且至少收到一条事件时结束循环,或者等到超时。同一子进程可能连续安装多个镜像(例如 `/bin/sh -c 'exec /bin/true'`),子进程回收后还要执行 `drain_events` 取尽 ring buffer 中的剩余事件。
回调签名是 `int callback(struct bpf_map *map, void *key, void *value)`。`value` 参数指向包含你的 `bpf_task_work` 的 map 元素,所以你可以通过周围的字段从调度钩子向回调传递数据。
## file dynptr 的工作机制
dynptr 用边界信息包装指针,BPF 验证器可以追踪这些边界。对于 file dynptr:
- `bpf_dynptr_from_file(file, flags, dynptr)` 从文件创建 dynptr
- `bpf_dynptr_read(dst, len, dynptr, offset, flags)` 读取指定偏移处的内容
- `bpf_dynptr_file_discard(dynptr)` 释放 dynptr 的内部状态
每条创建 dynptr 的路径——包括错误路径——都必须调用 `bpf_dynptr_file_discard` 来释放它。忘记释放会泄漏内部资源。
在不可睡眠上下文中,`bpf_dynptr_read` 只有在目标字节已经在页缓存中时才会成功。访问冷页返回 `-EFAULT`。在可睡眠上下文中,同样的调用可以触发缺页处理并等待 I/O,即使对冷页也能成功。这个差异就是 task work 的价值所在:回调运行在可睡眠上下文中,文件读取能可靠完成。
## 工具架构
用户态程序 fork 出一个子进程,但在子进程调用 `execvp` 之前用管道阻塞它。这给父进程时间去:
1. 记录子进程的 PID(它会成为 TGID——线程组 ID)
2. 打开 BPF skeleton 并把目标 TGID 写入只读数据段
3. 加载并挂载 BPF 程序
4. 创建 ring buffer reader
只有这些都完成后,父进程才通过写管道来释放子进程。这个握手消除了竞态条件:没有它的话,快速退出的命令可能在 BPF 程序挂载之前就结束了。
当子进程调用 `execvp` 时,`lsm/bprm_committed_creds` 钩子触发。BPF 程序比较当前 TGID 与配置的目标;如果匹配,它记录时间戳并安排 task work 回调。
回调——`inspect_executable`——在子进程的可睡眠上下文中执行。它:
1. 调用 `bpf_get_task_exe_file` 获取已安装的可执行文件(返回一个带引用的 `struct file`,必须用 `bpf_put_file` 释放)
2. 用 `bpf_path_d_path` 解析路径
3. 创建 file dynptr 并读取 64 字节 ELF 头部
4. 解析 ELF 字段:魔数、class(32/64 位)、data(字节序)、type(可执行文件 vs 共享对象)、machine(架构)
5. 通过 ring buffer 发送事件
用户态一边轮询 ring buffer 一边检查子进程是否已退出。单个子进程可能多次 exec(例如 `/bin/sh -c 'exec /bin/true'` 先 exec shell,然后 exec `/bin/true`),所以在子进程退出后,工具会取尽 ring buffer 中的剩余事件。
![exec 镜像检查器数据流](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/54-exec-image-inspector/exec-image-flow.png)
## 代码实现
## 代码详解
本工具由四个文件组成:共享头文件定义事件和统计结构、兼容性头文件声明新内核 kfunc、BPF 程序实现 hook 和回调、用户空间加载器管理生命周期。
实现分布在四个文件中:共享头文件、新内核接口的兼容性头文件、BPF 程序、用户态加载器。
### 共享头文件
`exec_image_inspector.h` 定义了 BPF 和用户空间共享的 ring buffer 事件结构和统计结构。
`exec_image_inspector.h` 定义 BPF 和用户态共享的结构:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -96,11 +133,11 @@ struct inspector_stats {
#endif /* __EXEC_IMAGE_INSPECTOR_H */
```
`exec_event` 包含了检查一次 exec 所需的全部信息:PID、TGID、进程名、路径、ELF 解析结果、探测结果、回调延迟和各类错误代码。`inspector_stats` 累计匹配、调度、回调、错误和丢弃计数,用户态在结束时读取 BSS 段并报告。
`exec_event` 携带报告一次 exec 所需的全部信息:进程标识符、解析后的路径、ELF 元数据、错误码。`inspector_stats` 累计各种计数器,用户态在退出时从 BSS 段读取并报告成功和失败率。
### 兼容性声明
### 兼容性头文件
Linux 6.18 引入 BPF task work,6.19 引入 file-backed dynptr。仓库当前生成的 UAPI 与 BTF 头文件早于这些功能,因此本工具把缺失声明放在局部头文件 `bpf_experimental.h` 中。
仓库的 vendored vmlinux 头文件早于 Linux 6.18/6.19,所以 `bpf_experimental.h` 在本地声明新接口:
```c
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
@@ -134,11 +171,11 @@ extern int bpf_dynptr_file_discard(struct bpf_dynptr *dynptr) __ksym;
#endif /* __EXEC_IMAGE_INSPECTOR_BPF_EXPERIMENTAL_H */
```
这个文件声明了 `bpf_task_work_schedule_signal`、`bpf_get_task_exe_file`、`bpf_put_file`、`bpf_path_d_path`、`bpf_dynptr_from_file` 和 `bpf_dynptr_file_discard` 等 kfunc。等仓库集成的 vmlinux 头文件包含这些声明后即可移除此文件。
这些声明用 `__ksym` 标记为在加载时解析的内核符号。等仓库的 vmlinux 头文件从 6.19+ 内核重新生成后,这个文件可以移除。
### BPF 程序
`exec_image_inspector.bpf.c` 实现了 LSM hook 和 task work 回调。
`exec_image_inspector.bpf.c` 实现 LSM 钩子和 task work 回调:
```c
// SPDX-License-Identifier: GPL-2.0
@@ -341,23 +378,23 @@ void BPF_PROG(schedule_exec_inspection, struct linux_binprm *bprm)
}
```
程序的入口是 `SEC("lsm/bprm_committed_creds")` 声明的 `schedule_exec_inspection` 函数。`bprm_committed_creds` hook 在新可执行文件的凭据提交后运行,此时 `bprm->file` 指向本次 exec 涉及的镜像。这个 hook 本身不可睡眠,但我们可以在这里识别目标进程并安排 task work。
入口点是用 `SEC("lsm/bprm_committed_creds")` 声明的 `schedule_exec_inspection`。这个 LSM 钩子在新可执行文件的凭据安装后触发,此时 `bprm->file` 指向正在被执行的文件。钩子本身不可睡眠,但可以识别目标并安排延迟工作。
两个 `const volatile` 变量 `target_tgid` 和 `probe_offset` 位于 `.rodata` 段,用户态在 `open()` 之后、`load()` 之前写入值,验证器将它们视为编译期常量。程序首先比较当前 TGID 与 `target_tgid`,不匹配则直接返回,避免观察无关进程的 exec。
两个 `const volatile` 变量——`target_tgid` 和 `probe_offset`——位于 `.rodata` 段。用户态在 `open()` 和 `load()` 之间写入值,验证器把它们当作编译期常量来优化。
命中目标后程序从单元素 `pending` ARRAY map 查找 key 0 得到 `struct exec_work`。这个结构保存调度时间戳、直接探测结果和 `struct bpf_task_work` 存储。本工具一次调用只观察一个子进程,一个槽位已经足够。设置 `--probe-offset` 时,`probe_file_without_sleep` 在当前不可睡眠上下文中尝试从 `bprm->file` 读取指定位置的 8 字节。测试程序会把标记放到冷页位置,验证此时直接读取返回 `-EFAULT`。
当目标匹配时,程序从单元素 `pending` ARRAY map 查找 `struct exec_work`。这个结构保存 `bpf_task_work` 存储以及时间戳和可选的直接探测结果。单个槽位就够了,因为本工具每次调用只观察一个子进程。
hook 保存时间戳和直接读取结果后调用 `bpf_task_work_schedule_signal`,传入当前 task、work 存储、map 指针与 `inspect_executable` 回调函数指针。内核持有回调执行所需的引用,回调会在该 task 返回用户态前的可睡眠上下文中执行。
可选的 `--probe-offset` 标志让工具通过 `probe_file_without_sleep` 在不可睡眠的钩子中尝试直接读取。测试套件用这个来验证:读取冷页在不可睡眠上下文中以 `-EFAULT` 失败,但在可睡眠回调中成功。
`inspect_executable` 是 task work 回调函数,签名为 `int callback(struct bpf_map *map, void *key, void *value)`。它首先计算回调延迟(当前时间减去调度时间戳)用于诊断,填充 PID、TGID、进程名和直接探测结果到事件结构。然后调用 `bpf_get_task_exe_file` 获取 task 已安装的可执行文件。这个 kfunc 返回带引用的 `struct file`,每次成功获取都必须用 `bpf_put_file` 释放。
保存时间戳和直接探测结果后,钩子调用 `bpf_task_work_schedule_signal`。内核持有稍后执行回调所需的引用。
回调先用 `bpf_path_d_path` 解析文件路径,再用 `bpf_dynptr_from_file` 创建 dynptr。因为现在处于可睡眠上下文,`bpf_dynptr_read` 可以触发缺页操作并等待 I/O 完成。回调只读取 64 字节 ELF 头部和可选的 8 字节标记。头部读取成功后程序检查 4 字节 ELF 魔数 `\x7fELF`,再解析 `EI_CLASS`(32 位或 64 位)、`EI_DATA`(小端或大端)、`e_type`(可执行文件或共享对象)和 `e_machine`(架构)。`read_elf_u16` 辅助函数根据字节序正确解析 16 位字段。
回调 `inspect_executable` 计算延迟用于诊断,然后用 `bpf_get_task_exe_file` 获取可执行文件。这返回一个带引用的 `struct file`,必须用 `bpf_put_file` 释放。回调解析路径、创建 file dynptr、读取 64 字节 ELF 头部并解析它。`read_elf_u16` 辅助函数处理字节序:ELF 文件在头部声明其字节序,多字节字段必须相应地读取。
所有创建 dynptr 的路径都必须调用 `bpf_dynptr_file_discard` 释放其内部状态,包括 helper 失败的分支。最后 `bpf_ringbuf_output` 把事件发送给用户态,发送失败时递增 `dropped` 计数器。
每条创建 dynptr 的路径——无论成功还是失败——都必须调用 `bpf_dynptr_file_discard`。最后,`bpf_ringbuf_output` 把事件发送给用户态。
### 用户空间加载器
### 用户态加载器
`exec_image_inspector.c` 负责解析命令行、协调阻塞子进程、加载 BPF、格式化事件并清理资源。
`exec_image_inspector.c` 协调子进程、加载 BPF、接收事件、报告结果:
```c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
@@ -898,17 +935,15 @@ cleanup:
}
```
加载器的流程围绕阻塞子进程握手展开。`start_blocked_child` 在 BPF 设置完成前 fork,但子进程会阻塞在 pipe 读取上。父进程此时已经知道子进程 PID,可以在打开 skeleton 后把它写入 `rodata->target_tgid`。挂载完成后 `release_child` 向 pipe 写入一个字节让子进程继续执行 `execvp`。这个握手消除了短命命令带来的 attach 竞态。
核心技术是阻塞子进程握手。`start_blocked_child` 在 BPF 设置前 fork,但子进程阻塞在 pipe 读取上。父进程记录子进程 PID、打开 skeleton、写入 `target_tgid`、加载并挂载、创建 ring buffer,然后才调用 `release_child` 写管道让子进程继续执行 `execvp`。
`setup_inspector` 函数打开 skeleton、设置只读数据、加载并挂载 BPF 程序、创建 ring buffer reader。加载失败时的错误信息会指出可能的原因,帮助用户排查内核版本或配置问题。
`wait_for_command` 是主循环。它打印 `READY` 行、释放子进程,然后交替轮询 ring buffer 和检查子进程是否退出。当子进程被回收且至少收到一条事件时循环结束,或者超时时结束。超时后,`reap_timed_out_child` 发送 SIGKILL 并等待。
`wait_for_command` 是主循环。它先打印 `READY` 行表示工具已就绪,然后释放子进程、设置超时 deadline、进入 poll 循环。循环中交替调用 `ring_buffer__poll` 接收事件和 `waitpid(WNOHANG)` 检查子进程状态。子进程回收且至少收到一条事件时结束循环,或者等到超时。超时后 `reap_timed_out_child` 向子进程发送 SIGKILL 并等待其退出。
单次 exec 可能触发多条事件,如果命令本身也调用 exec(例如 `/bin/sh -c 'exec /bin/true'`)。子进程退出后,`drain_events` 用零超时 poll 取尽剩余事件。
同一子进程可能连续安装多个镜像。例如运行 `/bin/sh -c 'exec /bin/true'` 时,shell 先 exec 自身,然后 exec `/bin/true`。子进程回收后 `drain_events` 用零超时 poll 取尽 ring buffer 中的剩余事件,确保最后一条镜像事件也能到达用户态。
`handle_event` 格式化输出,把数字 ELF 值转换为可读名称,同时保留原始值供脚本使用。
`handle_event` 解析 ring buffer 事件并打印格式化输出。它把 ELF 数值转换成可读名称如 `ELF64`、`LSB`、`ET_DYN`、`EM_X86_64`,同时保留原始数值。设置 `--probe-offset` 时还会打印 `PROBE` 行显示探测结果。
## 编译与运行
## 构建与运行
从源码构建:
@@ -918,13 +953,13 @@ make -C src/54-exec-image-inspector clean
make -C src/54-exec-image-inspector -j2
```
集成测试需要 Linux 6.19 或更新版本,执行前请先确认活动 LSM 列表中包含 `bpf`:
测试套件要求 Linux 6.19 或更新版本,且 BPF LSM 处于活动状态。检查 `bpf` 是否出现在 LSM 列表中:
```bash
cat /sys/kernel/security/lsm
```
要添加 `bpf`,请在内核启动参数中把 `lsm=<existing-list>` 改成 `lsm=<existing-list>,bpf`。
如果缺少 `bpf`,在内核命令行中添加它:把引导加载器配置中的 `lsm=<existing-list>` 改为 `lsm=<existing-list>,bpf`。
运行测试:
@@ -933,7 +968,7 @@ cd src/54-exec-image-inspector
sudo make test
```
仓库 CI 只编译本课。运行时行为在 x86_64 上通过功能测试,内核版本 `7.0.0-rc2+`。下面的会话记录给出测试框架和工具输出:
仓库 CI 只编译本课。运行时行为在 x86_64 上通过功能测试,内核版本 `7.0.0-rc2+`。示例输出:
```text
TEST-MISSING matched=0 events=0 command_exit=127
@@ -947,45 +982,52 @@ SUMMARY matched=1 scheduled=1 schedule_errors=0 callbacks=1 header_errors=0 path
PASS: missing-command, timeout cleanup, re-exec drain, ELF decode, and deferred file read succeeded
```
前三条测试记录覆盖重要边界:缺失命令以状态 127 退出,此时 committed-exec hook 尚未触发,因此 `matched=0`、`events=0`;超时用例产生 exec 事件后超过测试时限,由 SIGKILL 终止并以状态 137 回收;连续 exec 用例取尽两条镜像事件,确认 `/usr/bin/true` 最后出现。
前三行测试覆盖边界条件:
`EXEC` 行给出已安装镜像和 ELF 头部解析结果。测试程序在 exec 前刷新并驱逐标记所在页面,hook 中直接读取返回 `-EFAULT`(`-14`),而 task-work 回调成功返回 `454950524f424521`(`EIPROBE!` 的十六进制字节)。这验证了不可睡眠上下文无法读取冷页,而可睡眠上下文可以。
- **TEST-MISSING**:不存在的命令以状态 127 退出。LSM 钩子从未触发,因为 exec 在凭据提交前就失败了,所以 `matched=0` 且 `events=0`。
- **TEST-TIMEOUT**:命令运行太久,被 SIGKILL 终止,以状态 137(128 + 9)回收。观察到一条 exec 事件。
- **TEST-REEXEC**:内部使用 `exec` 的 shell 命令产生两条事件,最终路径是 `/usr/bin/true`。
检查其他命令时可省略测试程序专用的探测偏移:
`EXEC` 行显示已安装的镜像和解析的 ELF 字段。`PROBE` 行展示了可睡眠上下文的差异:测试向一个页面写入标记(`EIPROBE!`),刷新并从页缓存驱逐它,然后 exec。不可睡眠钩子中的直接读取返回 `-EFAULT`(`-14`),因为页是冷的。可睡眠回调中的延迟读取成功,返回标记字节的十六进制表示(`454950524f424521`)。
检查简单命令:
```bash
sudo ./exec_image_inspector --timeout-ms 3000 -- /bin/true
```
命令行格式如下:
命令行格式:
```text
exec_image_inspector [--probe-offset BYTES] [--timeout-ms MS] [--verbose] -- COMMAND [ARG...]
```
`--timeout-ms` 接受 100 至 60000 毫秒,默认值为 5000 毫秒,超过时限后加载器会终止并回收命令。`--probe-offset` 比较指定位置的 8 字节直接读取与延迟读取,用于验证冷页场景。`--verbose` 输出 libbpf 诊断,便于排查 load 或 attach 问题。`--` 明确结束 inspector 选项解析,后面的参数全部属于被观察命令。
- `--timeout-ms`:100 到 60000 毫秒(默认 5000)。超过此期限后工具会终止并回收命令。
- `--probe-offset`:在钩子中(直接)和回调中(延迟)分别读取此偏移处的 8 字节,以验证冷页差异。
- `--verbose`:打印 libbpf 诊断信息。
- `--`:分隔 inspector 选项和要运行的命令。
### 环境要求
| 要求 | 详情 |
|---|---|
| 内核版本 | Linux 6.19+(BPF task work 在 6.18 引入,file-backed dynptr 在 6.19 引入) |
| 内核版本 | Linux 6.19+(BPF task work 在 6.18 引入,file dynptr 在 6.19 引入) |
| 内核配置 | `CONFIG_BPF=y`, `CONFIG_BPF_SYSCALL=y`, `CONFIG_BPF_JIT=y`, `CONFIG_BPF_LSM=y`, `CONFIG_SECURITY=y`, `CONFIG_DEBUG_INFO_BTF=y` |
| 活动 LSM | `/sys/kernel/security/lsm` 中包含 `bpf` |
| 活动 LSM | `/sys/kernel/security/lsm` 必须包含 `bpf` |
| 架构 | 已在 x86_64 上测试 |
| 权限 | root |
## 适用范围
## 局限性与扩展
本工具观察自身创建的一个直接子进程,单个 map 槽位适合这种 CLI 场景。如果要扩展到并发服务,可以改为按 task 分配状态、设置准入上限并回收待处理回调。超时清理向该子进程发送 SIGKILL,调用方可以添加进程组管理和外部信号处理来覆盖更复杂的场景。
本工具每次调用观察一个直接子进程,使用单个 map 槽位。对于并发服务场景,需要按任务分配状态、实现准入限制、处理回调回收。超时清理只向子进程发送 SIGKILL;需要进程组管理或外部信号处理的调用方需自行添加这些功能。
## 总结
本教程展示了如何使用 BPF task work 和 file-backed dynptr 检查 exec 后真正安装的可执行镜像。相比读取 `/proc/<pid>/exe` 或在 tracepoint 中获取路径,这种方案可以在可睡眠上下文中读取文件内容,即使目标字节位于冷页也能完成。
本教程展示了如何结合 BPF task work 和 file dynptr 来检查 exec 实际安装的可执行镜像。关键思路是分离两个时刻:识别目标(在不可睡眠的 LSM 钩子中)和读取文件内容(在可睡眠的 task work 回调中)。这让 eBPF 程序能可靠地读取文件数据,即使目标字节不在页缓存中也能完成。
工具的核心思路是把识别目标进程的时刻(不可睡眠的 LSM hook)与读取文件内容的时刻(可睡眠的 task work 回调)分开。阻塞子进程握手消除了 attach 竞态,有界命令运行和最终 drain 确保所有事件都能到达用户态,显式资源释放保证清理完整。这些内核功能组合成了一个可复现的单命令工具,同时为并发场景留出扩展空间。
阻塞子进程握手消除了 attach 竞态,有界执行确保清理完整,最终 drain 捕获所有事件。这些技术组合产生一个可复现的单命令工具,同时为扩展留出空间。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
> 要深入了解 eBPF,请访问我们的教程仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或 <https://eunomia.dev/tutorials/>。
## 参考资料