From 2219cbc64fb886d07846ff6779245e92c137e75c Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 01:36:04 -0700 Subject: [PATCH] docs: rewrite four advanced eBPF tutorials --- .../bpf-tutorial-writing-style/SKILL.md | 3 + .../skills/find-bpf-tutorial-topic/SKILL.md | 3 + .../write-bpf-production-tutorial/SKILL.md | 4 + README.md | 4 + README.zh.md | 4 + TUTORIAL_IDEAS.md | 11 +- scripts/test_generate_toc.py | 1 + src/55-dns-egress/README.md | 632 +++++++----- src/55-dns-egress/README.zh.md | 634 +++++++----- src/56-tc-flow-index/README.md | 798 +++++++++++++++ src/56-tc-flow-index/README.zh.md | 798 +++++++++++++++ src/57-oom-watch/README.md | 932 ++++++++++++----- src/57-oom-watch/README.zh.md | 934 +++++++++++++----- src/60-afxdp-dump/README.md | 194 ++-- src/60-afxdp-dump/README.zh.md | 196 ++-- src/SUMMARY.md | 4 + src/SUMMARY.zh.md | 4 + src/compatibility.md | 4 + src/compatibility.zh.md | 4 + 19 files changed, 4010 insertions(+), 1154 deletions(-) create mode 100644 src/56-tc-flow-index/README.md create mode 100644 src/56-tc-flow-index/README.zh.md diff --git a/.agents/skills/bpf-tutorial-writing-style/SKILL.md b/.agents/skills/bpf-tutorial-writing-style/SKILL.md index e9c3673..e08f955 100644 --- a/.agents/skills/bpf-tutorial-writing-style/SKILL.md +++ b/.agents/skills/bpf-tutorial-writing-style/SKILL.md @@ -19,6 +19,7 @@ Introduce information at the point where the reader needs it. Compilation follow ## Tell one useful story +- Position the lesson at the narrowest useful capability that the executable really provides. Keep the title, opening, CLI, normal execution path, output, and test focused on that same capability. For example, a tool that learns one domain's A record and checks one TCP port is a minimal DNS-derived IP allowlist rather than a general DNS-aware firewall. - Open with the concrete question the tool answers. A short factual setup is often enough; avoid invented stories. - Near the first mention of eBPF, use one natural sentence to say that it runs verified programs at kernel hooks and can send selected state to user space. - Introduce the relevant kernel subsystem and new feature when the running example needs them. State when the feature entered Linux and what it enabled. @@ -33,6 +34,8 @@ Introduce information at the point where the reader needs it. Compilation follow - Keep each paragraph focused on one job. Connect facts through cause, sequence, or contrast instead of listing them like a specification. - Write Chinese naturally from the same facts instead of translating English sentence by sentence. Use restrained punctuation and spaces between Chinese text and Latin letters or numbers. - Keep technical names, code identifiers, commands, numbers, output, versions, and references exact. +- Match operational terms to behavior. Call a tool a profiler when it collects a profile such as sampled call paths or a distribution, an index when it maintains a searchable or ordered index, and a policy tool when it actually enforces a decision. +- For a stateful policy, teach the complete trust chain: which event creates candidate state, which fields correlate later events, which checks admit state, how time or deletion retires it, and which hook makes the final decision. Give poisoning, stale-state, and bypass resistance the space required to explain the mechanism. Use this Chinese paragraph only as a voice reference for connected rhythm, restrained symbols, and technical density: diff --git a/.agents/skills/find-bpf-tutorial-topic/SKILL.md b/.agents/skills/find-bpf-tutorial-topic/SKILL.md index 48df5df..d9360c5 100644 --- a/.agents/skills/find-bpf-tutorial-topic/SKILL.md +++ b/.agents/skills/find-bpf-tutorial-topic/SKILL.md @@ -53,6 +53,8 @@ For every serious candidate, write five short fields: Defer an idea when these fields remain vague. A helper name alone is not a tutorial topic. +Before marking a feature-driven candidate ready, verify the complete API path against the target kernel. Check the intended BPF program type, map fields, helpers or kfunc sets, sleepability, locking, and verifier restrictions together; a selftest using another program type is not evidence that the proposed hook can use the same API. Build and load the smallest representative probe in KVM when static inspection leaves doubt. If the target program type cannot use the feature, choose a scenario where it can instead of routing data through user space solely to make the API appear in the example. + ## Score value Score from evidence, then subtract costs. Keep the breakdown in working notes and place the final score in `TUTORIAL_IDEAS.md`. @@ -80,6 +82,7 @@ A candidate can become `ready` only when: - the repository does not already teach the same flow; - the example can fit one coherent tutorial; - primary sources establish the technical claims; +- the intended program type can load every required map and API on the target kernel; - required kernel, architecture, privilege, and hardware conditions are known; - the likely maintenance burden is proportionate to its value. diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index 9c24366..18bfcfb 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -19,6 +19,8 @@ Read these inputs before writing: Collect the facts the reader needs: the problem, the kernel/user-space flow, feature versions, requirements, commands, real output, cleanup, scope, and references. Add alternatives when they help the reader choose or understand the mechanism. Keep every claim grounded in the code, tests, captured output, or a primary source. Preserve an existing draft before a from-scratch rewrite. +Write down the narrowest useful description supported by the normal execution path before drafting. Use it to align the title, opening, CLI, output, and test. For stateful security examples, include the correlation key, admission checks, state lifetime, enforcement point, and the negative cases exercised by the test. + Make the example a small useful tool. Its normal mode works on a reader-selected process, cgroup, interface, or other real target. A deterministic demo or integration test may create its own workload. Keep the CLI compact and keep infrastructure details out of public text. Build and run the example when the environment supports it. Use `$test-bpf-tutorial-kvm` for kernel features that need the repository's KVM environment. Runtime details support the tutorial; local workspace paths, VM names, shared repositories, caches, prompts, and agent traces stay private. @@ -57,6 +59,8 @@ Inspect both files and the diff instead of trusting the model's final message. C - both languages are complete and tell the same technical story; - the opening reads like a tutorial rather than an abstract or feature list; +- every mechanism promised by the title and introduction appears in the executable path, and a feature-driven lesson both uses and explains the named API; +- the title and opening describe the narrowest useful behavior proven by the normal mode and test, without expanding a focused allowlist, profiler, index, capture tool, or monitor into a broader product category; - the relevant eBPF and kernel background appears before details that depend on it; - the high-level mechanism is explained before code sections; - compilation/execution is AFTER code analysis; diff --git a/README.md b/README.md index 4504b16..89d19bd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Networking: - [lesson 46-xdp-test](src/46-xdp-test/README.md) Building a High-Performance XDP Packet Generator - [lesson 50-tcx](src/50-tcx/README.md) Composable Traffic Control with TCX Links - [lesson 53-egress-pacer](src/53-egress-pacer/README.md) Building an Egress Pacer with BPF Qdisc +- [lesson 56-tc-flow-index](src/56-tc-flow-index/README.md) Index TC Flows in Two Refcounted rbtrees +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.md) Receive UDP Packets with AF_XDP Tracing: @@ -90,6 +92,7 @@ Tracing: - [lesson 40-mysql](src/40-mysql/README.md) Using eBPF to Trace MySQL Queries - [lesson 48-energy](src/48-energy/README.md) Energy Monitoring for Process-Level Power Analysis - [lesson 52-fsession-latency](src/52-fsession-latency/README.md) Tracing Slow vfs_read Calls with fsession +- [lesson 57-oom-watch](src/57-oom-watch/README.md) Profile memcg Reclaim Before an OOM Kill Security: @@ -102,6 +105,7 @@ Security: - [lesson 34-syscall](src/34-syscall/README.md) Modifying System Call Arguments with eBPF - [lesson 51-tcp-quarantine](src/51-tcp-quarantine/README.md) Precisely Isolating Established TCP Connections - [lesson 54-exec-image-inspector](src/54-exec-image-inspector/README.md) Inspecting the Executable Image After exec +- [lesson 55-dns-egress](src/55-dns-egress/README.md) Build a DNS-Derived IP Allowlist with cgroup BPF Features: diff --git a/README.zh.md b/README.zh.md index ca7a886..a874980 100644 --- a/README.zh.md +++ b/README.zh.md @@ -77,6 +77,8 @@ GPU: - [lesson 46-xdp-test](src/46-xdp-test/README.zh.md) eBPF 实例教程:构建高性能 XDP 数据包生成器 - [lesson 50-tcx](src/50-tcx/README.zh.md) eBPF 入门实践教程第五十篇:使用 TCX Link 实现可组合的流量控制 - [lesson 53-egress-pacer](src/53-egress-pacer/README.zh.md) eBPF 教程:用 BPF Qdisc 实现出口限速 +- [lesson 56-tc-flow-index](src/56-tc-flow-index/README.zh.md) eBPF 实战教程:用两棵 refcounted rbtree 索引 TC 流量 +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 在用户态接收 UDP 报文 追踪: @@ -89,6 +91,7 @@ GPU: - [lesson 40-mysql](src/40-mysql/README.zh.md) 使用 eBPF 跟踪 MySQL 查询 - [lesson 48-energy](src/48-energy/README.zh.md) eBPF 教程:进程级能源监控与功耗分析 - [lesson 52-fsession-latency](src/52-fsession-latency/README.zh.md) eBPF 教程:使用 fsession 追踪慢速 vfs_read 调用 +- [lesson 57-oom-watch](src/57-oom-watch/README.zh.md) eBPF 实战教程:在 OOM kill 之前分析 memcg reclaim 安全: @@ -101,6 +104,7 @@ GPU: - [lesson 34-syscall](src/34-syscall/README.zh.md) eBPF 开发实践:使用 eBPF 修改系统调用参数 - [lesson 51-tcp-quarantine](src/51-tcp-quarantine/README.zh.md) eBPF 教程:精准隔离已建立的 TCP 连接 - [lesson 54-exec-image-inspector](src/54-exec-image-inspector/README.zh.md) eBPF 教程:检查 exec 后实际安装的可执行镜像 +- [lesson 55-dns-egress](src/55-dns-egress/README.zh.md) eBPF 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表 特性: diff --git a/TUTORIAL_IDEAS.md b/TUTORIAL_IDEAS.md index 9edbc07..2816f4f 100644 --- a/TUTORIAL_IDEAS.md +++ b/TUTORIAL_IDEAS.md @@ -51,13 +51,13 @@ This lesson teaches one reusable architecture: BPF captures stable kernel facts, | 90 | ready | Container-aware service dependency map | Trace `connect`, accept/state, and cgroup identity for three local services; emit named edges, latency, and failures | Combines concepts nearest to tutorials 13, 14, and `src/cgroup`, then adds two-sided workload identity and topology | [Coroot node agent](https://github.com/coroot/coroot-node-agent), [Inspektor Gadget](https://github.com/inspektor-gadget/inspektor-gadget), [Hubble](https://github.com/cilium/hubble) | | 88 | ready | Signed BPF program verification | Create a key and certificate, trust it through the kernel keyring, load a signed program, then show unknown-key and tampered-object failures | Adds BPF deployment integrity and supply-chain verification | [Linux 6.18 BPF merge](https://github.com/torvalds/linux/commit/ae28ed4578e6d5a481e39c5a9827f27048661fdd), [bpftool](https://github.com/libbpf/bpftool) | | 87 | ready | io_uring request latency and async-punt diagnosis | Correlate submit, io-wq execution, and completion; report latency by opcode, batching, and punt ratio | Observes the io_uring lifecycle rather than block-device access alone | [uringscope](https://github.com/rch0wdhury/uringscope), [liburing](https://github.com/axboe/liburing) | -| 85 | research | OOM pre-kill profile and cgroup attribution | Trigger a bounded cgroup OOM, identify the victim and memory limit, and preserve a useful pre-kill profile | Complements allocation leak tracking with evidence from the failure moment | [OOMProf](https://github.com/parca-dev/oomprof), [Coroot node agent](https://github.com/coroot/coroot-node-agent) | +| 85 | covered | OOM pre-kill profile and cgroup attribution | Profile memcg reclaim latency and kernel stacks, then correlate the accumulated profile with the selected victim and its exit | Complements allocation leak tracking with evidence from the failure moment | [OOMProf](https://github.com/parca-dev/oomprof), [Coroot node agent](https://github.com/coroot/coroot-node-agent) | | 83 | research | AI-agent runtime activity audit | Run a fixture in one cgroup and correlate its process tree, file writes, network connections, and tool subprocesses into a timeline | Applies stable tracing hooks to a new security and provenance scenario | [AgentSight](https://github.com/agent-sight/agentsight), [Tetragon](https://github.com/cilium/tetragon), [Tracee](https://github.com/aquasecurity/tracee), [Falco](https://github.com/falcosecurity/falco) | | 82 | ready | `sk_lookup` transparent local service router | Route a virtual TCP or UDP address to one of two existing sockets with `bpf_sk_assign()` and demonstrate failover | Introduces a program type and socket-selection point absent from current examples | [Linux sk_lookup selftest](https://github.com/torvalds/linux/blob/master/tools/testing/selftests/bpf/prog_tests/sk_lookup.c) | | 81 | research | LLM and MCP call timeline | Trace one local OpenAI-compatible streaming request and one MCP stdio tool call; report model, tool, TTFT, and total latency | Turns TLS and I/O tracing into an end-to-end agent scenario | [agtap](https://github.com/zhebrak/agtap), [OpenTelemetry eBPF Instrumentation](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation) | | 80 | research | Container-aware runtime policy | Attach a BPF LSM policy to selected workload identity and audit or deny one file or network action | Moves beyond a host-wide LSM example into workload identity and policy | [Tetragon](https://github.com/cilium/tetragon), [Tracee](https://github.com/aquasecurity/tracee), [Falco](https://github.com/falcosecurity/falco) | | 79 | ready | Sidecarless service traffic split | Use cgroup socket hooks to translate one service address to two local backends and expose the chosen backend in a map | Teaches socket-level service routing rather than the existing sockhash fast path | [Cilium](https://github.com/cilium/cilium), [Kmesh](https://github.com/kmesh-net/kmesh) | -| 78 | ready | DNS-aware egress policy with BPF netfilter or cgroup hooks | Permit one resolver or domain-derived address set, count policy decisions, and show one accepted and one rejected query | Adds a concrete egress-control scenario and a new attachment choice | [Cilium](https://github.com/cilium/cilium), [Linux BPF selftests](https://github.com/torvalds/linux/tree/master/tools/testing/selftests/bpf) | +| 78 | covered | DNS-derived IP allowlist with cgroup BPF | Correlate one domain's queries and responses, preserve A records for their TTL, and enforce the resulting IP allowlist at `connect4` | Adds a concrete egress-control scenario and a new attachment choice | [Cilium](https://github.com/cilium/cilium), [Linux BPF selftests](https://github.com/torvalds/linux/tree/master/tools/testing/selftests/bpf) | | 77 | research | OCI-packaged eBPF Gadget with workload enrichment | Package one small tracer as an OCI artifact, run it on a local container, and enrich kernel IDs with container names | Teaches portable distribution and enrichment instead of only local loading | [Inspektor Gadget](https://github.com/inspektor-gadget/inspektor-gadget), [bpfman](https://github.com/bpfman/bpfman) | | 76 | ready | XDP multi-buffer parsing with `bpf_xdp_pull_data()` | Place an L4 header across fragments, show the initial boundary, pull data, reacquire pointers, and parse it | Adds non-linear packet handling to the XDP series | [Linux XDP pull-data selftest](https://github.com/torvalds/linux/blob/master/tools/testing/selftests/bpf/progs/test_xdp_pull_data.c) | | 75 | research | Zero-code trace export to OpenTelemetry | Trace a small HTTP client/server pair without SDK changes and emit one OTLP span with process and network attributes | Connects BPF events to an open telemetry model | [OpenTelemetry eBPF Instrumentation](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation), [Odigos](https://github.com/odigos-io/odigos) | @@ -70,7 +70,7 @@ This lesson teaches one reusable architecture: BPF captures stable kernel facts, | 67 | deferred | Cross-language continuous profiling | Collect one mixed native and managed-runtime stack and symbolize it through build identity | Goes beyond stack sampling into unwinding and symbolization architecture | [OpenTelemetry eBPF Profiler](https://github.com/open-telemetry/opentelemetry-ebpf-profiler), [Parca](https://github.com/parca-dev/parca), [Pyroscope](https://github.com/grafana/pyroscope) | | 67 | research | BPF-controlled io_uring loop with `io_uring_bpf_ops` | Let a BPF struct_ops callback inspect completions and submit one follow-up operation without returning to the ordinary userspace loop | Demonstrates a new Linux 7.1 control surface | [Linux io_uring BPF source](https://github.com/torvalds/linux/blob/master/io_uring/bpf-ops.c), [Linux io_uring BPF selftests](https://github.com/torvalds/linux/tree/master/tools/testing/selftests/bpf) | | 65 | deferred | GPU causal stall diagnosis | Correlate CUDA synchronization latency with CPU scheduling and block-I/O events in one controlled workload | Extends CUDA event tracing from isolated calls to a causal chain | [OpenTelemetry eBPF Profiler](https://github.com/open-telemetry/opentelemetry-ebpf-profiler) | -| 64 | research | BPF object reference counting and rbtree traversal | Maintain ordered objects with explicit ownership, lookup, acquire, and release paths | Extends graph-object coverage beyond the qdisc list example | [Linux rbtree selftest](https://github.com/torvalds/linux/blob/master/tools/testing/selftests/bpf/progs/rbtree_search.c) | +| 64 | covered | BPF object reference counting and rbtree traversal | Index TC egress flows in identity and traffic-ordered rbtrees while both trees own the same refcounted BPF object | Extends graph-object coverage beyond the qdisc list example | [Linux rbtree selftest](https://github.com/torvalds/linux/blob/master/tools/testing/selftests/bpf/progs/rbtree_search.c) | | 63 | deferred | NCCL collective straggler timeline | Correlate collective duration, rank, CPU preemption, and retransmits across a two-rank fixture | Adds distributed GPU communication behavior | [OpenTelemetry eBPF Profiler](https://github.com/open-telemetry/opentelemetry-ebpf-profiler), [NCCL](https://github.com/NVIDIA/nccl) | | 58 | maintenance | Ring-buffer overwrite mode | Add a bounded producer/slow-consumer demonstration to the existing ring-buffer material | The mechanism is too small for a separate numbered lesson | [Linux BPF selftests](https://github.com/torvalds/linux/tree/master/tools/testing/selftests/bpf) | @@ -101,6 +101,10 @@ Keep these entries to prevent repeated proposals. | covered | `fsession` latency tracing | Active tutorial 52 | | covered | BPF qdisc with graph objects and list ownership | Active tutorial 53 | | covered | BPF task work and file-backed dynptr inspection after exec | Active tutorial 54 | +| covered | DNS-derived IP allowlist with query correlation and TTL enforcement | Active tutorial 55 | +| covered | BPF object, refcount, and dual-rbtree TC flow index | Active tutorial 56 | +| covered | Memcg reclaim latency and stack profiler correlated with OOM victims | Active tutorial 57 | +| covered | Basic single-buffer AF_XDP receive path and frame recycling | Active tutorial 60; multi-buffer and RX metadata remain a separate candidate | ## Decision history @@ -108,3 +112,4 @@ Keep these entries to prevent repeated proposals. - 2026-07-20: Selected the container-aware service dependency map as the highest overall value. It combines a common question, stable hooks, a clear visual result, broad reuse, and deterministic KVM execution. - 2026-07-20: Kept LLM/MCP tracing as the most timely candidate while scoring its protocol and runtime maintenance cost explicitly. - 2026-07-20: Removed Ingero as a primary source after its GitHub repository returned 404 during link validation; retained the GPU candidates with currently auditable OpenTelemetry profiler and NCCL sources. +- 2026-07-22: Marked the DNS-derived allowlist, OOM reclaim profiler, and BPF object/refcount/rbtree topics as covered by active tutorials 55–57. Recorded tutorial 60 as the basic AF_XDP prerequisite while keeping multi-buffer and RX metadata as a separate candidate. diff --git a/scripts/test_generate_toc.py b/scripts/test_generate_toc.py index 4609336..7b0c8ca 100644 --- a/scripts/test_generate_toc.py +++ b/scripts/test_generate_toc.py @@ -16,6 +16,7 @@ TRACING_LESSONS = { "src/40-mysql", "src/48-energy", "src/52-fsession-latency", + "src/57-oom-watch", } diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md index 3e2ad21..4298ccd 100644 --- a/src/55-dns-egress/README.md +++ b/src/55-dns-egress/README.md @@ -1,18 +1,24 @@ -# eBPF Tutorial by Example: DNS-Aware Egress Policy Enforcement +# eBPF Tutorial by Example: Build a DNS-Derived IP Allowlist with cgroup BPF -How do you allow TCP connections on one protected port only to IP addresses that your application just resolved through DNS? This tutorial builds a cgroup-based egress filter that learns IPs from DNS responses, allows matching connections while the DNS TTL is valid, and rejects other destinations on that port. The kernel enforces the policy at connect time. +Suppose a service may open HTTPS connections to `api.example.com`, while direct connections to every other address should be rejected. A static IP allowlist is a poor fit because DNS answers change and expire. A connect hook sees an IP address rather than the name that produced it. The missing piece is a short-lived link between the application's DNS exchange and its later `connect()` call. + +This tutorial builds that link as a minimal working policy tool. It watches one domain through one configured DNS resolver, learns a direct IPv4 A record only from a matching query and response, and allows connections to one TCP port until the DNS TTL expires. > Complete source code: -## eBPF and cgroup Hooks +## Where eBPF Enforces the Decision -eBPF lets verified programs run at Linux kernel hooks and send selected state to user space. This tutorial uses cgroup BPF hooks to intercept network packets and connection attempts for one cgroup. The `cgroup_skb/egress` and `cgroup_skb/ingress` hooks observe DNS traffic, while `cgroup/connect4` intercepts IPv4 connect calls. LRU hash maps keep the most recently used query and address entries within fixed capacities, and the BPF ring buffer, introduced in Linux 5.8, carries policy events to user space. This implementation therefore requires Linux 5.8 or later. +eBPF runs verifier-checked programs at Linux kernel hooks and can retain state in maps or send selected events to user space. cgroup BPF makes those hooks follow a workload: packet programs can inspect traffic entering and leaving a cgroup, while a socket-address program can accept or reject an IPv4 connection before the kernel sends it. -## How the Implementation Works +This example attaches three programs to the same cgroup. `cgroup_skb/egress` recognizes the configured DNS question and records a pending query. `cgroup_skb/ingress` accepts an address into the allowlist only after the response matches that query. `cgroup/connect4` makes the final decision for the protected TCP port. The ring buffer reports learned, allowed, denied, and expired decisions without participating in enforcement. -The tool attaches three BPF programs to a cgroup. The egress program records outgoing DNS queries by saving the transaction ID, client port, and server IP as a pending query. Queries expire after 5 seconds if no matching response arrives. The ingress program learns IPs from DNS responses by verifying the response matches a pending query and extracting the A record IP with its TTL. For TCP connections to the configured protected port, the connect hook checks whether the destination IP exists in the allowed list and has not expired. Other destination ports proceed unchanged. +Follow one successful exchange. The application sends an A query for `lab.test`. The egress hook saves the resolver address, client address, client UDP port, and DNS transaction ID for five seconds. When the reply returns, the ingress hook reconstructs the same key, checks the response flags and question, and reads the first direct A answer. It stores the returned address with an expiration time derived from the TTL. A later `connect()` to that address and the protected port succeeds while the entry is live. -## Header File +The query record is the trust boundary. An unsolicited response has no pending key, and a response with a different transaction ID searches for a different key. Neither can populate the allowlist. Expiration is checked again at connect time, so an address naturally stops working even if its LRU entry is still present. + +## Shared Protocol and Event Types + +The shared header contains the small DNS layouts parsed by the BPF programs and the fixed event format consumed in user space. ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -63,9 +69,11 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -The header defines DNS protocol structures used to parse packets. The `dns_header` structure matches the DNS message header format. The `dns_question` and `dns_a_answer` structures parse the query and answer sections. The `dns_egress_event` structure carries policy events (learned, allowed, denied, expired) to user space through the ring buffer. +The protocol structs are packed because they describe bytes on the wire. The event carries both the learned TTL and the absolute kernel expiration timestamp. The command prints the TTL, while the BPF side uses the monotonic timestamp for its decision. -## BPF Program +## The Three BPF Hooks + +Here is the complete kernel-side program. ```c // SPDX-License-Identifier: GPL-2.0 @@ -160,6 +168,53 @@ static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) return true; } +static __always_inline bool parse_query_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *key) +{ + struct udphdr udp; + struct iphdr ip; + __u32 ip_header_len; + + if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) + return false; + if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || + (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || + ip.daddr != dns_server_ip) + return false; + ip_header_len = ip.ihl * 4; + if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || + bpf_ntohs(udp.dest) != dns_server_port) + return false; + *dns_offset = ip_header_len + sizeof(udp); + key->server_ip = ip.daddr; + key->client_ip = ip.saddr; + key->client_port = udp.source; + return true; +} + +static __always_inline bool parse_dns_query(struct __sk_buff *skb, + __u32 dns_offset, + struct dns_query_key *key) +{ + struct dns_question question; + struct dns_header header; + __u16 flags; + + if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) + return false; + flags = bpf_ntohs(header.flags); + if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) + return false; + if (!matches_qname(skb, dns_offset + sizeof(header))) + return false; + dns_offset += sizeof(header) + configured_qname_length; + if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || + bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) + return false; + key->transaction_id = header.id; + return true; +} + SEC("cgroup_skb/egress") int record_dns_query(struct __sk_buff *skb) { @@ -167,107 +222,110 @@ int record_dns_query(struct __sk_buff *skb) .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, }; struct dns_query_key key = {}; - struct dns_question question; - struct dns_header header; + __u32 dns_offset; + + if (!parse_query_transport(skb, &dns_offset, &key) || + !parse_dns_query(skb, dns_offset, &key)) + return 1; + bpf_map_update_elem(&pending_queries, &key, &state, BPF_ANY); + return 1; +} + +static __always_inline bool parse_response_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *query_key) +{ struct udphdr udp; struct iphdr ip; __u32 ip_header_len; - __u32 dns_offset; - __u16 flags; if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) - return 1; + return false; if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || - ip.daddr != dns_server_ip) - return 1; + ip.saddr != dns_server_ip) + return false; ip_header_len = ip.ihl * 4; if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || - bpf_ntohs(udp.dest) != dns_server_port) - return 1; - dns_offset = ip_header_len + sizeof(udp); + bpf_ntohs(udp.source) != dns_server_port) + return false; + *dns_offset = ip_header_len + sizeof(udp); + query_key->server_ip = ip.saddr; + query_key->client_ip = ip.daddr; + query_key->client_port = udp.dest; + return true; +} + +static __always_inline bool pending_query_is_live( + struct dns_query_key *query_key) +{ + struct dns_query_state *query; + + query = bpf_map_lookup_elem(&pending_queries, query_key); + if (!query) + return false; + if (bpf_ktime_get_ns() >= query->expires_ns) { + bpf_map_delete_elem(&pending_queries, query_key); + return false; + } + return true; +} + +static __always_inline bool parse_response_question( + struct __sk_buff *skb, __u32 dns_offset, + struct dns_query_key *query_key, __u32 *answer_offset) +{ + struct dns_question question; + struct dns_header header; + if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) - return 1; - flags = bpf_ntohs(header.flags); - if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) - return 1; + return false; + query_key->transaction_id = header.id; + if (!pending_query_is_live(query_key)) + return false; + if ((bpf_ntohs(header.flags) & 0xf80f) != 0x8000 || + bpf_ntohs(header.questions) != 1 || !bpf_ntohs(header.answers)) + return false; if (!matches_qname(skb, dns_offset + sizeof(header))) - return 1; + return false; dns_offset += sizeof(header) + configured_qname_length; if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) - return 1; + return false; + *answer_offset = dns_offset + sizeof(question); + return true; +} - key.server_ip = ip.daddr; - key.client_ip = ip.saddr; - key.client_port = udp.source; - key.transaction_id = header.id; - bpf_map_update_elem(&pending_queries, &key, &state, BPF_ANY); - return 1; +static __always_inline bool parse_direct_a_answer(struct __sk_buff *skb, + __u32 answer_offset, + __u32 *key, __u32 *ttl) +{ + struct dns_a_answer answer; + + if (bpf_skb_load_bytes(skb, answer_offset, &answer, sizeof(answer)) || + bpf_ntohs(answer.name) != 0xc00c || bpf_ntohs(answer.type) != 1 || + bpf_ntohs(answer.class) != 1 || + bpf_ntohs(answer.address_length) != 4) + return false; + *key = answer.address; + *ttl = bpf_ntohl(answer.ttl); + return *ttl && *ttl <= 86400; } SEC("cgroup_skb/ingress") int learn_dns_answer(struct __sk_buff *skb) { - struct dns_a_answer answer; - struct dns_question question; - struct dns_header header; struct dns_query_key query_key = {}; - struct dns_query_state *query; struct dns_state state = {}; - struct udphdr udp; - struct iphdr ip; __u64 ttl_ns, expires; - __u32 ip_header_len; - __u32 dns_offset; - __u32 key; - __u32 ttl; + __u32 dns_offset, answer_offset; + __u32 key, ttl; - if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) - return 1; - if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || - (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || - ip.saddr != dns_server_ip) - return 1; - ip_header_len = ip.ihl * 4; - if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || - bpf_ntohs(udp.source) != dns_server_port) - return 1; - dns_offset = ip_header_len + sizeof(udp); - if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) - return 1; - query_key.server_ip = ip.saddr; - query_key.client_ip = ip.daddr; - query_key.client_port = udp.dest; - query_key.transaction_id = header.id; - query = bpf_map_lookup_elem(&pending_queries, &query_key); - if (!query) - return 1; - if (bpf_ktime_get_ns() >= query->expires_ns) { - bpf_map_delete_elem(&pending_queries, &query_key); - return 1; - } - if ((bpf_ntohs(header.flags) & 0xf80f) != 0x8000 || - bpf_ntohs(header.questions) != 1 || !bpf_ntohs(header.answers)) - return 1; - if (!matches_qname(skb, dns_offset + sizeof(header))) - return 1; - dns_offset += sizeof(header) + configured_qname_length; - if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || - bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) - return 1; - dns_offset += sizeof(question); - if (bpf_skb_load_bytes(skb, dns_offset, &answer, sizeof(answer)) || - bpf_ntohs(answer.name) != 0xc00c || bpf_ntohs(answer.type) != 1 || - bpf_ntohs(answer.class) != 1 || - bpf_ntohs(answer.address_length) != 4) + if (!parse_response_transport(skb, &dns_offset, &query_key) || + !parse_response_question(skb, dns_offset, &query_key, + &answer_offset) || + !parse_direct_a_answer(skb, answer_offset, &key, &ttl)) return 1; bpf_map_delete_elem(&pending_queries, &query_key); - - key = answer.address; - ttl = bpf_ntohl(answer.ttl); - if (!ttl || ttl > 86400) - return 1; ttl_ns = (__u64)ttl * 1000000000ULL; expires = bpf_ktime_get_ns() + ttl_ns; state.expires_ns = expires; @@ -309,15 +367,17 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -The BPF program uses three maps. The `pending_queries` LRU hash tracks outgoing DNS queries using a composite key of server IP, client IP, client port, and transaction ID. The `allowed_ips` LRU hash stores learned IPs with their expiration timestamps. The `events` ring buffer sends policy decisions to user space. +`pending_queries` and `allowed_ips` are LRU hash maps with fixed capacities. The first map holds correlation state for at most five seconds. The second holds admitted IPv4 addresses for their DNS lifetime. LRU eviction keeps memory bounded; it also means this example favors recently active queries and addresses when either map reaches 1024 entries. -The `record_dns_query` function runs on cgroup egress and captures DNS queries to the configured resolver. It validates the packet is IPv4 UDP to the correct server and port, checks the query name matches, and stores the query with a 5-second expiration. +The egress path first validates IPv4, UDP, fragmentation state, resolver address, and resolver port. It then checks a standard one-question A query for the exact encoded name. Only after those checks does `record_dns_query()` insert the four-field correlation key. Packet observation itself returns `1`, so DNS traffic continues normally. -The `learn_dns_answer` function runs on cgroup ingress and parses DNS responses. It looks up the matching pending query, validates the response format, extracts the IP address and TTL from the first A record, and stores the IP in the allowed list with the TTL-based expiration. +On ingress, parsing starts with the reverse transport tuple. `parse_response_question()` adds the transaction ID, requires a live pending entry, and verifies a successful response containing the configured question. `parse_direct_a_answer()` accepts the compact `0xc00c` name pointer, class IN, type A, four-byte address, and a TTL from 1 to 86400 seconds. Once the answer passes, the pending query is consumed and the IP becomes eligible for connections. -The `enforce_dns_policy` function runs when a process calls `connect()`. IPv4 TCP connections to the configured protected port are checked against the allowed list and its expiration timestamps. A successful lookup returns 1 to permit the connection; a missing or stale entry returns 0, which makes `connect()` fail with `EPERM`. Connections to other ports return 1 immediately. +`enforce_dns_policy()` stays narrow. TCP connects to other destination ports pass immediately. A connect to the protected port looks up `ctx->user_ip4`; a live entry returns `1`, while a missing or expired entry returns `0`, which surfaces to the application as `EPERM`. The compare-and-swap on `expired_reported` keeps the expiry notification to one event even when several threads race on the stale address. -## User Space Program +## Loading the Policy and Exercising Its Trust Chain + +The complete user-space program configures the read-only BPF data, attaches all three programs to one cgroup, and consumes ring-buffer events. ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -353,6 +413,15 @@ struct options { bool demo; }; +struct dns_runtime { + struct dns_egress_bpf *skel; + struct bpf_link *query_link; + struct bpf_link *ingress_link; + struct bpf_link *connect_link; + struct ring_buffer *ring; + int cgroup_fd; +}; + static int event_counts[5]; static volatile sig_atomic_t stop; @@ -409,6 +478,42 @@ static void usage(const char *program) " %s --demo\n", program, program); } +static int apply_option(int option, const char *program, + struct options *options) +{ + switch (option) { + case 'c': options->cgroup_path = optarg; return 0; + case 'n': options->domain = optarg; return 0; + case 'r': options->dns_server = optarg; return 0; + case 'p': + return parse_uint(optarg, 65535, &options->port) || !options->port ? + -1 : 0; + case 's': + return parse_uint(optarg, 65535, &options->dns_port) || + !options->dns_port ? -1 : 0; + case 'd': + return parse_uint(optarg, 86400, &options->duration_seconds); + case 'D': options->demo = true; return 0; + case 'h': usage(program); exit(0); + default: return -1; + } +} + +static int finish_options(struct options *options) +{ + if (!options->demo) + return options->cgroup_path && options->domain && + options->dns_server ? 0 : -1; + if (options->cgroup_path || options->domain || options->dns_server) + return -1; + options->cgroup_path = "/sys/fs/cgroup"; + options->domain = "lab.test"; + options->dns_server = "127.0.0.1"; + options->port = DEMO_TCP_PORT; + options->dns_port = DEMO_DNS_PORT; + return 0; +} + static int parse_options(int argc, char **argv, struct options *options) { static const struct option long_options[] = { @@ -425,45 +530,10 @@ static int parse_options(int argc, char **argv, struct options *options) int option; while ((option = getopt_long(argc, argv, "c:n:r:p:s:d:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'c': options->cgroup_path = optarg; break; - case 'n': options->domain = optarg; break; - case 'r': options->dns_server = optarg; break; - case 'p': - if (parse_uint(optarg, 65535, &options->port) || - !options->port) - return -1; - break; - case 's': - if (parse_uint(optarg, 65535, &options->dns_port) || - !options->dns_port) - return -1; - break; - case 'd': - if (parse_uint(optarg, 86400, - &options->duration_seconds)) - return -1; - break; - case 'D': options->demo = true; break; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - if (optind != argc) - return -1; - if (options->demo) { - if (options->cgroup_path || options->domain || options->dns_server) + NULL)) != -1) + if (apply_option(option, argv[0], options)) return -1; - options->cgroup_path = "/sys/fs/cgroup"; - options->domain = "lab.test"; - options->dns_server = "127.0.0.1"; - options->port = DEMO_TCP_PORT; - options->dns_port = DEMO_DNS_PORT; - return 0; - } - return options->cgroup_path && options->domain && options->dns_server ? - 0 : -1; + return optind == argc ? finish_options(options) : -1; } static int encode_qname(const char *domain, unsigned char output[DNS_QNAME_MAX], @@ -663,18 +733,64 @@ static int complete_tcp(int listener, int client) { int accepted = accept4(listener, NULL, NULL, SOCK_CLOEXEC); char byte = 'x'; + int result = 0; - if (accepted < 0) - return -1; - if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) { - close(accepted); + if (accepted < 0) { + close(client); return -1; } + if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) + result = -1; close(accepted); close(client); + return result; +} + +static int poll_demo_events(struct ring_buffer *ring) +{ + int result = ring_buffer__poll(ring, 100); + + return result < 0 && result != -EINTR ? -1 : 0; +} + +static int expect_blocked_connect(struct ring_buffer *ring, + unsigned int port, const char *step) +{ + int client; + + errno = 0; + client = connect_tcp(port); + if (client >= 0) { + close(client); + return -1; + } + if (errno != EPERM || poll_demo_events(ring)) + return -1; + printf("demo step=%s result=blocked\n", step); return 0; } +static int expect_allowed_connect(struct ring_buffer *ring, int listener, + unsigned int port) +{ + int client = connect_tcp(port); + + if (client < 0 || complete_tcp(listener, client)) + return -1; + if (poll_demo_events(ring)) + return -1; + printf("demo step=live-answer result=allowed\n"); + return 0; +} + +static int expected_demo_events(void) +{ + return event_counts[DNS_LEARNED] == 1 && + event_counts[DNS_ALLOWED] == 1 && + event_counts[DNS_DENIED] == 4 && + event_counts[DNS_EXPIRED] == 1 ? 0 : -1; +} + static int run_demo(struct ring_buffer *ring, const struct options *options, const unsigned char *qname, unsigned int qname_length) { @@ -691,7 +807,7 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, struct sockaddr_in response_client_address; unsigned char dns_message[512]; size_t query_length; - int dns_server = -1, dns_client = -1, listener = -1, client = -1; + int dns_server = -1, dns_client = -1, listener = -1; int err = -1; dns_server = bind_udp(&server_address); @@ -700,22 +816,14 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, if (dns_server < 0 || dns_client < 0 || listener < 0) goto cleanup; - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (expect_blocked_connect(ring, options->port, "before-dns")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=before-dns result=blocked\n"); if (send_unsolicited_dns(dns_server, dns_client, qname, qname_length)) goto cleanup; - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "unsolicited-response")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=unsolicited-response result=blocked\n"); if (begin_dns_exchange(dns_server, dns_client, &server_address, qname, qname_length, dns_message, &query_length, @@ -723,59 +831,128 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, send_dns_answer(dns_server, dns_client, &response_client_address, dns_message, query_length, DNS_ID + 1, 30)) goto cleanup; - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "wrong-transaction-id")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=wrong-transaction-id result=blocked\n"); if (send_dns_answer(dns_server, dns_client, &response_client_address, dns_message, query_length, DNS_ID, 1)) goto cleanup; - ring_buffer__poll(ring, 100); - client = connect_tcp(options->port); - if (client < 0 || complete_tcp(listener, client)) + if (poll_demo_events(ring) || + expect_allowed_connect(ring, listener, options->port)) goto cleanup; - client = -1; - ring_buffer__poll(ring, 100); - printf("demo step=live-answer result=allowed\n"); nanosleep(&wait_time, NULL); - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "expired-answer")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=expired-answer result=blocked\n"); - if (event_counts[DNS_LEARNED] != 1 || event_counts[DNS_ALLOWED] != 1 || - event_counts[DNS_DENIED] != 4 || event_counts[DNS_EXPIRED] != 1) + if (expected_demo_events()) goto cleanup; err = 0; cleanup: - if (client >= 0) close(client); if (listener >= 0) close(listener); if (dns_client >= 0) close(dns_client); if (dns_server >= 0) close(dns_server); return err; } +static bool link_failed(struct bpf_link **link) +{ + if (!libbpf_get_error(*link)) + return false; + *link = NULL; + return true; +} + +static int prepare_runtime(struct dns_runtime *runtime, + const struct options *options, + const struct in_addr *dns_server, + const unsigned char *qname, + unsigned int qname_length) +{ + bool failed; + + runtime->cgroup_fd = open(options->cgroup_path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (runtime->cgroup_fd < 0) { + fprintf(stderr, "failed to open cgroup %s: %s\n", + options->cgroup_path, strerror(errno)); + return -1; + } + runtime->skel = dns_egress_bpf__open(); + if (!runtime->skel) + return -1; + runtime->skel->rodata->target_tgid = options->demo ? getpid() : 0; + runtime->skel->rodata->dns_server_ip = dns_server->s_addr; + runtime->skel->rodata->dns_server_port = options->dns_port; + runtime->skel->rodata->protected_tcp_port = options->port; + runtime->skel->rodata->configured_qname_length = qname_length; + memcpy((void *)runtime->skel->rodata->configured_qname, qname, + qname_length); + if (dns_egress_bpf__load(runtime->skel)) { + fprintf(stderr, "failed to load DNS egress BPF programs\n"); + return -1; + } + runtime->query_link = bpf_program__attach_cgroup( + runtime->skel->progs.record_dns_query, runtime->cgroup_fd); + runtime->ingress_link = bpf_program__attach_cgroup( + runtime->skel->progs.learn_dns_answer, runtime->cgroup_fd); + runtime->connect_link = bpf_program__attach_cgroup( + runtime->skel->progs.enforce_dns_policy, runtime->cgroup_fd); + failed = link_failed(&runtime->query_link); + failed |= link_failed(&runtime->ingress_link); + failed |= link_failed(&runtime->connect_link); + if (failed) { + fprintf(stderr, "failed to attach programs to cgroup %s\n", + options->cgroup_path); + return -1; + } + runtime->ring = ring_buffer__new( + bpf_map__fd(runtime->skel->maps.events), handle_event, NULL, NULL); + return runtime->ring ? 0 : -1; +} + +static int poll_policy_events(struct ring_buffer *ring, + unsigned int duration_seconds) +{ + unsigned long long deadline = 0; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)duration_seconds * 1000000000ULL; + while (!stop && (!deadline || monotonic_ns() < deadline)) { + int result = ring_buffer__poll(ring, 100); + + if (result < 0 && result != -EINTR) { + fprintf(stderr, "ring buffer poll failed: %d\n", result); + return -1; + } + } + return 0; +} + +static void destroy_runtime(struct dns_runtime *runtime) +{ + ring_buffer__free(runtime->ring); + bpf_link__destroy(runtime->connect_link); + bpf_link__destroy(runtime->ingress_link); + bpf_link__destroy(runtime->query_link); + if (runtime->cgroup_fd >= 0) + close(runtime->cgroup_fd); + dns_egress_bpf__destroy(runtime->skel); +} + int main(int argc, char **argv) { struct options options = { .port = 443, .dns_port = 53 }; - struct dns_egress_bpf *skel = NULL; - struct bpf_link *query_link = NULL, *ingress_link = NULL; - struct bpf_link *connect_link = NULL; - struct ring_buffer *ring = NULL; + struct dns_runtime runtime = { .cgroup_fd = -1 }; struct in_addr dns_server = {}; unsigned char qname[DNS_QNAME_MAX] = {}; - unsigned long long deadline = 0; unsigned int qname_length = 0; - int cgroup_fd = -1; int err = 1; setvbuf(stdout, NULL, _IONBF, 0); @@ -785,109 +962,56 @@ int main(int argc, char **argv) usage(argv[0]); return 2; } - cgroup_fd = open(options.cgroup_path, - O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (cgroup_fd < 0) { - fprintf(stderr, "failed to open cgroup %s: %s\n", - options.cgroup_path, strerror(errno)); - goto cleanup; - } - - skel = dns_egress_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->target_tgid = options.demo ? getpid() : 0; - skel->rodata->dns_server_ip = dns_server.s_addr; - skel->rodata->dns_server_port = options.dns_port; - skel->rodata->protected_tcp_port = options.port; - skel->rodata->configured_qname_length = qname_length; - memcpy((void *)skel->rodata->configured_qname, qname, qname_length); - if (dns_egress_bpf__load(skel)) { - fprintf(stderr, "failed to load DNS egress BPF programs\n"); - goto cleanup; - } - query_link = bpf_program__attach_cgroup(skel->progs.record_dns_query, - cgroup_fd); - ingress_link = bpf_program__attach_cgroup(skel->progs.learn_dns_answer, - cgroup_fd); - connect_link = bpf_program__attach_cgroup(skel->progs.enforce_dns_policy, - cgroup_fd); - if (libbpf_get_error(query_link) || libbpf_get_error(ingress_link) || - libbpf_get_error(connect_link)) { - fprintf(stderr, "failed to attach programs to cgroup %s\n", - options.cgroup_path); - query_link = libbpf_get_error(query_link) ? NULL : query_link; - ingress_link = libbpf_get_error(ingress_link) ? NULL : ingress_link; - connect_link = libbpf_get_error(connect_link) ? NULL : connect_link; - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + if (prepare_runtime(&runtime, &options, &dns_server, qname, + qname_length)) goto cleanup; printf("dns-egress attached cgroup=%s domain=%s resolver=%s tcp_port=%u dns_port=%u\n", options.cgroup_path, options.domain, options.dns_server, options.port, options.dns_port); if (options.demo) { - if (run_demo(ring, &options, qname, qname_length)) + if (run_demo(runtime.ring, &options, qname, qname_length)) goto cleanup; - } else { - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * - 1000000000ULL; - while (!stop && (!deadline || monotonic_ns() < deadline)) { - int poll_result = ring_buffer__poll(ring, 100); - - if (poll_result < 0 && poll_result != -EINTR) { - fprintf(stderr, "ring buffer poll failed: %d\n", - poll_result); - goto cleanup; - } - } - } + } else if (poll_policy_events(runtime.ring, options.duration_seconds)) + goto cleanup; err = 0; cleanup: - ring_buffer__free(ring); - bpf_link__destroy(connect_link); - bpf_link__destroy(ingress_link); - bpf_link__destroy(query_link); - if (cgroup_fd >= 0) close(cgroup_fd); - dns_egress_bpf__destroy(skel); + destroy_runtime(&runtime); return err; } ``` -The user space program loads and attaches the BPF programs to a cgroup. It encodes the domain name in DNS label format, configures the BPF programs with the target resolver and protected port, and polls the ring buffer for policy events. +The domain is converted to DNS label form before the BPF object is loaded. For example, `lab.test` becomes `\x03lab\x04test\x00`, the exact byte sequence compared in the packet hooks. The configured resolver and ports are written into skeleton `rodata`, which makes them constants from the verifier's point of view. -The demo mode runs a self-contained test: it creates local UDP and TCP sockets, simulates DNS resolution, and verifies the policy works correctly. It tests connection blocking before DNS, rejection of unsolicited responses, rejection of wrong transaction IDs, connection allowing after valid DNS, and connection blocking after TTL expiry. +Normal mode keeps the links alive and prints policy decisions until the duration ends or a signal arrives. Demo mode runs the whole trust chain with loopback sockets. It first proves that a connection is blocked, sends an unsolicited answer and a wrong-ID answer, admits a correctly correlated one-second answer, then waits for that TTL to expire. These are useful checks because a simple “parse every DNS response” implementation would pass the happy path while remaining easy to poison. -## Compilation and Execution +## Build and Run -Build the example: +Build the example with the repository's vendored libbpf and bpftool: ```bash cd src/55-dns-egress make ``` -Run with the required parameters: +Attach it to a service cgroup, watch one domain through its actual resolver, and protect TCP port 443: ```bash -sudo ./dns_egress --cgroup /sys/fs/cgroup/my-service --domain api.example.com --dns-server 127.0.0.53 --port 443 +sudo ./dns_egress \ + --cgroup /sys/fs/cgroup/my-service \ + --domain api.example.com \ + --dns-server 127.0.0.53 \ + --port 443 ``` -Or run the built-in demo: +The cgroup must contain the workload whose DNS packets and connections should share the policy state. TCP port 443 and DNS port 53 are the defaults; `--dns-port` selects another resolver port, and `--duration` adds a time limit. The built-in demo needs no external DNS server: ```bash sudo ./dns_egress --demo ``` -Example demo output: +A real demo run looks like this: ```text dns-egress attached cgroup=/sys/fs/cgroup domain=lab.test resolver=127.0.0.1 tcp_port=19090 dns_port=15353 @@ -905,27 +1029,31 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1 demo step=expired-answer result=blocked ``` +The three early `denied` events show that neither receiving DNS-shaped traffic nor seeing the right name is sufficient. `learned` appears only for the correlated response, `allowed` covers its live TTL, and `expired` is immediately followed by the denied connect. + ## Requirements | Requirement | Details | -|-------------|---------| -| Kernel | Linux 5.8+ (BPF ring buffer) | -| Config | `CONFIG_BPF_SYSCALL`, `CONFIG_CGROUP_BPF`, `CONFIG_DEBUG_INFO_BTF` | -| Privileges | Root | -| cgroup | cgroup v2 mounted | +|---|---| +| Kernel | Linux 5.8 or newer; the newest dependency is BPF ring buffer support | +| Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_CGROUP_BPF`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_INET` | +| cgroup | cgroup v2, with the target workload placed below the attached directory | +| Privileges | Root, or an equivalent set of BPF and network-administration capabilities | +| Architecture and hardware | x86-64 is the declared and tested target; no special network hardware | -## Scope and Limitations +## Scope -This implementation protects one configured TCP destination port and leaves other ports unchanged. It supports one exact query name configured at startup, IPv4 UDP DNS only, and parses direct first A record answers. Pending queries expire after 5 seconds. Allowed IPs use a 1024-entry LRU hash with DNS TTL. It does not handle CNAME chains, multiple answer layouts, TCP DNS, IPv6, DNS-over-HTTPS, or DNS-over-TLS. +The tool deliberately implements one exact domain, one resolver, one protected TCP port, IPv4 UDP DNS, and the first direct A answer. It understands the common compressed owner name `0xc00c`. CNAME chains, several answer layouts, TCP DNS, IPv6, DoH, and DoT need additional parsers or observation points. This compact scope keeps the important policy property visible: an IP enters the allowlist through a recent matching query and leaves it through DNS time. ## Summary -By correlating each DNS response with a recent query and carrying its TTL into connect-time policy, this example turns one domain name into a short-lived kernel allowlist. The three cgroup hooks separate DNS observation from connection enforcement, while ring buffer events make every decision visible in user space. +This example turns an observed DNS result into a time-bounded connect policy. The egress and ingress hooks establish a trustworthy query-response relation, the TTL controls the address lifetime, and the connect hook enforces the result for the workload's protected port. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . ## References -- [BPF ring buffer documentation](https://docs.kernel.org/6.6/bpf/ringbuf.html) -- [BPF hash map documentation](https://docs.kernel.org/bpf/map_hash.html) -- [cgroup BPF commit](https://github.com/torvalds/linux/commit/d74bad4e74ee) +- [BPF ring buffer](https://docs.kernel.org/bpf/ringbuf.html) +- [BPF LRU hash maps](https://docs.kernel.org/bpf/map_hash.html) +- [Control Group v2](https://docs.kernel.org/admin-guide/cgroup-v2.html) +- [RFC 1035: Domain Names — Implementation and Specification](https://www.rfc-editor.org/rfc/rfc1035.html) diff --git a/src/55-dns-egress/README.zh.md b/src/55-dns-egress/README.zh.md index c5e1547..290eca9 100644 --- a/src/55-dns-egress/README.zh.md +++ b/src/55-dns-egress/README.zh.md @@ -1,18 +1,24 @@ -# eBPF 实战教程:DNS 感知的出口策略执行 +# eBPF 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表 -如何让一个受保护端口只连接到应用程序刚通过 DNS 解析的 IP 地址?本教程构建一个基于 cgroup 的出口过滤器,从 DNS 响应中学习 IP 地址,在 DNS TTL 有效期间允许匹配的 TCP 连接,并拒绝这个端口上的其他目的地址,策略在 connect 时由内核执行。 +假设一个服务只能通过 HTTPS 访问 `api.example.com`,其余目的地址都需要拒绝。静态 IP 允许列表很难完成这件事,因为 DNS 应答会变化,也会过期;到了 `connect()` 阶段,内核看到的又只是一个 IP 地址,并不知道它来自哪个域名。这里缺少的是一条短期关联,把应用刚刚完成的 DNS 解析和随后的连接连起来。 + +本课会把这条关联做成一个最小可运行的策略工具。它通过指定的 DNS 解析器观察一个域名,只从匹配的查询与响应中学习 IPv4 A 记录,并在 DNS TTL 有效期间允许访问一个 TCP 端口。 > 完整源代码: -## eBPF 与 cgroup 钩子 +## eBPF 在哪里执行策略 -eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把选出的状态发送到用户态。本教程使用 cgroup BPF hook 拦截一个 cgroup 的网络报文和连接请求,`cgroup_skb/egress` 与 `cgroup_skb/ingress` 观察 DNS 流量,`cgroup/connect4` 拦截 IPv4 connect 调用。LRU 哈希映射让查询和地址条目保持在固定容量内,Linux 5.8 引入的 BPF ring buffer 则把策略事件交给用户态,因此本例需要 Linux 5.8 或更高版本。 +eBPF 可以让经过验证器检查的程序运行在 Linux 内核 hook 上,通过 map 保留状态,也可以把选出的事件交给用户态。cgroup BPF 让这些 hook 跟随一组工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在内核发起 IPv4 连接之前允许或拒绝这次请求。 -## 实现原理 +这个例子在同一个 cgroup 上挂载三个程序。`cgroup_skb/egress` 识别指定的 DNS 问题并记录待匹配查询,`cgroup_skb/ingress` 只从与查询对应的响应中接纳地址,`cgroup/connect4` 对受保护的 TCP 端口作最终判断。ring buffer 负责报告 learned、allowed、denied 和 expired 事件,并不参与策略结果。 -该工具将三个 BPF 程序附加到 cgroup。出口程序把事务 ID、客户端端口和服务器 IP 保存为待处理查询,用于记录出站 DNS 请求,5 秒内没有匹配响应的查询会过期。入口程序验证响应与待处理查询的关联,再提取 A 记录 IP 和 TTL。对于发往受保护端口的 TCP 连接,connect hook 检查目标 IP 是否在允许列表中并且仍然有效,其他目的端口保持原有行为。 +先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存解析器地址、客户端地址、客户端 UDP 端口和 DNS transaction ID,这份记录有效 5 秒。响应回来后,ingress hook 用反向报文重建同一个 key,检查响应标志与 question,再读取第一个直接 A 应答。通过检查的地址会和 TTL 推导出的过期时间一起进入允许列表,随后发往这个地址和受保护端口的 `connect()` 可以在 TTL 内成功。 -## 头文件 +待匹配查询就是这里的信任边界。未经请求的响应没有 pending key,transaction ID 不同的响应也会查到另一个 key,因此两者都无法写入允许列表。connect hook 还会再次比较过期时间,即使 LRU map 中仍保留着旧条目,地址也会按 DNS 时间自然失效。 + +## DNS 协议与事件结构 + +共享头文件包含 BPF 程序需要解析的少量 DNS 布局,以及用户态接收的固定事件格式。 ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -63,9 +69,11 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -头文件定义了用于解析数据包的 DNS 协议结构。`dns_header` 结构匹配 DNS 消息头格式。`dns_question` 和 `dns_a_answer` 结构解析查询和应答部分。`dns_egress_event` 结构通过 ring buffer 将策略事件(learned、allowed、denied、expired)传递给用户空间。 +这些协议结构直接描述线上字节,因此使用 packed 布局。事件同时携带 DNS TTL 和内核单调时钟下的绝对过期时间,命令行展示 TTL,BPF 程序则用时间戳作策略判断。 -## BPF 程序 +## 三个 BPF hook + +下面是完整的内核态程序。 ```c // SPDX-License-Identifier: GPL-2.0 @@ -160,6 +168,53 @@ static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) return true; } +static __always_inline bool parse_query_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *key) +{ + struct udphdr udp; + struct iphdr ip; + __u32 ip_header_len; + + if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) + return false; + if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || + (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || + ip.daddr != dns_server_ip) + return false; + ip_header_len = ip.ihl * 4; + if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || + bpf_ntohs(udp.dest) != dns_server_port) + return false; + *dns_offset = ip_header_len + sizeof(udp); + key->server_ip = ip.daddr; + key->client_ip = ip.saddr; + key->client_port = udp.source; + return true; +} + +static __always_inline bool parse_dns_query(struct __sk_buff *skb, + __u32 dns_offset, + struct dns_query_key *key) +{ + struct dns_question question; + struct dns_header header; + __u16 flags; + + if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) + return false; + flags = bpf_ntohs(header.flags); + if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) + return false; + if (!matches_qname(skb, dns_offset + sizeof(header))) + return false; + dns_offset += sizeof(header) + configured_qname_length; + if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || + bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) + return false; + key->transaction_id = header.id; + return true; +} + SEC("cgroup_skb/egress") int record_dns_query(struct __sk_buff *skb) { @@ -167,107 +222,110 @@ int record_dns_query(struct __sk_buff *skb) .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, }; struct dns_query_key key = {}; - struct dns_question question; - struct dns_header header; + __u32 dns_offset; + + if (!parse_query_transport(skb, &dns_offset, &key) || + !parse_dns_query(skb, dns_offset, &key)) + return 1; + bpf_map_update_elem(&pending_queries, &key, &state, BPF_ANY); + return 1; +} + +static __always_inline bool parse_response_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *query_key) +{ struct udphdr udp; struct iphdr ip; __u32 ip_header_len; - __u32 dns_offset; - __u16 flags; if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) - return 1; + return false; if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || - ip.daddr != dns_server_ip) - return 1; + ip.saddr != dns_server_ip) + return false; ip_header_len = ip.ihl * 4; if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || - bpf_ntohs(udp.dest) != dns_server_port) - return 1; - dns_offset = ip_header_len + sizeof(udp); + bpf_ntohs(udp.source) != dns_server_port) + return false; + *dns_offset = ip_header_len + sizeof(udp); + query_key->server_ip = ip.saddr; + query_key->client_ip = ip.daddr; + query_key->client_port = udp.dest; + return true; +} + +static __always_inline bool pending_query_is_live( + struct dns_query_key *query_key) +{ + struct dns_query_state *query; + + query = bpf_map_lookup_elem(&pending_queries, query_key); + if (!query) + return false; + if (bpf_ktime_get_ns() >= query->expires_ns) { + bpf_map_delete_elem(&pending_queries, query_key); + return false; + } + return true; +} + +static __always_inline bool parse_response_question( + struct __sk_buff *skb, __u32 dns_offset, + struct dns_query_key *query_key, __u32 *answer_offset) +{ + struct dns_question question; + struct dns_header header; + if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) - return 1; - flags = bpf_ntohs(header.flags); - if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) - return 1; + return false; + query_key->transaction_id = header.id; + if (!pending_query_is_live(query_key)) + return false; + if ((bpf_ntohs(header.flags) & 0xf80f) != 0x8000 || + bpf_ntohs(header.questions) != 1 || !bpf_ntohs(header.answers)) + return false; if (!matches_qname(skb, dns_offset + sizeof(header))) - return 1; + return false; dns_offset += sizeof(header) + configured_qname_length; if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) - return 1; + return false; + *answer_offset = dns_offset + sizeof(question); + return true; +} - key.server_ip = ip.daddr; - key.client_ip = ip.saddr; - key.client_port = udp.source; - key.transaction_id = header.id; - bpf_map_update_elem(&pending_queries, &key, &state, BPF_ANY); - return 1; +static __always_inline bool parse_direct_a_answer(struct __sk_buff *skb, + __u32 answer_offset, + __u32 *key, __u32 *ttl) +{ + struct dns_a_answer answer; + + if (bpf_skb_load_bytes(skb, answer_offset, &answer, sizeof(answer)) || + bpf_ntohs(answer.name) != 0xc00c || bpf_ntohs(answer.type) != 1 || + bpf_ntohs(answer.class) != 1 || + bpf_ntohs(answer.address_length) != 4) + return false; + *key = answer.address; + *ttl = bpf_ntohl(answer.ttl); + return *ttl && *ttl <= 86400; } SEC("cgroup_skb/ingress") int learn_dns_answer(struct __sk_buff *skb) { - struct dns_a_answer answer; - struct dns_question question; - struct dns_header header; struct dns_query_key query_key = {}; - struct dns_query_state *query; struct dns_state state = {}; - struct udphdr udp; - struct iphdr ip; __u64 ttl_ns, expires; - __u32 ip_header_len; - __u32 dns_offset; - __u32 key; - __u32 ttl; + __u32 dns_offset, answer_offset; + __u32 key, ttl; - if (bpf_skb_load_bytes(skb, 0, &ip, sizeof(ip))) - return 1; - if (ip.version != 4 || ip.protocol != IPPROTO_UDP || ip.ihl < 5 || - (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || - ip.saddr != dns_server_ip) - return 1; - ip_header_len = ip.ihl * 4; - if (bpf_skb_load_bytes(skb, ip_header_len, &udp, sizeof(udp)) || - bpf_ntohs(udp.source) != dns_server_port) - return 1; - dns_offset = ip_header_len + sizeof(udp); - if (bpf_skb_load_bytes(skb, dns_offset, &header, sizeof(header))) - return 1; - query_key.server_ip = ip.saddr; - query_key.client_ip = ip.daddr; - query_key.client_port = udp.dest; - query_key.transaction_id = header.id; - query = bpf_map_lookup_elem(&pending_queries, &query_key); - if (!query) - return 1; - if (bpf_ktime_get_ns() >= query->expires_ns) { - bpf_map_delete_elem(&pending_queries, &query_key); - return 1; - } - if ((bpf_ntohs(header.flags) & 0xf80f) != 0x8000 || - bpf_ntohs(header.questions) != 1 || !bpf_ntohs(header.answers)) - return 1; - if (!matches_qname(skb, dns_offset + sizeof(header))) - return 1; - dns_offset += sizeof(header) + configured_qname_length; - if (bpf_skb_load_bytes(skb, dns_offset, &question, sizeof(question)) || - bpf_ntohs(question.type) != 1 || bpf_ntohs(question.class) != 1) - return 1; - dns_offset += sizeof(question); - if (bpf_skb_load_bytes(skb, dns_offset, &answer, sizeof(answer)) || - bpf_ntohs(answer.name) != 0xc00c || bpf_ntohs(answer.type) != 1 || - bpf_ntohs(answer.class) != 1 || - bpf_ntohs(answer.address_length) != 4) + if (!parse_response_transport(skb, &dns_offset, &query_key) || + !parse_response_question(skb, dns_offset, &query_key, + &answer_offset) || + !parse_direct_a_answer(skb, answer_offset, &key, &ttl)) return 1; bpf_map_delete_elem(&pending_queries, &query_key); - - key = answer.address; - ttl = bpf_ntohl(answer.ttl); - if (!ttl || ttl > 86400) - return 1; ttl_ns = (__u64)ttl * 1000000000ULL; expires = bpf_ktime_get_ns() + ttl_ns; state.expires_ns = expires; @@ -309,15 +367,17 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -BPF 程序使用三个映射。`pending_queries` LRU 哈希使用服务器 IP、客户端 IP、客户端端口和事务 ID 的组合键跟踪出站 DNS 查询。`allowed_ips` LRU 哈希存储学习到的 IP 及其过期时间戳。`events` ring buffer 将策略决策发送到用户空间。 +`pending_queries` 和 `allowed_ips` 都是固定容量的 LRU hash map。前者保存最多 5 秒的查询关联状态,后者保存处于 DNS 生命周期内的 IPv4 地址。LRU 淘汰把内存开销限制在确定范围内;当条目超过 1024 个时,最近活跃的查询和地址也会得到优先保留。 -`record_dns_query` 函数在 cgroup 出口运行,捕获发往配置的解析器的 DNS 查询。它验证数据包是发往正确服务器和端口的 IPv4 UDP,检查查询名称是否匹配,并存储带有 5 秒过期时间的查询。 +egress 路径先验证 IPv4、UDP、分片状态、解析器地址和端口,再检查报文是不是指定名称的单 question A 查询。只有这些字段全部成立,`record_dns_query()` 才会插入四字段关联 key。程序返回 `1`,DNS 报文仍然按正常路径继续传输。 -`learn_dns_answer` 函数在 cgroup 入口运行,解析 DNS 响应。它查找匹配的待处理查询,验证响应格式,从第一个 A 记录提取 IP 地址和 TTL,并将 IP 存储到允许列表中,设置基于 TTL 的过期时间。 +ingress 路径从反向 transport tuple 开始解析。`parse_response_question()` 补上 transaction ID,要求 pending entry 仍然有效,并验证这是包含指定 question 的成功响应。`parse_direct_a_answer()` 接受常见的 `0xc00c` 压缩 name pointer、IN class、A type、4 字节地址,以及 1 到 86400 秒的 TTL。应答通过以后,pending query 会被消费,解析出的 IP 才获得连接资格。 -`enforce_dns_policy` 函数在进程调用 `connect()` 时运行,发往受保护端口的 IPv4 TCP 连接会查询允许列表和过期时间。命中的条目返回 1 继续连接,缺失或过期的条目返回 0,让 `connect()` 以 `EPERM` 失败,其他端口则直接返回 1。 +`enforce_dns_policy()` 的范围很窄。发往其他目的端口的 TCP 连接直接通过;发往受保护端口的连接使用 `ctx->user_ip4` 查表,有效条目返回 `1`,缺失或过期条目返回 `0`,应用会收到 `EPERM`。`expired_reported` 上的 compare-and-swap 让多个线程同时访问旧地址时只产生一次 expired 通知。 -## 用户空间程序 +## 加载策略并验证完整信任链 + +用户态程序会配置只读 BPF 数据,把三个程序附加到一个 cgroup,再消费 ring buffer 事件。 ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -353,6 +413,15 @@ struct options { bool demo; }; +struct dns_runtime { + struct dns_egress_bpf *skel; + struct bpf_link *query_link; + struct bpf_link *ingress_link; + struct bpf_link *connect_link; + struct ring_buffer *ring; + int cgroup_fd; +}; + static int event_counts[5]; static volatile sig_atomic_t stop; @@ -409,6 +478,42 @@ static void usage(const char *program) " %s --demo\n", program, program); } +static int apply_option(int option, const char *program, + struct options *options) +{ + switch (option) { + case 'c': options->cgroup_path = optarg; return 0; + case 'n': options->domain = optarg; return 0; + case 'r': options->dns_server = optarg; return 0; + case 'p': + return parse_uint(optarg, 65535, &options->port) || !options->port ? + -1 : 0; + case 's': + return parse_uint(optarg, 65535, &options->dns_port) || + !options->dns_port ? -1 : 0; + case 'd': + return parse_uint(optarg, 86400, &options->duration_seconds); + case 'D': options->demo = true; return 0; + case 'h': usage(program); exit(0); + default: return -1; + } +} + +static int finish_options(struct options *options) +{ + if (!options->demo) + return options->cgroup_path && options->domain && + options->dns_server ? 0 : -1; + if (options->cgroup_path || options->domain || options->dns_server) + return -1; + options->cgroup_path = "/sys/fs/cgroup"; + options->domain = "lab.test"; + options->dns_server = "127.0.0.1"; + options->port = DEMO_TCP_PORT; + options->dns_port = DEMO_DNS_PORT; + return 0; +} + static int parse_options(int argc, char **argv, struct options *options) { static const struct option long_options[] = { @@ -425,45 +530,10 @@ static int parse_options(int argc, char **argv, struct options *options) int option; while ((option = getopt_long(argc, argv, "c:n:r:p:s:d:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'c': options->cgroup_path = optarg; break; - case 'n': options->domain = optarg; break; - case 'r': options->dns_server = optarg; break; - case 'p': - if (parse_uint(optarg, 65535, &options->port) || - !options->port) - return -1; - break; - case 's': - if (parse_uint(optarg, 65535, &options->dns_port) || - !options->dns_port) - return -1; - break; - case 'd': - if (parse_uint(optarg, 86400, - &options->duration_seconds)) - return -1; - break; - case 'D': options->demo = true; break; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - if (optind != argc) - return -1; - if (options->demo) { - if (options->cgroup_path || options->domain || options->dns_server) + NULL)) != -1) + if (apply_option(option, argv[0], options)) return -1; - options->cgroup_path = "/sys/fs/cgroup"; - options->domain = "lab.test"; - options->dns_server = "127.0.0.1"; - options->port = DEMO_TCP_PORT; - options->dns_port = DEMO_DNS_PORT; - return 0; - } - return options->cgroup_path && options->domain && options->dns_server ? - 0 : -1; + return optind == argc ? finish_options(options) : -1; } static int encode_qname(const char *domain, unsigned char output[DNS_QNAME_MAX], @@ -663,18 +733,64 @@ static int complete_tcp(int listener, int client) { int accepted = accept4(listener, NULL, NULL, SOCK_CLOEXEC); char byte = 'x'; + int result = 0; - if (accepted < 0) - return -1; - if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) { - close(accepted); + if (accepted < 0) { + close(client); return -1; } + if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) + result = -1; close(accepted); close(client); + return result; +} + +static int poll_demo_events(struct ring_buffer *ring) +{ + int result = ring_buffer__poll(ring, 100); + + return result < 0 && result != -EINTR ? -1 : 0; +} + +static int expect_blocked_connect(struct ring_buffer *ring, + unsigned int port, const char *step) +{ + int client; + + errno = 0; + client = connect_tcp(port); + if (client >= 0) { + close(client); + return -1; + } + if (errno != EPERM || poll_demo_events(ring)) + return -1; + printf("demo step=%s result=blocked\n", step); return 0; } +static int expect_allowed_connect(struct ring_buffer *ring, int listener, + unsigned int port) +{ + int client = connect_tcp(port); + + if (client < 0 || complete_tcp(listener, client)) + return -1; + if (poll_demo_events(ring)) + return -1; + printf("demo step=live-answer result=allowed\n"); + return 0; +} + +static int expected_demo_events(void) +{ + return event_counts[DNS_LEARNED] == 1 && + event_counts[DNS_ALLOWED] == 1 && + event_counts[DNS_DENIED] == 4 && + event_counts[DNS_EXPIRED] == 1 ? 0 : -1; +} + static int run_demo(struct ring_buffer *ring, const struct options *options, const unsigned char *qname, unsigned int qname_length) { @@ -691,7 +807,7 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, struct sockaddr_in response_client_address; unsigned char dns_message[512]; size_t query_length; - int dns_server = -1, dns_client = -1, listener = -1, client = -1; + int dns_server = -1, dns_client = -1, listener = -1; int err = -1; dns_server = bind_udp(&server_address); @@ -700,22 +816,14 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, if (dns_server < 0 || dns_client < 0 || listener < 0) goto cleanup; - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (expect_blocked_connect(ring, options->port, "before-dns")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=before-dns result=blocked\n"); if (send_unsolicited_dns(dns_server, dns_client, qname, qname_length)) goto cleanup; - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "unsolicited-response")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=unsolicited-response result=blocked\n"); if (begin_dns_exchange(dns_server, dns_client, &server_address, qname, qname_length, dns_message, &query_length, @@ -723,59 +831,128 @@ static int run_demo(struct ring_buffer *ring, const struct options *options, send_dns_answer(dns_server, dns_client, &response_client_address, dns_message, query_length, DNS_ID + 1, 30)) goto cleanup; - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "wrong-transaction-id")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=wrong-transaction-id result=blocked\n"); if (send_dns_answer(dns_server, dns_client, &response_client_address, dns_message, query_length, DNS_ID, 1)) goto cleanup; - ring_buffer__poll(ring, 100); - client = connect_tcp(options->port); - if (client < 0 || complete_tcp(listener, client)) + if (poll_demo_events(ring) || + expect_allowed_connect(ring, listener, options->port)) goto cleanup; - client = -1; - ring_buffer__poll(ring, 100); - printf("demo step=live-answer result=allowed\n"); nanosleep(&wait_time, NULL); - ring_buffer__poll(ring, 100); - errno = 0; - client = connect_tcp(options->port); - if (client >= 0 || errno != EPERM) + if (poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, "expired-answer")) goto cleanup; - ring_buffer__poll(ring, 100); - printf("demo step=expired-answer result=blocked\n"); - if (event_counts[DNS_LEARNED] != 1 || event_counts[DNS_ALLOWED] != 1 || - event_counts[DNS_DENIED] != 4 || event_counts[DNS_EXPIRED] != 1) + if (expected_demo_events()) goto cleanup; err = 0; cleanup: - if (client >= 0) close(client); if (listener >= 0) close(listener); if (dns_client >= 0) close(dns_client); if (dns_server >= 0) close(dns_server); return err; } +static bool link_failed(struct bpf_link **link) +{ + if (!libbpf_get_error(*link)) + return false; + *link = NULL; + return true; +} + +static int prepare_runtime(struct dns_runtime *runtime, + const struct options *options, + const struct in_addr *dns_server, + const unsigned char *qname, + unsigned int qname_length) +{ + bool failed; + + runtime->cgroup_fd = open(options->cgroup_path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (runtime->cgroup_fd < 0) { + fprintf(stderr, "failed to open cgroup %s: %s\n", + options->cgroup_path, strerror(errno)); + return -1; + } + runtime->skel = dns_egress_bpf__open(); + if (!runtime->skel) + return -1; + runtime->skel->rodata->target_tgid = options->demo ? getpid() : 0; + runtime->skel->rodata->dns_server_ip = dns_server->s_addr; + runtime->skel->rodata->dns_server_port = options->dns_port; + runtime->skel->rodata->protected_tcp_port = options->port; + runtime->skel->rodata->configured_qname_length = qname_length; + memcpy((void *)runtime->skel->rodata->configured_qname, qname, + qname_length); + if (dns_egress_bpf__load(runtime->skel)) { + fprintf(stderr, "failed to load DNS egress BPF programs\n"); + return -1; + } + runtime->query_link = bpf_program__attach_cgroup( + runtime->skel->progs.record_dns_query, runtime->cgroup_fd); + runtime->ingress_link = bpf_program__attach_cgroup( + runtime->skel->progs.learn_dns_answer, runtime->cgroup_fd); + runtime->connect_link = bpf_program__attach_cgroup( + runtime->skel->progs.enforce_dns_policy, runtime->cgroup_fd); + failed = link_failed(&runtime->query_link); + failed |= link_failed(&runtime->ingress_link); + failed |= link_failed(&runtime->connect_link); + if (failed) { + fprintf(stderr, "failed to attach programs to cgroup %s\n", + options->cgroup_path); + return -1; + } + runtime->ring = ring_buffer__new( + bpf_map__fd(runtime->skel->maps.events), handle_event, NULL, NULL); + return runtime->ring ? 0 : -1; +} + +static int poll_policy_events(struct ring_buffer *ring, + unsigned int duration_seconds) +{ + unsigned long long deadline = 0; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)duration_seconds * 1000000000ULL; + while (!stop && (!deadline || monotonic_ns() < deadline)) { + int result = ring_buffer__poll(ring, 100); + + if (result < 0 && result != -EINTR) { + fprintf(stderr, "ring buffer poll failed: %d\n", result); + return -1; + } + } + return 0; +} + +static void destroy_runtime(struct dns_runtime *runtime) +{ + ring_buffer__free(runtime->ring); + bpf_link__destroy(runtime->connect_link); + bpf_link__destroy(runtime->ingress_link); + bpf_link__destroy(runtime->query_link); + if (runtime->cgroup_fd >= 0) + close(runtime->cgroup_fd); + dns_egress_bpf__destroy(runtime->skel); +} + int main(int argc, char **argv) { struct options options = { .port = 443, .dns_port = 53 }; - struct dns_egress_bpf *skel = NULL; - struct bpf_link *query_link = NULL, *ingress_link = NULL; - struct bpf_link *connect_link = NULL; - struct ring_buffer *ring = NULL; + struct dns_runtime runtime = { .cgroup_fd = -1 }; struct in_addr dns_server = {}; unsigned char qname[DNS_QNAME_MAX] = {}; - unsigned long long deadline = 0; unsigned int qname_length = 0; - int cgroup_fd = -1; int err = 1; setvbuf(stdout, NULL, _IONBF, 0); @@ -785,109 +962,56 @@ int main(int argc, char **argv) usage(argv[0]); return 2; } - cgroup_fd = open(options.cgroup_path, - O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (cgroup_fd < 0) { - fprintf(stderr, "failed to open cgroup %s: %s\n", - options.cgroup_path, strerror(errno)); - goto cleanup; - } - - skel = dns_egress_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->target_tgid = options.demo ? getpid() : 0; - skel->rodata->dns_server_ip = dns_server.s_addr; - skel->rodata->dns_server_port = options.dns_port; - skel->rodata->protected_tcp_port = options.port; - skel->rodata->configured_qname_length = qname_length; - memcpy((void *)skel->rodata->configured_qname, qname, qname_length); - if (dns_egress_bpf__load(skel)) { - fprintf(stderr, "failed to load DNS egress BPF programs\n"); - goto cleanup; - } - query_link = bpf_program__attach_cgroup(skel->progs.record_dns_query, - cgroup_fd); - ingress_link = bpf_program__attach_cgroup(skel->progs.learn_dns_answer, - cgroup_fd); - connect_link = bpf_program__attach_cgroup(skel->progs.enforce_dns_policy, - cgroup_fd); - if (libbpf_get_error(query_link) || libbpf_get_error(ingress_link) || - libbpf_get_error(connect_link)) { - fprintf(stderr, "failed to attach programs to cgroup %s\n", - options.cgroup_path); - query_link = libbpf_get_error(query_link) ? NULL : query_link; - ingress_link = libbpf_get_error(ingress_link) ? NULL : ingress_link; - connect_link = libbpf_get_error(connect_link) ? NULL : connect_link; - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + if (prepare_runtime(&runtime, &options, &dns_server, qname, + qname_length)) goto cleanup; printf("dns-egress attached cgroup=%s domain=%s resolver=%s tcp_port=%u dns_port=%u\n", options.cgroup_path, options.domain, options.dns_server, options.port, options.dns_port); if (options.demo) { - if (run_demo(ring, &options, qname, qname_length)) + if (run_demo(runtime.ring, &options, qname, qname_length)) goto cleanup; - } else { - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * - 1000000000ULL; - while (!stop && (!deadline || monotonic_ns() < deadline)) { - int poll_result = ring_buffer__poll(ring, 100); - - if (poll_result < 0 && poll_result != -EINTR) { - fprintf(stderr, "ring buffer poll failed: %d\n", - poll_result); - goto cleanup; - } - } - } + } else if (poll_policy_events(runtime.ring, options.duration_seconds)) + goto cleanup; err = 0; cleanup: - ring_buffer__free(ring); - bpf_link__destroy(connect_link); - bpf_link__destroy(ingress_link); - bpf_link__destroy(query_link); - if (cgroup_fd >= 0) close(cgroup_fd); - dns_egress_bpf__destroy(skel); + destroy_runtime(&runtime); return err; } ``` -用户空间程序加载并将 BPF 程序附加到 cgroup。它将域名编码为 DNS 标签格式,配置 BPF 程序的目标解析器和受保护端口,并轮询 ring buffer 获取策略事件。 +域名会在 BPF object 加载前编码为 DNS label 格式,例如 `lab.test` 会变成 `\x03lab\x04test\x00`,这正是 packet hook 逐字节比较的内容。解析器与端口写入 skeleton 的 `rodata`,验证器可以把它们视为常量。 -演示模式运行一个独立测试:它创建本地 UDP 和 TCP 套接字,模拟 DNS 解析,并验证策略是否正确工作。它测试 DNS 之前的连接阻止、拒绝未经请求的响应、拒绝错误的事务 ID、有效 DNS 后允许连接,以及 TTL 过期后阻止连接。 +普通模式会保持三个 link 存活,直到运行时间结束或收到退出信号。demo 模式用 loopback socket 走完信任链:先证明初始连接会被拒绝,再分别发送未经请求的应答和错误 ID 应答,随后接纳一个 TTL 为 1 秒的正确应答,最后等待它过期。这些检查很重要,因为“解析所有 DNS 响应”的简单实现也能通过成功路径,却很容易被伪造响应污染。 -## 编译和执行 +## 编译和运行 -构建示例: +使用仓库内置的 libbpf 与 bpftool 构建示例: ```bash cd src/55-dns-egress make ``` -使用必需参数运行: +将它挂到服务 cgroup,通过服务实际使用的解析器观察一个域名,并保护 TCP 443 端口: ```bash -sudo ./dns_egress --cgroup /sys/fs/cgroup/my-service --domain api.example.com --dns-server 127.0.0.53 --port 443 +sudo ./dns_egress \ + --cgroup /sys/fs/cgroup/my-service \ + --domain api.example.com \ + --dns-server 127.0.0.53 \ + --port 443 ``` -或运行内置演示: +这个 cgroup 需要包含目标工作负载,让它的 DNS 报文和连接共享同一份策略状态。TCP 443 和 DNS 53 是默认端口,`--dns-port` 可以选择其他解析器端口,`--duration` 则为运行时间设置上限。内置 demo 不依赖外部 DNS 服务器: ```bash sudo ./dns_egress --demo ``` -示例演示输出: +一次真实运行的输出如下: ```text dns-egress attached cgroup=/sys/fs/cgroup domain=lab.test resolver=127.0.0.1 tcp_port=19090 dns_port=15353 @@ -905,27 +1029,31 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1 demo step=expired-answer result=blocked ``` +前三个 denied 说明,仅仅收到 DNS 格式的流量或者看到正确域名还不足以学习地址。只有关联正确的响应会产生 learned,allowed 覆盖它的有效 TTL,expired 之后的连接则立刻回到 denied。 + ## 环境要求 -| 要求 | 详情 | -|------|------| -| 内核 | Linux 5.8+(BPF ring buffer) | -| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF` | -| 权限 | Root | -| cgroup | cgroup v2 已挂载 | +| 要求 | 说明 | +|---|---| +| 内核 | Linux 5.8 或更高版本,最新依赖来自 BPF ring buffer | +| 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_INET` | +| cgroup | cgroup v2,目标工作负载位于挂载目录之下 | +| 权限 | root,或者等价的 BPF 与网络管理 capability | +| 架构与硬件 | 当前声明并完成测试的目标是 x86-64,不需要特殊网卡 | -## 范围和限制 +## 实现范围 -此实现保护一个配置好的 TCP 目的端口,其他端口保持不变。它支持启动时配置的一个精确查询名称,仅支持 IPv4 UDP DNS,并解析直接的第一个 A 记录应答。待处理查询在 5 秒后过期,允许的 IP 使用带有 DNS TTL 的 1024 条目 LRU 哈希。它不处理 CNAME 链、多应答布局、TCP DNS、IPv6、DNS-over-HTTPS 或 DNS-over-TLS。 +这个工具处理一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答,并识别常见的 `0xc00c` owner name。CNAME 链、多种 answer 布局、TCP DNS、IPv6、DoH 和 DoT 需要更多解析逻辑或新的观察位置。这个紧凑范围保留了策略最关键的性质:IP 通过最近一次匹配查询进入允许列表,再按照 DNS 时间退出。 ## 总结 -这个例子把 DNS 响应与最近的查询关联起来,再把 TTL 带到 connect 阶段的策略判断中,从而把一个域名变成内核中的短期允许列表。三个 cgroup hook 分别负责 DNS 观察和连接执行,ring buffer 事件则让用户态能够看到每一次策略决定。 +这个例子把观察到的 DNS 结果变成有时间边界的 connect 策略。egress 与 ingress hook 建立可信的查询响应关联,TTL 管理地址生命周期,connect hook 则对工作负载的受保护端口执行结果。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 ## 参考资料 -- [BPF ring buffer 文档](https://docs.kernel.org/6.6/bpf/ringbuf.html) -- [BPF hash map 文档](https://docs.kernel.org/bpf/map_hash.html) -- [cgroup BPF commit](https://github.com/torvalds/linux/commit/d74bad4e74ee) +- [BPF ring buffer](https://docs.kernel.org/bpf/ringbuf.html) +- [BPF LRU hash map](https://docs.kernel.org/bpf/map_hash.html) +- [Control Group v2](https://docs.kernel.org/admin-guide/cgroup-v2.html) +- [RFC 1035:Domain Names — Implementation and Specification](https://www.rfc-editor.org/rfc/rfc1035.html) diff --git a/src/56-tc-flow-index/README.md b/src/56-tc-flow-index/README.md new file mode 100644 index 0000000..b91ed71 --- /dev/null +++ b/src/56-tc-flow-index/README.md @@ -0,0 +1,798 @@ +# eBPF Tutorial by Example: Index TC Flows in Two Refcounted rbtrees + +A flow monitor has two different lookup problems. Every packet needs to find its five-tuple quickly so the counters can be updated. At reporting time, the same records need to appear in traffic order so the busiest flows come first. One ordering cannot answer both questions efficiently, while two independent copies would let counters and lifetimes drift apart. + +This tutorial builds a TC egress flow index around one object with two views. Each IPv4 TCP or UDP flow is stored in an identity rbtree and a byte-ranked rbtree at the same time. BPF object allocation creates the record, a BPF refcount gives both trees ownership, and rbtree traversal produces top-flow output without rebuilding the index in user space. + +> Complete source code: + +## Why the Same Object Can Belong to Two Trees + +eBPF runs verifier-checked programs on kernel data paths such as TC and can keep structured state between invocations. Linux 6.4 added `bpf_refcount_acquire()`, which lets a dynamically allocated BPF object gain another owning reference. Linux 6.16 added `bpf_rbtree_root()`, `bpf_rbtree_left()`, and `bpf_rbtree_right()`, making ordered search and traversal available to BPF programs and completing the feature set used here. + +Each `flow_entry` embeds one `bpf_refcount` and two different `bpf_rb_node` fields. `identity_root` orders `by_identity` by source address, destination address, source port, destination port, and protocol. `traffic_root` orders `by_traffic` by descending bytes, then packets, then the five-tuple for a deterministic tie break. Both nodes lead back to the same counters and timestamps through `container_of()`. + +Follow the first packet of a new flow. The TC program searches the identity tree and finds no match. It allocates a `flow_entry`, then calls `bpf_refcount_acquire()` to obtain a second owning reference. The first `bpf_rbtree_add()` transfers one reference to the identity tree, and the second call transfers the other to the traffic tree. Once the critical section ends, no raw pointer escapes; the verifier can prove that both collections own the object. + +The next packet finds the entry by identity, but adding bytes changes its traffic rank. The program removes only `by_traffic`; removal returns that tree's owning reference. It updates the shared object and inserts the traffic node again at its new position. The identity node stays in place, so lookup order remains valid throughout the update. + +## Shared Flow and Cursor Types + +The shared header defines the five-tuple, the snapshot returned from BPF, and the cursor used to continue an ordered walk. + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __TC_FLOW_INDEX_H +#define __TC_FLOW_INDEX_H + +#define FLOW_COMM_LEN 16 + +struct flow_key { + unsigned int source_ip; + unsigned int destination_ip; + unsigned short source_port; + unsigned short destination_port; + unsigned char protocol; + unsigned char padding[3]; +}; + +struct flow_snapshot { + unsigned long long packets; + unsigned long long bytes; + unsigned long long last_seen_ns; + struct flow_key key; + unsigned int found; + char comm[FLOW_COMM_LEN]; +}; + +struct flow_cursor { + unsigned long long bytes; + unsigned long long packets; + struct flow_key key; + unsigned int valid; +}; + +#endif /* __TC_FLOW_INDEX_H */ +``` + +Addresses and ports remain in network byte order inside `flow_key`, which lets the BPF side copy packet fields directly. User space converts them only when formatting the table. + +## Experimental kfunc Declarations + +BPF graph APIs are kfuncs rather than stable UAPI helpers. The small compatibility header declares the exact functions used by the program and wraps the allocator calls with local BTF type IDs. + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __TC_FLOW_INDEX_EXPERIMENTAL_H +#define __TC_FLOW_INDEX_EXPERIMENTAL_H + +#include + +#define __contains(name, node) \ + __attribute__((btf_decl_tag("contains:" #name ":" #node))) + +extern void *bpf_obj_new_impl(__u64 local_type_id, void *meta) __ksym; +#define bpf_obj_new(type) \ + ((type *)bpf_obj_new_impl(bpf_core_type_id_local(type), NULL)) + +extern void bpf_obj_drop_impl(void *kptr, void *meta) __ksym; +#define bpf_obj_drop(kptr) bpf_obj_drop_impl(kptr, NULL) + +extern void *bpf_refcount_acquire_impl(void *kptr, void *meta) __ksym; +#define bpf_refcount_acquire(kptr) \ + bpf_refcount_acquire_impl(kptr, NULL) + +extern int bpf_rbtree_add_impl(struct bpf_rb_root *root, + struct bpf_rb_node *node, + bool (*less)(struct bpf_rb_node *, + const struct bpf_rb_node *), + void *meta, __u64 off) __ksym; +#define bpf_rbtree_add(root, node, less) \ + bpf_rbtree_add_impl(root, node, less, NULL, 0) + +extern struct bpf_rb_node * +bpf_rbtree_remove(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_first(struct bpf_rb_root *root) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_root(struct bpf_rb_root *root) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_left(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_right(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; + +#endif /* __TC_FLOW_INDEX_EXPERIMENTAL_H */ +``` + +`__contains(flow_entry, by_identity)` and its traffic counterpart tell the verifier which containing type and member belong to each root. The verifier combines that type relation with the neighboring spin lock to enforce graph ownership and critical-section rules. These kfunc interfaces can evolve between kernels, which is why the tutorial records a concrete minimum version and keeps the declarations beside the example. + +## Maintaining Both Indexes at TC Egress + +Here is the complete BPF program. + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "bpf_experimental.h" +#include "tc_flow_index.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define ETH_P_IP 0x0800 +#define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff +#define INDEX_SEARCH_DEPTH 32 +#define TC_ACT_OK 0 + +const volatile __u32 maximum_entries = 4096; + +struct flow_entry { + struct bpf_refcount ref; + struct bpf_rb_node by_identity; + struct bpf_rb_node by_traffic; + struct flow_key key; + __u64 packets; + __u64 bytes; + __u64 last_seen_ns; + char comm[FLOW_COMM_LEN]; +}; + +#define private(name) \ + SEC(".data." #name) __hidden __attribute__((aligned(8))) + +private(index) struct bpf_spin_lock index_lock; +private(index) struct bpf_rb_root identity_root + __contains(flow_entry, by_identity); +private(index) struct bpf_rb_root traffic_root + __contains(flow_entry, by_traffic); + +struct flow_cursor snapshot_cursor; +struct flow_snapshot snapshot_result; +__u64 observed_packets; +__u64 indexed_flows; +__u64 dropped_new_flows; +__u64 allocation_failures; +__u64 refcount_failures; +__u64 rank_update_failures; + +static __always_inline int compare_key(const struct flow_key *left, + const struct flow_key *right) +{ + if (left->source_ip != right->source_ip) + return left->source_ip < right->source_ip ? -1 : 1; + if (left->destination_ip != right->destination_ip) + return left->destination_ip < right->destination_ip ? -1 : 1; + if (left->source_port != right->source_port) + return left->source_port < right->source_port ? -1 : 1; + if (left->destination_port != right->destination_port) + return left->destination_port < right->destination_port ? -1 : 1; + if (left->protocol != right->protocol) + return left->protocol < right->protocol ? -1 : 1; + return 0; +} + +static bool identity_less(struct bpf_rb_node *a, + const struct bpf_rb_node *b) +{ + struct flow_entry *left = container_of(a, struct flow_entry, + by_identity); + struct flow_entry *right = container_of(b, struct flow_entry, + by_identity); + + return compare_key(&left->key, &right->key) < 0; +} + +static __always_inline bool traffic_before(__u64 left_bytes, + __u64 left_packets, + const struct flow_key *left_key, + __u64 right_bytes, + __u64 right_packets, + const struct flow_key *right_key) +{ + if (left_bytes != right_bytes) + return left_bytes > right_bytes; + if (left_packets != right_packets) + return left_packets > right_packets; + return compare_key(left_key, right_key) < 0; +} + +static bool traffic_less(struct bpf_rb_node *a, + const struct bpf_rb_node *b) +{ + struct flow_entry *left = container_of(a, struct flow_entry, by_traffic); + struct flow_entry *right = container_of(b, struct flow_entry, by_traffic); + + return traffic_before(left->bytes, left->packets, &left->key, + right->bytes, right->packets, &right->key); +} + +static __always_inline struct flow_entry * +find_flow_locked(const struct flow_key *key) +{ + struct bpf_rb_node *rb = bpf_rbtree_root(&identity_root); + + for (int i = 0; i < INDEX_SEARCH_DEPTH && rb; i++) { + struct flow_entry *entry = + container_of(rb, struct flow_entry, by_identity); + int comparison = compare_key(key, &entry->key); + + if (!comparison) + return entry; + if (comparison < 0) + rb = bpf_rbtree_left(&identity_root, rb); + else + rb = bpf_rbtree_right(&identity_root, rb); + } + return NULL; +} + +static __always_inline bool update_existing_locked(struct flow_entry *entry, + __u32 packet_bytes, + __u64 now_ns) +{ + struct flow_entry *owner; + struct bpf_rb_node *removed; + + removed = bpf_rbtree_remove(&traffic_root, &entry->by_traffic); + if (!removed) + return false; + owner = container_of(removed, struct flow_entry, by_traffic); + owner->packets++; + owner->bytes += packet_bytes; + owner->last_seen_ns = now_ns; + bpf_rbtree_add(&traffic_root, &owner->by_traffic, traffic_less); + return true; +} + +static __always_inline void update_flow(const struct flow_key *key, + __u32 packet_bytes, __u64 now_ns, + const char comm[FLOW_COMM_LEN]) +{ + struct flow_entry *entry, *new_entry, *traffic_owner; + + bpf_spin_lock(&index_lock); + entry = find_flow_locked(key); + if (entry) { + if (!update_existing_locked(entry, packet_bytes, now_ns)) + rank_update_failures++; + bpf_spin_unlock(&index_lock); + return; + } + bpf_spin_unlock(&index_lock); + + new_entry = bpf_obj_new(typeof(*new_entry)); + if (!new_entry) { + __sync_fetch_and_add(&allocation_failures, 1); + return; + } + traffic_owner = bpf_refcount_acquire(new_entry); + if (!traffic_owner) { + __sync_fetch_and_add(&refcount_failures, 1); + bpf_obj_drop(new_entry); + return; + } + new_entry->key.source_ip = key->source_ip; + new_entry->key.destination_ip = key->destination_ip; + new_entry->key.source_port = key->source_port; + new_entry->key.destination_port = key->destination_port; + new_entry->key.protocol = key->protocol; + new_entry->packets = 1; + new_entry->bytes = packet_bytes; + new_entry->last_seen_ns = now_ns; + __builtin_memcpy(new_entry->comm, comm, sizeof(new_entry->comm)); + + bpf_spin_lock(&index_lock); + entry = find_flow_locked(key); + if (entry) { + if (!update_existing_locked(entry, packet_bytes, now_ns)) + rank_update_failures++; + bpf_spin_unlock(&index_lock); + bpf_obj_drop(new_entry); + bpf_obj_drop(traffic_owner); + return; + } + if (indexed_flows >= maximum_entries) { + dropped_new_flows++; + bpf_spin_unlock(&index_lock); + bpf_obj_drop(new_entry); + bpf_obj_drop(traffic_owner); + return; + } + bpf_rbtree_add(&identity_root, &new_entry->by_identity, identity_less); + bpf_rbtree_add(&traffic_root, &traffic_owner->by_traffic, traffic_less); + indexed_flows++; + bpf_spin_unlock(&index_lock); +} + +static __always_inline bool parse_flow(struct __sk_buff *skb, + struct flow_key *key) +{ + struct ethhdr ethernet; + struct iphdr ip; + __u32 transport_offset; + __be16 ports[2]; + + if (bpf_skb_load_bytes(skb, 0, ðernet, sizeof(ethernet)) || + ethernet.h_proto != bpf_htons(ETH_P_IP)) + return false; + if (bpf_skb_load_bytes(skb, sizeof(ethernet), &ip, sizeof(ip)) || + ip.version != 4 || ip.ihl < 5 || + (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || + (ip.protocol != IPPROTO_TCP && ip.protocol != IPPROTO_UDP)) + return false; + transport_offset = sizeof(ethernet) + ip.ihl * 4; + if (bpf_skb_load_bytes(skb, transport_offset, ports, sizeof(ports))) + return false; + key->source_ip = ip.saddr; + key->destination_ip = ip.daddr; + key->source_port = ports[0]; + key->destination_port = ports[1]; + key->protocol = ip.protocol; + return true; +} + +SEC("tc") +int index_egress_flow(struct __sk_buff *skb) +{ + struct flow_key key = {}; + char comm[FLOW_COMM_LEN]; + + if (!parse_flow(skb, &key)) + return TC_ACT_OK; + __sync_fetch_and_add(&observed_packets, 1); + bpf_get_current_comm(comm, sizeof(comm)); + update_flow(&key, skb->len, bpf_ktime_get_ns(), comm); + return TC_ACT_OK; +} + +static __always_inline bool cursor_before_entry(const struct flow_cursor *cursor, + const struct flow_entry *entry) +{ + return traffic_before(cursor->bytes, cursor->packets, &cursor->key, + entry->bytes, entry->packets, &entry->key); +} + +SEC("syscall") +int snapshot_next(void *ctx) +{ + struct flow_entry *entry = NULL; + struct bpf_rb_node *candidate = NULL; + struct bpf_rb_node *rb; + + (void)ctx; + __builtin_memset(&snapshot_result, 0, sizeof(snapshot_result)); + bpf_spin_lock(&index_lock); + if (!snapshot_cursor.valid) { + candidate = bpf_rbtree_first(&traffic_root); + } else { + rb = bpf_rbtree_root(&traffic_root); + for (int i = 0; i < INDEX_SEARCH_DEPTH && rb; i++) { + entry = container_of(rb, struct flow_entry, by_traffic); + if (cursor_before_entry(&snapshot_cursor, entry)) { + candidate = rb; + rb = bpf_rbtree_left(&traffic_root, rb); + } else { + rb = bpf_rbtree_right(&traffic_root, rb); + } + } + } + if (candidate) { + entry = container_of(candidate, struct flow_entry, by_traffic); + snapshot_result.packets = entry->packets; + snapshot_result.bytes = entry->bytes; + snapshot_result.last_seen_ns = entry->last_seen_ns; + snapshot_result.key.source_ip = entry->key.source_ip; + snapshot_result.key.destination_ip = entry->key.destination_ip; + snapshot_result.key.source_port = entry->key.source_port; + snapshot_result.key.destination_port = entry->key.destination_port; + snapshot_result.key.protocol = entry->key.protocol; + snapshot_result.found = 1; + __builtin_memcpy(snapshot_result.comm, entry->comm, + sizeof(snapshot_result.comm)); + } + bpf_spin_unlock(&index_lock); + return 0; +} +``` + +`index_lock`, `identity_root`, and `traffic_root` share one private map value. Every search or mutation holds that lock. A bounded 32-step binary-tree walk keeps verifier-visible control flow finite; with at most 4096 entries, the balanced rbtree needs far fewer levels. + +Allocation happens after the first locked lookup. The program then locks again and repeats the lookup before insertion. This second check handles two CPUs seeing the same new flow: one object enters both trees, while the loser updates the existing entry and drops both references it allocated. Allocation, refcount, capacity, and re-ranking failures have separate counters so ownership problems remain visible. + +`parse_flow()` admits non-fragmented IPv4 TCP and UDP packets and builds the key from Ethernet, IP, and transport headers. `index_egress_flow()` always returns `TC_ACT_OK`; this tool observes and indexes traffic without changing packet delivery. + +The `snapshot_next` program reads the traffic tree under the same lock. With an empty cursor it returns `bpf_rbtree_first()`. Later calls search for the first entry after the previous `(bytes, packets, key)` tuple. The result and cursor live in BSS, so user space can request one ranked entry at a time without receiving a per-packet event stream. + +## Attaching TC and Reading a Stable Snapshot + +The user-space program attaches the classifier, generates optional demo traffic, detaches before the snapshot, then calls the snapshot program through `BPF_PROG_TEST_RUN`. + +```c +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "tc_flow_index.h" +#include "tc_flow_index.skel.h" + +#define MAX_TOP 64 + +struct options { + const char *interface; + unsigned int duration_seconds; + unsigned int top; + bool demo; +}; + +static volatile sig_atomic_t stop; + +static void handle_signal(int signal_number) +{ + (void)signal_number; + stop = 1; +} + +static unsigned long long monotonic_ns(void) +{ + struct timespec now; + + clock_gettime(CLOCK_MONOTONIC, &now); + return (unsigned long long)now.tv_sec * 1000000000ULL + now.tv_nsec; +} + +static int parse_uint(const char *text, unsigned int maximum, + unsigned int *value) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(text, &end, 10); + if (errno || !*text || *end || !parsed || parsed > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --interface IFACE [--duration SEC] [--top N]\n" + " %s --demo [--top N]\n", program, program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "interface", required_argument, NULL, 'i' }, + { "duration", required_argument, NULL, 'd' }, + { "top", required_argument, NULL, 't' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "i:d:t:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'i': options->interface = optarg; break; + case 'd': + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 't': + if (parse_uint(optarg, MAX_TOP, &options->top)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + if (options->demo) { + if (options->interface) + return -1; + options->interface = "lo"; + } + return optind == argc && options->interface ? 0 : -1; +} + +static int send_demo_flow(unsigned int datagrams, size_t payload_size) +{ + struct sockaddr_in receiver_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + struct sockaddr_in sender_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + socklen_t address_length = sizeof(receiver_address); + char payload[1000] = {}; + int receiver = -1, sender = -1; + int err = -1; + + if (payload_size > sizeof(payload)) + return -1; + receiver = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + sender = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (receiver < 0 || sender < 0 || + bind(receiver, (struct sockaddr *)&receiver_address, + sizeof(receiver_address)) || + getsockname(receiver, (struct sockaddr *)&receiver_address, + &address_length) || + bind(sender, (struct sockaddr *)&sender_address, + sizeof(sender_address))) + goto cleanup; + for (unsigned int i = 0; i < datagrams; i++) + if (sendto(sender, payload, payload_size, 0, + (struct sockaddr *)&receiver_address, + sizeof(receiver_address)) != (ssize_t)payload_size) + goto cleanup; + err = 0; + +cleanup: + if (receiver >= 0) close(receiver); + if (sender >= 0) close(sender); + return err; +} + +static int run_demo_traffic(void) +{ + return send_demo_flow(2, 100) || + send_demo_flow(4, 300) || + send_demo_flow(6, 700); +} + +static int snapshot_next(struct tc_flow_index_bpf *skel, + struct flow_snapshot *result) +{ + LIBBPF_OPTS(bpf_test_run_opts, opts); + + if (bpf_prog_test_run_opts(bpf_program__fd(skel->progs.snapshot_next), + &opts)) { + fprintf(stderr, "failed to query BPF flow index: %s\n", + strerror(errno)); + return -1; + } + *result = skel->bss->snapshot_result; + if (result->found) { + skel->bss->snapshot_cursor.bytes = result->bytes; + skel->bss->snapshot_cursor.packets = result->packets; + skel->bss->snapshot_cursor.key = result->key; + skel->bss->snapshot_cursor.valid = 1; + } + return 0; +} + +static int print_flows(struct tc_flow_index_bpf *skel, unsigned int top) +{ + struct flow_snapshot entry; + + memset(&skel->bss->snapshot_cursor, 0, + sizeof(skel->bss->snapshot_cursor)); + printf("\nTop egress flows, ranked in the BPF rbtree by bytes:\n"); + printf("%-21s %-21s %-5s %10s %12s %-16s\n", + "SOURCE", "DESTINATION", "PROTO", "PACKETS", "BYTES", "COMM"); + for (unsigned int i = 0; i < top; i++) { + char source_ip[INET_ADDRSTRLEN], destination_ip[INET_ADDRSTRLEN]; + char source[64], destination[64]; + + if (snapshot_next(skel, &entry)) + return -1; + if (!entry.found) + break; + inet_ntop(AF_INET, &entry.key.source_ip, source_ip, + sizeof(source_ip)); + inet_ntop(AF_INET, &entry.key.destination_ip, destination_ip, + sizeof(destination_ip)); + snprintf(source, sizeof(source), "%s:%u", source_ip, + ntohs(entry.key.source_port)); + snprintf(destination, sizeof(destination), "%s:%u", + destination_ip, ntohs(entry.key.destination_port)); + printf("%-21s %-21s %-5s %10llu %12llu %-16s\n", + source, destination, + entry.key.protocol == IPPROTO_TCP ? "TCP" : "UDP", + entry.packets, entry.bytes, entry.comm); + } + return 0; +} + +static int attach_tc_program(struct bpf_tc_hook *hook, + struct bpf_tc_opts *attach, + bool *hook_created, bool *attached) +{ + int err = bpf_tc_hook_create(hook); + + if (!err) + *hook_created = true; + else if (err != -EEXIST) { + fprintf(stderr, "failed to create clsact hook: %s\n", strerror(-err)); + return -1; + } + err = bpf_tc_attach(hook, attach); + if (err) { + fprintf(stderr, "failed to attach TC program: %s\n", strerror(-err)); + return -1; + } + *attached = true; + return 0; +} + +static int capture_traffic(const struct options *options) +{ + unsigned long long deadline; + + if (options->demo) + return run_demo_traffic(); + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + deadline = monotonic_ns() + + (unsigned long long)options->duration_seconds * 1000000000ULL; + while (!stop && monotonic_ns() < deadline) { + struct timespec pause = { .tv_nsec = 100000000 }; + + nanosleep(&pause, NULL); + } + return 0; +} + +static int detach_tc_program(struct bpf_tc_hook *hook, + struct bpf_tc_opts *detach, bool *attached) +{ + int err = bpf_tc_detach(hook, detach); + + if (err) { + fprintf(stderr, "failed to detach TC program: %s\n", strerror(-err)); + return -1; + } + *attached = false; + return 0; +} + +int main(int argc, char **argv) +{ + struct options options = { .duration_seconds = 10, .top = 10 }; + struct tc_flow_index_bpf *skel = NULL; + LIBBPF_OPTS(bpf_tc_hook, hook, .attach_point = BPF_TC_EGRESS); + LIBBPF_OPTS(bpf_tc_opts, attach, .handle = 1, .priority = 1); + LIBBPF_OPTS(bpf_tc_opts, detach, .handle = 1, .priority = 1); + bool hook_created = false; + bool attached = false; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + hook.ifindex = if_nametoindex(options.interface); + if (!hook.ifindex) { + fprintf(stderr, "interface does not exist: %s\n", options.interface); + return 2; + } + + skel = tc_flow_index_bpf__open_and_load(); + if (!skel) { + fprintf(stderr, "failed to load TC flow index\n"); + goto cleanup; + } + attach.prog_fd = bpf_program__fd(skel->progs.index_egress_flow); + if (attach_tc_program(&hook, &attach, &hook_created, &attached)) + goto cleanup; + printf("Indexing IPv4 TCP/UDP egress flows on %s for %u seconds.\n", + options.interface, options.demo ? 0 : options.duration_seconds); + + if (capture_traffic(&options)) { + fprintf(stderr, "failed to generate traffic\n"); + goto cleanup; + } + + if (detach_tc_program(&hook, &detach, &attached)) + goto cleanup; + if (print_flows(skel, options.top)) + goto cleanup; + printf("observed_packets=%llu indexed_flows=%llu dropped_new=%llu " + "allocation_failures=%llu refcount_failures=%llu rank_update_failures=%llu\n", + (unsigned long long)skel->bss->observed_packets, + (unsigned long long)skel->bss->indexed_flows, + (unsigned long long)skel->bss->dropped_new_flows, + (unsigned long long)skel->bss->allocation_failures, + (unsigned long long)skel->bss->refcount_failures, + (unsigned long long)skel->bss->rank_update_failures); + if (options.demo && + (skel->bss->indexed_flows != 3 || skel->bss->rank_update_failures)) + goto cleanup; + err = 0; + +cleanup: + if (attached) + bpf_tc_detach(&hook, &detach); + if (hook_created) + bpf_tc_hook_destroy(&hook); + tc_flow_index_bpf__destroy(skel); + return err; +} +``` + +Detaching before output freezes packet updates, so the cursor walks one stable traffic ordering. `snapshot_next()` copies each result from BSS and advances the cursor until `found` becomes zero or `--top` entries have been printed. The index itself, including both trees and their object ownership, stays in BPF memory until the skeleton is destroyed. + +Demo mode opens three different UDP socket pairs and sends 2 × 100-byte, 4 × 300-byte, and 6 × 700-byte payloads over loopback. Ethernet, IPv4, and UDP headers make their observed totals 284, 1368, and 4452 bytes. This creates a deterministic ranking while exercising repeated remove-update-reinsert operations. + +## Build and Run + +Build the tool: + +```bash +cd src/56-tc-flow-index +make +``` + +Index egress traffic on an interface for 30 seconds and print its ten busiest flows: + +```bash +sudo ./tc_flow_index --interface eth0 --duration 30 --top 10 +``` + +Run the deterministic loopback demo: + +```bash +sudo ./tc_flow_index --demo --top 3 +``` + +A real demo run shows the two-tree ordering directly: + +```text +Indexing IPv4 TCP/UDP egress flows on lo for 0 seconds. + +Top egress flows, ranked in the BPF rbtree by bytes: +SOURCE DESTINATION PROTO PACKETS BYTES COMM +127.0.0.1:38918 127.0.0.1:47669 UDP 6 4452 tc_flow_index +127.0.0.1:57860 127.0.0.1:52539 UDP 4 1368 tc_flow_index +127.0.0.1:54177 127.0.0.1:45452 UDP 2 284 tc_flow_index +observed_packets=12 indexed_flows=3 dropped_new=0 allocation_failures=0 refcount_failures=0 rank_update_failures=0 +``` + +The table is already descending by `BYTES` when it reaches user space. Twelve packets created three identity entries, and all ownership-related counters remain zero. + +## Requirements + +| Requirement | Details | +|---|---| +| Kernel | Linux 6.16 or newer for refcounted BPF objects plus searchable rbtree kfuncs | +| Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_NET_SCHED`, `CONFIG_NET_CLS_BPF` | +| Privileges | Root, or equivalent BPF and network-administration capabilities | +| Interface | Any interface that can host a `clsact` egress program | +| Architecture and hardware | x86-64 is the declared and tested target; an ordinary network interface is sufficient | + +## Scope + +The index covers non-fragmented IPv4 TCP and UDP egress traffic and remembers the command name observed when a flow is first created. It has a fixed 4096-flow capacity and keeps entries until the program exits, which suits bounded experiments and short observation windows. A long-running service can add idle expiry and remove both nodes before dropping their references. + +## Summary + +This example uses BPF object ownership to maintain two useful orderings over one flow record. The identity tree handles per-packet updates, the traffic tree supplies top-flow traversal, and refcounts let both collections share the object without duplicating its state. + +> If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . + +## References + +- [BPF graph data structures](https://docs.kernel.org/bpf/graph_ds_impl.html) +- [Linux rbtree search selftest](https://github.com/torvalds/linux/blob/v6.16/tools/testing/selftests/bpf/progs/rbtree_search.c) +- [Linux refcounted graph selftests](https://github.com/torvalds/linux/tree/v6.16/tools/testing/selftests/bpf/progs) +- [libbpf TC attach implementation](https://github.com/libbpf/libbpf/blob/master/src/netlink.c) diff --git a/src/56-tc-flow-index/README.zh.md b/src/56-tc-flow-index/README.zh.md new file mode 100644 index 0000000..8bb59d3 --- /dev/null +++ b/src/56-tc-flow-index/README.zh.md @@ -0,0 +1,798 @@ +# eBPF 实战教程:用两棵 refcounted rbtree 索引 TC 流量 + +流量监控会遇到两种完全不同的查询。每个报文到来时,需要按五元组快速找到原有 flow 并更新计数;输出报告时,又希望同一批记录按流量大小排列,让最繁忙的 flow 出现在前面。一种排序很难同时高效回答两个问题,两份独立副本又会让计数和生命周期逐渐分叉。 + +本课围绕一个对象的两种视图构建 TC egress flow index。每条 IPv4 TCP 或 UDP 流会同时进入 identity rbtree 和按字节数排序的 traffic rbtree,BPF object allocator 创建记录,BPF refcount 让两棵树共同持有对象,rbtree traversal 则直接产出 top-flow 结果,用户态无需重建索引。 + +> 完整源代码: + +## 为什么同一个对象可以进入两棵树 + +eBPF 可以让经过验证器检查的程序运行在 TC 等内核数据路径上,并在多次调用之间保留结构化状态。Linux 6.4 引入 `bpf_refcount_acquire()`,动态分配的 BPF object 可以获得新的 owning reference。Linux 6.16 又加入 `bpf_rbtree_root()`、`bpf_rbtree_left()` 和 `bpf_rbtree_right()`,BPF 程序开始能够搜索并遍历有序树,也补齐了本例使用的整组能力。 + +每个 `flow_entry` 内嵌一个 `bpf_refcount` 和两个不同的 `bpf_rb_node`。`identity_root` 通过 `by_identity` 按源地址、目的地址、源端口、目的端口和协议排序;`traffic_root` 通过 `by_traffic` 按 bytes、packets 和五元组排序,其中 bytes 与 packets 都是降序,五元组负责稳定处理并列项。两个 node 最终都能通过 `container_of()` 回到同一份计数和时间戳。 + +先看一条新 flow 的第一个报文。TC 程序搜索 identity tree,没有发现匹配项,于是分配一个 `flow_entry`,再调用 `bpf_refcount_acquire()` 取得第二个 owning reference。第一次 `bpf_rbtree_add()` 把一个 reference 交给 identity tree,第二次调用把另一个交给 traffic tree。临界区结束以后没有裸指针逃逸,验证器可以证明两棵 collection 都拥有这个对象。 + +后续报文会在 identity tree 找到条目,但 bytes 增加后,原来的 traffic 顺序已经失效。程序只从 traffic tree 移除 `by_traffic`,remove 返回这棵树持有的 owning reference;计数更新以后,同一个 traffic node 会重新插入正确位置。identity node 始终留在原位,因此查找顺序在整个更新期间保持有效。 + +## 共享的 flow 与 cursor 类型 + +共享头文件定义五元组、BPF 返回的 snapshot,以及继续有序遍历所需的 cursor。 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __TC_FLOW_INDEX_H +#define __TC_FLOW_INDEX_H + +#define FLOW_COMM_LEN 16 + +struct flow_key { + unsigned int source_ip; + unsigned int destination_ip; + unsigned short source_port; + unsigned short destination_port; + unsigned char protocol; + unsigned char padding[3]; +}; + +struct flow_snapshot { + unsigned long long packets; + unsigned long long bytes; + unsigned long long last_seen_ns; + struct flow_key key; + unsigned int found; + char comm[FLOW_COMM_LEN]; +}; + +struct flow_cursor { + unsigned long long bytes; + unsigned long long packets; + struct flow_key key; + unsigned int valid; +}; + +#endif /* __TC_FLOW_INDEX_H */ +``` + +`flow_key` 中的地址和端口保留 network byte order,BPF 侧可以直接复制报文字段,只有用户态格式化表格时才作转换。 + +## 实验性 kfunc 声明 + +BPF graph API 使用 kfunc,而不是稳定 UAPI helper。这个兼容头文件声明程序实际调用的函数,并用本地 BTF type ID 包装 object allocator。 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __TC_FLOW_INDEX_EXPERIMENTAL_H +#define __TC_FLOW_INDEX_EXPERIMENTAL_H + +#include + +#define __contains(name, node) \ + __attribute__((btf_decl_tag("contains:" #name ":" #node))) + +extern void *bpf_obj_new_impl(__u64 local_type_id, void *meta) __ksym; +#define bpf_obj_new(type) \ + ((type *)bpf_obj_new_impl(bpf_core_type_id_local(type), NULL)) + +extern void bpf_obj_drop_impl(void *kptr, void *meta) __ksym; +#define bpf_obj_drop(kptr) bpf_obj_drop_impl(kptr, NULL) + +extern void *bpf_refcount_acquire_impl(void *kptr, void *meta) __ksym; +#define bpf_refcount_acquire(kptr) \ + bpf_refcount_acquire_impl(kptr, NULL) + +extern int bpf_rbtree_add_impl(struct bpf_rb_root *root, + struct bpf_rb_node *node, + bool (*less)(struct bpf_rb_node *, + const struct bpf_rb_node *), + void *meta, __u64 off) __ksym; +#define bpf_rbtree_add(root, node, less) \ + bpf_rbtree_add_impl(root, node, less, NULL, 0) + +extern struct bpf_rb_node * +bpf_rbtree_remove(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_first(struct bpf_rb_root *root) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_root(struct bpf_rb_root *root) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_left(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; +extern struct bpf_rb_node * +bpf_rbtree_right(struct bpf_rb_root *root, struct bpf_rb_node *node) __ksym; + +#endif /* __TC_FLOW_INDEX_EXPERIMENTAL_H */ +``` + +`__contains(flow_entry, by_identity)` 和 traffic tree 对应的声明会告诉验证器,每个 root 关联的 containing type 与 node member。验证器再把这层类型关系和相邻 spin lock 结合起来,检查 graph ownership 与临界区规则。kfunc 接口可能随内核演进,因此本课明确记录最低版本,并把声明放在例子旁边。 + +## 在 TC egress 维护两份索引 + +下面是完整 BPF 程序。 + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "bpf_experimental.h" +#include "tc_flow_index.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define ETH_P_IP 0x0800 +#define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff +#define INDEX_SEARCH_DEPTH 32 +#define TC_ACT_OK 0 + +const volatile __u32 maximum_entries = 4096; + +struct flow_entry { + struct bpf_refcount ref; + struct bpf_rb_node by_identity; + struct bpf_rb_node by_traffic; + struct flow_key key; + __u64 packets; + __u64 bytes; + __u64 last_seen_ns; + char comm[FLOW_COMM_LEN]; +}; + +#define private(name) \ + SEC(".data." #name) __hidden __attribute__((aligned(8))) + +private(index) struct bpf_spin_lock index_lock; +private(index) struct bpf_rb_root identity_root + __contains(flow_entry, by_identity); +private(index) struct bpf_rb_root traffic_root + __contains(flow_entry, by_traffic); + +struct flow_cursor snapshot_cursor; +struct flow_snapshot snapshot_result; +__u64 observed_packets; +__u64 indexed_flows; +__u64 dropped_new_flows; +__u64 allocation_failures; +__u64 refcount_failures; +__u64 rank_update_failures; + +static __always_inline int compare_key(const struct flow_key *left, + const struct flow_key *right) +{ + if (left->source_ip != right->source_ip) + return left->source_ip < right->source_ip ? -1 : 1; + if (left->destination_ip != right->destination_ip) + return left->destination_ip < right->destination_ip ? -1 : 1; + if (left->source_port != right->source_port) + return left->source_port < right->source_port ? -1 : 1; + if (left->destination_port != right->destination_port) + return left->destination_port < right->destination_port ? -1 : 1; + if (left->protocol != right->protocol) + return left->protocol < right->protocol ? -1 : 1; + return 0; +} + +static bool identity_less(struct bpf_rb_node *a, + const struct bpf_rb_node *b) +{ + struct flow_entry *left = container_of(a, struct flow_entry, + by_identity); + struct flow_entry *right = container_of(b, struct flow_entry, + by_identity); + + return compare_key(&left->key, &right->key) < 0; +} + +static __always_inline bool traffic_before(__u64 left_bytes, + __u64 left_packets, + const struct flow_key *left_key, + __u64 right_bytes, + __u64 right_packets, + const struct flow_key *right_key) +{ + if (left_bytes != right_bytes) + return left_bytes > right_bytes; + if (left_packets != right_packets) + return left_packets > right_packets; + return compare_key(left_key, right_key) < 0; +} + +static bool traffic_less(struct bpf_rb_node *a, + const struct bpf_rb_node *b) +{ + struct flow_entry *left = container_of(a, struct flow_entry, by_traffic); + struct flow_entry *right = container_of(b, struct flow_entry, by_traffic); + + return traffic_before(left->bytes, left->packets, &left->key, + right->bytes, right->packets, &right->key); +} + +static __always_inline struct flow_entry * +find_flow_locked(const struct flow_key *key) +{ + struct bpf_rb_node *rb = bpf_rbtree_root(&identity_root); + + for (int i = 0; i < INDEX_SEARCH_DEPTH && rb; i++) { + struct flow_entry *entry = + container_of(rb, struct flow_entry, by_identity); + int comparison = compare_key(key, &entry->key); + + if (!comparison) + return entry; + if (comparison < 0) + rb = bpf_rbtree_left(&identity_root, rb); + else + rb = bpf_rbtree_right(&identity_root, rb); + } + return NULL; +} + +static __always_inline bool update_existing_locked(struct flow_entry *entry, + __u32 packet_bytes, + __u64 now_ns) +{ + struct flow_entry *owner; + struct bpf_rb_node *removed; + + removed = bpf_rbtree_remove(&traffic_root, &entry->by_traffic); + if (!removed) + return false; + owner = container_of(removed, struct flow_entry, by_traffic); + owner->packets++; + owner->bytes += packet_bytes; + owner->last_seen_ns = now_ns; + bpf_rbtree_add(&traffic_root, &owner->by_traffic, traffic_less); + return true; +} + +static __always_inline void update_flow(const struct flow_key *key, + __u32 packet_bytes, __u64 now_ns, + const char comm[FLOW_COMM_LEN]) +{ + struct flow_entry *entry, *new_entry, *traffic_owner; + + bpf_spin_lock(&index_lock); + entry = find_flow_locked(key); + if (entry) { + if (!update_existing_locked(entry, packet_bytes, now_ns)) + rank_update_failures++; + bpf_spin_unlock(&index_lock); + return; + } + bpf_spin_unlock(&index_lock); + + new_entry = bpf_obj_new(typeof(*new_entry)); + if (!new_entry) { + __sync_fetch_and_add(&allocation_failures, 1); + return; + } + traffic_owner = bpf_refcount_acquire(new_entry); + if (!traffic_owner) { + __sync_fetch_and_add(&refcount_failures, 1); + bpf_obj_drop(new_entry); + return; + } + new_entry->key.source_ip = key->source_ip; + new_entry->key.destination_ip = key->destination_ip; + new_entry->key.source_port = key->source_port; + new_entry->key.destination_port = key->destination_port; + new_entry->key.protocol = key->protocol; + new_entry->packets = 1; + new_entry->bytes = packet_bytes; + new_entry->last_seen_ns = now_ns; + __builtin_memcpy(new_entry->comm, comm, sizeof(new_entry->comm)); + + bpf_spin_lock(&index_lock); + entry = find_flow_locked(key); + if (entry) { + if (!update_existing_locked(entry, packet_bytes, now_ns)) + rank_update_failures++; + bpf_spin_unlock(&index_lock); + bpf_obj_drop(new_entry); + bpf_obj_drop(traffic_owner); + return; + } + if (indexed_flows >= maximum_entries) { + dropped_new_flows++; + bpf_spin_unlock(&index_lock); + bpf_obj_drop(new_entry); + bpf_obj_drop(traffic_owner); + return; + } + bpf_rbtree_add(&identity_root, &new_entry->by_identity, identity_less); + bpf_rbtree_add(&traffic_root, &traffic_owner->by_traffic, traffic_less); + indexed_flows++; + bpf_spin_unlock(&index_lock); +} + +static __always_inline bool parse_flow(struct __sk_buff *skb, + struct flow_key *key) +{ + struct ethhdr ethernet; + struct iphdr ip; + __u32 transport_offset; + __be16 ports[2]; + + if (bpf_skb_load_bytes(skb, 0, ðernet, sizeof(ethernet)) || + ethernet.h_proto != bpf_htons(ETH_P_IP)) + return false; + if (bpf_skb_load_bytes(skb, sizeof(ethernet), &ip, sizeof(ip)) || + ip.version != 4 || ip.ihl < 5 || + (bpf_ntohs(ip.frag_off) & (IP_MF | IP_OFFSET)) || + (ip.protocol != IPPROTO_TCP && ip.protocol != IPPROTO_UDP)) + return false; + transport_offset = sizeof(ethernet) + ip.ihl * 4; + if (bpf_skb_load_bytes(skb, transport_offset, ports, sizeof(ports))) + return false; + key->source_ip = ip.saddr; + key->destination_ip = ip.daddr; + key->source_port = ports[0]; + key->destination_port = ports[1]; + key->protocol = ip.protocol; + return true; +} + +SEC("tc") +int index_egress_flow(struct __sk_buff *skb) +{ + struct flow_key key = {}; + char comm[FLOW_COMM_LEN]; + + if (!parse_flow(skb, &key)) + return TC_ACT_OK; + __sync_fetch_and_add(&observed_packets, 1); + bpf_get_current_comm(comm, sizeof(comm)); + update_flow(&key, skb->len, bpf_ktime_get_ns(), comm); + return TC_ACT_OK; +} + +static __always_inline bool cursor_before_entry(const struct flow_cursor *cursor, + const struct flow_entry *entry) +{ + return traffic_before(cursor->bytes, cursor->packets, &cursor->key, + entry->bytes, entry->packets, &entry->key); +} + +SEC("syscall") +int snapshot_next(void *ctx) +{ + struct flow_entry *entry = NULL; + struct bpf_rb_node *candidate = NULL; + struct bpf_rb_node *rb; + + (void)ctx; + __builtin_memset(&snapshot_result, 0, sizeof(snapshot_result)); + bpf_spin_lock(&index_lock); + if (!snapshot_cursor.valid) { + candidate = bpf_rbtree_first(&traffic_root); + } else { + rb = bpf_rbtree_root(&traffic_root); + for (int i = 0; i < INDEX_SEARCH_DEPTH && rb; i++) { + entry = container_of(rb, struct flow_entry, by_traffic); + if (cursor_before_entry(&snapshot_cursor, entry)) { + candidate = rb; + rb = bpf_rbtree_left(&traffic_root, rb); + } else { + rb = bpf_rbtree_right(&traffic_root, rb); + } + } + } + if (candidate) { + entry = container_of(candidate, struct flow_entry, by_traffic); + snapshot_result.packets = entry->packets; + snapshot_result.bytes = entry->bytes; + snapshot_result.last_seen_ns = entry->last_seen_ns; + snapshot_result.key.source_ip = entry->key.source_ip; + snapshot_result.key.destination_ip = entry->key.destination_ip; + snapshot_result.key.source_port = entry->key.source_port; + snapshot_result.key.destination_port = entry->key.destination_port; + snapshot_result.key.protocol = entry->key.protocol; + snapshot_result.found = 1; + __builtin_memcpy(snapshot_result.comm, entry->comm, + sizeof(snapshot_result.comm)); + } + bpf_spin_unlock(&index_lock); + return 0; +} +``` + +`index_lock`、`identity_root` 和 `traffic_root` 位于同一个 private map value,所有搜索与修改都持有这把锁。二叉树搜索最多执行 32 步,让验证器看到确定的控制流;索引最多保存 4096 个 entry,平衡 rbtree 的实际深度远小于这个上限。 + +第一次加锁查找失败以后,程序会在锁外分配对象,再次加锁并重新查找。这次 double check 用于处理两个 CPU 同时发现新 flow 的情况:胜者把对象交给两棵树,另一方更新已有条目,并释放自己刚分配的两个 reference。allocation、refcount、capacity 和 re-ranking failure 都有独立计数,ownership 问题可以直接从输出中看到。 + +`parse_flow()` 接收未分片的 IPv4 TCP 与 UDP 报文,从 Ethernet、IP 和 transport header 组装 key。`index_egress_flow()` 始终返回 `TC_ACT_OK`,工具只观察并建立索引,不会改变报文传输。 + +`snapshot_next` 程序在同一把锁下读取 traffic tree。空 cursor 从 `bpf_rbtree_first()` 开始,之后则搜索前一个 `(bytes, packets, key)` 之后的第一项。result 与 cursor 都放在 BSS,用户态每次取一个有序 entry,不需要接收每报文事件流。 + +## 挂载 TC 并读取稳定 snapshot + +用户态程序挂载 classifier,按需生成 demo 流量,在 snapshot 之前解除挂载,再通过 `BPF_PROG_TEST_RUN` 调用 snapshot program。 + +```c +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "tc_flow_index.h" +#include "tc_flow_index.skel.h" + +#define MAX_TOP 64 + +struct options { + const char *interface; + unsigned int duration_seconds; + unsigned int top; + bool demo; +}; + +static volatile sig_atomic_t stop; + +static void handle_signal(int signal_number) +{ + (void)signal_number; + stop = 1; +} + +static unsigned long long monotonic_ns(void) +{ + struct timespec now; + + clock_gettime(CLOCK_MONOTONIC, &now); + return (unsigned long long)now.tv_sec * 1000000000ULL + now.tv_nsec; +} + +static int parse_uint(const char *text, unsigned int maximum, + unsigned int *value) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(text, &end, 10); + if (errno || !*text || *end || !parsed || parsed > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --interface IFACE [--duration SEC] [--top N]\n" + " %s --demo [--top N]\n", program, program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "interface", required_argument, NULL, 'i' }, + { "duration", required_argument, NULL, 'd' }, + { "top", required_argument, NULL, 't' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "i:d:t:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'i': options->interface = optarg; break; + case 'd': + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 't': + if (parse_uint(optarg, MAX_TOP, &options->top)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + if (options->demo) { + if (options->interface) + return -1; + options->interface = "lo"; + } + return optind == argc && options->interface ? 0 : -1; +} + +static int send_demo_flow(unsigned int datagrams, size_t payload_size) +{ + struct sockaddr_in receiver_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + struct sockaddr_in sender_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + socklen_t address_length = sizeof(receiver_address); + char payload[1000] = {}; + int receiver = -1, sender = -1; + int err = -1; + + if (payload_size > sizeof(payload)) + return -1; + receiver = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + sender = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (receiver < 0 || sender < 0 || + bind(receiver, (struct sockaddr *)&receiver_address, + sizeof(receiver_address)) || + getsockname(receiver, (struct sockaddr *)&receiver_address, + &address_length) || + bind(sender, (struct sockaddr *)&sender_address, + sizeof(sender_address))) + goto cleanup; + for (unsigned int i = 0; i < datagrams; i++) + if (sendto(sender, payload, payload_size, 0, + (struct sockaddr *)&receiver_address, + sizeof(receiver_address)) != (ssize_t)payload_size) + goto cleanup; + err = 0; + +cleanup: + if (receiver >= 0) close(receiver); + if (sender >= 0) close(sender); + return err; +} + +static int run_demo_traffic(void) +{ + return send_demo_flow(2, 100) || + send_demo_flow(4, 300) || + send_demo_flow(6, 700); +} + +static int snapshot_next(struct tc_flow_index_bpf *skel, + struct flow_snapshot *result) +{ + LIBBPF_OPTS(bpf_test_run_opts, opts); + + if (bpf_prog_test_run_opts(bpf_program__fd(skel->progs.snapshot_next), + &opts)) { + fprintf(stderr, "failed to query BPF flow index: %s\n", + strerror(errno)); + return -1; + } + *result = skel->bss->snapshot_result; + if (result->found) { + skel->bss->snapshot_cursor.bytes = result->bytes; + skel->bss->snapshot_cursor.packets = result->packets; + skel->bss->snapshot_cursor.key = result->key; + skel->bss->snapshot_cursor.valid = 1; + } + return 0; +} + +static int print_flows(struct tc_flow_index_bpf *skel, unsigned int top) +{ + struct flow_snapshot entry; + + memset(&skel->bss->snapshot_cursor, 0, + sizeof(skel->bss->snapshot_cursor)); + printf("\nTop egress flows, ranked in the BPF rbtree by bytes:\n"); + printf("%-21s %-21s %-5s %10s %12s %-16s\n", + "SOURCE", "DESTINATION", "PROTO", "PACKETS", "BYTES", "COMM"); + for (unsigned int i = 0; i < top; i++) { + char source_ip[INET_ADDRSTRLEN], destination_ip[INET_ADDRSTRLEN]; + char source[64], destination[64]; + + if (snapshot_next(skel, &entry)) + return -1; + if (!entry.found) + break; + inet_ntop(AF_INET, &entry.key.source_ip, source_ip, + sizeof(source_ip)); + inet_ntop(AF_INET, &entry.key.destination_ip, destination_ip, + sizeof(destination_ip)); + snprintf(source, sizeof(source), "%s:%u", source_ip, + ntohs(entry.key.source_port)); + snprintf(destination, sizeof(destination), "%s:%u", + destination_ip, ntohs(entry.key.destination_port)); + printf("%-21s %-21s %-5s %10llu %12llu %-16s\n", + source, destination, + entry.key.protocol == IPPROTO_TCP ? "TCP" : "UDP", + entry.packets, entry.bytes, entry.comm); + } + return 0; +} + +static int attach_tc_program(struct bpf_tc_hook *hook, + struct bpf_tc_opts *attach, + bool *hook_created, bool *attached) +{ + int err = bpf_tc_hook_create(hook); + + if (!err) + *hook_created = true; + else if (err != -EEXIST) { + fprintf(stderr, "failed to create clsact hook: %s\n", strerror(-err)); + return -1; + } + err = bpf_tc_attach(hook, attach); + if (err) { + fprintf(stderr, "failed to attach TC program: %s\n", strerror(-err)); + return -1; + } + *attached = true; + return 0; +} + +static int capture_traffic(const struct options *options) +{ + unsigned long long deadline; + + if (options->demo) + return run_demo_traffic(); + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + deadline = monotonic_ns() + + (unsigned long long)options->duration_seconds * 1000000000ULL; + while (!stop && monotonic_ns() < deadline) { + struct timespec pause = { .tv_nsec = 100000000 }; + + nanosleep(&pause, NULL); + } + return 0; +} + +static int detach_tc_program(struct bpf_tc_hook *hook, + struct bpf_tc_opts *detach, bool *attached) +{ + int err = bpf_tc_detach(hook, detach); + + if (err) { + fprintf(stderr, "failed to detach TC program: %s\n", strerror(-err)); + return -1; + } + *attached = false; + return 0; +} + +int main(int argc, char **argv) +{ + struct options options = { .duration_seconds = 10, .top = 10 }; + struct tc_flow_index_bpf *skel = NULL; + LIBBPF_OPTS(bpf_tc_hook, hook, .attach_point = BPF_TC_EGRESS); + LIBBPF_OPTS(bpf_tc_opts, attach, .handle = 1, .priority = 1); + LIBBPF_OPTS(bpf_tc_opts, detach, .handle = 1, .priority = 1); + bool hook_created = false; + bool attached = false; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + hook.ifindex = if_nametoindex(options.interface); + if (!hook.ifindex) { + fprintf(stderr, "interface does not exist: %s\n", options.interface); + return 2; + } + + skel = tc_flow_index_bpf__open_and_load(); + if (!skel) { + fprintf(stderr, "failed to load TC flow index\n"); + goto cleanup; + } + attach.prog_fd = bpf_program__fd(skel->progs.index_egress_flow); + if (attach_tc_program(&hook, &attach, &hook_created, &attached)) + goto cleanup; + printf("Indexing IPv4 TCP/UDP egress flows on %s for %u seconds.\n", + options.interface, options.demo ? 0 : options.duration_seconds); + + if (capture_traffic(&options)) { + fprintf(stderr, "failed to generate traffic\n"); + goto cleanup; + } + + if (detach_tc_program(&hook, &detach, &attached)) + goto cleanup; + if (print_flows(skel, options.top)) + goto cleanup; + printf("observed_packets=%llu indexed_flows=%llu dropped_new=%llu " + "allocation_failures=%llu refcount_failures=%llu rank_update_failures=%llu\n", + (unsigned long long)skel->bss->observed_packets, + (unsigned long long)skel->bss->indexed_flows, + (unsigned long long)skel->bss->dropped_new_flows, + (unsigned long long)skel->bss->allocation_failures, + (unsigned long long)skel->bss->refcount_failures, + (unsigned long long)skel->bss->rank_update_failures); + if (options.demo && + (skel->bss->indexed_flows != 3 || skel->bss->rank_update_failures)) + goto cleanup; + err = 0; + +cleanup: + if (attached) + bpf_tc_detach(&hook, &detach); + if (hook_created) + bpf_tc_hook_destroy(&hook); + tc_flow_index_bpf__destroy(skel); + return err; +} +``` + +输出前解除挂载会冻结所有 packet update,cursor 因此可以遍历一份稳定的 traffic ordering。`snapshot_next()` 从 BSS 复制结果并推进 cursor,直到 `found` 变成 0,或者已经打印 `--top` 指定的数量。包括两棵树和 object ownership 在内的索引仍保存在 BPF 内存中,直到 skeleton 被销毁。 + +demo 模式创建三组 UDP socket pair,在 loopback 上分别发送 2 × 100 字节、4 × 300 字节和 6 × 700 字节 payload。算上 Ethernet、IPv4 与 UDP header,最终观测到 284、1368 和 4452 字节。这组流量会稳定产生三档排名,同时反复执行 traffic node 的 remove、update 和 reinsert。 + +## 编译和运行 + +构建工具: + +```bash +cd src/56-tc-flow-index +make +``` + +在指定接口上索引 30 秒 egress 流量,并打印最繁忙的 10 条 flow: + +```bash +sudo ./tc_flow_index --interface eth0 --duration 30 --top 10 +``` + +运行可重复的 loopback demo: + +```bash +sudo ./tc_flow_index --demo --top 3 +``` + +一次真实 demo 会直接展示双树排序结果: + +```text +Indexing IPv4 TCP/UDP egress flows on lo for 0 seconds. + +Top egress flows, ranked in the BPF rbtree by bytes: +SOURCE DESTINATION PROTO PACKETS BYTES COMM +127.0.0.1:38918 127.0.0.1:47669 UDP 6 4452 tc_flow_index +127.0.0.1:57860 127.0.0.1:52539 UDP 4 1368 tc_flow_index +127.0.0.1:54177 127.0.0.1:45452 UDP 2 284 tc_flow_index +observed_packets=12 indexed_flows=3 dropped_new=0 allocation_failures=0 refcount_failures=0 rank_update_failures=0 +``` + +表格到达用户态时已经按照 `BYTES` 降序排列,12 个报文创建 3 个 identity entry,所有 ownership 相关失败计数都是 0。 + +## 环境要求 + +| 要求 | 说明 | +|---|---| +| 内核 | Linux 6.16 或更高版本,需要 refcounted BPF object 与可搜索的 rbtree kfunc | +| 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_NET_SCHED`、`CONFIG_NET_CLS_BPF` | +| 权限 | root,或者等价的 BPF 与网络管理 capability | +| 网络接口 | 可以挂载 `clsact` egress program 的接口 | +| 架构与硬件 | 当前声明并完成测试的目标是 x86-64,普通网络接口即可 | + +## 实现范围 + +这个索引覆盖未分片的 IPv4 TCP 与 UDP egress 流量,并保留 flow 首次创建时观察到的 command name。容量固定为 4096 条 flow,entry 会一直保留到程序退出,适合有边界的实验和短时间观察。长期运行的服务可以继续加入 idle expiry,在释放 reference 之前从两棵树移除对应 node。 + +## 总结 + +这个例子用 BPF object ownership 在同一条 flow 记录上维护两种有用顺序。identity tree 负责每报文更新,traffic tree 提供 top-flow traversal,refcount 则让两棵 collection 共享对象,同时保持状态只有一份。 + +> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 + +## 参考资料 + +- [BPF graph data structures](https://docs.kernel.org/bpf/graph_ds_impl.html) +- [Linux rbtree search selftest](https://github.com/torvalds/linux/blob/v6.16/tools/testing/selftests/bpf/progs/rbtree_search.c) +- [Linux refcounted graph selftests](https://github.com/torvalds/linux/tree/v6.16/tools/testing/selftests/bpf/progs) +- [libbpf TC attach implementation](https://github.com/libbpf/libbpf/blob/master/src/netlink.c) diff --git a/src/57-oom-watch/README.md b/src/57-oom-watch/README.md index 6bf3575..94a8aef 100644 --- a/src/57-oom-watch/README.md +++ b/src/57-oom-watch/README.md @@ -1,37 +1,70 @@ -# eBPF Tutorial by Example: OOM Victim Monitoring with Reclaim Profiling +# eBPF Tutorial by Example: Profile memcg Reclaim Before an OOM Kill -When the kernel kills a process due to memory pressure, how do you know which cgroup was under stress and how hard it was fighting to reclaim memory before the OOM killer stepped in? This tutorial builds a tool that tracks memory reclaim activity per cgroup and captures detailed context when the OOM killer selects a victim. +An OOM log answers the last question in a memory failure: which task did the kernel choose to kill? It says much less about the work that led there. Operators still need to know how often the target memory cgroup entered reclaim, how long those attempts took, which kernel paths consumed the time, and whether the victim actually exited. + +This tutorial builds `oom-watch` to preserve that missing history. It profiles memcg reclaim as a latency distribution and a set of sampled kernel stacks, then attaches the accumulated profile to the selected OOM victim and follows that victim to process exit. > Complete source code: -## eBPF and OOM Tracepoints +## Reclaim Is the Story Before the Kill -eBPF lets verified programs run at Linux kernel hooks and send selected state to user space. This tutorial uses the `oom/mark_victim` tracepoint to capture when the OOM killer selects a victim, and `mm_vmscan_memcg_reclaim_begin/end` tracepoints to track memory reclaim activity. Linux 7.1 added the target `mem_cgroup` to these vmscan tracepoints, which lets the BPF program attribute reclaim to the memory cgroup being scanned instead of the task that happened to trigger it. Victim lookup uses the `bpf_task_from_pid()` kfunc introduced in Linux 6.2, while ordinary tracepoint programs gained tracing-kfunc access in Linux 6.12. The vmscan tracepoint change is the newest dependency, so this implementation requires Linux 7.1 or later. +When a memory cgroup approaches its limit, an allocation may enter reclaim and scan that memcg for pages it can free. Several short attempts can succeed partially, or repeated reclaim can make little progress until the OOM killer selects a victim. Looking only at `oom/mark_victim` loses both the time distribution and the call paths of those attempts. -## How the Implementation Works +eBPF runs verifier-checked programs at kernel events and uses maps to carry state between them. Linux 7.1 added the target `mem_cgroup` to the `mm_vmscan_memcg_reclaim_begin` and `mm_vmscan_memcg_reclaim_end` tracepoints. That argument matters because the current task can reclaim a different cgroup through `memory.reclaim`; attribution now follows the memcg being scanned instead of the task that triggered the work. -The tool tracks two phases of the OOM lifecycle. Before any OOM event, the kernel attempts memory reclaim. We count reclaim cycles per memory cgroup, track how many pages were reclaimed, and note when reclaim was triggered from a different cgroup (cross-cgroup reclaim). When the OOM killer selects a victim, we capture the victim's memory statistics, link it to the accumulated reclaim profile, and track the victim until it exits. +The second dependency is victim lookup. `oom/mark_victim` reports the selected thread ID, while diagnosis also needs its thread-group ID and cgroup. `bpf_task_from_pid()` arrived in Linux 6.2, and regular tracepoint programs gained access to this tracing kfunc class in Linux 6.12. The newer vmscan tracepoint signature therefore sets the complete tool's minimum kernel at Linux 7.1. -## Header File +Follow one reclaim interval. At the begin tracepoint, the BPF program records the monotonic start time and target cgroup under the current `pid_tgid`. According to `--sample-every`, it also captures a kernel stack ID. The matching end tracepoint computes duration, increments a power-of-two microsecond bucket, and adds reclaimed pages. A second map groups sampled intervals by `(cgroup_id, stack_id)` and accumulates sample count, total time, maximum time, and reclaimed pages for each path. + +When OOM selects a victim, the program resolves that TID to its TGID and cgroup, copies the cgroup's reclaim profile into a ring-buffer event, and stores victim state keyed by TID. `sched_process_exit` later consumes that state and reports the exit code. The result connects activity accumulated before the kill with the exact thread the kernel marked and the process lifecycle that followed. + +## Profile and Event Layouts + +The shared header defines the cgroup-level histogram, per-stack aggregate, and the event that carries an OOM snapshot. ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ #ifndef __OOM_WATCH_H #define __OOM_WATCH_H +#define OOM_RECLAIM_BUCKETS 20 +#define OOM_STACK_DEPTH 127 + enum oom_watch_event_type { OOM_VICTIM_MARKED = 1, OOM_VICTIM_EXITED = 2, }; -struct oom_watch_event { - unsigned long long timestamp_ns; - unsigned long long cgroup_id; - unsigned long long reclaim_begin_count; - unsigned long long reclaim_end_count; +struct reclaim_profile { + unsigned long long begin_count; + unsigned long long end_count; unsigned long long reclaimed_pages; unsigned long long cross_cgroup_reclaims; unsigned long long last_reclaim_ns; + unsigned long long total_reclaim_ns; + unsigned long long maximum_reclaim_ns; + unsigned long long stack_samples; + unsigned long long stack_failures; + unsigned long long latency_slots[OOM_RECLAIM_BUCKETS]; +}; + +struct reclaim_stack_key { + unsigned long long cgroup_id; + signed int stack_id; + unsigned int padding; +}; + +struct reclaim_stack_profile { + unsigned long long samples; + unsigned long long total_ns; + unsigned long long maximum_ns; + unsigned long long reclaimed_pages; +}; + +struct oom_watch_event { + unsigned long long timestamp_ns; + unsigned long long cgroup_id; + struct reclaim_profile profile; unsigned long long total_vm_kb; unsigned long long anon_rss_kb; unsigned long long file_rss_kb; @@ -46,9 +79,11 @@ struct oom_watch_event { #endif /* __OOM_WATCH_H */ ``` -The header defines the event structure sent to user space. Each event includes the cgroup ID, reclaim statistics (begin/end counts, pages reclaimed, cross-cgroup reclaims), victim memory statistics from the tracepoint, and exit code when the victim terminates. +`latency_slots` contains 20 base-2 microsecond ranges. Bucket 0 covers 0–1 µs, bucket 1 covers 2–3 µs, then 4–7 µs and so on; the final bucket contains every interval of at least 524288 µs. The event embeds a complete `reclaim_profile`, so the numbers printed with a victim describe the profile as it stood when that victim was selected. -## BPF Program +## Profiling Reclaim and Tracking the Victim + +Here is the complete BPF program. ```c // SPDX-License-Identifier: GPL-2.0 @@ -61,16 +96,16 @@ The header defines the event structure sent to user space. Each event includes t char LICENSE[] SEC("license") = "GPL"; const volatile __u64 target_cgroup_id; +const volatile __u32 sample_every = 1; extern struct task_struct *bpf_task_from_pid(__s32 pid) __ksym; extern void bpf_task_release(struct task_struct *task) __ksym; -struct reclaim_profile { - __u64 begin_count; - __u64 end_count; - __u64 reclaimed_pages; - __u64 cross_cgroup_reclaims; - __u64 last_reclaim_ns; +struct active_reclaim { + __u64 started_ns; + __u64 cgroup_id; + __s32 stack_id; + __u32 padding; }; struct victim_state { @@ -86,6 +121,27 @@ struct { __type(value, struct reclaim_profile); } profiles SEC(".maps"); +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, __u64); + __type(value, struct active_reclaim); +} active_reclaims SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_STACK_TRACE); + __uint(max_entries, 1024); + __uint(key_size, sizeof(__u32)); + __uint(value_size, OOM_STACK_DEPTH * sizeof(__u64)); +} stack_traces SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 8192); + __type(key, struct reclaim_stack_key); + __type(value, struct reclaim_stack_profile); +} stack_profiles SEC(".maps"); + struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); @@ -99,6 +155,7 @@ struct { } events SEC(".maps"); __u64 dropped_victim_states; +__u64 dropped_reclaim_states; static __always_inline bool selected_cgroup(__u64 cgroup_id) { @@ -142,12 +199,68 @@ static __always_inline struct reclaim_profile *get_profile(__u64 cgroup_id) return bpf_map_lookup_elem(&profiles, &cgroup_id); } +static __always_inline __u32 latency_bucket(__u64 duration_ns) +{ + __u64 microseconds = duration_ns / 1000; + __u32 bucket = 0; + + for (int i = 0; i < OOM_RECLAIM_BUCKETS - 1; i++) { + if (microseconds < 2) + break; + microseconds >>= 1; + bucket++; + } + return bucket; +} + +static __always_inline void update_maximum(__u64 *maximum, __u64 value) +{ + __u64 previous = *maximum; + + for (int i = 0; i < 8 && previous < value; i++) { + __u64 observed = __sync_val_compare_and_swap(maximum, previous, + value); + + if (observed == previous) + break; + previous = observed; + } +} + +static __always_inline void update_stack_profile(__u64 cgroup_id, + __s32 stack_id, + __u64 duration_ns, + __u64 reclaimed) +{ + struct reclaim_stack_key key = { + .cgroup_id = cgroup_id, + .stack_id = stack_id, + }; + struct reclaim_stack_profile initial = {}; + struct reclaim_stack_profile *profile; + + profile = bpf_map_lookup_elem(&stack_profiles, &key); + if (!profile) { + bpf_map_update_elem(&stack_profiles, &key, &initial, BPF_NOEXIST); + profile = bpf_map_lookup_elem(&stack_profiles, &key); + } + if (!profile) + return; + __sync_fetch_and_add(&profile->samples, 1); + __sync_fetch_and_add(&profile->total_ns, duration_ns); + __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); + update_maximum(&profile->maximum_ns, duration_ns); +} + SEC("tp_btf/mm_vmscan_memcg_reclaim_begin") -int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, +int BPF_PROG(profile_reclaim_begin, gfp_t gfp_flags, int order, struct mem_cgroup *memcg) { + struct active_reclaim active = { .stack_id = -1 }; struct reclaim_profile *profile; + __u64 pid_tgid = bpf_get_current_pid_tgid(); __u64 cgroup_id = memcg_cgroup_id(memcg); + __u64 sequence; (void)gfp_flags; (void)order; @@ -156,28 +269,56 @@ int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, profile = get_profile(cgroup_id); if (!profile) return 0; - __sync_fetch_and_add(&profile->begin_count, 1); + sequence = __sync_fetch_and_add(&profile->begin_count, 1); if (bpf_get_current_cgroup_id() != cgroup_id) __sync_fetch_and_add(&profile->cross_cgroup_reclaims, 1); profile->last_reclaim_ns = bpf_ktime_get_ns(); + active.started_ns = profile->last_reclaim_ns; + active.cgroup_id = cgroup_id; + if (!sample_every || sequence % sample_every == 0) { + active.stack_id = bpf_get_stackid(ctx, &stack_traces, + BPF_F_FAST_STACK_CMP | 2); + if (active.stack_id >= 0) + __sync_fetch_and_add(&profile->stack_samples, 1); + else + __sync_fetch_and_add(&profile->stack_failures, 1); + } + if (bpf_map_update_elem(&active_reclaims, &pid_tgid, &active, BPF_ANY)) + __sync_fetch_and_add(&dropped_reclaim_states, 1); return 0; } SEC("tp_btf/mm_vmscan_memcg_reclaim_end") -int BPF_PROG(track_reclaim_end, unsigned long reclaimed, +int BPF_PROG(profile_reclaim_end, unsigned long reclaimed, struct mem_cgroup *memcg) { + struct active_reclaim *active; struct reclaim_profile *profile; + __u64 pid_tgid = bpf_get_current_pid_tgid(); __u64 cgroup_id = memcg_cgroup_id(memcg); + __u64 duration_ns; + __s32 stack_id; if (!selected_cgroup(cgroup_id)) return 0; - profile = get_profile(cgroup_id); - if (!profile) + active = bpf_map_lookup_elem(&active_reclaims, &pid_tgid); + if (!active || active->cgroup_id != cgroup_id) return 0; - __sync_fetch_and_add(&profile->end_count, 1); - __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); - profile->last_reclaim_ns = bpf_ktime_get_ns(); + duration_ns = bpf_ktime_get_ns() - active->started_ns; + stack_id = active->stack_id; + profile = get_profile(cgroup_id); + if (profile) { + __sync_fetch_and_add(&profile->end_count, 1); + __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); + __sync_fetch_and_add(&profile->total_reclaim_ns, duration_ns); + __sync_fetch_and_add(&profile->latency_slots[latency_bucket(duration_ns)], + 1); + update_maximum(&profile->maximum_reclaim_ns, duration_ns); + profile->last_reclaim_ns = bpf_ktime_get_ns(); + } + if (stack_id >= 0) + update_stack_profile(cgroup_id, stack_id, duration_ns, reclaimed); + bpf_map_delete_elem(&active_reclaims, &pid_tgid); return 0; } @@ -219,13 +360,8 @@ int capture_oom_victim(struct trace_event_raw_mark_victim *ctx) (void *)ctx + (ctx->__data_loc_comm & 0xffff)); profile = bpf_map_lookup_elem(&profiles, &cgroup_id); - if (profile) { - event->reclaim_begin_count = profile->begin_count; - event->reclaim_end_count = profile->end_count; - event->reclaimed_pages = profile->reclaimed_pages; - event->cross_cgroup_reclaims = profile->cross_cgroup_reclaims; - event->last_reclaim_ns = profile->last_reclaim_ns; - } + if (profile) + __builtin_memcpy(&event->profile, profile, sizeof(event->profile)); bpf_ringbuf_submit(event, 0); return 0; } @@ -262,15 +398,17 @@ int capture_victim_exit(void *ctx) } ``` -The BPF program uses `bpf_task_from_pid()` kfunc to look up the victim task from its PID. This kfunc was introduced in Linux 6.2 and returns a referenced pointer that must be released with `bpf_task_release()`. `BPF_PROG_TYPE_TRACEPOINT` programs gained access to tracing kfuncs in Linux 6.12. The reclaim callbacks also consume the target `mem_cgroup` argument added to vmscan tracepoints in Linux 7.1, which sets the minimum kernel version for the complete tool. +`active_reclaims` joins begin and end by `pid_tgid`. The begin callback records the target cgroup ID from `memcg->css.cgroup->kn->id`, rather than using the current task's cgroup. Comparing those two IDs produces `cross_cgroup_reclaims`, which makes proactive reclaim from another cgroup visible. -The `track_reclaim_begin` and `track_reclaim_end` functions attach to memcg reclaim tracepoints. They count reclaim cycles and track cross-cgroup reclaims (when a task in one cgroup triggers reclaim in another cgroup's memory space). +Stack sampling happens at begin, before the reclaim work runs. `sample_every=1` captures every interval; a larger value reduces stack-map and unwinding cost while the latency histogram still counts every matched interval. The value `2` in the low eight flag bits skips two tracing frames, while `bpf_get_stackid()` deduplicates the remaining stack in `stack_traces`. `stack_profiles` attaches timing and reclaimed-page totals to each ID. Atomic updates let reclaim from several CPUs contribute to one cgroup profile. -The `capture_oom_victim` function runs when the OOM killer selects a victim. It uses `bpf_task_from_pid()` to get the victim's cgroup ID and TGID, stores victim state for exit tracking, and sends an event with memory statistics and accumulated reclaim profile. +`profile_reclaim_end()` removes the active state after accounting. It updates total and maximum latency, chooses the histogram bucket, and updates the sampled stack aggregate. Separate counters expose active-state insertion failures and stack-capture failures, while a difference between begin and end counts reveals an interval that could not be paired. -The `capture_victim_exit` function tracks when the victim actually exits, confirming the kill completed and reporting the exit signal. +The OOM path handles process identity carefully. The tracepoint's `ctx->pid` is retained as `victim_tid`; `bpf_task_from_pid()` supplies the task's TGID and cgroup ID, and `bpf_task_release()` releases the referenced task pointer. Victim state stays keyed by TID because `sched_process_exit` runs in the context of that exact thread. This also covers a multithreaded process whose leader and selected victim have different IDs. -## User Space Program +## Symbolizing and Presenting the Profile + +The user-space program loads kernel symbols, ranks stack aggregates, manages the optional cgroup filter, and provides a self-contained OOM demonstration. ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -281,6 +419,7 @@ The `capture_victim_exit` function tracks when the victim actually exits, confir #include #include #include +#include #include #include #include @@ -290,6 +429,7 @@ The `capture_victim_exit` function tracks when the victim actually exits, confir #include #include #include +#include #include #include "oom_watch.h" #include "oom_watch.skel.h" @@ -297,6 +437,7 @@ The `capture_victim_exit` function tracks when the victim actually exits, confir struct options { const char *cgroup_path; unsigned int duration_seconds; + unsigned int sample_every; bool demo; }; @@ -308,6 +449,51 @@ static unsigned int observed_victim_pid; static unsigned int observed_victim_tid; static unsigned long long observed_reclaims; static unsigned long long observed_cross_cgroup_reclaims; +static unsigned long long observed_stack_samples; + +struct kernel_symbol { + unsigned long long address; + char *name; +}; + +struct kernel_symbols { + struct kernel_symbol *items; + size_t count; + size_t capacity; +}; + +struct runtime_context { + int profiles_fd; + int stack_profiles_fd; + int stack_traces_fd; + struct kernel_symbols symbols; +}; + +struct oom_runtime { + struct oom_watch_bpf *skel; + struct ring_buffer *ring; + struct runtime_context context; +}; + +struct selected_cgroup { + char demo_path[256]; + const char *path; + struct stat metadata; + bool demo_created; + bool memory_enabled_by_demo; +}; + +struct demo_process { + pid_t child; + int ready_pipe[2]; + int continue_pipe[2]; + int status; +}; + +struct ranked_stack { + struct reclaim_stack_key key; + struct reclaim_stack_profile profile; +}; struct allocation_context { int ready_fd; @@ -363,20 +549,203 @@ static int memory_controller_enabled(bool *enabled) return 0; } +static int compare_symbols(const void *left, const void *right) +{ + const struct kernel_symbol *a = left; + const struct kernel_symbol *b = right; + + return a->address < b->address ? -1 : a->address > b->address ? 1 : 0; +} + +static int load_kernel_symbols(struct kernel_symbols *symbols) +{ + char name[256]; + char type; + unsigned long long address; + FILE *file = fopen("/proc/kallsyms", "r"); + + if (!file) + return -1; + while (fscanf(file, "%llx %c %255s%*[^\n]\n", &address, &type, + name) == 3) { + struct kernel_symbol *item; + + (void)type; + if (symbols->count == symbols->capacity) { + size_t capacity = symbols->capacity ? symbols->capacity * 2 : 4096; + void *items = realloc(symbols->items, + capacity * sizeof(*symbols->items)); + + if (!items) + goto error; + symbols->items = items; + symbols->capacity = capacity; + } + item = &symbols->items[symbols->count++]; + item->address = address; + item->name = strdup(name); + if (!item->name) + goto error; + } + fclose(file); + qsort(symbols->items, symbols->count, sizeof(*symbols->items), + compare_symbols); + return symbols->count ? 0 : -1; + +error: + fclose(file); + return -1; +} + +static void free_kernel_symbols(struct kernel_symbols *symbols) +{ + for (size_t i = 0; i < symbols->count; i++) + free(symbols->items[i].name); + free(symbols->items); +} + +static const struct kernel_symbol *find_kernel_symbol( + const struct kernel_symbols *symbols, unsigned long long address) +{ + size_t low = 0, high = symbols->count; + + while (low < high) { + size_t middle = low + (high - low) / 2; + + if (symbols->items[middle].address <= address) + low = middle + 1; + else + high = middle; + } + return low ? &symbols->items[low - 1] : NULL; +} + +static void insert_ranked_stack(struct ranked_stack top[5], size_t *count, + const struct reclaim_stack_key *key, + const struct reclaim_stack_profile *profile) +{ + size_t position = 0; + + while (position < *count && + top[position].profile.total_ns >= profile->total_ns) + position++; + if (position >= 5) + return; + if (*count < 5) + (*count)++; + for (size_t i = *count - 1; i > position; i--) + top[i] = top[i - 1]; + top[position].key = *key; + top[position].profile = *profile; +} + +static void print_reclaim_stacks(struct runtime_context *runtime, + __u64 cgroup_id) +{ + struct ranked_stack top[5] = {}; + struct reclaim_stack_key previous, next; + bool have_previous = false; + size_t count = 0; + + while (!bpf_map_get_next_key(runtime->stack_profiles_fd, + have_previous ? &previous : NULL, &next)) { + struct reclaim_stack_profile profile; + + if (next.cgroup_id == cgroup_id && + !bpf_map_lookup_elem(runtime->stack_profiles_fd, &next, + &profile)) + insert_ranked_stack(top, &count, &next, &profile); + previous = next; + have_previous = true; + } + + for (size_t rank = 0; rank < count; rank++) { + unsigned long long addresses[OOM_STACK_DEPTH] = {}; + + printf("reclaim_stack rank=%zu samples=%llu total_ms=%.3f " + "max_ms=%.3f reclaimed_pages=%llu\n", + rank + 1, top[rank].profile.samples, + top[rank].profile.total_ns / 1000000.0, + top[rank].profile.maximum_ns / 1000000.0, + top[rank].profile.reclaimed_pages); + if (bpf_map_lookup_elem(runtime->stack_traces_fd, + &top[rank].key.stack_id, addresses)) + continue; + for (size_t frame = 0; frame < OOM_STACK_DEPTH && addresses[frame]; + frame++) { + const struct kernel_symbol *symbol = + find_kernel_symbol(&runtime->symbols, addresses[frame]); + + if (symbol && symbol->address) + printf(" #%zu %s+0x%llx\n", frame, symbol->name, + addresses[frame] - symbol->address); + else + printf(" #%zu 0x%llx\n", frame, addresses[frame]); + } + } +} + +static void print_reclaim_profile(struct runtime_context *runtime, + __u64 cgroup_id, + const struct reclaim_profile *profile) +{ + printf("reclaim_profile cgroup_id=%llu cycles=%llu completed=%llu " + "total_ms=%.3f max_ms=%.3f reclaimed_pages=%llu " + "cross_cgroup=%llu stack_samples=%llu stack_failures=%llu\n", + (unsigned long long)cgroup_id, profile->begin_count, + profile->end_count, profile->total_reclaim_ns / 1000000.0, + profile->maximum_reclaim_ns / 1000000.0, + profile->reclaimed_pages, profile->cross_cgroup_reclaims, + profile->stack_samples, profile->stack_failures); + for (unsigned int bucket = 0; bucket < OOM_RECLAIM_BUCKETS; bucket++) { + unsigned long long low, high; + + if (!profile->latency_slots[bucket]) + continue; + low = bucket ? 1ULL << bucket : 0; + high = (1ULL << (bucket + 1)) - 1; + if (bucket == OOM_RECLAIM_BUCKETS - 1) + printf("reclaim_latency_us=>=%llu count=%llu\n", low, + profile->latency_slots[bucket]); + else + printf("reclaim_latency_us=%llu-%llu count=%llu\n", low, + high, profile->latency_slots[bucket]); + } + print_reclaim_stacks(runtime, cgroup_id); +} + +static void print_live_profiles(struct runtime_context *runtime) +{ + __u64 previous, next; + bool have_previous = false; + + while (!bpf_map_get_next_key(runtime->profiles_fd, + have_previous ? &previous : NULL, &next)) { + struct reclaim_profile profile; + + if (!bpf_map_lookup_elem(runtime->profiles_fd, &next, &profile)) + print_reclaim_profile(runtime, next, &profile); + previous = next; + have_previous = true; + } +} + static int handle_event(void *ctx, void *data, size_t size) { const struct oom_watch_event *event = data; + struct runtime_context *runtime = ctx; - (void)ctx; if (size != sizeof(*event)) return 0; if (event->type == OOM_VICTIM_MARKED) { victim_events++; observed_cgroup_id = event->cgroup_id; observed_victim_pid = event->victim_pid; - observed_reclaims = event->reclaim_begin_count; + observed_reclaims = event->profile.begin_count; observed_victim_tid = event->victim_tid; - observed_cross_cgroup_reclaims = event->cross_cgroup_reclaims; + observed_cross_cgroup_reclaims = + event->profile.cross_cgroup_reclaims; + observed_stack_samples = event->profile.stack_samples; printf("event=oom-victim pid=%u tid=%u comm=%s trigger_pid=%u cgroup_id=%llu " "anon_rss_kb=%llu file_rss_kb=%llu total_vm_kb=%llu " "reclaim_cycles=%llu cross_cgroup_reclaims=%llu " @@ -387,9 +756,10 @@ static int handle_event(void *ctx, void *data, size_t size) (unsigned long long)event->anon_rss_kb, (unsigned long long)event->file_rss_kb, (unsigned long long)event->total_vm_kb, - (unsigned long long)event->reclaim_begin_count, - (unsigned long long)event->cross_cgroup_reclaims, - (unsigned long long)event->reclaimed_pages); + (unsigned long long)event->profile.begin_count, + (unsigned long long)event->profile.cross_cgroup_reclaims, + (unsigned long long)event->profile.reclaimed_pages); + print_reclaim_profile(runtime, event->cgroup_id, &event->profile); } else if (event->type == OOM_VICTIM_EXITED) { exit_events++; printf("event=victim-exit pid=%u tid=%u cgroup_id=%llu exit_code=%d\n", @@ -399,14 +769,15 @@ static int handle_event(void *ctx, void *data, size_t size) return 0; } -static int parse_uint(const char *text, unsigned int *value) +static int parse_uint(const char *text, unsigned int maximum, + unsigned int *value) { char *end = NULL; unsigned long parsed; errno = 0; parsed = strtoul(text, &end, 10); - if (errno || !*text || *end || parsed > 86400) + if (errno || !*text || *end || !parsed || parsed > maximum) return -1; *value = parsed; return 0; @@ -414,8 +785,8 @@ static int parse_uint(const char *text, unsigned int *value) static void usage(const char *program) { - printf("Usage: %s [--cgroup PATH] [--duration SEC]\n" - " %s --demo\n", program, program); + printf("Usage: %s [--cgroup PATH] [--duration SEC] [--sample-every N]\n" + " %s --demo [--sample-every N]\n", program, program); } static int parse_options(int argc, char **argv, struct options *options) @@ -423,18 +794,23 @@ static int parse_options(int argc, char **argv, struct options *options) static const struct option long_options[] = { { "cgroup", required_argument, NULL, 'c' }, { "duration", required_argument, NULL, 'd' }, + { "sample-every", required_argument, NULL, 's' }, { "demo", no_argument, NULL, 'D' }, { "help", no_argument, NULL, 'h' }, {}, }; int option; - while ((option = getopt_long(argc, argv, "c:d:Dh", long_options, + while ((option = getopt_long(argc, argv, "c:d:s:Dh", long_options, NULL)) != -1) { switch (option) { case 'c': options->cgroup_path = optarg; break; case 'd': - if (parse_uint(optarg, &options->duration_seconds)) + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 's': + if (parse_uint(optarg, 1000000, &options->sample_every)) return -1; break; case 'D': options->demo = true; break; @@ -524,236 +900,338 @@ static int trigger_cross_cgroup_reclaim(const char *cgroup_path) return -1; } +static int select_cgroup(const struct options *options, + struct selected_cgroup *selected) +{ + if (options->demo) { + snprintf(selected->demo_path, sizeof(selected->demo_path), + "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); + if (configure_demo_cgroup(selected->demo_path, + &selected->demo_created, + &selected->memory_enabled_by_demo)) { + fprintf(stderr, "failed to configure demo memory cgroup: %s\n", + strerror(errno)); + return -1; + } + selected->path = selected->demo_path; + } else { + selected->path = options->cgroup_path; + } + if (!selected->path) + return 0; + if (!stat(selected->path, &selected->metadata)) + return 0; + fprintf(stderr, "failed to stat cgroup %s: %s\n", selected->path, + strerror(errno)); + return -1; +} + +static void cleanup_selected_cgroup(struct selected_cgroup *selected, + int *result) +{ + if (selected->demo_created && rmdir(selected->demo_path) && !*result) + *result = 1; + if (selected->memory_enabled_by_demo && + write_text("/sys/fs/cgroup/cgroup.subtree_control", "-memory") && + !*result) + *result = 1; +} + +static int prepare_runtime(struct oom_runtime *runtime, + const struct options *options, + const struct selected_cgroup *selected) +{ + runtime->skel = oom_watch_bpf__open(); + if (!runtime->skel) + return -1; + runtime->skel->rodata->target_cgroup_id = + selected->path ? selected->metadata.st_ino : 0; + runtime->skel->rodata->sample_every = options->sample_every; + if (oom_watch_bpf__load(runtime->skel) || + oom_watch_bpf__attach(runtime->skel)) { + fprintf(stderr, "failed to load and attach OOM watcher\n"); + return -1; + } + runtime->context.profiles_fd = + bpf_map__fd(runtime->skel->maps.profiles); + runtime->context.stack_profiles_fd = + bpf_map__fd(runtime->skel->maps.stack_profiles); + runtime->context.stack_traces_fd = + bpf_map__fd(runtime->skel->maps.stack_traces); + if (load_kernel_symbols(&runtime->context.symbols)) + fprintf(stderr, "warning: kernel symbols unavailable; printing raw stack addresses\n"); + runtime->ring = ring_buffer__new( + bpf_map__fd(runtime->skel->maps.events), handle_event, + &runtime->context, NULL); + return runtime->ring ? 0 : -1; +} + +static void destroy_runtime(struct oom_runtime *runtime) +{ + ring_buffer__free(runtime->ring); + free_kernel_symbols(&runtime->context.symbols); + oom_watch_bpf__destroy(runtime->skel); +} + +static void init_demo_process(struct demo_process *demo) +{ + memset(demo, 0, sizeof(*demo)); + demo->child = -1; + demo->ready_pipe[0] = -1; + demo->ready_pipe[1] = -1; + demo->continue_pipe[0] = -1; + demo->continue_pipe[1] = -1; +} + +static void close_demo_pipe(int *fd) +{ + if (*fd >= 0) + close(*fd); + *fd = -1; +} + +static void cleanup_demo_process(struct demo_process *demo) +{ + if (demo->child > 0) { + kill(demo->child, SIGKILL); + waitpid(demo->child, NULL, 0); + } + close_demo_pipe(&demo->ready_pipe[0]); + close_demo_pipe(&demo->ready_pipe[1]); + close_demo_pipe(&demo->continue_pipe[0]); + close_demo_pipe(&demo->continue_pipe[1]); +} + +static int start_demo_process(struct demo_process *demo, + const char *cgroup_path) +{ + struct pollfd ready = { .events = POLLIN }; + struct timespec leader_exit_delay = { .tv_nsec = 100000000 }; + char byte = 'x'; + + if (pipe(demo->ready_pipe) || pipe(demo->continue_pipe)) + return -1; + demo->child = fork(); + if (demo->child < 0) + return -1; + if (!demo->child) { + close(demo->ready_pipe[0]); + close(demo->continue_pipe[1]); + allocate_until_killed(cgroup_path, demo->ready_pipe[1], + demo->continue_pipe[0]); + } + close_demo_pipe(&demo->ready_pipe[1]); + close_demo_pipe(&demo->continue_pipe[0]); + ready.fd = demo->ready_pipe[0]; + if (poll(&ready, 1, 5000) != 1 || + read(demo->ready_pipe[0], &byte, 1) != 1 || + trigger_cross_cgroup_reclaim(cgroup_path)) + return -1; + nanosleep(&leader_exit_delay, NULL); + if (write(demo->continue_pipe[1], &byte, 1) != 1) + return -1; + close_demo_pipe(&demo->ready_pipe[0]); + close_demo_pipe(&demo->continue_pipe[1]); + return 0; +} + +static int collect_demo_events(struct demo_process *demo, + struct ring_buffer *ring) +{ + for (int i = 0; i < 200; i++) { + pid_t waited; + + ring_buffer__poll(ring, 50); + waited = waitpid(demo->child, &demo->status, WNOHANG); + if (waited == demo->child) { + demo->child = -1; + break; + } + } + for (int i = 0; i < 10 && exit_events < 1; i++) + ring_buffer__poll(ring, 50); + return demo->child < 0 ? 0 : -1; +} + +static bool valid_demo_observation(const struct demo_process *demo, + unsigned long long cgroup_id, + const struct oom_watch_bpf *skel) +{ + return WIFSIGNALED(demo->status) && + WTERMSIG(demo->status) == SIGKILL && victim_events == 1 && + exit_events == 1 && observed_cgroup_id == cgroup_id && + observed_victim_pid && observed_victim_tid && + observed_victim_pid != observed_victim_tid && observed_reclaims && + observed_cross_cgroup_reclaims && observed_stack_samples && + !skel->bss->dropped_victim_states && + !skel->bss->dropped_reclaim_states; +} + +static int run_demo(struct oom_runtime *runtime, + const struct selected_cgroup *selected) +{ + struct demo_process demo; + int result = -1; + + init_demo_process(&demo); + if (start_demo_process(&demo, selected->path) || + collect_demo_events(&demo, runtime->ring)) + goto cleanup; + printf("demo workload signaled=%d signal=%d\n", + WIFSIGNALED(demo.status), + WIFSIGNALED(demo.status) ? WTERMSIG(demo.status) : 0); + if (!valid_demo_observation(&demo, selected->metadata.st_ino, + runtime->skel)) + goto cleanup; + printf("demo result=matched-profile-to-victim\n"); + result = 0; + +cleanup: + cleanup_demo_process(&demo); + return result; +} + +static int watch_profiles(struct oom_runtime *runtime, + unsigned int duration_seconds) +{ + unsigned long long deadline = 0; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)duration_seconds * 1000000000ULL; + while (!stop && (!deadline || monotonic_ns() < deadline)) { + int result = ring_buffer__poll(runtime->ring, 100); + + if (result < 0 && result != -EINTR) { + fprintf(stderr, "ring buffer poll failed: %d\n", result); + return -1; + } + } + print_live_profiles(&runtime->context); + return 0; +} + int main(int argc, char **argv) { - struct options options = {}; - struct oom_watch_bpf *skel = NULL; - struct ring_buffer *ring = NULL; - char demo_cgroup_path[256]; - unsigned long long deadline = 0; - struct stat cgroup_stat = {}; - const char *selected_path = NULL; - pid_t child = -1; - int ready_pipe[2] = { -1, -1 }; - int continue_pipe[2] = { -1, -1 }; - int status = 0; + struct options options = { .sample_every = 1 }; + struct selected_cgroup selected = {}; + struct oom_runtime runtime = {}; int err = 1; - bool demo_cgroup_created = false; - bool memory_enabled_by_demo = false; setvbuf(stdout, NULL, _IONBF, 0); if (parse_options(argc, argv, &options)) { usage(argv[0]); return 2; } - if (options.demo) { - snprintf(demo_cgroup_path, sizeof(demo_cgroup_path), - "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); - if (configure_demo_cgroup(demo_cgroup_path, - &demo_cgroup_created, - &memory_enabled_by_demo)) { - fprintf(stderr, "failed to configure demo memory cgroup: %s\n", - strerror(errno)); - goto cleanup; - } - selected_path = demo_cgroup_path; - } else { - selected_path = options.cgroup_path; - } - if (selected_path && stat(selected_path, &cgroup_stat)) { - fprintf(stderr, "failed to stat cgroup %s: %s\n", selected_path, - strerror(errno)); - goto cleanup; - } - - skel = oom_watch_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->target_cgroup_id = selected_path ? cgroup_stat.st_ino : 0; - if (oom_watch_bpf__load(skel) || oom_watch_bpf__attach(skel)) { - fprintf(stderr, "failed to load and attach OOM watcher\n"); - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + if (select_cgroup(&options, &selected) || + prepare_runtime(&runtime, &options, &selected)) goto cleanup; - if (selected_path) + if (selected.path) printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", - selected_path, (unsigned long long)cgroup_stat.st_ino); + selected.path, + (unsigned long long)selected.metadata.st_ino); else printf("oom-watch tracing all cgroups\n"); - if (options.demo) { - struct pollfd ready = { .events = POLLIN }; - struct timespec leader_exit_delay = { .tv_nsec = 100000000 }; - char byte = 'x'; - - if (pipe(ready_pipe) || pipe(continue_pipe)) - goto cleanup; - child = fork(); - if (child < 0) - goto cleanup; - if (!child) { - close(ready_pipe[0]); - close(continue_pipe[1]); - allocate_until_killed(selected_path, ready_pipe[1], - continue_pipe[0]); - } - close(ready_pipe[1]); ready_pipe[1] = -1; - close(continue_pipe[0]); continue_pipe[0] = -1; - ready.fd = ready_pipe[0]; - if (poll(&ready, 1, 5000) != 1 || - read(ready_pipe[0], &byte, 1) != 1 || - trigger_cross_cgroup_reclaim(selected_path)) - goto cleanup; - nanosleep(&leader_exit_delay, NULL); - if (write(continue_pipe[1], &byte, 1) != 1) - goto cleanup; - close(ready_pipe[0]); ready_pipe[0] = -1; - close(continue_pipe[1]); continue_pipe[1] = -1; - - for (int i = 0; i < 200; i++) { - pid_t waited; - - ring_buffer__poll(ring, 50); - waited = waitpid(child, &status, WNOHANG); - if (waited == child) { - child = -1; - break; - } - } - for (int i = 0; i < 10 && exit_events < 1; i++) - ring_buffer__poll(ring, 50); - - printf("demo workload signaled=%d signal=%d\n", - WIFSIGNALED(status), - WIFSIGNALED(status) ? WTERMSIG(status) : 0); - if (!WIFSIGNALED(status) || WTERMSIG(status) != SIGKILL || - victim_events != 1 || exit_events != 1 || - observed_cgroup_id != (unsigned long long)cgroup_stat.st_ino || - !observed_victim_pid || !observed_victim_tid || - observed_victim_pid == observed_victim_tid || - !observed_reclaims || !observed_cross_cgroup_reclaims || - skel->bss->dropped_victim_states) - goto cleanup; - printf("demo result=matched-profile-to-victim\n"); - } else { - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * - 1000000000ULL; - while (!stop && (!deadline || monotonic_ns() < deadline)) { - int poll_result = ring_buffer__poll(ring, 100); - - if (poll_result < 0 && poll_result != -EINTR) { - fprintf(stderr, "ring buffer poll failed: %d\n", - poll_result); - goto cleanup; - } - } - } - printf("dropped_victim_states=%llu\n", - (unsigned long long)skel->bss->dropped_victim_states); + if ((options.demo && run_demo(&runtime, &selected)) || + (!options.demo && watch_profiles(&runtime, + options.duration_seconds))) + goto cleanup; + printf("dropped_victim_states=%llu dropped_reclaim_states=%llu\n", + (unsigned long long)runtime.skel->bss->dropped_victim_states, + (unsigned long long)runtime.skel->bss->dropped_reclaim_states); err = 0; cleanup: - if (child > 0) { - kill(child, SIGKILL); - waitpid(child, NULL, 0); - } - for (size_t i = 0; i < 2; i++) { - if (ready_pipe[i] >= 0) close(ready_pipe[i]); - if (continue_pipe[i] >= 0) close(continue_pipe[i]); - } - ring_buffer__free(ring); - oom_watch_bpf__destroy(skel); - if (demo_cgroup_created && rmdir(demo_cgroup_path) && !err) - err = 1; - if (memory_enabled_by_demo && - write_text("/sys/fs/cgroup/cgroup.subtree_control", "-memory") && - !err) - err = 1; + destroy_runtime(&runtime); + cleanup_selected_cgroup(&selected, &err); return err; } ``` -The user space program creates events for OOM victims and their exits. In demo mode, it creates a memory-limited cgroup (32MB), forks a child that joins the cgroup and allocates memory until killed, and triggers cross-cgroup reclaim to generate reclaim profile data before the OOM. +At startup, the loader reads `/proc/kallsyms`, sorts symbols by address, and uses binary search to resolve each address from the BPF stack-trace map. If symbol addresses are hidden, the same profile is still printed with raw addresses. Stack groups are ranked by cumulative reclaim time, which highlights a path that is called often as well as one unusually slow interval. -## Compilation and Execution +Normal mode can trace one cgroup or all cgroups. When tracing stops, it walks the profile map and prints each live histogram even if no OOM happened. An OOM event prints the same profile immediately, followed by the top five stack groups and their frames. -Build the tool: +Demo mode creates a cgroup with `memory.max=32 MiB`, no swap, and grouped OOM behavior. A worker first faults 24 MiB, pauses, and lets the parent request 8 MiB through `memory.reclaim`; that deliberate cross-cgroup request exercises target-memcg attribution. The worker then continues faulting a 128 MiB mapping until the cgroup OOM killer selects it. The process leader exits before that second stage, so the test also proves that TGID and victim TID are handled separately. Cleanup restores the memory controller state it found at startup. + +## Build and Run + +Build the profiler: ```bash cd src/57-oom-watch make ``` -Run with cgroup filtering: +Profile one service cgroup for 60 seconds and sample one kernel stack for every ten reclaim intervals: ```bash -sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +sudo ./oom_watch \ + --cgroup /sys/fs/cgroup/my-service \ + --duration 60 \ + --sample-every 10 ``` -Or watch all OOM events: - -```bash -sudo ./oom_watch --duration 60 -``` - -Run the built-in demo: +Omit `--cgroup` to watch all cgroups. Omit `--duration` to run until interrupted. The built-in demo captures every reclaim stack: ```bash sudo ./oom_watch --demo ``` -Example output: +The variable PIDs, cgroup ID, addresses, and timings change between runs. The following excerpt comes from a real run; it keeps the complete profile totals and shortens the stack frames for readability: ```text -oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1246 cgroup_id=28 -event=oom-victim pid=1257 tid=1258 comm=oom_watch trigger_pid=1257 cgroup_id=28 anon_rss_kb=32820 file_rss_kb=1576 total_vm_kb=144200 reclaim_cycles=43 cross_cgroup_reclaims=21 reclaimed_pages=40 -event=victim-exit pid=1257 tid=1258 cgroup_id=28 exit_code=9 +oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1262 cgroup_id=151 +event=oom-victim pid=1263 tid=1264 comm=oom_watch trigger_pid=1263 cgroup_id=151 anon_rss_kb=32064 file_rss_kb=0 total_vm_kb=141844 reclaim_cycles=44 cross_cgroup_reclaims=22 reclaimed_pages=40 +reclaim_profile cgroup_id=151 cycles=44 completed=44 total_ms=0.274 max_ms=0.056 reclaimed_pages=40 cross_cgroup=22 stack_samples=44 stack_failures=0 +reclaim_latency_us=0-1 count=9 +reclaim_latency_us=2-3 count=13 +reclaim_latency_us=4-7 count=14 +reclaim_latency_us=8-15 count=5 +reclaim_latency_us=16-31 count=2 +reclaim_latency_us=32-63 count=1 +reclaim_stack rank=1 samples=22 total_ms=0.181 max_ms=0.056 reclaimed_pages=40 + #0 try_to_free_mem_cgroup_pages+0x... + #1 try_charge_memcg+0x... +reclaim_stack rank=2 samples=22 total_ms=0.093 max_ms=0.012 reclaimed_pages=0 + #0 try_to_free_mem_cgroup_pages+0x... + #1 user_proactive_reclaim+0x... +event=victim-exit pid=1263 tid=1264 cgroup_id=151 exit_code=9 demo workload signaled=1 signal=9 demo result=matched-profile-to-victim -dropped_victim_states=0 +dropped_victim_states=0 dropped_reclaim_states=0 ``` +The 44 completed intervals equal the 44 begin events, and all histogram counts sum to 44. Half were triggered from outside the target cgroup. The two ranked paths separate allocation charge reclaim from the explicit `memory.reclaim` request, while the exit event confirms that the marked TID ended with `SIGKILL`. + ## Requirements | Requirement | Details | -|-------------|---------| -| Kernel | Linux 7.1+ (target memcg in vmscan tracepoints) | -| Config | `CONFIG_BPF_SYSCALL`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_MEMCG` | -| Privileges | Root | -| cgroup | cgroup v2 with memory controller enabled | +|---|---| +| Kernel | Linux 7.1 or newer for target-memcg vmscan tracepoints | +| Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_BPF_EVENTS`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_MEMCG`; `CONFIG_KALLSYMS` improves symbols | +| cgroup | cgroup v2 with the memory controller; demo mode also needs writable cgroup administration | +| Privileges | Root, or equivalent BPF, tracing, and cgroup-management capabilities | +| Architecture and hardware | x86-64 is the declared and tested target; no special hardware | -## Understanding the Output +## Scope -The victim event includes: - -- **pid/tid**: TGID and TID of the victim (may differ for multithreaded processes) -- **trigger_pid**: The process whose allocation triggered the OOM killer -- **anon_rss_kb/file_rss_kb/total_vm_kb**: Memory statistics from the tracepoint -- **reclaim_cycles**: How many reclaim attempts occurred in this cgroup -- **cross_cgroup_reclaims**: Reclaims triggered by other cgroups -- **reclaimed_pages**: Total pages reclaimed before OOM - -The reclaim counters accumulate from the time tracing starts. The `dropped_victim_states` counter records victim states that could not be inserted into the 1024-entry map. +Profiles accumulate from attachment until exit and use bounded LRU maps: 4096 cgroup profiles, 4096 active intervals, 8192 stack aggregates, and 1024 unique stacks. One active interval is retained per `pid_tgid`, which matches the traced begin/end path. The tool captures kernel stacks rather than user stacks and treats symbolization as presentation, so restricted `kallsyms` changes names into addresses without changing the measurements. ## Summary -`oom-watch` joins three moments that are usually inspected separately: memory cgroup reclaim, OOM victim selection, and the victim's eventual exit. The resulting event preserves both TGID and TID, adds the victim's memory footprint, and carries the reclaim profile that accumulated while the tool was tracing. +`oom-watch` turns the period before an OOM kill into evidence that can be inspected. It measures every matched memcg reclaim interval, samples and ranks the kernel paths, attributes work to the target cgroup, then joins that profile to victim selection and exit. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . ## References -- [BPF kfuncs documentation](https://docs.kernel.org/6.2/bpf/kfuncs.html) -- [OOM tracepoints](https://github.com/torvalds/linux/blob/master/include/trace/events/oom.h) -- [vmscan tracepoints](https://github.com/torvalds/linux/blob/master/include/trace/events/vmscan.h) -- [cgroup v2 documentation](https://docs.kernel.org/admin-guide/cgroup-v2.html) -- [Tracepoint kfunc access commit](https://github.com/torvalds/linux/commit/bc638d8cb5be813d4eeb9f63cce52caaa18f3960) -- [vmscan memcg attribution commit](https://github.com/torvalds/linux/commit/874a0a566ede40f3d6062cae8fe1022e616edd1a) +- [Linux vmscan tracepoints](https://github.com/torvalds/linux/blob/v7.1/include/trace/events/vmscan.h) +- [Target-memcg vmscan attribution commit](https://github.com/torvalds/linux/commit/874a0a566ede40f3d6062cae8fe1022e616edd1a) +- [Linux OOM tracepoints](https://github.com/torvalds/linux/blob/v7.1/include/trace/events/oom.h) +- [BPF kfunc documentation](https://docs.kernel.org/bpf/kfuncs.html) +- [Control Group v2 memory interface](https://docs.kernel.org/admin-guide/cgroup-v2.html) diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md index 104c669..73e0bc2 100644 --- a/src/57-oom-watch/README.zh.md +++ b/src/57-oom-watch/README.zh.md @@ -1,37 +1,70 @@ -# eBPF 实战教程:带回收分析的 OOM 受害者监控 +# eBPF 实战教程:在 OOM kill 之前分析 memcg reclaim -当内核因内存压力杀死进程时,你如何知道哪个 cgroup 处于压力之下,以及在 OOM killer 介入之前它为回收内存付出了多大努力?本教程构建一个工具,跟踪每个 cgroup 的内存回收活动,并在 OOM killer 选择受害者时捕获详细上下文。 +一条 OOM 日志回答了内存故障的最后一个问题:内核选择了哪个 task 作为 victim。此前发生的工作却很难从这条记录里看出来,排查时仍然需要知道目标 memory cgroup 进入了多少次 reclaim、每次花了多久、时间主要消耗在哪些内核路径,以及 victim 最终是否退出。 + +本课构建 `oom-watch` 保存这段缺失的历史。它把 memcg reclaim 聚合为延迟分布和采样内核栈,再把积累得到的 profile 关联到 OOM victim,并继续跟踪这条 victim 记录直到进程退出。 > 完整源代码: -## eBPF 与 OOM 跟踪点 +## reclaim 是 OOM kill 之前的故事 -eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把选出的状态发送到用户态。本教程使用 `oom/mark_victim` 跟踪点捕获 OOM killer 选择受害者的时刻,再使用 `mm_vmscan_memcg_reclaim_begin/end` 跟踪点记录内存回收活动。Linux 7.1 为这些 vmscan 跟踪点加入目标 `mem_cgroup`,BPF 程序因此可以把回收归因到正在扫描的内存 cgroup,而不是碰巧触发回收的任务。受害者查找使用 Linux 6.2 引入的 `bpf_task_from_pid()` kfunc,普通 tracepoint 程序则从 Linux 6.12 开始访问 tracing kfunc。vmscan 跟踪点的变化是最新依赖,因此本例需要 Linux 7.1 或更高版本。 +memory cgroup 接近上限时,一次内存分配可能进入 reclaim,扫描这个 memcg 中可以释放的页面。多次短暂尝试有时只能回收少量内存,也可能几乎没有进展,最终由 OOM killer 选择 victim。只观察 `oom/mark_victim` 会同时丢失这些尝试的延迟分布和调用路径。 -## 实现原理 +eBPF 可以让经过验证器检查的程序运行在内核事件上,再通过 map 连接不同的时刻。Linux 7.1 为 `mm_vmscan_memcg_reclaim_begin` 和 `mm_vmscan_memcg_reclaim_end` tracepoint 加入目标 `mem_cgroup`。这个参数很重要,因为当前 task 可以通过 `memory.reclaim` 回收另一个 cgroup;现在归因可以跟随真正被扫描的 memcg,而不是碰巧触发工作的 task。 -该工具跟踪 OOM 生命周期的两个阶段。在任何 OOM 事件之前,内核会尝试内存回收。我们统计每个内存 cgroup 的回收周期,跟踪回收了多少页面,并记录何时从不同的 cgroup 触发了回收(跨 cgroup 回收)。当 OOM killer 选择受害者时,我们捕获受害者的内存统计信息,将其与累积的回收分析关联,并跟踪受害者直到它退出。 +另一项依赖来自 victim lookup。`oom/mark_victim` 给出被选中线程的 ID,诊断还需要它的 thread-group ID 和 cgroup。`bpf_task_from_pid()` 在 Linux 6.2 引入,普通 tracepoint program 从 Linux 6.12 开始可以调用这类 tracing kfunc。更新的 vmscan tracepoint signature 最终把完整工具的最低内核版本定在 Linux 7.1。 -## 头文件 +跟着一次 reclaim interval 走一遍。begin tracepoint 到来时,BPF 程序按当前 `pid_tgid` 保存单调时钟下的开始时间和目标 cgroup,并根据 `--sample-every` 捕获 kernel stack ID。对应的 end tracepoint 计算 duration,增加一个 2 倍区间的微秒直方图 bucket,再累计 reclaimed pages。另一张 map 按 `(cgroup_id, stack_id)` 聚合每条采样路径的样本数、总时间、最长时间和回收页数。 + +OOM 选中 victim 后,程序把 TID 解析为 TGID 和 cgroup,把这个 cgroup 已积累的 reclaim profile 复制到 ring buffer event,并按 TID 保存 victim state。随后 `sched_process_exit` 消费这份状态并报告 exit code。最终结果会把 kill 之前的回收活动、内核标记的具体线程和之后的进程生命周期连在一起。 + +## profile 与 event 布局 + +共享头文件定义 cgroup 级直方图、每 stack aggregate 和携带 OOM snapshot 的事件。 ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ #ifndef __OOM_WATCH_H #define __OOM_WATCH_H +#define OOM_RECLAIM_BUCKETS 20 +#define OOM_STACK_DEPTH 127 + enum oom_watch_event_type { OOM_VICTIM_MARKED = 1, OOM_VICTIM_EXITED = 2, }; -struct oom_watch_event { - unsigned long long timestamp_ns; - unsigned long long cgroup_id; - unsigned long long reclaim_begin_count; - unsigned long long reclaim_end_count; +struct reclaim_profile { + unsigned long long begin_count; + unsigned long long end_count; unsigned long long reclaimed_pages; unsigned long long cross_cgroup_reclaims; unsigned long long last_reclaim_ns; + unsigned long long total_reclaim_ns; + unsigned long long maximum_reclaim_ns; + unsigned long long stack_samples; + unsigned long long stack_failures; + unsigned long long latency_slots[OOM_RECLAIM_BUCKETS]; +}; + +struct reclaim_stack_key { + unsigned long long cgroup_id; + signed int stack_id; + unsigned int padding; +}; + +struct reclaim_stack_profile { + unsigned long long samples; + unsigned long long total_ns; + unsigned long long maximum_ns; + unsigned long long reclaimed_pages; +}; + +struct oom_watch_event { + unsigned long long timestamp_ns; + unsigned long long cgroup_id; + struct reclaim_profile profile; unsigned long long total_vm_kb; unsigned long long anon_rss_kb; unsigned long long file_rss_kb; @@ -46,9 +79,11 @@ struct oom_watch_event { #endif /* __OOM_WATCH_H */ ``` -头文件定义了发送到用户空间的事件结构。每个事件包括 cgroup ID、回收统计(开始/结束计数、回收的页面、跨 cgroup 回收)、来自跟踪点的受害者内存统计,以及受害者终止时的退出代码。 +`latency_slots` 包含 20 个 base-2 微秒区间。bucket 0 覆盖 0–1 µs,bucket 1 覆盖 2–3 µs,之后依次是 4–7 µs 等范围,最后一个 bucket 收纳所有不小于 524288 µs 的 interval。event 内嵌完整 `reclaim_profile`,因此 victim 旁边打印的数字正是它被选中时的 profile snapshot。 -## BPF 程序 +## 分析 reclaim 并跟踪 victim + +下面是完整 BPF 程序。 ```c // SPDX-License-Identifier: GPL-2.0 @@ -61,16 +96,16 @@ struct oom_watch_event { char LICENSE[] SEC("license") = "GPL"; const volatile __u64 target_cgroup_id; +const volatile __u32 sample_every = 1; extern struct task_struct *bpf_task_from_pid(__s32 pid) __ksym; extern void bpf_task_release(struct task_struct *task) __ksym; -struct reclaim_profile { - __u64 begin_count; - __u64 end_count; - __u64 reclaimed_pages; - __u64 cross_cgroup_reclaims; - __u64 last_reclaim_ns; +struct active_reclaim { + __u64 started_ns; + __u64 cgroup_id; + __s32 stack_id; + __u32 padding; }; struct victim_state { @@ -86,6 +121,27 @@ struct { __type(value, struct reclaim_profile); } profiles SEC(".maps"); +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, __u64); + __type(value, struct active_reclaim); +} active_reclaims SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_STACK_TRACE); + __uint(max_entries, 1024); + __uint(key_size, sizeof(__u32)); + __uint(value_size, OOM_STACK_DEPTH * sizeof(__u64)); +} stack_traces SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 8192); + __type(key, struct reclaim_stack_key); + __type(value, struct reclaim_stack_profile); +} stack_profiles SEC(".maps"); + struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); @@ -99,6 +155,7 @@ struct { } events SEC(".maps"); __u64 dropped_victim_states; +__u64 dropped_reclaim_states; static __always_inline bool selected_cgroup(__u64 cgroup_id) { @@ -142,12 +199,68 @@ static __always_inline struct reclaim_profile *get_profile(__u64 cgroup_id) return bpf_map_lookup_elem(&profiles, &cgroup_id); } +static __always_inline __u32 latency_bucket(__u64 duration_ns) +{ + __u64 microseconds = duration_ns / 1000; + __u32 bucket = 0; + + for (int i = 0; i < OOM_RECLAIM_BUCKETS - 1; i++) { + if (microseconds < 2) + break; + microseconds >>= 1; + bucket++; + } + return bucket; +} + +static __always_inline void update_maximum(__u64 *maximum, __u64 value) +{ + __u64 previous = *maximum; + + for (int i = 0; i < 8 && previous < value; i++) { + __u64 observed = __sync_val_compare_and_swap(maximum, previous, + value); + + if (observed == previous) + break; + previous = observed; + } +} + +static __always_inline void update_stack_profile(__u64 cgroup_id, + __s32 stack_id, + __u64 duration_ns, + __u64 reclaimed) +{ + struct reclaim_stack_key key = { + .cgroup_id = cgroup_id, + .stack_id = stack_id, + }; + struct reclaim_stack_profile initial = {}; + struct reclaim_stack_profile *profile; + + profile = bpf_map_lookup_elem(&stack_profiles, &key); + if (!profile) { + bpf_map_update_elem(&stack_profiles, &key, &initial, BPF_NOEXIST); + profile = bpf_map_lookup_elem(&stack_profiles, &key); + } + if (!profile) + return; + __sync_fetch_and_add(&profile->samples, 1); + __sync_fetch_and_add(&profile->total_ns, duration_ns); + __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); + update_maximum(&profile->maximum_ns, duration_ns); +} + SEC("tp_btf/mm_vmscan_memcg_reclaim_begin") -int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, +int BPF_PROG(profile_reclaim_begin, gfp_t gfp_flags, int order, struct mem_cgroup *memcg) { + struct active_reclaim active = { .stack_id = -1 }; struct reclaim_profile *profile; + __u64 pid_tgid = bpf_get_current_pid_tgid(); __u64 cgroup_id = memcg_cgroup_id(memcg); + __u64 sequence; (void)gfp_flags; (void)order; @@ -156,28 +269,56 @@ int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, profile = get_profile(cgroup_id); if (!profile) return 0; - __sync_fetch_and_add(&profile->begin_count, 1); + sequence = __sync_fetch_and_add(&profile->begin_count, 1); if (bpf_get_current_cgroup_id() != cgroup_id) __sync_fetch_and_add(&profile->cross_cgroup_reclaims, 1); profile->last_reclaim_ns = bpf_ktime_get_ns(); + active.started_ns = profile->last_reclaim_ns; + active.cgroup_id = cgroup_id; + if (!sample_every || sequence % sample_every == 0) { + active.stack_id = bpf_get_stackid(ctx, &stack_traces, + BPF_F_FAST_STACK_CMP | 2); + if (active.stack_id >= 0) + __sync_fetch_and_add(&profile->stack_samples, 1); + else + __sync_fetch_and_add(&profile->stack_failures, 1); + } + if (bpf_map_update_elem(&active_reclaims, &pid_tgid, &active, BPF_ANY)) + __sync_fetch_and_add(&dropped_reclaim_states, 1); return 0; } SEC("tp_btf/mm_vmscan_memcg_reclaim_end") -int BPF_PROG(track_reclaim_end, unsigned long reclaimed, +int BPF_PROG(profile_reclaim_end, unsigned long reclaimed, struct mem_cgroup *memcg) { + struct active_reclaim *active; struct reclaim_profile *profile; + __u64 pid_tgid = bpf_get_current_pid_tgid(); __u64 cgroup_id = memcg_cgroup_id(memcg); + __u64 duration_ns; + __s32 stack_id; if (!selected_cgroup(cgroup_id)) return 0; - profile = get_profile(cgroup_id); - if (!profile) + active = bpf_map_lookup_elem(&active_reclaims, &pid_tgid); + if (!active || active->cgroup_id != cgroup_id) return 0; - __sync_fetch_and_add(&profile->end_count, 1); - __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); - profile->last_reclaim_ns = bpf_ktime_get_ns(); + duration_ns = bpf_ktime_get_ns() - active->started_ns; + stack_id = active->stack_id; + profile = get_profile(cgroup_id); + if (profile) { + __sync_fetch_and_add(&profile->end_count, 1); + __sync_fetch_and_add(&profile->reclaimed_pages, reclaimed); + __sync_fetch_and_add(&profile->total_reclaim_ns, duration_ns); + __sync_fetch_and_add(&profile->latency_slots[latency_bucket(duration_ns)], + 1); + update_maximum(&profile->maximum_reclaim_ns, duration_ns); + profile->last_reclaim_ns = bpf_ktime_get_ns(); + } + if (stack_id >= 0) + update_stack_profile(cgroup_id, stack_id, duration_ns, reclaimed); + bpf_map_delete_elem(&active_reclaims, &pid_tgid); return 0; } @@ -219,13 +360,8 @@ int capture_oom_victim(struct trace_event_raw_mark_victim *ctx) (void *)ctx + (ctx->__data_loc_comm & 0xffff)); profile = bpf_map_lookup_elem(&profiles, &cgroup_id); - if (profile) { - event->reclaim_begin_count = profile->begin_count; - event->reclaim_end_count = profile->end_count; - event->reclaimed_pages = profile->reclaimed_pages; - event->cross_cgroup_reclaims = profile->cross_cgroup_reclaims; - event->last_reclaim_ns = profile->last_reclaim_ns; - } + if (profile) + __builtin_memcpy(&event->profile, profile, sizeof(event->profile)); bpf_ringbuf_submit(event, 0); return 0; } @@ -262,15 +398,17 @@ int capture_victim_exit(void *ctx) } ``` -BPF 程序使用 `bpf_task_from_pid()` kfunc 从 PID 查找受害者任务,这个 kfunc 在 Linux 6.2 中引入,返回的引用指针需要用 `bpf_task_release()` 释放。`BPF_PROG_TYPE_TRACEPOINT` 程序从 Linux 6.12 开始访问 tracing kfunc,回收回调还会读取 Linux 7.1 加入 vmscan 跟踪点的目标 `mem_cgroup` 参数,因此完整工具的最低内核版本是 Linux 7.1。 +`active_reclaims` 按 `pid_tgid` 关联 begin 与 end。begin callback 从 `memcg->css.cgroup->kn->id` 取得目标 cgroup ID,而不是读取当前 task 的 cgroup。两者比较产生 `cross_cgroup_reclaims`,另一个 cgroup 发起的 proactive reclaim 因此可以被直接观察。 -`track_reclaim_begin` 和 `track_reclaim_end` 函数附加到 memcg 回收跟踪点。它们统计回收周期并跟踪跨 cgroup 回收(当一个 cgroup 中的任务触发另一个 cgroup 内存空间的回收时)。 +stack sampling 发生在 begin,也就是 reclaim 工作开始之前。`sample_every=1` 会捕获每个 interval,设置更大的值可以降低 stack map 与 unwinding 开销,同时 latency histogram 仍会统计所有匹配 interval。flag 低 8 位中的 `2` 会跳过两个 tracing frame,`bpf_get_stackid()` 再把剩余的相同调用栈合并到 `stack_traces`,`stack_profiles` 则为每个 ID 保存 timing 与 reclaimed-page 总计。原子更新让多个 CPU 的 reclaim 可以共同写入同一份 cgroup profile。 -`capture_oom_victim` 函数在 OOM killer 选择受害者时运行。它使用 `bpf_task_from_pid()` 获取受害者的 cgroup ID 和 TGID,存储受害者状态以进行退出跟踪,并发送包含内存统计和累积回收分析的事件。 +`profile_reclaim_end()` 完成统计后删除 active state。它会更新总延迟和最大延迟,选择 histogram bucket,再更新对应的 sampled stack aggregate。active-state 插入失败和 stack-capture 失败都有独立计数,begin 与 end count 的差值也能反映未完成配对的 interval。 -`capture_victim_exit` 函数跟踪受害者实际退出的时间,确认杀死完成并报告退出信号。 +OOM 路径需要谨慎处理进程 identity。tracepoint 中的 `ctx->pid` 保留为 `victim_tid`,`bpf_task_from_pid()` 提供 task 的 TGID 与 cgroup ID,`bpf_task_release()` 释放带引用的 task pointer。victim state 继续按 TID 存储,因为 `sched_process_exit` 会在同一个线程上下文中运行。即使多线程进程的 leader 和被选 victim ID 不同,这条关联仍然有效。 -## 用户空间程序 +## 符号化并展示 profile + +用户态程序加载 kernel symbol、排序 stack aggregate、管理可选 cgroup filter,并提供可重复的 OOM demo。 ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -281,6 +419,7 @@ BPF 程序使用 `bpf_task_from_pid()` kfunc 从 PID 查找受害者任务,这 #include #include #include +#include #include #include #include @@ -290,6 +429,7 @@ BPF 程序使用 `bpf_task_from_pid()` kfunc 从 PID 查找受害者任务,这 #include #include #include +#include #include #include "oom_watch.h" #include "oom_watch.skel.h" @@ -297,6 +437,7 @@ BPF 程序使用 `bpf_task_from_pid()` kfunc 从 PID 查找受害者任务,这 struct options { const char *cgroup_path; unsigned int duration_seconds; + unsigned int sample_every; bool demo; }; @@ -308,6 +449,51 @@ static unsigned int observed_victim_pid; static unsigned int observed_victim_tid; static unsigned long long observed_reclaims; static unsigned long long observed_cross_cgroup_reclaims; +static unsigned long long observed_stack_samples; + +struct kernel_symbol { + unsigned long long address; + char *name; +}; + +struct kernel_symbols { + struct kernel_symbol *items; + size_t count; + size_t capacity; +}; + +struct runtime_context { + int profiles_fd; + int stack_profiles_fd; + int stack_traces_fd; + struct kernel_symbols symbols; +}; + +struct oom_runtime { + struct oom_watch_bpf *skel; + struct ring_buffer *ring; + struct runtime_context context; +}; + +struct selected_cgroup { + char demo_path[256]; + const char *path; + struct stat metadata; + bool demo_created; + bool memory_enabled_by_demo; +}; + +struct demo_process { + pid_t child; + int ready_pipe[2]; + int continue_pipe[2]; + int status; +}; + +struct ranked_stack { + struct reclaim_stack_key key; + struct reclaim_stack_profile profile; +}; struct allocation_context { int ready_fd; @@ -363,20 +549,203 @@ static int memory_controller_enabled(bool *enabled) return 0; } +static int compare_symbols(const void *left, const void *right) +{ + const struct kernel_symbol *a = left; + const struct kernel_symbol *b = right; + + return a->address < b->address ? -1 : a->address > b->address ? 1 : 0; +} + +static int load_kernel_symbols(struct kernel_symbols *symbols) +{ + char name[256]; + char type; + unsigned long long address; + FILE *file = fopen("/proc/kallsyms", "r"); + + if (!file) + return -1; + while (fscanf(file, "%llx %c %255s%*[^\n]\n", &address, &type, + name) == 3) { + struct kernel_symbol *item; + + (void)type; + if (symbols->count == symbols->capacity) { + size_t capacity = symbols->capacity ? symbols->capacity * 2 : 4096; + void *items = realloc(symbols->items, + capacity * sizeof(*symbols->items)); + + if (!items) + goto error; + symbols->items = items; + symbols->capacity = capacity; + } + item = &symbols->items[symbols->count++]; + item->address = address; + item->name = strdup(name); + if (!item->name) + goto error; + } + fclose(file); + qsort(symbols->items, symbols->count, sizeof(*symbols->items), + compare_symbols); + return symbols->count ? 0 : -1; + +error: + fclose(file); + return -1; +} + +static void free_kernel_symbols(struct kernel_symbols *symbols) +{ + for (size_t i = 0; i < symbols->count; i++) + free(symbols->items[i].name); + free(symbols->items); +} + +static const struct kernel_symbol *find_kernel_symbol( + const struct kernel_symbols *symbols, unsigned long long address) +{ + size_t low = 0, high = symbols->count; + + while (low < high) { + size_t middle = low + (high - low) / 2; + + if (symbols->items[middle].address <= address) + low = middle + 1; + else + high = middle; + } + return low ? &symbols->items[low - 1] : NULL; +} + +static void insert_ranked_stack(struct ranked_stack top[5], size_t *count, + const struct reclaim_stack_key *key, + const struct reclaim_stack_profile *profile) +{ + size_t position = 0; + + while (position < *count && + top[position].profile.total_ns >= profile->total_ns) + position++; + if (position >= 5) + return; + if (*count < 5) + (*count)++; + for (size_t i = *count - 1; i > position; i--) + top[i] = top[i - 1]; + top[position].key = *key; + top[position].profile = *profile; +} + +static void print_reclaim_stacks(struct runtime_context *runtime, + __u64 cgroup_id) +{ + struct ranked_stack top[5] = {}; + struct reclaim_stack_key previous, next; + bool have_previous = false; + size_t count = 0; + + while (!bpf_map_get_next_key(runtime->stack_profiles_fd, + have_previous ? &previous : NULL, &next)) { + struct reclaim_stack_profile profile; + + if (next.cgroup_id == cgroup_id && + !bpf_map_lookup_elem(runtime->stack_profiles_fd, &next, + &profile)) + insert_ranked_stack(top, &count, &next, &profile); + previous = next; + have_previous = true; + } + + for (size_t rank = 0; rank < count; rank++) { + unsigned long long addresses[OOM_STACK_DEPTH] = {}; + + printf("reclaim_stack rank=%zu samples=%llu total_ms=%.3f " + "max_ms=%.3f reclaimed_pages=%llu\n", + rank + 1, top[rank].profile.samples, + top[rank].profile.total_ns / 1000000.0, + top[rank].profile.maximum_ns / 1000000.0, + top[rank].profile.reclaimed_pages); + if (bpf_map_lookup_elem(runtime->stack_traces_fd, + &top[rank].key.stack_id, addresses)) + continue; + for (size_t frame = 0; frame < OOM_STACK_DEPTH && addresses[frame]; + frame++) { + const struct kernel_symbol *symbol = + find_kernel_symbol(&runtime->symbols, addresses[frame]); + + if (symbol && symbol->address) + printf(" #%zu %s+0x%llx\n", frame, symbol->name, + addresses[frame] - symbol->address); + else + printf(" #%zu 0x%llx\n", frame, addresses[frame]); + } + } +} + +static void print_reclaim_profile(struct runtime_context *runtime, + __u64 cgroup_id, + const struct reclaim_profile *profile) +{ + printf("reclaim_profile cgroup_id=%llu cycles=%llu completed=%llu " + "total_ms=%.3f max_ms=%.3f reclaimed_pages=%llu " + "cross_cgroup=%llu stack_samples=%llu stack_failures=%llu\n", + (unsigned long long)cgroup_id, profile->begin_count, + profile->end_count, profile->total_reclaim_ns / 1000000.0, + profile->maximum_reclaim_ns / 1000000.0, + profile->reclaimed_pages, profile->cross_cgroup_reclaims, + profile->stack_samples, profile->stack_failures); + for (unsigned int bucket = 0; bucket < OOM_RECLAIM_BUCKETS; bucket++) { + unsigned long long low, high; + + if (!profile->latency_slots[bucket]) + continue; + low = bucket ? 1ULL << bucket : 0; + high = (1ULL << (bucket + 1)) - 1; + if (bucket == OOM_RECLAIM_BUCKETS - 1) + printf("reclaim_latency_us=>=%llu count=%llu\n", low, + profile->latency_slots[bucket]); + else + printf("reclaim_latency_us=%llu-%llu count=%llu\n", low, + high, profile->latency_slots[bucket]); + } + print_reclaim_stacks(runtime, cgroup_id); +} + +static void print_live_profiles(struct runtime_context *runtime) +{ + __u64 previous, next; + bool have_previous = false; + + while (!bpf_map_get_next_key(runtime->profiles_fd, + have_previous ? &previous : NULL, &next)) { + struct reclaim_profile profile; + + if (!bpf_map_lookup_elem(runtime->profiles_fd, &next, &profile)) + print_reclaim_profile(runtime, next, &profile); + previous = next; + have_previous = true; + } +} + static int handle_event(void *ctx, void *data, size_t size) { const struct oom_watch_event *event = data; + struct runtime_context *runtime = ctx; - (void)ctx; if (size != sizeof(*event)) return 0; if (event->type == OOM_VICTIM_MARKED) { victim_events++; observed_cgroup_id = event->cgroup_id; observed_victim_pid = event->victim_pid; - observed_reclaims = event->reclaim_begin_count; + observed_reclaims = event->profile.begin_count; observed_victim_tid = event->victim_tid; - observed_cross_cgroup_reclaims = event->cross_cgroup_reclaims; + observed_cross_cgroup_reclaims = + event->profile.cross_cgroup_reclaims; + observed_stack_samples = event->profile.stack_samples; printf("event=oom-victim pid=%u tid=%u comm=%s trigger_pid=%u cgroup_id=%llu " "anon_rss_kb=%llu file_rss_kb=%llu total_vm_kb=%llu " "reclaim_cycles=%llu cross_cgroup_reclaims=%llu " @@ -387,9 +756,10 @@ static int handle_event(void *ctx, void *data, size_t size) (unsigned long long)event->anon_rss_kb, (unsigned long long)event->file_rss_kb, (unsigned long long)event->total_vm_kb, - (unsigned long long)event->reclaim_begin_count, - (unsigned long long)event->cross_cgroup_reclaims, - (unsigned long long)event->reclaimed_pages); + (unsigned long long)event->profile.begin_count, + (unsigned long long)event->profile.cross_cgroup_reclaims, + (unsigned long long)event->profile.reclaimed_pages); + print_reclaim_profile(runtime, event->cgroup_id, &event->profile); } else if (event->type == OOM_VICTIM_EXITED) { exit_events++; printf("event=victim-exit pid=%u tid=%u cgroup_id=%llu exit_code=%d\n", @@ -399,14 +769,15 @@ static int handle_event(void *ctx, void *data, size_t size) return 0; } -static int parse_uint(const char *text, unsigned int *value) +static int parse_uint(const char *text, unsigned int maximum, + unsigned int *value) { char *end = NULL; unsigned long parsed; errno = 0; parsed = strtoul(text, &end, 10); - if (errno || !*text || *end || parsed > 86400) + if (errno || !*text || *end || !parsed || parsed > maximum) return -1; *value = parsed; return 0; @@ -414,8 +785,8 @@ static int parse_uint(const char *text, unsigned int *value) static void usage(const char *program) { - printf("Usage: %s [--cgroup PATH] [--duration SEC]\n" - " %s --demo\n", program, program); + printf("Usage: %s [--cgroup PATH] [--duration SEC] [--sample-every N]\n" + " %s --demo [--sample-every N]\n", program, program); } static int parse_options(int argc, char **argv, struct options *options) @@ -423,18 +794,23 @@ static int parse_options(int argc, char **argv, struct options *options) static const struct option long_options[] = { { "cgroup", required_argument, NULL, 'c' }, { "duration", required_argument, NULL, 'd' }, + { "sample-every", required_argument, NULL, 's' }, { "demo", no_argument, NULL, 'D' }, { "help", no_argument, NULL, 'h' }, {}, }; int option; - while ((option = getopt_long(argc, argv, "c:d:Dh", long_options, + while ((option = getopt_long(argc, argv, "c:d:s:Dh", long_options, NULL)) != -1) { switch (option) { case 'c': options->cgroup_path = optarg; break; case 'd': - if (parse_uint(optarg, &options->duration_seconds)) + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 's': + if (parse_uint(optarg, 1000000, &options->sample_every)) return -1; break; case 'D': options->demo = true; break; @@ -524,236 +900,338 @@ static int trigger_cross_cgroup_reclaim(const char *cgroup_path) return -1; } +static int select_cgroup(const struct options *options, + struct selected_cgroup *selected) +{ + if (options->demo) { + snprintf(selected->demo_path, sizeof(selected->demo_path), + "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); + if (configure_demo_cgroup(selected->demo_path, + &selected->demo_created, + &selected->memory_enabled_by_demo)) { + fprintf(stderr, "failed to configure demo memory cgroup: %s\n", + strerror(errno)); + return -1; + } + selected->path = selected->demo_path; + } else { + selected->path = options->cgroup_path; + } + if (!selected->path) + return 0; + if (!stat(selected->path, &selected->metadata)) + return 0; + fprintf(stderr, "failed to stat cgroup %s: %s\n", selected->path, + strerror(errno)); + return -1; +} + +static void cleanup_selected_cgroup(struct selected_cgroup *selected, + int *result) +{ + if (selected->demo_created && rmdir(selected->demo_path) && !*result) + *result = 1; + if (selected->memory_enabled_by_demo && + write_text("/sys/fs/cgroup/cgroup.subtree_control", "-memory") && + !*result) + *result = 1; +} + +static int prepare_runtime(struct oom_runtime *runtime, + const struct options *options, + const struct selected_cgroup *selected) +{ + runtime->skel = oom_watch_bpf__open(); + if (!runtime->skel) + return -1; + runtime->skel->rodata->target_cgroup_id = + selected->path ? selected->metadata.st_ino : 0; + runtime->skel->rodata->sample_every = options->sample_every; + if (oom_watch_bpf__load(runtime->skel) || + oom_watch_bpf__attach(runtime->skel)) { + fprintf(stderr, "failed to load and attach OOM watcher\n"); + return -1; + } + runtime->context.profiles_fd = + bpf_map__fd(runtime->skel->maps.profiles); + runtime->context.stack_profiles_fd = + bpf_map__fd(runtime->skel->maps.stack_profiles); + runtime->context.stack_traces_fd = + bpf_map__fd(runtime->skel->maps.stack_traces); + if (load_kernel_symbols(&runtime->context.symbols)) + fprintf(stderr, "warning: kernel symbols unavailable; printing raw stack addresses\n"); + runtime->ring = ring_buffer__new( + bpf_map__fd(runtime->skel->maps.events), handle_event, + &runtime->context, NULL); + return runtime->ring ? 0 : -1; +} + +static void destroy_runtime(struct oom_runtime *runtime) +{ + ring_buffer__free(runtime->ring); + free_kernel_symbols(&runtime->context.symbols); + oom_watch_bpf__destroy(runtime->skel); +} + +static void init_demo_process(struct demo_process *demo) +{ + memset(demo, 0, sizeof(*demo)); + demo->child = -1; + demo->ready_pipe[0] = -1; + demo->ready_pipe[1] = -1; + demo->continue_pipe[0] = -1; + demo->continue_pipe[1] = -1; +} + +static void close_demo_pipe(int *fd) +{ + if (*fd >= 0) + close(*fd); + *fd = -1; +} + +static void cleanup_demo_process(struct demo_process *demo) +{ + if (demo->child > 0) { + kill(demo->child, SIGKILL); + waitpid(demo->child, NULL, 0); + } + close_demo_pipe(&demo->ready_pipe[0]); + close_demo_pipe(&demo->ready_pipe[1]); + close_demo_pipe(&demo->continue_pipe[0]); + close_demo_pipe(&demo->continue_pipe[1]); +} + +static int start_demo_process(struct demo_process *demo, + const char *cgroup_path) +{ + struct pollfd ready = { .events = POLLIN }; + struct timespec leader_exit_delay = { .tv_nsec = 100000000 }; + char byte = 'x'; + + if (pipe(demo->ready_pipe) || pipe(demo->continue_pipe)) + return -1; + demo->child = fork(); + if (demo->child < 0) + return -1; + if (!demo->child) { + close(demo->ready_pipe[0]); + close(demo->continue_pipe[1]); + allocate_until_killed(cgroup_path, demo->ready_pipe[1], + demo->continue_pipe[0]); + } + close_demo_pipe(&demo->ready_pipe[1]); + close_demo_pipe(&demo->continue_pipe[0]); + ready.fd = demo->ready_pipe[0]; + if (poll(&ready, 1, 5000) != 1 || + read(demo->ready_pipe[0], &byte, 1) != 1 || + trigger_cross_cgroup_reclaim(cgroup_path)) + return -1; + nanosleep(&leader_exit_delay, NULL); + if (write(demo->continue_pipe[1], &byte, 1) != 1) + return -1; + close_demo_pipe(&demo->ready_pipe[0]); + close_demo_pipe(&demo->continue_pipe[1]); + return 0; +} + +static int collect_demo_events(struct demo_process *demo, + struct ring_buffer *ring) +{ + for (int i = 0; i < 200; i++) { + pid_t waited; + + ring_buffer__poll(ring, 50); + waited = waitpid(demo->child, &demo->status, WNOHANG); + if (waited == demo->child) { + demo->child = -1; + break; + } + } + for (int i = 0; i < 10 && exit_events < 1; i++) + ring_buffer__poll(ring, 50); + return demo->child < 0 ? 0 : -1; +} + +static bool valid_demo_observation(const struct demo_process *demo, + unsigned long long cgroup_id, + const struct oom_watch_bpf *skel) +{ + return WIFSIGNALED(demo->status) && + WTERMSIG(demo->status) == SIGKILL && victim_events == 1 && + exit_events == 1 && observed_cgroup_id == cgroup_id && + observed_victim_pid && observed_victim_tid && + observed_victim_pid != observed_victim_tid && observed_reclaims && + observed_cross_cgroup_reclaims && observed_stack_samples && + !skel->bss->dropped_victim_states && + !skel->bss->dropped_reclaim_states; +} + +static int run_demo(struct oom_runtime *runtime, + const struct selected_cgroup *selected) +{ + struct demo_process demo; + int result = -1; + + init_demo_process(&demo); + if (start_demo_process(&demo, selected->path) || + collect_demo_events(&demo, runtime->ring)) + goto cleanup; + printf("demo workload signaled=%d signal=%d\n", + WIFSIGNALED(demo.status), + WIFSIGNALED(demo.status) ? WTERMSIG(demo.status) : 0); + if (!valid_demo_observation(&demo, selected->metadata.st_ino, + runtime->skel)) + goto cleanup; + printf("demo result=matched-profile-to-victim\n"); + result = 0; + +cleanup: + cleanup_demo_process(&demo); + return result; +} + +static int watch_profiles(struct oom_runtime *runtime, + unsigned int duration_seconds) +{ + unsigned long long deadline = 0; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)duration_seconds * 1000000000ULL; + while (!stop && (!deadline || monotonic_ns() < deadline)) { + int result = ring_buffer__poll(runtime->ring, 100); + + if (result < 0 && result != -EINTR) { + fprintf(stderr, "ring buffer poll failed: %d\n", result); + return -1; + } + } + print_live_profiles(&runtime->context); + return 0; +} + int main(int argc, char **argv) { - struct options options = {}; - struct oom_watch_bpf *skel = NULL; - struct ring_buffer *ring = NULL; - char demo_cgroup_path[256]; - unsigned long long deadline = 0; - struct stat cgroup_stat = {}; - const char *selected_path = NULL; - pid_t child = -1; - int ready_pipe[2] = { -1, -1 }; - int continue_pipe[2] = { -1, -1 }; - int status = 0; + struct options options = { .sample_every = 1 }; + struct selected_cgroup selected = {}; + struct oom_runtime runtime = {}; int err = 1; - bool demo_cgroup_created = false; - bool memory_enabled_by_demo = false; setvbuf(stdout, NULL, _IONBF, 0); if (parse_options(argc, argv, &options)) { usage(argv[0]); return 2; } - if (options.demo) { - snprintf(demo_cgroup_path, sizeof(demo_cgroup_path), - "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); - if (configure_demo_cgroup(demo_cgroup_path, - &demo_cgroup_created, - &memory_enabled_by_demo)) { - fprintf(stderr, "failed to configure demo memory cgroup: %s\n", - strerror(errno)); - goto cleanup; - } - selected_path = demo_cgroup_path; - } else { - selected_path = options.cgroup_path; - } - if (selected_path && stat(selected_path, &cgroup_stat)) { - fprintf(stderr, "failed to stat cgroup %s: %s\n", selected_path, - strerror(errno)); - goto cleanup; - } - - skel = oom_watch_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->target_cgroup_id = selected_path ? cgroup_stat.st_ino : 0; - if (oom_watch_bpf__load(skel) || oom_watch_bpf__attach(skel)) { - fprintf(stderr, "failed to load and attach OOM watcher\n"); - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + if (select_cgroup(&options, &selected) || + prepare_runtime(&runtime, &options, &selected)) goto cleanup; - if (selected_path) + if (selected.path) printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", - selected_path, (unsigned long long)cgroup_stat.st_ino); + selected.path, + (unsigned long long)selected.metadata.st_ino); else printf("oom-watch tracing all cgroups\n"); - if (options.demo) { - struct pollfd ready = { .events = POLLIN }; - struct timespec leader_exit_delay = { .tv_nsec = 100000000 }; - char byte = 'x'; - - if (pipe(ready_pipe) || pipe(continue_pipe)) - goto cleanup; - child = fork(); - if (child < 0) - goto cleanup; - if (!child) { - close(ready_pipe[0]); - close(continue_pipe[1]); - allocate_until_killed(selected_path, ready_pipe[1], - continue_pipe[0]); - } - close(ready_pipe[1]); ready_pipe[1] = -1; - close(continue_pipe[0]); continue_pipe[0] = -1; - ready.fd = ready_pipe[0]; - if (poll(&ready, 1, 5000) != 1 || - read(ready_pipe[0], &byte, 1) != 1 || - trigger_cross_cgroup_reclaim(selected_path)) - goto cleanup; - nanosleep(&leader_exit_delay, NULL); - if (write(continue_pipe[1], &byte, 1) != 1) - goto cleanup; - close(ready_pipe[0]); ready_pipe[0] = -1; - close(continue_pipe[1]); continue_pipe[1] = -1; - - for (int i = 0; i < 200; i++) { - pid_t waited; - - ring_buffer__poll(ring, 50); - waited = waitpid(child, &status, WNOHANG); - if (waited == child) { - child = -1; - break; - } - } - for (int i = 0; i < 10 && exit_events < 1; i++) - ring_buffer__poll(ring, 50); - - printf("demo workload signaled=%d signal=%d\n", - WIFSIGNALED(status), - WIFSIGNALED(status) ? WTERMSIG(status) : 0); - if (!WIFSIGNALED(status) || WTERMSIG(status) != SIGKILL || - victim_events != 1 || exit_events != 1 || - observed_cgroup_id != (unsigned long long)cgroup_stat.st_ino || - !observed_victim_pid || !observed_victim_tid || - observed_victim_pid == observed_victim_tid || - !observed_reclaims || !observed_cross_cgroup_reclaims || - skel->bss->dropped_victim_states) - goto cleanup; - printf("demo result=matched-profile-to-victim\n"); - } else { - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * - 1000000000ULL; - while (!stop && (!deadline || monotonic_ns() < deadline)) { - int poll_result = ring_buffer__poll(ring, 100); - - if (poll_result < 0 && poll_result != -EINTR) { - fprintf(stderr, "ring buffer poll failed: %d\n", - poll_result); - goto cleanup; - } - } - } - printf("dropped_victim_states=%llu\n", - (unsigned long long)skel->bss->dropped_victim_states); + if ((options.demo && run_demo(&runtime, &selected)) || + (!options.demo && watch_profiles(&runtime, + options.duration_seconds))) + goto cleanup; + printf("dropped_victim_states=%llu dropped_reclaim_states=%llu\n", + (unsigned long long)runtime.skel->bss->dropped_victim_states, + (unsigned long long)runtime.skel->bss->dropped_reclaim_states); err = 0; cleanup: - if (child > 0) { - kill(child, SIGKILL); - waitpid(child, NULL, 0); - } - for (size_t i = 0; i < 2; i++) { - if (ready_pipe[i] >= 0) close(ready_pipe[i]); - if (continue_pipe[i] >= 0) close(continue_pipe[i]); - } - ring_buffer__free(ring); - oom_watch_bpf__destroy(skel); - if (demo_cgroup_created && rmdir(demo_cgroup_path) && !err) - err = 1; - if (memory_enabled_by_demo && - write_text("/sys/fs/cgroup/cgroup.subtree_control", "-memory") && - !err) - err = 1; + destroy_runtime(&runtime); + cleanup_selected_cgroup(&selected, &err); return err; } ``` -用户空间程序为 OOM 受害者及其退出创建事件。在演示模式下,它创建一个内存受限的 cgroup(32MB),fork 一个子进程加入该 cgroup 并分配内存直到被杀死,并触发跨 cgroup 回收以在 OOM 之前生成回收分析数据。 +启动时,loader 读取 `/proc/kallsyms`,按地址排序,再用二分搜索解析 BPF stack-trace map 中的每个地址。symbol address 被隐藏时,同一份 profile 仍然可以输出 raw address。stack group 按累计 reclaim time 排序,因此调用频繁的路径和一次特别慢的 interval 都能反映在排名中。 -## 编译和执行 +普通模式可以观察一个 cgroup,也可以覆盖全部 cgroup。跟踪结束后,程序遍历 profile map,即使没有发生 OOM,也会打印仍然存在的 histogram。OOM event 到来时会立即打印 snapshot,并附上累计时间最高的 5 组调用栈和 frame。 -构建工具: +demo 模式创建 `memory.max=32 MiB`、无 swap、启用 grouped OOM behavior 的 cgroup。worker 先 fault 24 MiB 并暂停,让 parent 通过 `memory.reclaim` 请求回收 8 MiB;这次有意制造的 cross-cgroup request 会验证 target-memcg attribution。随后 worker 继续 fault 一段 128 MiB mapping,直到 cgroup OOM killer 选中它。进程 leader 会在第二阶段之前退出,因此测试也会验证 TGID 和 victim TID 的独立处理。清理阶段会恢复启动时观察到的 memory controller 状态。 + +## 编译和运行 + +构建 profiler: ```bash cd src/57-oom-watch make ``` -使用 cgroup 过滤运行: +分析一个 service cgroup 60 秒,每 10 次 reclaim interval 采样一次 kernel stack: ```bash -sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +sudo ./oom_watch \ + --cgroup /sys/fs/cgroup/my-service \ + --duration 60 \ + --sample-every 10 ``` -或监视所有 OOM 事件: - -```bash -sudo ./oom_watch --duration 60 -``` - -运行内置演示: +省略 `--cgroup` 会观察全部 cgroup,省略 `--duration` 则持续运行到收到中断。内置 demo 会捕获每次 reclaim stack: ```bash sudo ./oom_watch --demo ``` -示例输出: +PID、cgroup ID、地址和延迟会随运行变化。下面是一次真实运行的节选,保留完整 profile 总计,并缩短 stack frame 便于阅读: ```text -oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1246 cgroup_id=28 -event=oom-victim pid=1257 tid=1258 comm=oom_watch trigger_pid=1257 cgroup_id=28 anon_rss_kb=32820 file_rss_kb=1576 total_vm_kb=144200 reclaim_cycles=43 cross_cgroup_reclaims=21 reclaimed_pages=40 -event=victim-exit pid=1257 tid=1258 cgroup_id=28 exit_code=9 +oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1262 cgroup_id=151 +event=oom-victim pid=1263 tid=1264 comm=oom_watch trigger_pid=1263 cgroup_id=151 anon_rss_kb=32064 file_rss_kb=0 total_vm_kb=141844 reclaim_cycles=44 cross_cgroup_reclaims=22 reclaimed_pages=40 +reclaim_profile cgroup_id=151 cycles=44 completed=44 total_ms=0.274 max_ms=0.056 reclaimed_pages=40 cross_cgroup=22 stack_samples=44 stack_failures=0 +reclaim_latency_us=0-1 count=9 +reclaim_latency_us=2-3 count=13 +reclaim_latency_us=4-7 count=14 +reclaim_latency_us=8-15 count=5 +reclaim_latency_us=16-31 count=2 +reclaim_latency_us=32-63 count=1 +reclaim_stack rank=1 samples=22 total_ms=0.181 max_ms=0.056 reclaimed_pages=40 + #0 try_to_free_mem_cgroup_pages+0x... + #1 try_charge_memcg+0x... +reclaim_stack rank=2 samples=22 total_ms=0.093 max_ms=0.012 reclaimed_pages=0 + #0 try_to_free_mem_cgroup_pages+0x... + #1 user_proactive_reclaim+0x... +event=victim-exit pid=1263 tid=1264 cgroup_id=151 exit_code=9 demo workload signaled=1 signal=9 demo result=matched-profile-to-victim -dropped_victim_states=0 +dropped_victim_states=0 dropped_reclaim_states=0 ``` +44 个 completed interval 与 44 个 begin event 一一对应,所有 histogram count 相加也是 44,其中一半由目标 cgroup 之外触发。两条排名路径分别显示 allocation charge reclaim 和显式 `memory.reclaim`,exit event 则确认被标记的 TID 最终收到 `SIGKILL`。 + ## 环境要求 -| 要求 | 详情 | -|------|------| -| 内核 | Linux 7.1+(vmscan 跟踪点提供目标 memcg) | -| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_MEMCG` | -| 权限 | Root | -| cgroup | 启用内存控制器的 cgroup v2 | +| 要求 | 说明 | +|---|---| +| 内核 | Linux 7.1 或更高版本,需要携带 target memcg 的 vmscan tracepoint | +| 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_BPF_EVENTS`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_MEMCG`,`CONFIG_KALLSYMS` 可以改善符号输出 | +| cgroup | cgroup v2 与 memory controller,demo 模式还需要 cgroup 管理写权限 | +| 权限 | root,或者等价的 BPF、tracing 与 cgroup 管理 capability | +| 架构与硬件 | 当前声明并完成测试的目标是 x86-64,不需要特殊硬件 | -## 理解输出 +## 实现范围 -受害者事件包括: - -- **pid/tid**:受害者的 TGID 和 TID(对于多线程进程可能不同) -- **trigger_pid**:其分配触发 OOM killer 的进程 -- **anon_rss_kb/file_rss_kb/total_vm_kb**:来自跟踪点的内存统计 -- **reclaim_cycles**:在此 cgroup 中发生了多少次回收尝试 -- **cross_cgroup_reclaims**:由其他 cgroup 触发的回收 -- **reclaimed_pages**:OOM 之前回收的总页数 - -回收计数从工具开始跟踪时持续累积,`dropped_victim_states` 则记录未能写入 1024 条目受害者映射的状态。 +profile 从程序挂载开始累计到退出,并使用有界 LRU map:4096 份 cgroup profile、4096 条 active interval、8192 组 stack aggregate 和 1024 个 unique stack。每个 `pid_tgid` 保存一个 active interval,与这里观察的 begin/end 路径一致。工具捕获 kernel stack,symbolization 只负责展示,因此受限的 `kallsyms` 会把名称换成地址,并不会改变测量结果。 ## 总结 -`oom-watch` 把通常分开查看的三个时刻连在一起,包括内存 cgroup 回收、OOM 受害者选择以及受害者最终退出。事件同时保留 TGID 和 TID,记录受害者的内存占用,并带上工具运行期间积累的回收画像。 +`oom-watch` 把 OOM kill 之前的阶段变成可以检查的证据。它测量每个匹配的 memcg reclaim interval,采样并排序内核路径,把工作归因到目标 cgroup,再将 profile 与 victim selection 和 exit 关联起来。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 ## 参考资料 -- [BPF kfuncs 文档](https://docs.kernel.org/6.2/bpf/kfuncs.html) -- [OOM 跟踪点](https://github.com/torvalds/linux/blob/master/include/trace/events/oom.h) -- [vmscan 跟踪点](https://github.com/torvalds/linux/blob/master/include/trace/events/vmscan.h) -- [cgroup v2 文档](https://docs.kernel.org/admin-guide/cgroup-v2.html) -- [Tracepoint kfunc 访问 commit](https://github.com/torvalds/linux/commit/bc638d8cb5be813d4eeb9f63cce52caaa18f3960) -- [vmscan memcg 归因 commit](https://github.com/torvalds/linux/commit/874a0a566ede40f3d6062cae8fe1022e616edd1a) +- [Linux vmscan tracepoint](https://github.com/torvalds/linux/blob/v7.1/include/trace/events/vmscan.h) +- [target-memcg vmscan attribution commit](https://github.com/torvalds/linux/commit/874a0a566ede40f3d6062cae8fe1022e616edd1a) +- [Linux OOM tracepoint](https://github.com/torvalds/linux/blob/v7.1/include/trace/events/oom.h) +- [BPF kfunc 文档](https://docs.kernel.org/bpf/kfuncs.html) +- [Control Group v2 memory interface](https://docs.kernel.org/admin-guide/cgroup-v2.html) diff --git a/src/60-afxdp-dump/README.md b/src/60-afxdp-dump/README.md index 8263207..5730abd 100644 --- a/src/60-afxdp-dump/README.md +++ b/src/60-afxdp-dump/README.md @@ -1,18 +1,24 @@ -# eBPF Tutorial by Example: AF_XDP Packet Dump +# eBPF Tutorial by Example: Receive UDP Packets with AF_XDP -How do you receive network packets directly into user space? AF_XDP provides a path for packet processing by sharing memory between the kernel and application. This tutorial builds a packet dumper that uses XDP to redirect selected UDP packets to an AF_XDP socket. This implementation uses copy mode for compatibility. +Sometimes a packet needs to reach a user-space program before the normal socket stack processes it. A packet recorder, protocol prototype, or specialized forwarder may want a small, explicit receive path: select traffic at the driver boundary, place it in shared memory, inspect it, then return the buffer for another packet. + +This tutorial builds that path from the AF_XDP ABI rather than hiding it behind a library. The resulting `afxdp-dump` tool captures IPv4 UDP packets for one port and queue, prints a payload preview, and recycles every frame. It uses one queue, single-buffer packets, and copy mode, which makes it a practical first AF_XDP program before zero-copy and multi-buffer extensions. > Complete source code: -## eBPF, XDP, and AF_XDP +## XDP Selects; AF_XDP Delivers -eBPF lets verified programs run at Linux kernel hooks and send selected state to user space. XDP (eXpress Data Path) runs packet programs before the normal kernel networking stack, while AF_XDP, introduced in Linux 4.18, connects an XDP redirect to user-space packet rings. This implementation uses `bpf_map_lookup_elem()` on XSKMAP to check whether an AF_XDP socket is bound before redirecting, a capability enabled in Linux 5.3. This makes Linux 5.3 the minimum kernel version for this tool. +eBPF runs verifier-checked programs at kernel hooks, and XDP places one of those hooks at the earliest receive point in the Linux network path. AF_XDP, added in Linux 4.18, connects an XDP redirect to a socket backed by user-registered memory. An XSKMAP supplies the missing association between an RX queue number and the AF_XDP socket that serves it. -## How the Implementation Works +The roles are deliberately separate. The XDP program parses just enough of the packet to decide whether it belongs to the tool. The AF_XDP socket owns the shared-memory rings and carries selected bytes to user space. This example also checks XSKMAP with `bpf_map_lookup_elem()` before redirecting, support added in Linux 5.3, so Linux 5.3 is its minimum kernel version. -Our tool attaches an XDP program that filters UDP packets by destination port. Matching packets are redirected to an AF_XDP socket; everything else passes through to the normal stack. The XDP program checks if an AF_XDP socket is bound for the queue before redirecting. +Follow one packet and one frame. User space allocates 64 frames of 4096 bytes in UMEM and posts their addresses to the fill ring. A UDP packet for the configured port reaches the XDP hook. The program validates Ethernet, IPv4, and UDP lengths, finds the socket registered for `ctx->rx_queue_index`, and returns `XDP_REDIRECT`. In copy mode the kernel copies the packet into one posted frame and publishes an `xdp_desc` on the RX ring. User space reads the descriptor, prints the packet, advances the consumer index, and puts the same address back on the fill ring. -## Header File +That last step is what keeps the receiver alive. Without recycling, the initial 64 addresses would be exhausted after 64 packets. The integration test deliberately receives 65 packets to prove that at least one frame has completed the entire ownership cycle. + +## Shared Queue Limit + +The shared header sets the XSKMAP capacity. Queue IDs are the map keys, so this example can address queues 0 through 63 while binding one queue per process. ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -24,9 +30,9 @@ Our tool attaches an XDP program that filters UDP packets by destination port. M #endif /* __AFXDP_DUMP_H */ ``` -The header defines the maximum number of RX queues supported by the XSKMAP. +## Filtering and Redirecting in XDP -## BPF Program +The complete BPF program is small because buffer management belongs to the AF_XDP side. ```c // SPDX-License-Identifier: GPL-2.0 @@ -93,11 +99,13 @@ int redirect_udp(struct xdp_md *ctx) } ``` -The XDP program parses Ethernet, IP, and UDP headers with bounds checking at each level. It filters for IPv4 UDP packets to the configured port, skipping fragmented packets. The `bpf_map_lookup_elem()` check ensures we only redirect if an AF_XDP socket is bound for this queue. The fallback action in `bpf_redirect_map()` is `XDP_PASS`, so if redirect fails, the packet passes through normally. +Every header access is preceded by a bounds or length check. The program accepts Ethernet IPv4 packets, excludes fragments, follows the IPv4 header length to UDP, and verifies that the UDP length fits inside the IP payload. Only the configured destination port reaches the redirect path. -The XSKMAP associates queue IDs with AF_XDP socket file descriptors. When `bpf_redirect_map()` succeeds, the packet is delivered directly to the AF_XDP socket rather than the kernel network stack. +The XSKMAP lookup is also part of correctness. A queue with no socket entry returns `XDP_PASS`, as do unrelated or malformed packets. `bpf_redirect_map()` uses the same pass action as its fallback. Once a redirect succeeds, the packet is consumed by AF_XDP rather than mirrored; a regular UDP socket will not receive that selected packet. -## User Space Program +## Building an AF_XDP Socket from the ABI + +The user-space side below performs the UMEM registration, maps the rings, binds the socket, loads XDP, and runs the receive loop. ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -301,43 +309,73 @@ static int recycle_frame(struct xsk_state *xsk, unsigned long long address) return 0; } +static const struct iphdr *parse_ipv4(const void *packet, unsigned int length, + unsigned int *ip_header_length, + unsigned int *ip_length) +{ + const struct ethhdr *ethernet = packet; + const struct iphdr *ip; + unsigned int available_ip; + + if (length < sizeof(*ethernet) + sizeof(*ip) || + ethernet->h_proto != htons(ETH_P_IP)) + return NULL; + ip = packet + sizeof(*ethernet); + *ip_header_length = ip->ihl * 4; + if (ip->version != 4 || ip->protocol != IPPROTO_UDP || + (ntohs(ip->frag_off) & + (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET)) || + *ip_header_length < sizeof(*ip) || + length < sizeof(*ethernet) + *ip_header_length) + return NULL; + *ip_length = ntohs(ip->tot_len); + available_ip = length - sizeof(*ethernet); + if (*ip_length < *ip_header_length + sizeof(struct udphdr) || + *ip_length > available_ip) + return NULL; + return ip; +} + +static const struct udphdr *parse_udp(const void *packet, + unsigned int length, + unsigned int ip_header_length, + unsigned int ip_length, + const unsigned char **payload, + unsigned int *payload_length) +{ + const struct udphdr *udp = packet + sizeof(struct ethhdr) + + ip_header_length; + unsigned int available_payload; + unsigned int udp_length; + + *payload = (const unsigned char *)(udp + 1); + udp_length = ntohs(udp->len); + available_payload = length - (*payload - (const unsigned char *)packet); + if (udp_length < sizeof(*udp) || + udp_length > ip_length - ip_header_length || + udp_length - sizeof(*udp) > available_payload) + return NULL; + *payload_length = udp_length - sizeof(*udp); + return udp; +} + static void dump_packet(const void *packet, unsigned int length, unsigned int packet_number) { - const struct ethhdr *ethernet = packet; const struct iphdr *ip; const struct udphdr *udp; const unsigned char *payload; char source[INET_ADDRSTRLEN], destination[INET_ADDRSTRLEN]; char preview[PAYLOAD_PREVIEW + 1]; - unsigned int available_ip, available_payload, ip_header_length; - unsigned int ip_length, payload_length, preview_length, udp_length; + unsigned int ip_header_length, ip_length, payload_length, preview_length; - if (length < sizeof(*ethernet) + sizeof(*ip) || - ethernet->h_proto != htons(ETH_P_IP)) + ip = parse_ipv4(packet, length, &ip_header_length, &ip_length); + if (!ip) return; - ip = packet + sizeof(*ethernet); - ip_header_length = ip->ihl * 4; - if (ip->version != 4 || ip->protocol != IPPROTO_UDP || - (ntohs(ip->frag_off) & - (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET)) || - ip_header_length < sizeof(*ip) || - length < sizeof(*ethernet) + ip_header_length + sizeof(*udp)) + udp = parse_udp(packet, length, ip_header_length, ip_length, &payload, + &payload_length); + if (!udp) return; - ip_length = ntohs(ip->tot_len); - available_ip = length - sizeof(*ethernet); - if (ip_length < ip_header_length + sizeof(*udp) || - ip_length > available_ip) - return; - udp = packet + sizeof(*ethernet) + ip_header_length; - payload = (const unsigned char *)(udp + 1); - udp_length = ntohs(udp->len); - available_payload = length - (payload - (const unsigned char *)packet); - if (udp_length < sizeof(*udp) || - udp_length > ip_length - ip_header_length || - udp_length - sizeof(*udp) > available_payload) - return; - payload_length = udp_length - sizeof(*udp); preview_length = payload_length < PAYLOAD_PREVIEW ? payload_length : PAYLOAD_PREVIEW; for (unsigned int i = 0; i < preview_length; i++) @@ -561,48 +599,38 @@ cleanup: } ``` -The user space program sets up AF_XDP with several components: +`open_xsk()` creates a 256 KiB UMEM area, registers it through `XDP_UMEM_REG`, and asks the kernel for 64 fill, completion, and RX entries. `XDP_MMAP_OFFSETS` describes where each producer, consumer, flags, and descriptor array lives. The program maps those pages and posts all frame addresses before traffic can be redirected. -**UMEM**: A 256KB region (64 frames of 4KB each) allocated with `mmap()` and registered with the kernel via `XDP_UMEM_REG`. Packets are received directly into these frames. +The ring indices are shared between kernel and user space, so their memory ordering matters. A producer writes a descriptor before publishing its new index with release semantics. A consumer acquires the producer index before reading descriptors. The code applies that pattern both while consuming RX entries and while returning addresses to the fill ring. -**Fill ring**: Pre-populated with all 64 frame addresses to tell the kernel where to place incoming packets. +`XDP_COPY` on the socket bind and XDP attach mode solve different problems. Copy mode tells AF_XDP how packet data enters UMEM and works without driver zero-copy support. The XDP program itself first tries native driver mode, then falls back to generic SKB mode when the interface rejects native XDP. `--skb-mode` selects the generic path directly. -**RX ring**: Polled for packet descriptors. When a packet arrives, the kernel posts a descriptor here containing the frame address and length. +The receive loop expects one descriptor per packet. `XDP_PKT_CONTD` therefore produces `EMSGSIZE` instead of silently printing only the first fragment of a multi-buffer packet. Address and packet-length checks also keep every descriptor within the registered UMEM. On shutdown, compare-and-detach supplies the program FD as `old_prog_fd`, so the tool removes only the XDP program it attached. -**XSKMAP**: Updated with the AF_XDP socket file descriptor to enable packet redirection from XDP. +## Build and Run -The `XDP_COPY` flag is used when binding the socket, which copies packets from driver buffers to UMEM rather than using DMA-based zero-copy. Copy mode supports a broader range of drivers and keeps this first AF_XDP example independent of zero-copy driver support. - -## Compilation and Execution - -Build the tool: +Build the executable: ```bash cd src/60-afxdp-dump make ``` -Run with options: +Capture five UDP packets arriving on queue 0 and destination port 8080: ```bash sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 ``` -Options: +`--count 0` runs until a signal. Add `--skb-mode` for generic XDP. The selected queue must receive the traffic; on a multi-queue interface this depends on the NIC's receive-side steering configuration. -- `--interface IFACE`: Network interface to capture on -- `--queue N`: RX queue index (default 0) -- `--port PORT`: UDP destination port to filter (default 8080) -- `--count N`: Number of packets to capture (0 for unlimited) -- `--skb-mode`: Force SKB mode instead of driver mode - -Run the deterministic integration test, which exercises frame recycling beyond the 64 initially posted frames and verifies that a non-matching UDP packet still reaches the normal stack: +The integration test creates an isolated veth path, verifies that port 8081 still reaches a normal UDP socket, and sends 65 matching packets through AF_XDP: ```bash sudo make test ``` -Representative test output: +A real test run produces: ```text afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 @@ -613,54 +641,30 @@ nonmatching-pass=verified AF_XDP dump integration test: PASS ``` +`packet=65` demonstrates frame reuse beyond the 64 initially posted frames. `redirected=65` comes from the BPF-side counter, while `nonmatching-pass=verified` confirms that the filter leaves other UDP traffic on the normal network path. + ## Requirements | Requirement | Details | -|-------------|---------| -| Kernel | Linux 5.3+ (XSKMAP lookup in XDP) | -| Config | `CONFIG_BPF_SYSCALL`, `CONFIG_XDP_SOCKETS` | -| Privileges | Root | -| Interface | A network interface with the selected RX queue; native XDP is optional because the tool falls back to generic XDP | +|---|---| +| Kernel | Linux 5.3 or newer; AF_XDP arrived in 4.18 and this example also uses XSKMAP lookup from XDP | +| Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_XDP_SOCKETS`, `CONFIG_DEBUG_INFO_BTF` | +| Privileges | Root, or equivalent BPF and network-administration capabilities | +| Interface | An interface with the selected RX queue; native XDP is optional because generic XDP is available | +| Architecture and hardware | x86-64 is the declared and tested target; copy mode needs no AF_XDP zero-copy driver support | -## What Happens to Redirected Packets +## Scope -Packets redirected to AF_XDP are consumed, not mirrored. They do not continue to the normal kernel networking stack. Applications listening on regular sockets will not see these packets. Non-matching packets (wrong port, wrong protocol) pass through normally and are processed by the kernel. - -## Implementation Details - -This is a basic single-buffer receive-only AF_XDP dumper: - -- **Copy mode**: Uses `XDP_COPY` so the example does not depend on driver zero-copy support -- **Single queue**: Binds to one RX queue -- **64-frame UMEM**: Fixed buffer with bounded memory use -- **Frame recycling**: Returns frames to fill ring after processing -- **Safe attach**: Uses `UPDATE_IF_NOEXIST` and compare-and-detach cleanup - -## XDP Attach Modes - -The tool supports two XDP modes: - -- **Driver mode** (native): Requires driver support for XDP -- **SKB mode** (generic): Does not require native driver XDP support - -We try driver mode first, then fall back to SKB mode: - -```c -err = bpf_xdp_attach(ifindex, program_fd, XDP_FLAGS_DRV_MODE, NULL); -if (err && (err == -EOPNOTSUPP || err == -EINVAL)) { - err = bpf_xdp_attach(ifindex, program_fd, XDP_FLAGS_SKB_MODE, NULL); -} -``` +`afxdp-dump` is a receive-only, single-queue, single-buffer IPv4 UDP tool. It uses a fixed 64-frame UMEM and prints at most 32 payload characters. It provides the buffer lifecycle needed for a useful packet receiver while keeping TX rings, shared UMEM, zero-copy setup, multi-buffer reconstruction, and RX metadata for later examples. ## Summary -This example follows the complete AF_XDP receive lifecycle: an XDP program selects one UDP flow, XSKMAP redirects it to a bound queue, user space reads descriptors from the RX ring, and processed frames return to the fill ring. Copy mode and automatic generic-XDP fallback keep the tool usable on systems without native zero-copy support. +This example exposes the complete AF_XDP receive contract. XDP selects one UDP flow, XSKMAP resolves the RX queue to a socket, the kernel publishes a UMEM descriptor, and user space returns the frame after inspection. The 65-packet test closes the loop by proving that ownership really comes back to the fill ring. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . ## References -- [AF_XDP kernel documentation](https://docs.kernel.org/networking/af_xdp.html) +- [Linux AF_XDP documentation](https://docs.kernel.org/networking/af_xdp.html) - [AF_XDP introduction commit](https://github.com/torvalds/linux/commit/c0c77d8fb787cfe0c3fca689c2a30d1dad4eaba7) -- [XSKMAP lookup in XDP commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) -- [libxdp documentation](https://github.com/xdp-project/xdp-tools/blob/master/lib/libxdp/README.org) +- [XSKMAP lookup from XDP commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md index 28cba1b..eeba061 100644 --- a/src/60-afxdp-dump/README.zh.md +++ b/src/60-afxdp-dump/README.zh.md @@ -1,18 +1,24 @@ -# eBPF 实战教程:AF_XDP 数据包转储 +# eBPF 实战教程:用 AF_XDP 在用户态接收 UDP 报文 -如何将网络数据包直接接收到用户空间?AF_XDP 通过在内核和应用程序之间共享内存提供数据包处理路径。本教程构建一个数据包转储器,使用 XDP 将选定的 UDP 数据包重定向到 AF_XDP 套接字。此实现使用拷贝模式以获得兼容性。 +有些报文需要在进入普通 socket stack 之前交给用户态程序。抓包工具、协议原型或专用转发器通常需要一条清晰的接收路径:在驱动入口筛选流量,把报文放入共享内存,完成检查,再把 buffer 交还给下一次接收。 + +本课直接使用 AF_XDP ABI 构建这条路径,不用库隐藏 ring 和 UMEM 的细节。最终得到的 `afxdp-dump` 会捕获一个队列上发往指定端口的 IPv4 UDP 报文,打印 payload 预览并循环使用每个 frame。它采用单队列、single-buffer packet 和 copy mode,适合作为 zero-copy 与 multi-buffer 之前的第一个 AF_XDP 例子。 > 完整源代码: -## eBPF、XDP 与 AF_XDP +## XDP 选择报文,AF_XDP 完成交付 -eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把选出的状态发送到用户态。XDP(eXpress Data Path)在报文进入普通内核网络栈前运行程序,Linux 4.18 引入的 AF_XDP 则把 XDP 重定向与用户态报文 ring 连接起来。本例使用 `bpf_map_lookup_elem()` 查找 XSKMAP,在重定向前确认 AF_XDP 套接字已经绑定,这项能力在 Linux 5.3 中启用,因此本工具的最低内核版本是 Linux 5.3。 +eBPF 可以让经过验证器检查的程序运行在内核 hook 上,XDP 则把其中一个 hook 放在 Linux 网络接收路径的最前端。Linux 4.18 引入的 AF_XDP 把 XDP redirect 连接到由用户态注册内存支撑的 socket,XSKMAP 再建立 RX queue number 与对应 AF_XDP socket 之间的关系。 -## 实现原理 +两边的职责很明确。XDP 程序只解析足够的信息,判断报文是否属于这个工具;AF_XDP socket 管理共享内存 ring,并把选中的字节交给用户态。本例还会在 redirect 之前用 `bpf_map_lookup_elem()` 检查 XSKMAP,这项能力从 Linux 5.3 开始可用,因此最低内核版本是 5.3。 -我们的工具附加一个 XDP 程序,按目标端口过滤 UDP 数据包。匹配的数据包被重定向到 AF_XDP 套接字;其他所有数据包都传递到正常的网络栈。XDP 程序在重定向之前检查是否有 AF_XDP 套接字绑定到该队列。 +跟着一个报文和一个 frame 走一遍。用户态在 UMEM 中分配 64 个 4096 字节 frame,把它们的地址发布到 fill ring。发往指定端口的 UDP 报文到达 XDP hook 后,程序检查 Ethernet、IPv4 与 UDP 长度,通过 `ctx->rx_queue_index` 找到注册 socket,再返回 `XDP_REDIRECT`。copy mode 下,内核把报文复制到一个已发布的 frame,并把 `xdp_desc` 放入 RX ring。用户态读取 descriptor、打印报文、推进 consumer index,最后把同一个地址放回 fill ring。 -## 头文件 +最后一步决定了接收循环能否持续。如果 frame 没有回收,最初的 64 个地址会在 64 个报文之后耗尽。集成测试故意接收 65 个报文,用第 65 个报文证明至少有一个 frame 已经走完完整的所有权循环。 + +## 共享的队列上限 + +共享头文件定义 XSKMAP 容量。queue ID 是 map key,因此例子可以表示 0 到 63 号队列,每个进程绑定其中一个。 ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -24,9 +30,9 @@ eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把 #endif /* __AFXDP_DUMP_H */ ``` -头文件定义了 XSKMAP 支持的最大 RX 队列数。 +## 在 XDP 中筛选并重定向 -## BPF 程序 +完整 BPF 程序很短,因为 buffer 管理属于 AF_XDP 一侧。 ```c // SPDX-License-Identifier: GPL-2.0 @@ -93,11 +99,13 @@ int redirect_udp(struct xdp_md *ctx) } ``` -XDP 程序在每个层级解析以太网、IP 和 UDP 头,并进行边界检查。它过滤发往配置端口的 IPv4 UDP 数据包,跳过分片数据包。`bpf_map_lookup_elem()` 检查确保我们只在此队列实际绑定了 AF_XDP 套接字时才重定向。`bpf_redirect_map()` 中的回退操作是 `XDP_PASS`,所以如果重定向失败,数据包正常传递。 +程序在每次 header 访问之前都检查边界或协议长度。它接收 Ethernet IPv4 报文,排除分片,根据 IPv4 header length 找到 UDP,再确认 UDP length 位于 IP payload 内。只有指定目的端口会进入 redirect 路径。 -XSKMAP 将队列 ID 与 AF_XDP 套接字文件描述符关联。当 `bpf_redirect_map()` 成功时,数据包直接传递到 AF_XDP 套接字而不是内核网络栈。 +XSKMAP lookup 也是正确性的一部分。队列没有 socket entry 时返回 `XDP_PASS`,无关报文和格式异常的报文同样沿普通网络路径继续;`bpf_redirect_map()` 的 fallback action 也是 pass。一旦 redirect 成功,报文会由 AF_XDP 消费,而不是复制一份镜像,因此普通 UDP socket 不会再收到这条被选中的报文。 -## 用户空间程序 +## 直接使用 ABI 构建 AF_XDP socket + +下面的用户态程序完成 UMEM 注册、ring 映射、socket bind、XDP 加载和接收循环。 ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -301,43 +309,73 @@ static int recycle_frame(struct xsk_state *xsk, unsigned long long address) return 0; } +static const struct iphdr *parse_ipv4(const void *packet, unsigned int length, + unsigned int *ip_header_length, + unsigned int *ip_length) +{ + const struct ethhdr *ethernet = packet; + const struct iphdr *ip; + unsigned int available_ip; + + if (length < sizeof(*ethernet) + sizeof(*ip) || + ethernet->h_proto != htons(ETH_P_IP)) + return NULL; + ip = packet + sizeof(*ethernet); + *ip_header_length = ip->ihl * 4; + if (ip->version != 4 || ip->protocol != IPPROTO_UDP || + (ntohs(ip->frag_off) & + (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET)) || + *ip_header_length < sizeof(*ip) || + length < sizeof(*ethernet) + *ip_header_length) + return NULL; + *ip_length = ntohs(ip->tot_len); + available_ip = length - sizeof(*ethernet); + if (*ip_length < *ip_header_length + sizeof(struct udphdr) || + *ip_length > available_ip) + return NULL; + return ip; +} + +static const struct udphdr *parse_udp(const void *packet, + unsigned int length, + unsigned int ip_header_length, + unsigned int ip_length, + const unsigned char **payload, + unsigned int *payload_length) +{ + const struct udphdr *udp = packet + sizeof(struct ethhdr) + + ip_header_length; + unsigned int available_payload; + unsigned int udp_length; + + *payload = (const unsigned char *)(udp + 1); + udp_length = ntohs(udp->len); + available_payload = length - (*payload - (const unsigned char *)packet); + if (udp_length < sizeof(*udp) || + udp_length > ip_length - ip_header_length || + udp_length - sizeof(*udp) > available_payload) + return NULL; + *payload_length = udp_length - sizeof(*udp); + return udp; +} + static void dump_packet(const void *packet, unsigned int length, unsigned int packet_number) { - const struct ethhdr *ethernet = packet; const struct iphdr *ip; const struct udphdr *udp; const unsigned char *payload; char source[INET_ADDRSTRLEN], destination[INET_ADDRSTRLEN]; char preview[PAYLOAD_PREVIEW + 1]; - unsigned int available_ip, available_payload, ip_header_length; - unsigned int ip_length, payload_length, preview_length, udp_length; + unsigned int ip_header_length, ip_length, payload_length, preview_length; - if (length < sizeof(*ethernet) + sizeof(*ip) || - ethernet->h_proto != htons(ETH_P_IP)) + ip = parse_ipv4(packet, length, &ip_header_length, &ip_length); + if (!ip) return; - ip = packet + sizeof(*ethernet); - ip_header_length = ip->ihl * 4; - if (ip->version != 4 || ip->protocol != IPPROTO_UDP || - (ntohs(ip->frag_off) & - (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET)) || - ip_header_length < sizeof(*ip) || - length < sizeof(*ethernet) + ip_header_length + sizeof(*udp)) + udp = parse_udp(packet, length, ip_header_length, ip_length, &payload, + &payload_length); + if (!udp) return; - ip_length = ntohs(ip->tot_len); - available_ip = length - sizeof(*ethernet); - if (ip_length < ip_header_length + sizeof(*udp) || - ip_length > available_ip) - return; - udp = packet + sizeof(*ethernet) + ip_header_length; - payload = (const unsigned char *)(udp + 1); - udp_length = ntohs(udp->len); - available_payload = length - (payload - (const unsigned char *)packet); - if (udp_length < sizeof(*udp) || - udp_length > ip_length - ip_header_length || - udp_length - sizeof(*udp) > available_payload) - return; - payload_length = udp_length - sizeof(*udp); preview_length = payload_length < PAYLOAD_PREVIEW ? payload_length : PAYLOAD_PREVIEW; for (unsigned int i = 0; i < preview_length; i++) @@ -561,48 +599,38 @@ cleanup: } ``` -用户空间程序设置 AF_XDP 涉及几个组件: +`open_xsk()` 创建 256 KiB UMEM,通过 `XDP_UMEM_REG` 注册内存,再向内核申请各 64 个 fill、completion 和 RX entry。`XDP_MMAP_OFFSETS` 描述每个 producer、consumer、flags 和 descriptor array 的位置,程序据此映射页面,并在 redirect 开始前发布全部 frame 地址。 -**UMEM**:一个 256KB 区域(64 个 4KB 帧),使用 `mmap()` 分配并通过 `XDP_UMEM_REG` 注册到内核。数据包直接接收到这些帧中。 +ring index 由内核和用户态共享,因此内存顺序很关键。producer 需要先写 descriptor,再用 release 语义发布新 index;consumer 则先 acquire producer index,再读取 descriptor。代码在消费 RX entry 和向 fill ring 归还地址时都遵循这组规则。 -**Fill ring(填充环)**:预填充所有 64 个帧地址,告诉内核将传入数据包放在哪里。 +socket bind 使用的 `XDP_COPY` 与 XDP attach mode 解决的是两个问题。copy mode 决定报文如何进入 UMEM,不依赖驱动的 zero-copy 支持;XDP 程序本身会先尝试 native driver mode,如果网卡不支持,再回退到 generic SKB mode。`--skb-mode` 可以直接选择 generic 路径。 -**RX ring(接收环)**:轮询数据包描述符。当数据包到达时,内核在此处发布包含帧地址和长度的描述符。 +接收循环要求一个 descriptor 对应一个完整报文。遇到 `XDP_PKT_CONTD` 时会返回 `EMSGSIZE`,避免把 multi-buffer packet 的第一个 fragment 当成完整报文打印。地址和长度检查还会保证 descriptor 始终落在已注册 UMEM 内。退出时 compare-and-detach 把当前 program FD 作为 `old_prog_fd`,工具只会移除自己挂载的 XDP 程序。 -**XSKMAP**:使用 AF_XDP 套接字文件描述符更新,以启用从 XDP 的数据包重定向。 +## 编译和运行 -绑定套接字时使用 `XDP_COPY` 标志,它把报文从驱动缓冲区复制到 UMEM,而不是使用基于 DMA 的零拷贝。拷贝模式可以覆盖更多驱动,也让这篇入门 AF_XDP 教程不依赖驱动的零拷贝支持。 - -## 编译和执行 - -构建工具: +构建可执行文件: ```bash cd src/60-afxdp-dump make ``` -使用选项运行: +捕获 queue 0 上发往 UDP 8080 端口的 5 个报文: ```bash sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 ``` -选项: +`--count 0` 会持续运行到收到信号,`--skb-mode` 用于 generic XDP。选中的 queue 必须实际接收这条流量,在多队列网卡上,这取决于 NIC 的 receive-side steering 配置。 -- `--interface IFACE`:要捕获的网络接口 -- `--queue N`:RX 队列索引(默认 0) -- `--port PORT`:要过滤的 UDP 目标端口(默认 8080) -- `--count N`:要捕获的数据包数量(0 表示无限) -- `--skb-mode`:强制使用 SKB 模式而不是驱动模式 - -运行确定性的集成测试,它会在最初投递的 64 个 frame 之外继续验证 frame 回收,并确认不匹配的 UDP 报文仍然进入普通网络栈: +集成测试会创建隔离的 veth 路径,验证 8081 端口仍能到达普通 UDP socket,再通过 AF_XDP 发送 65 个匹配报文: ```bash sudo make test ``` -测试输出如下: +一次真实测试的输出如下: ```text afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 @@ -613,54 +641,30 @@ nonmatching-pass=verified AF_XDP dump integration test: PASS ``` +`packet=65` 证明 frame 已经在最初的 64 个地址之外完成复用,`redirected=65` 来自 BPF 侧计数器,`nonmatching-pass=verified` 则说明 filter 让其他 UDP 流量继续走普通网络路径。 + ## 环境要求 -| 要求 | 详情 | -|------|------| -| 内核 | Linux 5.3+(XDP 中的 XSKMAP 查找) | -| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_XDP_SOCKETS` | -| 权限 | Root | -| 接口 | 具有所选 RX 队列的网络接口,工具会回退到 generic XDP,因此 native XDP 是可选项 | +| 要求 | 说明 | +|---|---| +| 内核 | Linux 5.3 或更高版本,AF_XDP 在 4.18 引入,本例还使用 XDP 对 XSKMAP 的 lookup | +| 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_XDP_SOCKETS`、`CONFIG_DEBUG_INFO_BTF` | +| 权限 | root,或者等价的 BPF 与网络管理 capability | +| 网络接口 | 接口包含所选 RX queue,native XDP 可选,工具也支持 generic XDP | +| 架构与硬件 | 当前声明并完成测试的目标是 x86-64,copy mode 不要求驱动支持 AF_XDP zero-copy | -## 重定向数据包的去向 +## 实现范围 -重定向到 AF_XDP 的数据包是被消费的,而不是被镜像。它们不会继续进入正常的内核网络栈。在常规套接字上监听的应用程序将看不到这些数据包。不匹配的数据包(错误的端口、错误的协议)正常传递并由内核处理。 - -## 实现细节 - -这是一个基本的单缓冲区仅接收 AF_XDP 转储器: - -- **拷贝模式**:使用 `XDP_COPY`,不依赖驱动的零拷贝支持 -- **单队列**:绑定到一个 RX 队列 -- **64 帧 UMEM**:使用固定大小控制内存占用 -- **帧回收**:处理后将帧返回 fill ring -- **安全附加**:使用 `UPDATE_IF_NOEXIST` 和比较-分离清理 - -## XDP 附加模式 - -该工具支持两种 XDP 模式: - -- **驱动模式**(原生):需要驱动支持 XDP -- **SKB 模式**(通用):无需驱动提供 native XDP 支持 - -我们首先尝试驱动模式,然后回退到 SKB 模式: - -```c -err = bpf_xdp_attach(ifindex, program_fd, XDP_FLAGS_DRV_MODE, NULL); -if (err && (err == -EOPNOTSUPP || err == -EINVAL)) { - err = bpf_xdp_attach(ifindex, program_fd, XDP_FLAGS_SKB_MODE, NULL); -} -``` +`afxdp-dump` 是一个 receive-only、单队列、single-buffer 的 IPv4 UDP 工具,使用固定的 64-frame UMEM,最多打印 32 个 payload 字符。它保留了一个实用接收器需要的 buffer 生命周期,TX ring、shared UMEM、zero-copy、multi-buffer 重组和 RX metadata 可以在后续例子中继续展开。 ## 总结 -这个例子走完了 AF_XDP 接收路径,XDP 程序选择一个 UDP 流,XSKMAP 把它重定向到已经绑定的队列,用户态从 RX ring 读取描述符,再把处理过的 frame 放回 fill ring。拷贝模式和 generic XDP 自动回退让工具可以运行在没有 native zero-copy 支持的系统上。 +这个例子展示了完整的 AF_XDP 接收契约:XDP 选择一条 UDP 流,XSKMAP 把 RX queue 解析到 socket,内核发布 UMEM descriptor,用户态检查完成后归还 frame。65 个报文的测试进一步证明所有权确实回到了 fill ring。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 ## 参考资料 -- [AF_XDP 内核文档](https://docs.kernel.org/networking/af_xdp.html) +- [Linux AF_XDP 文档](https://docs.kernel.org/networking/af_xdp.html) - [AF_XDP 引入 commit](https://github.com/torvalds/linux/commit/c0c77d8fb787cfe0c3fca689c2a30d1dad4eaba7) -- [XDP 中的 XSKMAP 查找 commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) -- [libxdp 文档](https://github.com/xdp-project/xdp-tools/blob/master/lib/libxdp/README.org) +- [XDP 支持 XSKMAP lookup 的 commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 96c9e73..39d8dd6 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -69,6 +69,8 @@ Networking: - [lesson 46-xdp-test](46-xdp-test/README.md) Building a High-Performance XDP Packet Generator - [lesson 50-tcx](50-tcx/README.md) Composable Traffic Control with TCX Links - [lesson 53-egress-pacer](53-egress-pacer/README.md) Building an Egress Pacer with BPF Qdisc +- [lesson 56-tc-flow-index](56-tc-flow-index/README.md) Index TC Flows in Two Refcounted rbtrees +- [lesson 60-afxdp-dump](60-afxdp-dump/README.md) Receive UDP Packets with AF_XDP Tracing: @@ -81,6 +83,7 @@ Tracing: - [lesson 40-mysql](40-mysql/README.md) Using eBPF to Trace MySQL Queries - [lesson 48-energy](48-energy/README.md) Energy Monitoring for Process-Level Power Analysis - [lesson 52-fsession-latency](52-fsession-latency/README.md) Tracing Slow vfs_read Calls with fsession +- [lesson 57-oom-watch](57-oom-watch/README.md) Profile memcg Reclaim Before an OOM Kill Security: @@ -93,6 +96,7 @@ Security: - [lesson 34-syscall](34-syscall/README.md) Modifying System Call Arguments with eBPF - [lesson 51-tcp-quarantine](51-tcp-quarantine/README.md) Precisely Isolating Established TCP Connections - [lesson 54-exec-image-inspector](54-exec-image-inspector/README.md) Inspecting the Executable Image After exec +- [lesson 55-dns-egress](55-dns-egress/README.md) Build a DNS-Derived IP Allowlist with cgroup BPF Features: diff --git a/src/SUMMARY.zh.md b/src/SUMMARY.zh.md index 46490ec..a0eee1a 100644 --- a/src/SUMMARY.zh.md +++ b/src/SUMMARY.zh.md @@ -69,6 +69,8 @@ GPU: - [lesson 46-xdp-test](46-xdp-test/README.zh.md) eBPF 实例教程:构建高性能 XDP 数据包生成器 - [lesson 50-tcx](50-tcx/README.zh.md) eBPF 入门实践教程第五十篇:使用 TCX Link 实现可组合的流量控制 - [lesson 53-egress-pacer](53-egress-pacer/README.zh.md) eBPF 教程:用 BPF Qdisc 实现出口限速 +- [lesson 56-tc-flow-index](56-tc-flow-index/README.zh.md) eBPF 实战教程:用两棵 refcounted rbtree 索引 TC 流量 +- [lesson 60-afxdp-dump](60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 在用户态接收 UDP 报文 追踪: @@ -81,6 +83,7 @@ GPU: - [lesson 40-mysql](40-mysql/README.zh.md) 使用 eBPF 跟踪 MySQL 查询 - [lesson 48-energy](48-energy/README.zh.md) eBPF 教程:进程级能源监控与功耗分析 - [lesson 52-fsession-latency](52-fsession-latency/README.zh.md) eBPF 教程:使用 fsession 追踪慢速 vfs_read 调用 +- [lesson 57-oom-watch](57-oom-watch/README.zh.md) eBPF 实战教程:在 OOM kill 之前分析 memcg reclaim 安全: @@ -93,6 +96,7 @@ GPU: - [lesson 34-syscall](34-syscall/README.zh.md) eBPF 开发实践:使用 eBPF 修改系统调用参数 - [lesson 51-tcp-quarantine](51-tcp-quarantine/README.zh.md) eBPF 教程:精准隔离已建立的 TCP 连接 - [lesson 54-exec-image-inspector](54-exec-image-inspector/README.zh.md) eBPF 教程:检查 exec 后实际安装的可执行镜像 +- [lesson 55-dns-egress](55-dns-egress/README.zh.md) eBPF 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表 特性: diff --git a/src/compatibility.md b/src/compatibility.md index 07f6aa0..40a321c 100644 --- a/src/compatibility.md +++ b/src/compatibility.md @@ -61,6 +61,10 @@ This table is generated from each tutorial's `.config` metadata. `Minimum kernel | [eBPF Tutorial: Tracing Slow vfs_read Calls with fsession](52-fsession-latency/README.md) | 7.0 | Tutorial docs | x86_64 | Required | 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 | None | Required | CI build | | [eBPF Tutorial: Building an Egress Pacer with BPF Qdisc](53-egress-pacer/README.md) | 6.16 | Tutorial docs | x86_64 | Required | 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 | Network interface | Required | CI build | | [eBPF Tutorial: Inspecting the Executable Image After exec](54-exec-image-inspector/README.md) | 6.19 | Required feature | x86_64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_BPF_LSM=y, CONFIG_SECURITY=y, CONFIG_DEBUG_INFO_BTF=y | None | Required | CI build | +| [eBPF Tutorial by Example: Build a DNS-Derived IP Allowlist with cgroup BPF](55-dns-egress/README.md) | 5.8 | Required feature | x86_64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_CGROUP_BPF=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_INET=y | None | Required | CI build | +| [eBPF Tutorial by Example: Index TC Flows in Two Refcounted rbtrees](56-tc-flow-index/README.md) | 6.16 | Required feature | x86_64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_NET_SCHED=y, CONFIG_NET_CLS_BPF=y | Network interface | Required | CI build | +| [eBPF Tutorial by Example: Profile memcg Reclaim Before an OOM Kill](57-oom-watch/README.md) | 7.1 | Required feature | x86_64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_BPF_EVENTS=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_MEMCG=y | None | Required | CI build | +| [eBPF Tutorial by Example: Receive UDP Packets with AF_XDP](60-afxdp-dump/README.md) | 5.3 | Required feature | x86_64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_XDP_SOCKETS=y | Network interface | Required | CI build | | [eBPF Tutorial: cgroup-based Policy Control](cgroup/README.md) | 5.8 | Required feature | x86_64, arm64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_CGROUPS=y, CONFIG_CGROUP_BPF=y, CONFIG_CGROUP_DEVICE=y, CONFIG_DEBUG_INFO_BTF=y | None | Required | CI build | | [eBPF Tutorial by Example: BPF Arena for Zero-Copy Shared Memory](features/bpf_arena/README.md) | 6.9 | Tutorial docs | x86_64, arm64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y | None | Required | CI build | | [eBPF Tutorial: BPF Iterators for Kernel Data Export](features/bpf_iters/README.md) | 5.8 | Tutorial docs | x86_64, arm64 | Required | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_DEBUG_INFO_BTF=y | None | Required | CI build | diff --git a/src/compatibility.zh.md b/src/compatibility.zh.md index 4d6353f..1108092 100644 --- a/src/compatibility.zh.md +++ b/src/compatibility.zh.md @@ -61,6 +61,10 @@ | [eBPF 教程:使用 fsession 追踪慢速 vfs_read 调用](52-fsession-latency/README.zh.md) | 7.0 | 教程文档 | x86_64 | 需要 | 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 | 无 | 需要 | CI 构建测试 | | [eBPF 教程:用 BPF Qdisc 实现出口限速](53-egress-pacer/README.zh.md) | 6.16 | 教程文档 | x86_64 | 需要 | 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 | 网络接口 | 需要 | CI 构建测试 | | [eBPF 教程:检查 exec 后实际安装的可执行镜像](54-exec-image-inspector/README.zh.md) | 6.19 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_BPF_LSM=y, CONFIG_SECURITY=y, CONFIG_DEBUG_INFO_BTF=y | 无 | 需要 | CI 构建测试 | +| [eBPF 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表](55-dns-egress/README.zh.md) | 5.8 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_CGROUP_BPF=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_INET=y | 无 | 需要 | CI 构建测试 | +| [eBPF 实战教程:用两棵 refcounted rbtree 索引 TC 流量](56-tc-flow-index/README.zh.md) | 6.16 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_NET_SCHED=y, CONFIG_NET_CLS_BPF=y | 网络接口 | 需要 | CI 构建测试 | +| [eBPF 实战教程:在 OOM kill 之前分析 memcg reclaim](57-oom-watch/README.zh.md) | 7.1 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_BPF_EVENTS=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_MEMCG=y | 无 | 需要 | CI 构建测试 | +| [eBPF 实战教程:用 AF_XDP 在用户态接收 UDP 报文](60-afxdp-dump/README.zh.md) | 5.3 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_XDP_SOCKETS=y | 网络接口 | 需要 | CI 构建测试 | | [eBPF 实例教程:基于 cgroup 的策略控制](cgroup/README.zh.md) | 5.8 | 必需特性 | x86_64, arm64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_CGROUPS=y, CONFIG_CGROUP_BPF=y, CONFIG_CGROUP_DEVICE=y, CONFIG_DEBUG_INFO_BTF=y | 无 | 需要 | CI 构建测试 | | [eBPF 实例教程:BPF Arena 零拷贝共享内存](features/bpf_arena/README.zh.md) | 6.9 | 教程文档 | x86_64, arm64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y | 无 | 需要 | CI 构建测试 | | [eBPF 教程:BPF 迭代器用于内核数据导出](features/bpf_iters/README.zh.md) | 5.8 | 教程文档 | x86_64, arm64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_DEBUG_INFO_BTF=y | 无 | 需要 | CI 构建测试 |