From bb092b8a7a1058bb19d76994f091616fff941a0e Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Tue, 21 Jul 2026 22:05:35 -0700 Subject: [PATCH 01/13] docs: streamline BPF tutorial authoring skills --- .../bpf-tutorial-writing-style/SKILL.md | 65 +++---------------- .../scripts/run-in-kvm.sh | 7 +- .../write-bpf-production-tutorial/SKILL.md | 44 ++++++------- 3 files changed, 34 insertions(+), 82 deletions(-) diff --git a/.agents/skills/bpf-tutorial-writing-style/SKILL.md b/.agents/skills/bpf-tutorial-writing-style/SKILL.md index d51a2d1b..e9c3673a 100644 --- a/.agents/skills/bpf-tutorial-writing-style/SKILL.md +++ b/.agents/skills/bpf-tutorial-writing-style/SKILL.md @@ -11,71 +11,24 @@ Read the complete guidelines first: Then read the English and Chinese versions of `47-cuda-events`, `48-energy`, and `49-hid` as style references. Learn their teaching rhythm and level of detail without copying their topic or wording. -## Document Structure (strictly follow guideline) +## Let the topic choose the structure -For advanced tutorials (40+), follow this exact section order: +Follow `scripts/guideline_advance.md` without turning its suggestions into ten mandatory headings. Use a direct title, a short opening, the background the example actually needs, a high-level flow, complete source with explanation, compilation and execution, a compact ending, and primary references. Choose section names that sound natural for the topic. -1. **Title**: `# eBPF 教程:[Topic Description]` or `# eBPF Tutorial by Example: [Topic Description]` - -2. **Introduction**: Brief intro with a concrete problem scenario. Highlight significance and what readers will learn. Link to complete source once here. - -3. **Background / Why This Approach**: - - Explain WHY this approach is needed - - List traditional/alternative approaches and their limitations (e.g., killing process, firewall rules, user-space tools) - - Explain what eBPF/the new kernel feature enables that traditional approaches cannot - - State when the feature entered Linux (kernel version + commit if relevant) - -4. **High-Level Mechanism**: - - Explain HOW the eBPF feature/tool works at a high level BEFORE showing code - - Describe the overall flow: what happens in kernel, what happens in user space, how they interact - - Use diagrams for complex flows with branches, waits, ownership transfers, or 3+ dependent states - -5. **Code Implementation**: - - First introduce the overall processing logic - - Show complete source code for each component (header, BPF program, user-space loader) - - After each complete code block, explain the key parts with paragraph style (not bullet lists) - - Focus on logic and advanced features, not basic syntax - -6. **Additional Concepts** (if needed): Deeper explanation of specific features, edge cases, or semantics - -7. **Compilation and Execution** (AFTER code, not before): - - Build commands - - Run commands with examples - - Expected output with explanation - - Environment requirements table (kernel version, config, privileges, architecture) - -8. **Summary**: Key points, scope boundaries, future extensions - -9. **Call to Action**: Repository and website invitation (as blockquote) - -10. **References**: Links to kernel commits, upstream selftests, documentation +Introduce information at the point where the reader needs it. Compilation follows the code discussion. Extra concept sections, alternative approaches, requirements tables, and diagrams are useful when they clarify a real decision or a flow with several dependent states. ## Tell one useful story -- Open with a concrete situation that the example can reproduce and the question it answers. -- Near the first mention of eBPF, use one natural sentence to say what eBPF is and why it fits this problem. +- 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. - Follow one packet, event, task, or device interaction through kernel space and user space. Explain what happens, why it happens, and what the next step enables. -- Present the whole flow before detailed code. A small diagram helps when the reader must track a branch, wait, retry, ownership transfer, or at least three dependent states. - -## Explain the "Why" thoroughly - -This is critical. The Background section must explain: -- What problem you're solving -- What traditional approaches exist (killing process, firewall rules, user-space tools like `ss --kill`, sampling, etc.) -- Why each traditional approach doesn't work well (race conditions, incomplete coverage, performance overhead, etc.) -- What the eBPF approach enables that wasn't possible before - -Example pattern for the "Why" section: -> **杀掉进程**是最直接的想法,但一个进程往往维护着多条连接,杀进程会中断所有业务流量。 -> **防火墙规则**可以阻止新连接,但对已建立的连接无能为力。 -> **用户态工具**如 `ss --kill` 依赖 `/proc/net/tcp` 遍历和注入 RST 报文,但这种方式有竞态问题。 -> **内核态方案**才能真正解决这个问题。BPF 迭代器可以在持有适当锁的情况下遍历内核的套接字表... +- Present the whole flow before detailed code. Add a diagram only when it makes a branch, wait, retry, ownership transfer, or multi-stage path materially easier to follow. ## Sound like a tutorial - Use familiar words, direct verbs, and connected paragraphs. Attraction comes from the problem and mechanism rather than promotional language. -- Prefer positive descriptions of what the example does. Put remaining limits and safety boundaries in one short paragraph near the end. +- Prefer positive descriptions of what the example does. Use negative constructions sparingly. Put the most relevant scope note in one short paragraph near the end. - Use prose for the main explanation and lists for genuinely parallel items. - 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. @@ -91,14 +44,14 @@ Use this Chinese paragraph only as a voice reference for connected rhythm, restr - Keep source byte-exact. Preserve comments and commands; use focused excerpts only after the complete block. - Use neither `
` nor HTML synchronization markers. - Link to the complete lesson once through its GitHub directory. Avoid an opening catalog of individual files. -- Every Markdown link uses an absolute `https://github.com/...` target. Omit a link when no stable GitHub target exists. +- Every Markdown link uses a stable absolute `https://` target. GitHub, kernel.org, and authoritative documentation sites are all valid; relative links are prohibited. - Public prose contains no local path, shared test repository, VM name, copy route, cache, prompt, model, agent, or trace detail. ## Finish the lesson - Show copyable build and run commands, representative real output, and what that output proves. - State kernel, configuration, privilege, architecture, and hardware requirements that affect the example (use a table). -- End with a compact scope boundary, summary, repository invitation, and primary references. +- End with a compact summary, repository invitation, and primary references. - Keep the English and Chinese files aligned on structure, facts, source, commands, output, limits, and references while allowing each language to sound natural. The final read should answer: what problem is solved, how one event moves through the system, which eBPF mechanism makes it possible, which code matters, how to run it, what success looks like, and where the example stops. diff --git a/.agents/skills/test-bpf-tutorial-kvm/scripts/run-in-kvm.sh b/.agents/skills/test-bpf-tutorial-kvm/scripts/run-in-kvm.sh index fa51db87..ddf411f2 100755 --- a/.agents/skills/test-bpf-tutorial-kvm/scripts/run-in-kvm.sh +++ b/.agents/skills/test-bpf-tutorial-kvm/scripts/run-in-kvm.sh @@ -146,13 +146,15 @@ fi die "/dev/kvm is not available with read/write permission; refusing a TCG fallback" benchmark_root=$(realpath -e -- "$benchmark_root") -kernel_source=$benchmark_root/vendor/linux-framework kernel_build=$benchmark_root/vendor/build/x86/linux +kernel_source_link=$kernel_build/source kernel_image=$kernel_build/arch/x86/boot/bzImage kernel_config=$kernel_build/.config kernel_release_file=$kernel_build/include/config/kernel.release -[[ -d $kernel_source ]] || die "kernel source directory is missing: $kernel_source" +[[ -e $kernel_source_link ]] || die "kernel build source link is missing: $kernel_source_link" +kernel_source=$(realpath -e -- "$kernel_source_link") +[[ -d $kernel_source ]] || die "kernel build source directory is missing: $kernel_source" [[ -s $kernel_image ]] || die "built benchmark kernel is missing: $kernel_image" [[ -r $kernel_config ]] || die "kernel config is missing: $kernel_config" [[ -r $kernel_release_file ]] || die "kernel release file is missing: $kernel_release_file" @@ -191,6 +193,7 @@ printf '%s\n' \ 'KVM preflight: OK' \ "benchmark_root=$benchmark_root" \ "kernel_image=$kernel_image" \ + "kernel_source=$kernel_source" \ "kernel_release=$kernel_release" \ "kernel_sha256=$kernel_sha256" \ "kernel_config_sha256=$config_sha256" \ diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index df86af6d..9c24366b 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -1,6 +1,6 @@ --- name: write-bpf-production-tutorial -description: Write or rewrite one bilingual bpf-developer-tutorial lesson with pinned Claude Opus 4.5, then verify the result against the implementation. Use when creating README.md and README.zh.md, preserving full source, testing the example, or preparing tutorial changes for review. +description: Write or rewrite one or more bilingual bpf-developer-tutorial lessons with pinned Claude Opus 4.5, then verify the result against the implementation. Use when creating README.md and README.zh.md, preserving full source, testing the example, or preparing tutorial changes for review. --- # Write a BPF Tutorial @@ -17,43 +17,39 @@ Read these inputs before writing: - the current README pair when revising an existing lesson; - primary upstream sources for versions and feature semantics. -Collect the facts the reader needs: the problem, why traditional approaches fail, the kernel/user-space flow, feature versions, requirements, commands, real output, cleanup, limits, and references. Keep every claim grounded in the code, tests, captured output, or a primary source. Preserve an existing draft before a from-scratch rewrite. +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. + +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. -## 2. Structure requirements +## 2. Shape the lesson -For advanced tutorials (40+), follow this exact section order as defined in `$bpf-tutorial-writing-style`: +Follow `scripts/guideline_advance.md`, then organize the article around the example rather than a fixed heading template. A complete lesson normally has: -1. **Title + Introduction**: Concrete problem scenario, link to source -2. **Background / Why This Approach**: Explain traditional approaches and their limitations, then what eBPF enables -3. **High-Level Mechanism**: How the feature works before showing code -4. **Code Implementation**: Complete source blocks, then paragraph explanations -5. **Additional Concepts** (if needed) -6. **Compilation and Execution**: AFTER code analysis, not before -7. **Summary + Call to Action** -8. **References** +- a direct title and a short introduction that says what the tool answers; +- enough eBPF, kernel-subsystem, and feature background to make the mechanism understandable, including the feature's kernel version when relevant; +- the complete kernel and user-space flow before detailed code; +- every core source file in a normal Markdown fence, followed by focused explanation; +- copyable build and run commands, real output, requirements, a compact ending, and primary references. -The "Background / Why" section is critical. It must explain: -- What traditional approaches exist -- Why each doesn't work well -- What the eBPF approach enables +Use headings that fit the topic. Keep compilation after the code discussion. Explain alternatives, limits, diagrams, and extra concepts only when they materially improve the lesson. ## 3. Let Opus write Claude Opus writes all reader-facing tutorial prose. Use the exact model ID `claude-opus-4-5-20251101`. Stop if that model is unavailable instead of substituting another model. -Use one non-interactive invocation for the complete English and Chinese pair. The prompt stays short and names only: +Use one non-interactive invocation for the complete requested set. A single lesson means one English and Chinese pair; a batch means every requested pair is finished in the same invocation before Opus returns. The prompt stays short and names only: -- the two target README files; +- every target README pair; - `scripts/guideline_advance.md` or `scripts/guideline_basic.md`; - `$bpf-tutorial-writing-style`; - `src/47-cuda-events`, `src/48-energy`, and `src/49-hid` as style references; -- the request to read the implementation and write both files completely before returning. +- the request to read the implementation, rewrite each paragraph in place, and finish both files before returning without questions. Do not paste a second checklist, paragraph plan, fact inventory, or review rubric into the prompt. Add a technical fact only when it is unavailable in the repository. -Run Claude from the repository root with the pinned model and permission to read the repository and write the two README files. Opus does not commit or push. +Run Claude from the repository root with the pinned model and permission to read the repository and write only the target README files. Opus does not commit or push. ## 4. Check the result @@ -61,17 +57,17 @@ 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; -- there is a "Why" section explaining traditional approaches and their limitations; -- the high-level mechanism is explained BEFORE code sections; +- 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; - every core source file appears once in a complete ordinary Markdown fence; - code, commands, output, versions, requirements, cleanup, and limits agree with the repository; - the opening source link points only to the lesson directory; -- every published link is an absolute `https://github.com/...` URL; +- every published link uses a stable absolute `https://` URL; GitHub, kernel.org, and authoritative documentation sites are valid, while relative links are prohibited; - no local infrastructure, prompt, model, agent, or trace detail appears in public text. Check source blocks with the bundled `sync-source-blocks.py`, then run `git diff --check`, the lesson build, its tests, and the relevant runtime test. Treat a functional run as a functional run rather than a benchmark. -Read the finished pair once as an intermediate eBPF developer. If a concrete problem remains, give Opus a short defect list in the same session and let it revise the whole pair before returning. Keep prompts, responses, partial drafts, and failed runs; never delete real conversation or agent history. +Read the finished pair once as an intermediate eBPF developer. Remove template-like detours, repeated setup, unexplained jargon, and details that belong only to the test harness. If a concrete problem remains, give Opus a short defect list in the same session and let it revise the affected paragraphs before returning. Keep prompts, responses, partial drafts, and failed runs; never delete real conversation or agent history. Stop after the reviewed local result unless the user asks to commit, push, or update a PR. From 5806d6c7126c031d8e09ee50aca93283c0686330 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Tue, 21 Jul 2026 22:05:46 -0700 Subject: [PATCH 02/13] feat: add four production-shaped eBPF tutorial tools --- src/55-dns-egress/.gitignore | 2 + src/55-dns-egress/Makefile | 94 ++ src/55-dns-egress/README.md | 931 ++++++++++++++++++ src/55-dns-egress/README.zh.md | 931 ++++++++++++++++++ src/55-dns-egress/dns_egress.bpf.c | 239 +++++ src/55-dns-egress/dns_egress.c | 540 ++++++++++ src/55-dns-egress/dns_egress.h | 46 + src/55-dns-egress/tests/test_dns_egress.py | 29 + src/56-slow-syscall-index/.gitignore | 2 + src/56-slow-syscall-index/Makefile | 94 ++ src/56-slow-syscall-index/README.md | 604 ++++++++++++ src/56-slow-syscall-index/README.zh.md | 604 ++++++++++++ .../slow_syscall_index.bpf.c | 88 ++ .../slow_syscall_index.c | 389 ++++++++ .../slow_syscall_index.h | 17 + .../tests/test_slow_syscall_index.py | 25 + src/57-oom-watch/.gitignore | 2 + src/57-oom-watch/Makefile | 94 ++ src/57-oom-watch/README.md | 759 ++++++++++++++ src/57-oom-watch/README.zh.md | 759 ++++++++++++++ src/57-oom-watch/oom_watch.bpf.c | 209 ++++ src/57-oom-watch/oom_watch.c | 406 ++++++++ src/57-oom-watch/oom_watch.h | 29 + src/57-oom-watch/tests/test_oom_watch.py | 36 + src/60-afxdp-dump/.gitignore | 2 + src/60-afxdp-dump/Makefile | 94 ++ src/60-afxdp-dump/README.md | 666 +++++++++++++ src/60-afxdp-dump/README.zh.md | 666 +++++++++++++ src/60-afxdp-dump/afxdp_dump.bpf.c | 62 ++ src/60-afxdp-dump/afxdp_dump.c | 459 +++++++++ src/60-afxdp-dump/afxdp_dump.h | 7 + src/60-afxdp-dump/tests/test_afxdp_dump.py | 118 +++ 32 files changed, 9003 insertions(+) create mode 100644 src/55-dns-egress/.gitignore create mode 100644 src/55-dns-egress/Makefile create mode 100644 src/55-dns-egress/README.md create mode 100644 src/55-dns-egress/README.zh.md create mode 100644 src/55-dns-egress/dns_egress.bpf.c create mode 100644 src/55-dns-egress/dns_egress.c create mode 100644 src/55-dns-egress/dns_egress.h create mode 100644 src/55-dns-egress/tests/test_dns_egress.py create mode 100644 src/56-slow-syscall-index/.gitignore create mode 100644 src/56-slow-syscall-index/Makefile create mode 100644 src/56-slow-syscall-index/README.md create mode 100644 src/56-slow-syscall-index/README.zh.md create mode 100644 src/56-slow-syscall-index/slow_syscall_index.bpf.c create mode 100644 src/56-slow-syscall-index/slow_syscall_index.c create mode 100644 src/56-slow-syscall-index/slow_syscall_index.h create mode 100644 src/56-slow-syscall-index/tests/test_slow_syscall_index.py create mode 100644 src/57-oom-watch/.gitignore create mode 100644 src/57-oom-watch/Makefile create mode 100644 src/57-oom-watch/README.md create mode 100644 src/57-oom-watch/README.zh.md create mode 100644 src/57-oom-watch/oom_watch.bpf.c create mode 100644 src/57-oom-watch/oom_watch.c create mode 100644 src/57-oom-watch/oom_watch.h create mode 100644 src/57-oom-watch/tests/test_oom_watch.py create mode 100644 src/60-afxdp-dump/.gitignore create mode 100644 src/60-afxdp-dump/Makefile create mode 100644 src/60-afxdp-dump/README.md create mode 100644 src/60-afxdp-dump/README.zh.md create mode 100644 src/60-afxdp-dump/afxdp_dump.bpf.c create mode 100644 src/60-afxdp-dump/afxdp_dump.c create mode 100644 src/60-afxdp-dump/afxdp_dump.h create mode 100644 src/60-afxdp-dump/tests/test_afxdp_dump.py diff --git a/src/55-dns-egress/.gitignore b/src/55-dns-egress/.gitignore new file mode 100644 index 00000000..84cad9ba --- /dev/null +++ b/src/55-dns-egress/.gitignore @@ -0,0 +1,2 @@ +.output/ +dns_egress diff --git a/src/55-dns-egress/Makefile b/src/55-dns-egress/Makefile new file mode 100644 index 00000000..7042a969 --- /dev/null +++ b/src/55-dns-egress/Makefile @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +OUTPUT := .output +CLANG ?= clang +LIBBPF_SRC := $(abspath ../third_party/libbpf/src) +BPFTOOL_SRC := $(abspath ../third_party/bpftool/src) +LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a) +BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool) +BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool +ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \ + | sed 's/arm.*/arm/' \ + | sed 's/aarch64/arm64/' \ + | sed 's/ppc64le/powerpc/' \ + | sed 's/mips.*/mips/' \ + | sed 's/riscv64/riscv/' \ + | sed 's/loongarch64/loongarch/') +VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h +INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. +CFLAGS := -g -Wall -Wextra -D_GNU_SOURCE +ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) +APPS = dns_egress + +CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ + | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') + +ifeq ($(V),1) + Q = + msg = +else + Q = @ + msg = @printf ' %-8s %s%s\n' "$(1)" \ + "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \ + "$(if $(3), $(3))"; + MAKEFLAGS += --no-print-directory +endif + +define allow-override + $(if $(or $(findstring environment,$(origin $(1))),\ + $(findstring command line,$(origin $(1)))),,\ + $(eval $(1) = $(2))) +endef + +$(call allow-override,CC,$(CROSS_COMPILE)cc) + +.PHONY: all clean test +all: $(APPS) + +clean: + $(call msg,CLEAN) + $(Q)rm -rf $(OUTPUT) $(APPS) + +test: all + @if [ "$$(id -u)" -ne 0 ]; then \ + echo "test requires root; run inside the documented KVM guest" >&2; \ + exit 1; \ + fi + python3 tests/test_dns_egress.py ./dns_egress + +$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): + $(call msg,MKDIR,$@) + $(Q)mkdir -p $@ + +$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf + $(call msg,LIB,$@) + $(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \ + OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \ + INCLUDEDIR= LIBDIR= UAPIDIR= install + +$(BPFTOOL): | $(BPFTOOL_OUTPUT) + $(call msg,BPFTOOL,$@) + $(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap + +$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL) + $(call msg,BPF,$@) + $(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \ + $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \ + -c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + $(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + +$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL) + $(call msg,GEN-SKEL,$@) + $(Q)$(BPFTOOL) gen skeleton $< > $@ + +$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h + +$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT) + $(call msg,CC,$@) + $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@ + +$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT) + $(call msg,BINARY,$@) + $(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@ + +.DELETE_ON_ERROR: +.SECONDARY: diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md new file mode 100644 index 00000000..3e2ad216 --- /dev/null +++ b/src/55-dns-egress/README.md @@ -0,0 +1,931 @@ +# eBPF Tutorial by Example: DNS-Aware Egress Policy Enforcement + +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. + +> Complete source code: + +## eBPF and cgroup Hooks + +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. + +## How the Implementation Works + +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. + +## Header File + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __DNS_EGRESS_H +#define __DNS_EGRESS_H + +#define DNS_QNAME_MAX 64 + +enum dns_egress_event_type { + DNS_LEARNED = 1, + DNS_ALLOWED = 2, + DNS_DENIED = 3, + DNS_EXPIRED = 4, +}; + +struct dns_header { + unsigned short id; + unsigned short flags; + unsigned short questions; + unsigned short answers; + unsigned short authorities; + unsigned short additionals; +} __attribute__((packed)); + +struct dns_question { + unsigned short type; + unsigned short class; +} __attribute__((packed)); + +struct dns_a_answer { + unsigned short name; + unsigned short type; + unsigned short class; + unsigned int ttl; + unsigned short address_length; + unsigned int address; +} __attribute__((packed)); + +struct dns_egress_event { + unsigned long long timestamp_ns; + unsigned long long expires_ns; + unsigned int type; + unsigned int pid; + unsigned int ip4; + unsigned int ttl_seconds; +}; + +#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. + +## BPF Program + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "dns_egress.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define IPPROTO_UDP 17 +#define IPPROTO_TCP 6 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff +#define DNS_QUERY_LIFETIME_NS (5ULL * 1000000000ULL) + +const volatile __u32 target_tgid; +const volatile __u32 dns_server_ip; +const volatile __u16 dns_server_port = 53; +const volatile __u16 protected_tcp_port = 443; +const volatile __u32 configured_qname_length; +const volatile unsigned char configured_qname[DNS_QNAME_MAX]; + +struct dns_state { + __u64 expires_ns; + __u32 ttl_seconds; + __u32 pad; + __u64 expired_reported; +}; + +struct dns_query_key { + __u32 server_ip; + __u32 client_ip; + __u16 client_port; + __u16 transaction_id; +}; + +struct dns_query_state { + __u64 expires_ns; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, struct dns_query_key); + __type(value, struct dns_query_state); +} pending_queries SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct dns_state); +} allowed_ips SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +static __always_inline void emit_event(__u32 type, __u32 ip4, + __u32 ttl_seconds, __u64 expires_ns) +{ + struct dns_egress_event *event; + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return; + event->timestamp_ns = bpf_ktime_get_ns(); + event->expires_ns = expires_ns; + event->type = type; + event->pid = bpf_get_current_pid_tgid() >> 32; + event->ip4 = ip4; + event->ttl_seconds = ttl_seconds; + bpf_ringbuf_submit(event, 0); +} + +static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) +{ + unsigned char byte; + + if (!configured_qname_length || configured_qname_length > DNS_QNAME_MAX) + return false; +#pragma clang loop unroll(disable) + for (int i = 0; i < DNS_QNAME_MAX; i++) { + if (i >= configured_qname_length) + break; + if (bpf_skb_load_bytes(skb, offset + i, &byte, sizeof(byte)) || + byte != configured_qname[i]) + return false; + } + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + struct dns_question question; + struct dns_header header; + 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; + 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_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); + 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; + 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; + + 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; +} + +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; + + 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) + 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; + state.ttl_seconds = ttl; + if (bpf_map_update_elem(&allowed_ips, &key, &state, BPF_ANY)) + return 1; + emit_event(DNS_LEARNED, key, ttl, expires); + return 1; +} + +SEC("cgroup/connect4") +int enforce_dns_policy(struct bpf_sock_addr *ctx) +{ + struct dns_state *state; + __u64 expires = 0; + __u32 ip4; + __u32 ttl = 0; + + if ((target_tgid && + (__u32)(bpf_get_current_pid_tgid() >> 32) != target_tgid) || + ctx->protocol != IPPROTO_TCP || + bpf_ntohs((__u16)ctx->user_port) != protected_tcp_port) + return 1; + + ip4 = ctx->user_ip4; + state = bpf_map_lookup_elem(&allowed_ips, &ip4); + if (state) { + expires = state->expires_ns; + ttl = state->ttl_seconds; + if (bpf_ktime_get_ns() < expires) { + emit_event(DNS_ALLOWED, ip4, ttl, expires); + return 1; + } + if (__sync_val_compare_and_swap(&state->expired_reported, 0, 1) == 0) + emit_event(DNS_EXPIRED, ip4, ttl, expires); + } + emit_event(DNS_DENIED, ip4, ttl, expires); + return 0; +} +``` + +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. + +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 `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. + +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. + +## User Space 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 +#include "dns_egress.h" +#include "dns_egress.skel.h" + +#define DEMO_DNS_PORT 15353 +#define DEMO_TCP_PORT 19090 +#define DNS_ID 0x4b1d + +struct options { + const char *cgroup_path; + const char *domain; + const char *dns_server; + unsigned int port; + unsigned int dns_port; + unsigned int duration_seconds; + bool demo; +}; + +static int event_counts[5]; +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 handle_event(void *ctx, void *data, size_t size) +{ + const struct dns_egress_event *event = data; + char address[INET_ADDRSTRLEN]; + const char *name; + + (void)ctx; + if (size != sizeof(*event) || event->type > DNS_EXPIRED) + return 0; + event_counts[event->type]++; + inet_ntop(AF_INET, &event->ip4, address, sizeof(address)); + name = event->type == DNS_LEARNED ? "learned" : + event->type == DNS_ALLOWED ? "allowed" : + event->type == DNS_DENIED ? "denied" : "expired"; + printf("event=%s pid=%u ip=%s ttl=%u\n", name, event->pid, address, + event->ttl_seconds); + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --cgroup PATH --domain NAME --dns-server IPV4 [--port PORT] [--dns-port PORT] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "cgroup", required_argument, NULL, 'c' }, + { "domain", required_argument, NULL, 'n' }, + { "dns-server", required_argument, NULL, 'r' }, + { "port", required_argument, NULL, 'p' }, + { "dns-port", required_argument, NULL, 's' }, + { "duration", required_argument, NULL, 'd' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + 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) + 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; +} + +static int encode_qname(const char *domain, unsigned char output[DNS_QNAME_MAX], + unsigned int *output_length) +{ + const char *label = domain; + unsigned int used = 0; + + if (!*domain) + return -1; + while (*label) { + const char *dot = strchr(label, '.'); + size_t length = dot ? (size_t)(dot - label) : strlen(label); + + if (!length || length > 63 || used + length + 2 > DNS_QNAME_MAX) + return -1; + output[used++] = length; + memcpy(output + used, label, length); + used += length; + if (!dot) + break; + label = dot + 1; + if (!*label) + break; + } + output[used++] = 0; + *output_length = used; + return 0; +} + +static int bind_udp(struct sockaddr_in *address) +{ + socklen_t length = sizeof(*address); + int fd; + + fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (bind(fd, (struct sockaddr *)address, sizeof(*address)) || + getsockname(fd, (struct sockaddr *)address, &length)) { + close(fd); + return -1; + } + return fd; +} + +static int begin_dns_exchange(int server, int client, + struct sockaddr_in *server_address, + const unsigned char *qname, + unsigned int qname_length, + unsigned char message[512], + size_t *query_length, + struct sockaddr_in *client_address) +{ + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(*client_address); + ssize_t received; + + memset(message, 0, 512); + header->id = htons(DNS_ID); + header->flags = htons(0x0100); + header->questions = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + *query_length = sizeof(*header) + qname_length + sizeof(question); + if (sendto(client, message, *query_length, 0, + (struct sockaddr *)server_address, sizeof(*server_address)) != + (ssize_t)*query_length) + return -1; + received = recvfrom(server, message, 512, 0, + (struct sockaddr *)client_address, &address_length); + return received == (ssize_t)*query_length ? 0 : -1; +} + +static int send_dns_answer(int server, int client, + struct sockaddr_in *client_address, + unsigned char message[512], size_t query_length, + unsigned short transaction_id, + unsigned int ttl_seconds) +{ + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(ttl_seconds), + .address_length = htons(4), + }; + struct dns_header *header = (void *)message; + + if (query_length + sizeof(answer) > 512) + return -1; + header->id = htons(transaction_id); + header->flags = htons(0x8180); + header->answers = htons(1); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + query_length, &answer, sizeof(answer)); + if (sendto(server, message, query_length + sizeof(answer), 0, + (struct sockaddr *)client_address, sizeof(*client_address)) != + (ssize_t)(query_length + sizeof(answer))) + return -1; + return recv(client, message, 512, 0) == + (ssize_t)(query_length + sizeof(answer)) ? 0 : -1; +} + +static int send_unsolicited_dns(int server, int client, + const unsigned char *qname, + unsigned int qname_length) +{ + unsigned char message[512] = {}; + struct sockaddr_in client_address; + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(30), + .address_length = htons(4), + }; + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(client_address); + size_t message_length; + + if (getsockname(client, (struct sockaddr *)&client_address, + &address_length)) + return -1; + header->id = htons(DNS_ID + 1); + header->flags = htons(0x8180); + header->questions = htons(1); + header->answers = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + message_length = sizeof(*header) + qname_length + sizeof(question); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + message_length, &answer, sizeof(answer)); + message_length += sizeof(answer); + if (sendto(server, message, message_length, 0, + (struct sockaddr *)&client_address, address_length) != + (ssize_t)message_length) + return -1; + return recv(client, message, sizeof(message), 0) == + (ssize_t)message_length ? 0 : -1; +} + +static int create_tcp_listener(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int one = 1; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + if (bind(fd, (struct sockaddr *)&address, sizeof(address)) || + listen(fd, 4)) { + close(fd); + return -1; + } + return fd; +} + +static int connect_tcp(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int saved_errno; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (!connect(fd, (struct sockaddr *)&address, sizeof(address))) + return fd; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; +} + +static int complete_tcp(int listener, int client) +{ + int accepted = accept4(listener, NULL, NULL, SOCK_CLOEXEC); + char byte = 'x'; + + if (accepted < 0) + return -1; + if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) { + close(accepted); + return -1; + } + close(accepted); + close(client); + return 0; +} + +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct sockaddr_in server_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), + }; + struct sockaddr_in client_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + 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 err = -1; + + dns_server = bind_udp(&server_address); + dns_client = bind_udp(&client_address); + listener = create_tcp_listener(options->port); + if (dns_server < 0 || dns_client < 0 || listener < 0) + goto cleanup; + + errno = 0; + client = connect_tcp(options->port); + if (client >= 0 || errno != EPERM) + 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) + 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, + &response_client_address) || + 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) + 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)) + 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) + 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) + 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; +} + +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 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); + if (parse_options(argc, argv, &options) || + encode_qname(options.domain, qname, &qname_length) || + inet_pton(AF_INET, options.dns_server, &dns_server) != 1) { + 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) + 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)) + 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; + } + } + } + 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); + 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 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. + +## Compilation and Execution + +Build the example: + +```bash +cd src/55-dns-egress +make +``` + +Run with the required parameters: + +```bash +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: + +```bash +sudo ./dns_egress --demo +``` + +Example demo output: + +```text +dns-egress attached cgroup=/sys/fs/cgroup domain=lab.test resolver=127.0.0.1 tcp_port=19090 dns_port=15353 +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=before-dns result=blocked +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=unsolicited-response result=blocked +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=wrong-transaction-id result=blocked +event=learned pid=1246 ip=127.0.0.1 ttl=1 +event=allowed pid=1246 ip=127.0.0.1 ttl=1 +demo step=live-answer result=allowed +event=expired pid=1246 ip=127.0.0.1 ttl=1 +event=denied pid=1246 ip=127.0.0.1 ttl=1 +demo step=expired-answer result=blocked +``` + +## 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 | + +## Scope and Limitations + +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. + +## 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. + +> 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) diff --git a/src/55-dns-egress/README.zh.md b/src/55-dns-egress/README.zh.md new file mode 100644 index 00000000..c5e15478 --- /dev/null +++ b/src/55-dns-egress/README.zh.md @@ -0,0 +1,931 @@ +# eBPF 实战教程:DNS 感知的出口策略执行 + +如何让一个受保护端口只连接到应用程序刚通过 DNS 解析的 IP 地址?本教程构建一个基于 cgroup 的出口过滤器,从 DNS 响应中学习 IP 地址,在 DNS TTL 有效期间允许匹配的 TCP 连接,并拒绝这个端口上的其他目的地址,策略在 connect 时由内核执行。 + +> 完整源代码: + +## eBPF 与 cgroup 钩子 + +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 或更高版本。 + +## 实现原理 + +该工具将三个 BPF 程序附加到 cgroup。出口程序把事务 ID、客户端端口和服务器 IP 保存为待处理查询,用于记录出站 DNS 请求,5 秒内没有匹配响应的查询会过期。入口程序验证响应与待处理查询的关联,再提取 A 记录 IP 和 TTL。对于发往受保护端口的 TCP 连接,connect hook 检查目标 IP 是否在允许列表中并且仍然有效,其他目的端口保持原有行为。 + +## 头文件 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __DNS_EGRESS_H +#define __DNS_EGRESS_H + +#define DNS_QNAME_MAX 64 + +enum dns_egress_event_type { + DNS_LEARNED = 1, + DNS_ALLOWED = 2, + DNS_DENIED = 3, + DNS_EXPIRED = 4, +}; + +struct dns_header { + unsigned short id; + unsigned short flags; + unsigned short questions; + unsigned short answers; + unsigned short authorities; + unsigned short additionals; +} __attribute__((packed)); + +struct dns_question { + unsigned short type; + unsigned short class; +} __attribute__((packed)); + +struct dns_a_answer { + unsigned short name; + unsigned short type; + unsigned short class; + unsigned int ttl; + unsigned short address_length; + unsigned int address; +} __attribute__((packed)); + +struct dns_egress_event { + unsigned long long timestamp_ns; + unsigned long long expires_ns; + unsigned int type; + unsigned int pid; + unsigned int ip4; + unsigned int ttl_seconds; +}; + +#endif /* __DNS_EGRESS_H */ +``` + +头文件定义了用于解析数据包的 DNS 协议结构。`dns_header` 结构匹配 DNS 消息头格式。`dns_question` 和 `dns_a_answer` 结构解析查询和应答部分。`dns_egress_event` 结构通过 ring buffer 将策略事件(learned、allowed、denied、expired)传递给用户空间。 + +## BPF 程序 + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "dns_egress.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define IPPROTO_UDP 17 +#define IPPROTO_TCP 6 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff +#define DNS_QUERY_LIFETIME_NS (5ULL * 1000000000ULL) + +const volatile __u32 target_tgid; +const volatile __u32 dns_server_ip; +const volatile __u16 dns_server_port = 53; +const volatile __u16 protected_tcp_port = 443; +const volatile __u32 configured_qname_length; +const volatile unsigned char configured_qname[DNS_QNAME_MAX]; + +struct dns_state { + __u64 expires_ns; + __u32 ttl_seconds; + __u32 pad; + __u64 expired_reported; +}; + +struct dns_query_key { + __u32 server_ip; + __u32 client_ip; + __u16 client_port; + __u16 transaction_id; +}; + +struct dns_query_state { + __u64 expires_ns; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, struct dns_query_key); + __type(value, struct dns_query_state); +} pending_queries SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct dns_state); +} allowed_ips SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +static __always_inline void emit_event(__u32 type, __u32 ip4, + __u32 ttl_seconds, __u64 expires_ns) +{ + struct dns_egress_event *event; + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return; + event->timestamp_ns = bpf_ktime_get_ns(); + event->expires_ns = expires_ns; + event->type = type; + event->pid = bpf_get_current_pid_tgid() >> 32; + event->ip4 = ip4; + event->ttl_seconds = ttl_seconds; + bpf_ringbuf_submit(event, 0); +} + +static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) +{ + unsigned char byte; + + if (!configured_qname_length || configured_qname_length > DNS_QNAME_MAX) + return false; +#pragma clang loop unroll(disable) + for (int i = 0; i < DNS_QNAME_MAX; i++) { + if (i >= configured_qname_length) + break; + if (bpf_skb_load_bytes(skb, offset + i, &byte, sizeof(byte)) || + byte != configured_qname[i]) + return false; + } + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + struct dns_question question; + struct dns_header header; + 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; + 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_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); + 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; + 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; + + 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; +} + +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; + + 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) + 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; + state.ttl_seconds = ttl; + if (bpf_map_update_elem(&allowed_ips, &key, &state, BPF_ANY)) + return 1; + emit_event(DNS_LEARNED, key, ttl, expires); + return 1; +} + +SEC("cgroup/connect4") +int enforce_dns_policy(struct bpf_sock_addr *ctx) +{ + struct dns_state *state; + __u64 expires = 0; + __u32 ip4; + __u32 ttl = 0; + + if ((target_tgid && + (__u32)(bpf_get_current_pid_tgid() >> 32) != target_tgid) || + ctx->protocol != IPPROTO_TCP || + bpf_ntohs((__u16)ctx->user_port) != protected_tcp_port) + return 1; + + ip4 = ctx->user_ip4; + state = bpf_map_lookup_elem(&allowed_ips, &ip4); + if (state) { + expires = state->expires_ns; + ttl = state->ttl_seconds; + if (bpf_ktime_get_ns() < expires) { + emit_event(DNS_ALLOWED, ip4, ttl, expires); + return 1; + } + if (__sync_val_compare_and_swap(&state->expired_reported, 0, 1) == 0) + emit_event(DNS_EXPIRED, ip4, ttl, expires); + } + emit_event(DNS_DENIED, ip4, ttl, expires); + return 0; +} +``` + +BPF 程序使用三个映射。`pending_queries` LRU 哈希使用服务器 IP、客户端 IP、客户端端口和事务 ID 的组合键跟踪出站 DNS 查询。`allowed_ips` LRU 哈希存储学习到的 IP 及其过期时间戳。`events` ring buffer 将策略决策发送到用户空间。 + +`record_dns_query` 函数在 cgroup 出口运行,捕获发往配置的解析器的 DNS 查询。它验证数据包是发往正确服务器和端口的 IPv4 UDP,检查查询名称是否匹配,并存储带有 5 秒过期时间的查询。 + +`learn_dns_answer` 函数在 cgroup 入口运行,解析 DNS 响应。它查找匹配的待处理查询,验证响应格式,从第一个 A 记录提取 IP 地址和 TTL,并将 IP 存储到允许列表中,设置基于 TTL 的过期时间。 + +`enforce_dns_policy` 函数在进程调用 `connect()` 时运行,发往受保护端口的 IPv4 TCP 连接会查询允许列表和过期时间。命中的条目返回 1 继续连接,缺失或过期的条目返回 0,让 `connect()` 以 `EPERM` 失败,其他端口则直接返回 1。 + +## 用户空间程序 + +```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 +#include "dns_egress.h" +#include "dns_egress.skel.h" + +#define DEMO_DNS_PORT 15353 +#define DEMO_TCP_PORT 19090 +#define DNS_ID 0x4b1d + +struct options { + const char *cgroup_path; + const char *domain; + const char *dns_server; + unsigned int port; + unsigned int dns_port; + unsigned int duration_seconds; + bool demo; +}; + +static int event_counts[5]; +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 handle_event(void *ctx, void *data, size_t size) +{ + const struct dns_egress_event *event = data; + char address[INET_ADDRSTRLEN]; + const char *name; + + (void)ctx; + if (size != sizeof(*event) || event->type > DNS_EXPIRED) + return 0; + event_counts[event->type]++; + inet_ntop(AF_INET, &event->ip4, address, sizeof(address)); + name = event->type == DNS_LEARNED ? "learned" : + event->type == DNS_ALLOWED ? "allowed" : + event->type == DNS_DENIED ? "denied" : "expired"; + printf("event=%s pid=%u ip=%s ttl=%u\n", name, event->pid, address, + event->ttl_seconds); + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --cgroup PATH --domain NAME --dns-server IPV4 [--port PORT] [--dns-port PORT] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "cgroup", required_argument, NULL, 'c' }, + { "domain", required_argument, NULL, 'n' }, + { "dns-server", required_argument, NULL, 'r' }, + { "port", required_argument, NULL, 'p' }, + { "dns-port", required_argument, NULL, 's' }, + { "duration", required_argument, NULL, 'd' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + 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) + 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; +} + +static int encode_qname(const char *domain, unsigned char output[DNS_QNAME_MAX], + unsigned int *output_length) +{ + const char *label = domain; + unsigned int used = 0; + + if (!*domain) + return -1; + while (*label) { + const char *dot = strchr(label, '.'); + size_t length = dot ? (size_t)(dot - label) : strlen(label); + + if (!length || length > 63 || used + length + 2 > DNS_QNAME_MAX) + return -1; + output[used++] = length; + memcpy(output + used, label, length); + used += length; + if (!dot) + break; + label = dot + 1; + if (!*label) + break; + } + output[used++] = 0; + *output_length = used; + return 0; +} + +static int bind_udp(struct sockaddr_in *address) +{ + socklen_t length = sizeof(*address); + int fd; + + fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (bind(fd, (struct sockaddr *)address, sizeof(*address)) || + getsockname(fd, (struct sockaddr *)address, &length)) { + close(fd); + return -1; + } + return fd; +} + +static int begin_dns_exchange(int server, int client, + struct sockaddr_in *server_address, + const unsigned char *qname, + unsigned int qname_length, + unsigned char message[512], + size_t *query_length, + struct sockaddr_in *client_address) +{ + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(*client_address); + ssize_t received; + + memset(message, 0, 512); + header->id = htons(DNS_ID); + header->flags = htons(0x0100); + header->questions = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + *query_length = sizeof(*header) + qname_length + sizeof(question); + if (sendto(client, message, *query_length, 0, + (struct sockaddr *)server_address, sizeof(*server_address)) != + (ssize_t)*query_length) + return -1; + received = recvfrom(server, message, 512, 0, + (struct sockaddr *)client_address, &address_length); + return received == (ssize_t)*query_length ? 0 : -1; +} + +static int send_dns_answer(int server, int client, + struct sockaddr_in *client_address, + unsigned char message[512], size_t query_length, + unsigned short transaction_id, + unsigned int ttl_seconds) +{ + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(ttl_seconds), + .address_length = htons(4), + }; + struct dns_header *header = (void *)message; + + if (query_length + sizeof(answer) > 512) + return -1; + header->id = htons(transaction_id); + header->flags = htons(0x8180); + header->answers = htons(1); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + query_length, &answer, sizeof(answer)); + if (sendto(server, message, query_length + sizeof(answer), 0, + (struct sockaddr *)client_address, sizeof(*client_address)) != + (ssize_t)(query_length + sizeof(answer))) + return -1; + return recv(client, message, 512, 0) == + (ssize_t)(query_length + sizeof(answer)) ? 0 : -1; +} + +static int send_unsolicited_dns(int server, int client, + const unsigned char *qname, + unsigned int qname_length) +{ + unsigned char message[512] = {}; + struct sockaddr_in client_address; + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(30), + .address_length = htons(4), + }; + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(client_address); + size_t message_length; + + if (getsockname(client, (struct sockaddr *)&client_address, + &address_length)) + return -1; + header->id = htons(DNS_ID + 1); + header->flags = htons(0x8180); + header->questions = htons(1); + header->answers = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + message_length = sizeof(*header) + qname_length + sizeof(question); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + message_length, &answer, sizeof(answer)); + message_length += sizeof(answer); + if (sendto(server, message, message_length, 0, + (struct sockaddr *)&client_address, address_length) != + (ssize_t)message_length) + return -1; + return recv(client, message, sizeof(message), 0) == + (ssize_t)message_length ? 0 : -1; +} + +static int create_tcp_listener(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int one = 1; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + if (bind(fd, (struct sockaddr *)&address, sizeof(address)) || + listen(fd, 4)) { + close(fd); + return -1; + } + return fd; +} + +static int connect_tcp(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int saved_errno; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (!connect(fd, (struct sockaddr *)&address, sizeof(address))) + return fd; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; +} + +static int complete_tcp(int listener, int client) +{ + int accepted = accept4(listener, NULL, NULL, SOCK_CLOEXEC); + char byte = 'x'; + + if (accepted < 0) + return -1; + if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) { + close(accepted); + return -1; + } + close(accepted); + close(client); + return 0; +} + +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct sockaddr_in server_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), + }; + struct sockaddr_in client_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + 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 err = -1; + + dns_server = bind_udp(&server_address); + dns_client = bind_udp(&client_address); + listener = create_tcp_listener(options->port); + if (dns_server < 0 || dns_client < 0 || listener < 0) + goto cleanup; + + errno = 0; + client = connect_tcp(options->port); + if (client >= 0 || errno != EPERM) + 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) + 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, + &response_client_address) || + 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) + 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)) + 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) + 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) + 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; +} + +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 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); + if (parse_options(argc, argv, &options) || + encode_qname(options.domain, qname, &qname_length) || + inet_pton(AF_INET, options.dns_server, &dns_server) != 1) { + 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) + 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)) + 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; + } + } + } + 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); + return err; +} +``` + +用户空间程序加载并将 BPF 程序附加到 cgroup。它将域名编码为 DNS 标签格式,配置 BPF 程序的目标解析器和受保护端口,并轮询 ring buffer 获取策略事件。 + +演示模式运行一个独立测试:它创建本地 UDP 和 TCP 套接字,模拟 DNS 解析,并验证策略是否正确工作。它测试 DNS 之前的连接阻止、拒绝未经请求的响应、拒绝错误的事务 ID、有效 DNS 后允许连接,以及 TTL 过期后阻止连接。 + +## 编译和执行 + +构建示例: + +```bash +cd src/55-dns-egress +make +``` + +使用必需参数运行: + +```bash +sudo ./dns_egress --cgroup /sys/fs/cgroup/my-service --domain api.example.com --dns-server 127.0.0.53 --port 443 +``` + +或运行内置演示: + +```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 +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=before-dns result=blocked +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=unsolicited-response result=blocked +event=denied pid=1246 ip=127.0.0.1 ttl=0 +demo step=wrong-transaction-id result=blocked +event=learned pid=1246 ip=127.0.0.1 ttl=1 +event=allowed pid=1246 ip=127.0.0.1 ttl=1 +demo step=live-answer result=allowed +event=expired pid=1246 ip=127.0.0.1 ttl=1 +event=denied pid=1246 ip=127.0.0.1 ttl=1 +demo step=expired-answer result=blocked +``` + +## 环境要求 + +| 要求 | 详情 | +|------|------| +| 内核 | Linux 5.8+(BPF ring buffer) | +| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF` | +| 权限 | Root | +| cgroup | cgroup v2 已挂载 | + +## 范围和限制 + +此实现保护一个配置好的 TCP 目的端口,其他端口保持不变。它支持启动时配置的一个精确查询名称,仅支持 IPv4 UDP DNS,并解析直接的第一个 A 记录应答。待处理查询在 5 秒后过期,允许的 IP 使用带有 DNS TTL 的 1024 条目 LRU 哈希。它不处理 CNAME 链、多应答布局、TCP DNS、IPv6、DNS-over-HTTPS 或 DNS-over-TLS。 + +## 总结 + +这个例子把 DNS 响应与最近的查询关联起来,再把 TTL 带到 connect 阶段的策略判断中,从而把一个域名变成内核中的短期允许列表。三个 cgroup hook 分别负责 DNS 观察和连接执行,ring buffer 事件则让用户态能够看到每一次策略决定。 + +> 如果你想深入了解 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) diff --git a/src/55-dns-egress/dns_egress.bpf.c b/src/55-dns-egress/dns_egress.bpf.c new file mode 100644 index 00000000..b10bca58 --- /dev/null +++ b/src/55-dns-egress/dns_egress.bpf.c @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "dns_egress.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define IPPROTO_UDP 17 +#define IPPROTO_TCP 6 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff +#define DNS_QUERY_LIFETIME_NS (5ULL * 1000000000ULL) + +const volatile __u32 target_tgid; +const volatile __u32 dns_server_ip; +const volatile __u16 dns_server_port = 53; +const volatile __u16 protected_tcp_port = 443; +const volatile __u32 configured_qname_length; +const volatile unsigned char configured_qname[DNS_QNAME_MAX]; + +struct dns_state { + __u64 expires_ns; + __u32 ttl_seconds; + __u32 pad; + __u64 expired_reported; +}; + +struct dns_query_key { + __u32 server_ip; + __u32 client_ip; + __u16 client_port; + __u16 transaction_id; +}; + +struct dns_query_state { + __u64 expires_ns; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, struct dns_query_key); + __type(value, struct dns_query_state); +} pending_queries SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct dns_state); +} allowed_ips SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +static __always_inline void emit_event(__u32 type, __u32 ip4, + __u32 ttl_seconds, __u64 expires_ns) +{ + struct dns_egress_event *event; + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return; + event->timestamp_ns = bpf_ktime_get_ns(); + event->expires_ns = expires_ns; + event->type = type; + event->pid = bpf_get_current_pid_tgid() >> 32; + event->ip4 = ip4; + event->ttl_seconds = ttl_seconds; + bpf_ringbuf_submit(event, 0); +} + +static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) +{ + unsigned char byte; + + if (!configured_qname_length || configured_qname_length > DNS_QNAME_MAX) + return false; +#pragma clang loop unroll(disable) + for (int i = 0; i < DNS_QNAME_MAX; i++) { + if (i >= configured_qname_length) + break; + if (bpf_skb_load_bytes(skb, offset + i, &byte, sizeof(byte)) || + byte != configured_qname[i]) + return false; + } + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + struct dns_question question; + struct dns_header header; + 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; + 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_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); + 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; + 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; + + 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; +} + +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; + + 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) + 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; + state.ttl_seconds = ttl; + if (bpf_map_update_elem(&allowed_ips, &key, &state, BPF_ANY)) + return 1; + emit_event(DNS_LEARNED, key, ttl, expires); + return 1; +} + +SEC("cgroup/connect4") +int enforce_dns_policy(struct bpf_sock_addr *ctx) +{ + struct dns_state *state; + __u64 expires = 0; + __u32 ip4; + __u32 ttl = 0; + + if ((target_tgid && + (__u32)(bpf_get_current_pid_tgid() >> 32) != target_tgid) || + ctx->protocol != IPPROTO_TCP || + bpf_ntohs((__u16)ctx->user_port) != protected_tcp_port) + return 1; + + ip4 = ctx->user_ip4; + state = bpf_map_lookup_elem(&allowed_ips, &ip4); + if (state) { + expires = state->expires_ns; + ttl = state->ttl_seconds; + if (bpf_ktime_get_ns() < expires) { + emit_event(DNS_ALLOWED, ip4, ttl, expires); + return 1; + } + if (__sync_val_compare_and_swap(&state->expired_reported, 0, 1) == 0) + emit_event(DNS_EXPIRED, ip4, ttl, expires); + } + emit_event(DNS_DENIED, ip4, ttl, expires); + return 0; +} diff --git a/src/55-dns-egress/dns_egress.c b/src/55-dns-egress/dns_egress.c new file mode 100644 index 00000000..8bd07f20 --- /dev/null +++ b/src/55-dns-egress/dns_egress.c @@ -0,0 +1,540 @@ +// 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 +#include "dns_egress.h" +#include "dns_egress.skel.h" + +#define DEMO_DNS_PORT 15353 +#define DEMO_TCP_PORT 19090 +#define DNS_ID 0x4b1d + +struct options { + const char *cgroup_path; + const char *domain; + const char *dns_server; + unsigned int port; + unsigned int dns_port; + unsigned int duration_seconds; + bool demo; +}; + +static int event_counts[5]; +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 handle_event(void *ctx, void *data, size_t size) +{ + const struct dns_egress_event *event = data; + char address[INET_ADDRSTRLEN]; + const char *name; + + (void)ctx; + if (size != sizeof(*event) || event->type > DNS_EXPIRED) + return 0; + event_counts[event->type]++; + inet_ntop(AF_INET, &event->ip4, address, sizeof(address)); + name = event->type == DNS_LEARNED ? "learned" : + event->type == DNS_ALLOWED ? "allowed" : + event->type == DNS_DENIED ? "denied" : "expired"; + printf("event=%s pid=%u ip=%s ttl=%u\n", name, event->pid, address, + event->ttl_seconds); + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --cgroup PATH --domain NAME --dns-server IPV4 [--port PORT] [--dns-port PORT] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "cgroup", required_argument, NULL, 'c' }, + { "domain", required_argument, NULL, 'n' }, + { "dns-server", required_argument, NULL, 'r' }, + { "port", required_argument, NULL, 'p' }, + { "dns-port", required_argument, NULL, 's' }, + { "duration", required_argument, NULL, 'd' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + 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) + 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; +} + +static int encode_qname(const char *domain, unsigned char output[DNS_QNAME_MAX], + unsigned int *output_length) +{ + const char *label = domain; + unsigned int used = 0; + + if (!*domain) + return -1; + while (*label) { + const char *dot = strchr(label, '.'); + size_t length = dot ? (size_t)(dot - label) : strlen(label); + + if (!length || length > 63 || used + length + 2 > DNS_QNAME_MAX) + return -1; + output[used++] = length; + memcpy(output + used, label, length); + used += length; + if (!dot) + break; + label = dot + 1; + if (!*label) + break; + } + output[used++] = 0; + *output_length = used; + return 0; +} + +static int bind_udp(struct sockaddr_in *address) +{ + socklen_t length = sizeof(*address); + int fd; + + fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (bind(fd, (struct sockaddr *)address, sizeof(*address)) || + getsockname(fd, (struct sockaddr *)address, &length)) { + close(fd); + return -1; + } + return fd; +} + +static int begin_dns_exchange(int server, int client, + struct sockaddr_in *server_address, + const unsigned char *qname, + unsigned int qname_length, + unsigned char message[512], + size_t *query_length, + struct sockaddr_in *client_address) +{ + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(*client_address); + ssize_t received; + + memset(message, 0, 512); + header->id = htons(DNS_ID); + header->flags = htons(0x0100); + header->questions = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + *query_length = sizeof(*header) + qname_length + sizeof(question); + if (sendto(client, message, *query_length, 0, + (struct sockaddr *)server_address, sizeof(*server_address)) != + (ssize_t)*query_length) + return -1; + received = recvfrom(server, message, 512, 0, + (struct sockaddr *)client_address, &address_length); + return received == (ssize_t)*query_length ? 0 : -1; +} + +static int send_dns_answer(int server, int client, + struct sockaddr_in *client_address, + unsigned char message[512], size_t query_length, + unsigned short transaction_id, + unsigned int ttl_seconds) +{ + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(ttl_seconds), + .address_length = htons(4), + }; + struct dns_header *header = (void *)message; + + if (query_length + sizeof(answer) > 512) + return -1; + header->id = htons(transaction_id); + header->flags = htons(0x8180); + header->answers = htons(1); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + query_length, &answer, sizeof(answer)); + if (sendto(server, message, query_length + sizeof(answer), 0, + (struct sockaddr *)client_address, sizeof(*client_address)) != + (ssize_t)(query_length + sizeof(answer))) + return -1; + return recv(client, message, 512, 0) == + (ssize_t)(query_length + sizeof(answer)) ? 0 : -1; +} + +static int send_unsolicited_dns(int server, int client, + const unsigned char *qname, + unsigned int qname_length) +{ + unsigned char message[512] = {}; + struct sockaddr_in client_address; + struct dns_a_answer answer = { + .name = htons(0xc00c), + .type = htons(1), + .class = htons(1), + .ttl = htonl(30), + .address_length = htons(4), + }; + struct dns_question question = { + .type = htons(1), + .class = htons(1), + }; + struct dns_header *header = (void *)message; + socklen_t address_length = sizeof(client_address); + size_t message_length; + + if (getsockname(client, (struct sockaddr *)&client_address, + &address_length)) + return -1; + header->id = htons(DNS_ID + 1); + header->flags = htons(0x8180); + header->questions = htons(1); + header->answers = htons(1); + memcpy(message + sizeof(*header), qname, qname_length); + memcpy(message + sizeof(*header) + qname_length, &question, + sizeof(question)); + message_length = sizeof(*header) + qname_length + sizeof(question); + inet_pton(AF_INET, "127.0.0.1", &answer.address); + memcpy(message + message_length, &answer, sizeof(answer)); + message_length += sizeof(answer); + if (sendto(server, message, message_length, 0, + (struct sockaddr *)&client_address, address_length) != + (ssize_t)message_length) + return -1; + return recv(client, message, sizeof(message), 0) == + (ssize_t)message_length ? 0 : -1; +} + +static int create_tcp_listener(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int one = 1; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + if (bind(fd, (struct sockaddr *)&address, sizeof(address)) || + listen(fd, 4)) { + close(fd); + return -1; + } + return fd; +} + +static int connect_tcp(unsigned int port) +{ + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(port), + }; + int saved_errno; + int fd; + + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + if (!connect(fd, (struct sockaddr *)&address, sizeof(address))) + return fd; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; +} + +static int complete_tcp(int listener, int client) +{ + int accepted = accept4(listener, NULL, NULL, SOCK_CLOEXEC); + char byte = 'x'; + + if (accepted < 0) + return -1; + if (write(accepted, &byte, 1) != 1 || read(client, &byte, 1) != 1) { + close(accepted); + return -1; + } + close(accepted); + close(client); + return 0; +} + +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct sockaddr_in server_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), + }; + struct sockaddr_in client_address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + 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 err = -1; + + dns_server = bind_udp(&server_address); + dns_client = bind_udp(&client_address); + listener = create_tcp_listener(options->port); + if (dns_server < 0 || dns_client < 0 || listener < 0) + goto cleanup; + + errno = 0; + client = connect_tcp(options->port); + if (client >= 0 || errno != EPERM) + 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) + 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, + &response_client_address) || + 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) + 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)) + 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) + 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) + 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; +} + +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 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); + if (parse_options(argc, argv, &options) || + encode_qname(options.domain, qname, &qname_length) || + inet_pton(AF_INET, options.dns_server, &dns_server) != 1) { + 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) + 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)) + 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; + } + } + } + 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); + return err; +} diff --git a/src/55-dns-egress/dns_egress.h b/src/55-dns-egress/dns_egress.h new file mode 100644 index 00000000..3802feee --- /dev/null +++ b/src/55-dns-egress/dns_egress.h @@ -0,0 +1,46 @@ +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __DNS_EGRESS_H +#define __DNS_EGRESS_H + +#define DNS_QNAME_MAX 64 + +enum dns_egress_event_type { + DNS_LEARNED = 1, + DNS_ALLOWED = 2, + DNS_DENIED = 3, + DNS_EXPIRED = 4, +}; + +struct dns_header { + unsigned short id; + unsigned short flags; + unsigned short questions; + unsigned short answers; + unsigned short authorities; + unsigned short additionals; +} __attribute__((packed)); + +struct dns_question { + unsigned short type; + unsigned short class; +} __attribute__((packed)); + +struct dns_a_answer { + unsigned short name; + unsigned short type; + unsigned short class; + unsigned int ttl; + unsigned short address_length; + unsigned int address; +} __attribute__((packed)); + +struct dns_egress_event { + unsigned long long timestamp_ns; + unsigned long long expires_ns; + unsigned int type; + unsigned int pid; + unsigned int ip4; + unsigned int ttl_seconds; +}; + +#endif /* __DNS_EGRESS_H */ diff --git a/src/55-dns-egress/tests/test_dns_egress.py b/src/55-dns-egress/tests/test_dns_egress.py new file mode 100644 index 00000000..02874aae --- /dev/null +++ b/src/55-dns-egress/tests/test_dns_egress.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import subprocess +import sys + + +def main(): + result = subprocess.run( + [sys.argv[1], "--demo"], + text=True, + capture_output=True, + check=False, + timeout=30, + ) + output = result.stdout + result.stderr + if result.returncode: + raise AssertionError(output) + assert "demo step=before-dns result=blocked" in output, output + assert "demo step=unsolicited-response result=blocked" in output, output + assert "demo step=wrong-transaction-id result=blocked" in output, output + assert "event=learned" in output and "ip=127.0.0.1 ttl=1" in output, output + assert "demo step=live-answer result=allowed" in output, output + assert "event=expired" in output, output + assert "demo step=expired-answer result=blocked" in output, output + assert output.count("event=denied") == 4, output + print("DNS-aware egress integration test: PASS") + + +if __name__ == "__main__": + main() diff --git a/src/56-slow-syscall-index/.gitignore b/src/56-slow-syscall-index/.gitignore new file mode 100644 index 00000000..442bb931 --- /dev/null +++ b/src/56-slow-syscall-index/.gitignore @@ -0,0 +1,2 @@ +.output/ +slow_syscall_index diff --git a/src/56-slow-syscall-index/Makefile b/src/56-slow-syscall-index/Makefile new file mode 100644 index 00000000..0f5c8c3a --- /dev/null +++ b/src/56-slow-syscall-index/Makefile @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +OUTPUT := .output +CLANG ?= clang +LIBBPF_SRC := $(abspath ../third_party/libbpf/src) +BPFTOOL_SRC := $(abspath ../third_party/bpftool/src) +LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a) +BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool) +BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool +ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \ + | sed 's/arm.*/arm/' \ + | sed 's/aarch64/arm64/' \ + | sed 's/ppc64le/powerpc/' \ + | sed 's/mips.*/mips/' \ + | sed 's/riscv64/riscv/' \ + | sed 's/loongarch64/loongarch/') +VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h +INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. +CFLAGS := -g -Wall -Wextra -D_GNU_SOURCE +ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) +APPS = slow_syscall_index + +CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ + | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') + +ifeq ($(V),1) + Q = + msg = +else + Q = @ + msg = @printf ' %-8s %s%s\n' "$(1)" \ + "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \ + "$(if $(3), $(3))"; + MAKEFLAGS += --no-print-directory +endif + +define allow-override + $(if $(or $(findstring environment,$(origin $(1))),\ + $(findstring command line,$(origin $(1)))),,\ + $(eval $(1) = $(2))) +endef + +$(call allow-override,CC,$(CROSS_COMPILE)cc) + +.PHONY: all clean test +all: $(APPS) + +clean: + $(call msg,CLEAN) + $(Q)rm -rf $(OUTPUT) $(APPS) + +test: all + @if [ "$$(id -u)" -ne 0 ]; then \ + echo "test requires root; run inside the documented KVM guest" >&2; \ + exit 1; \ + fi + python3 tests/test_slow_syscall_index.py ./slow_syscall_index + +$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): + $(call msg,MKDIR,$@) + $(Q)mkdir -p $@ + +$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf + $(call msg,LIB,$@) + $(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \ + OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \ + INCLUDEDIR= LIBDIR= UAPIDIR= install + +$(BPFTOOL): | $(BPFTOOL_OUTPUT) + $(call msg,BPFTOOL,$@) + $(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap + +$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL) + $(call msg,BPF,$@) + $(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \ + $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \ + -c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + $(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + +$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL) + $(call msg,GEN-SKEL,$@) + $(Q)$(BPFTOOL) gen skeleton $< > $@ + +$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h + +$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT) + $(call msg,CC,$@) + $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@ + +$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT) + $(call msg,BINARY,$@) + $(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@ + +.DELETE_ON_ERROR: +.SECONDARY: diff --git a/src/56-slow-syscall-index/README.md b/src/56-slow-syscall-index/README.md new file mode 100644 index 00000000..3dfb6b03 --- /dev/null +++ b/src/56-slow-syscall-index/README.md @@ -0,0 +1,604 @@ +# eBPF Tutorial by Example: Slow Syscall Latency Index + +Which syscalls are taking the longest in your application? This tutorial builds a tool that traces syscall latency per process using BPF task local storage, aggregates the results by TGID and syscall number, and ranks them by total latency. The result shows which recorded slow syscalls account for most of the observed syscall latency. + +> Complete source code: + +## eBPF and Syscall Tracing + +eBPF lets verified programs run at Linux kernel hooks and send selected state to user space. This tutorial uses the `raw_syscalls/sys_enter` and `raw_syscalls/sys_exit` tracepoints, which fire on every syscall entry and exit system-wide. To correlate entry with exit, we use BPF task local storage, a map type introduced in Linux 5.11 that attaches storage directly to each task struct. The kernel removes that storage when the task exits, which fits per-thread state with task lifetime. The measurement is syscall enter-to-exit wall-clock latency rather than scheduler-only blocked time. This implementation requires Linux 5.11 or later. + +## How the Implementation Works + +The tool attaches to the raw syscall entry and exit tracepoints. On entry, it records the start timestamp and syscall ID in task local storage. On exit, it calculates the duration and reports events exceeding the configured threshold. User space aggregates events by TGID and syscall, tracking count, total latency, maximum latency, and error count. + +## Header File + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __SLOW_SYSCALL_INDEX_H +#define __SLOW_SYSCALL_INDEX_H + +#define SLOW_SYSCALL_COMM_LEN 16 + +struct slow_syscall_event { + unsigned long long timestamp_ns; + unsigned long long duration_ns; + long long return_value; + unsigned int tgid; + unsigned int tid; + unsigned int syscall_id; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +#endif /* __SLOW_SYSCALL_INDEX_H */ +``` + +The header defines the event structure sent to user space via ring buffer. Each event includes the timestamp, duration, return value, TGID, TID, syscall number, and process name. + +## BPF Program + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include "slow_syscall_index.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; +const volatile __u32 target_tgid; +const volatile __u32 ignored_tgid; + +struct syscall_state { + __u64 started_ns; + __u32 syscall_id; + __u32 pad; +}; + +struct { + __uint(type, BPF_MAP_TYPE_TASK_STORAGE); + __uint(map_flags, BPF_F_NO_PREALLOC); + __type(key, int); + __type(value, struct syscall_state); +} active_syscalls SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 completed_syscalls; +__u64 slow_syscalls; +__u64 dropped_events; + +SEC("tp/raw_syscalls/sys_enter") +int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) +{ + struct task_struct *task; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tgid = pid_tgid >> 32; + + if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) + return 0; + task = bpf_get_current_task_btf(); + state = bpf_task_storage_get(&active_syscalls, task, 0, + BPF_LOCAL_STORAGE_GET_F_CREATE); + if (!state) + return 0; + state->started_ns = bpf_ktime_get_ns(); + state->syscall_id = ctx->id; + return 0; +} + +SEC("tp/raw_syscalls/sys_exit") +int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) +{ + struct task_struct *task = bpf_get_current_task_btf(); + struct slow_syscall_event *event; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u64 duration_ns; + + state = bpf_task_storage_get(&active_syscalls, task, 0, 0); + if (!state) + return 0; + duration_ns = bpf_ktime_get_ns() - state->started_ns; + __sync_fetch_and_add(&completed_syscalls, 1); + if (duration_ns < minimum_ns) + goto out; + + __sync_fetch_and_add(&slow_syscalls, 1); + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) { + __sync_fetch_and_add(&dropped_events, 1); + goto out; + } + event->timestamp_ns = bpf_ktime_get_ns(); + event->duration_ns = duration_ns; + event->return_value = ctx->ret; + event->tgid = pid_tgid >> 32; + event->tid = (__u32)pid_tgid; + event->syscall_id = state->syscall_id; + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); +out: + bpf_task_storage_delete(&active_syscalls, task); + return 0; +} +``` + +The BPF program uses `BPF_MAP_TYPE_TASK_STORAGE` to store syscall state per task. This map type was introduced in Linux 5.11 and provides storage that is automatically cleaned up when the task exits. + +The `record_syscall_entry` function runs on syscall entry, storing the start timestamp and syscall ID. It filters by target TGID and ignores the tracer process itself. + +The `report_slow_syscall` function runs on syscall exit. It calculates the duration by subtracting the start timestamp from the current time. If the duration exceeds the threshold, it reserves space in the ring buffer, fills in the event, and submits it. The task storage is deleted after each syscall to keep memory usage bounded. + +The program tracks three global counters: `completed_syscalls` counts all traced syscalls, `slow_syscalls` counts those exceeding the threshold, and `dropped_events` counts ring buffer allocation failures. + +## User Space 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 "slow_syscall_index.h" +#include "slow_syscall_index.skel.h" + +#define MAX_STATS 512 + +struct options { + unsigned int pid; + unsigned int minimum_ms; + unsigned int duration_seconds; + unsigned int top; + bool demo; +}; + +struct syscall_stats { + unsigned int tgid; + unsigned int syscall_id; + unsigned long long count; + unsigned long long total_ns; + unsigned long long maximum_ns; + unsigned long long errors; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +static struct syscall_stats stats[MAX_STATS]; +static size_t stats_count; +static volatile sig_atomic_t stop; +static unsigned int received_events; +static unsigned int unaggregated_events; + +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 const char *syscall_name(unsigned int id) +{ + switch (id) { +#ifdef __NR_read + case __NR_read: return "read"; +#endif +#ifdef __NR_write + case __NR_write: return "write"; +#endif +#ifdef __NR_openat + case __NR_openat: return "openat"; +#endif +#ifdef __NR_close + case __NR_close: return "close"; +#endif +#ifdef __NR_fsync + case __NR_fsync: return "fsync"; +#endif +#ifdef __NR_fdatasync + case __NR_fdatasync: return "fdatasync"; +#endif +#ifdef __NR_poll + case __NR_poll: return "poll"; +#endif +#ifdef __NR_ppoll + case __NR_ppoll: return "ppoll"; +#endif +#ifdef __NR_epoll_wait + case __NR_epoll_wait: return "epoll_wait"; +#endif +#ifdef __NR_epoll_pwait + case __NR_epoll_pwait: return "epoll_pwait"; +#endif +#ifdef __NR_futex + case __NR_futex: return "futex"; +#endif +#ifdef __NR_nanosleep + case __NR_nanosleep: return "nanosleep"; +#endif +#ifdef __NR_clock_nanosleep + case __NR_clock_nanosleep: return "clock_nanosleep"; +#endif +#ifdef __NR_connect + case __NR_connect: return "connect"; +#endif +#ifdef __NR_accept + case __NR_accept: return "accept"; +#endif +#ifdef __NR_accept4 + case __NR_accept4: return "accept4"; +#endif +#ifdef __NR_recvfrom + case __NR_recvfrom: return "recvfrom"; +#endif +#ifdef __NR_recvmsg + case __NR_recvmsg: return "recvmsg"; +#endif +#ifdef __NR_sendto + case __NR_sendto: return "sendto"; +#endif +#ifdef __NR_sendmsg + case __NR_sendmsg: return "sendmsg"; +#endif + default: return "unknown"; + } +} + +static struct syscall_stats *get_stats(const struct slow_syscall_event *event) +{ + struct syscall_stats *entry; + + for (size_t i = 0; i < stats_count; i++) + if (stats[i].tgid == event->tgid && + stats[i].syscall_id == event->syscall_id) + return &stats[i]; + if (stats_count == MAX_STATS) + return NULL; + entry = &stats[stats_count++]; + entry->tgid = event->tgid; + entry->syscall_id = event->syscall_id; + memcpy(entry->comm, event->comm, sizeof(entry->comm)); + entry->comm[sizeof(entry->comm) - 1] = '\0'; + return entry; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct slow_syscall_event *event = data; + struct syscall_stats *entry; + + (void)ctx; + if (size != sizeof(*event)) + return 0; + entry = get_stats(event); + if (entry) { + entry->count++; + entry->total_ns += event->duration_ns; + if (event->duration_ns > entry->maximum_ns) + entry->maximum_ns = event->duration_ns; + if (event->return_value < 0) + entry->errors++; + } else { + unaggregated_events++; + } + received_events++; + printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", + event->tgid, event->tid, event->comm, + syscall_name(event->syscall_id), event->syscall_id, + event->duration_ns / 1000000.0, event->return_value); + return 0; +} + +static int compare_stats(const void *left, const void *right) +{ + const struct syscall_stats *a = left; + const struct syscall_stats *b = right; + + return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; +} + +static void print_summary(unsigned int top) +{ + size_t limit; + + qsort(stats, stats_count, sizeof(stats[0]), compare_stats); + limit = stats_count < top ? stats_count : top; + printf("\nSlow syscall index, ranked by total latency:\n"); + printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", + "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); + for (size_t i = 0; i < limit; i++) { + const struct syscall_stats *entry = &stats[i]; + + printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", + entry->tgid, entry->comm, syscall_name(entry->syscall_id), + entry->syscall_id, entry->count, + entry->total_ns / 1000000.0, + entry->maximum_ns / 1000000.0, entry->errors); + } +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "pid", required_argument, NULL, 'p' }, + { "min-ms", required_argument, NULL, 'm' }, + { "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, "p:m:d:t:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'p': + if (parse_uint(optarg, 0xffffffffU, &options->pid)) + return -1; + break; + case 'm': + if (parse_uint(optarg, 60000, &options->minimum_ms)) + return -1; + break; + case 'd': + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 't': + if (parse_uint(optarg, MAX_STATS, &options->top)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->minimum_ms && options->top ? 0 : -1; +} + +static pid_t start_demo(int gate[2], int data_pipe[2]) +{ + pid_t child = fork(); + char byte; + + if (child) + return child; + close(gate[1]); + close(data_pipe[1]); + if (read(gate[0], &byte, 1) != 1) + _exit(2); + if (read(data_pipe[0], &byte, 1) != 1) + _exit(3); + _exit(0); +} + +int main(int argc, char **argv) +{ + struct options options = { .minimum_ms = 10, .top = 10 }; + struct slow_syscall_index_bpf *skel = NULL; + struct ring_buffer *ring = NULL; + unsigned long long deadline = 0; + int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; + pid_t demo_child = -1; + int demo_status = 0; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + if (options.demo) { + if (options.pid) { + fprintf(stderr, "--demo and --pid cannot be combined\n"); + return 2; + } + if (pipe(gate) || pipe(data_pipe)) { + perror("pipe"); + goto cleanup; + } + demo_child = start_demo(gate, data_pipe); + if (demo_child < 0) { + perror("fork"); + goto cleanup; + } + options.pid = demo_child; + } + + skel = slow_syscall_index_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; + skel->rodata->target_tgid = options.pid; + skel->rodata->ignored_tgid = getpid(); + if (slow_syscall_index_bpf__load(skel) || + slow_syscall_index_bpf__attach(skel)) { + fprintf(stderr, "failed to load and attach slow syscall tracer\n"); + goto cleanup; + } + ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, + NULL, NULL); + if (!ring) + goto cleanup; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (options.pid) + printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.pid, options.minimum_ms); + else + printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.minimum_ms); + if (options.duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)options.duration_seconds * 1000000000ULL; + + if (options.demo) { + char byte = 'x'; + struct timespec delay = { + .tv_sec = options.minimum_ms / 1000, + .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, + }; + + if (delay.tv_nsec >= 1000000000L) { + delay.tv_sec++; + delay.tv_nsec -= 1000000000L; + } + close(gate[0]); gate[0] = -1; + close(data_pipe[0]); data_pipe[0] = -1; + write(gate[1], &byte, 1); + nanosleep(&delay, NULL); + write(data_pipe[1], &byte, 1); + waitpid(demo_child, &demo_status, 0); + demo_child = -1; + for (int i = 0; i < 20 && !received_events; i++) + ring_buffer__poll(ring, 50); + } else { + 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; + } + } + } + + print_summary(options.top); + printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", + (unsigned long long)skel->bss->completed_syscalls, + (unsigned long long)skel->bss->slow_syscalls, + (unsigned long long)skel->bss->dropped_events, + unaggregated_events); + if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || + !received_events)) + goto cleanup; + err = 0; + +cleanup: + if (demo_child > 0) { + kill(demo_child, SIGKILL); + waitpid(demo_child, NULL, 0); + } + for (size_t i = 0; i < 2; i++) { + if (gate[i] >= 0) close(gate[i]); + if (data_pipe[i] >= 0) close(data_pipe[i]); + } + ring_buffer__free(ring); + slow_syscall_index_bpf__destroy(skel); + return err; +} +``` + +The user space program aggregates events by TGID and syscall number. Each event updates the count, total latency, maximum latency, and error count for that combination. On exit, it sorts the results by total latency and prints the top N entries. + +The demo mode forks a child process that blocks on a pipe read. The parent delays long enough to exceed the threshold, then writes to unblock the child. This creates a guaranteed slow syscall for testing. + +## Compilation and Execution + +Build the tool: + +```bash +cd src/56-slow-syscall-index +make +``` + +Run with options: + +```bash +sudo ./slow_syscall_index --pid 1234 --min-ms 10 --duration 60 --top 20 +``` + +Or run the built-in demo: + +```bash +sudo ./slow_syscall_index --demo --min-ms 10 --top 10 +``` + +Example output: + +```text +Tracing PID 1255 syscalls slower than 10 ms. Press Ctrl-C to stop. +pid=1255 tid=1255 comm=slow_syscall_in syscall=read(0) latency_ms=30.080 return=1 + +Slow syscall index, ranked by total latency: +PID COMM SYSCALL COUNT TOTAL_MS MAX_MS ERRORS +1255 slow_syscall_in read ( 0) 1 30.080 30.080 0 +completed=1 slow=1 dropped=0 unaggregated=0 +``` + +## Requirements + +| Requirement | Details | +|-------------|---------| +| Kernel | Linux 5.11+ (BPF task local storage) | +| Config | `CONFIG_BPF_SYSCALL`, `CONFIG_DEBUG_INFO_BTF` | +| Privileges | Root | + +## Understanding the Output + +The index shows: + +- **PID**: Thread group ID (the process ID carried in the event) +- **COMM**: Process name +- **SYSCALL**: Syscall name and number +- **COUNT**: Number of slow invocations +- **TOTAL_MS**: Sum of all slow syscall durations +- **MAX_MS**: Longest single invocation +- **ERRORS**: Count of invocations returning negative values + +The `completed` counter shows all traced syscalls, `slow` shows those exceeding the threshold, and `dropped` shows ring buffer allocation failures. User space keeps 512 `(TGID, syscall)` groups; events beyond that table capacity are still received and counted as `unaggregated`. + +## Summary + +Task local storage joins each syscall exit to its matching entry without a user-managed PID map. Kernel-side thresholding keeps the event stream focused, and user-space aggregation turns the remaining completions into a ranked latency index. The result measures wall-clock syscall latency; scheduler correlation can be added when off-CPU attribution is needed. + +> If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . + +## References + +- [BPF task local storage documentation](https://docs.ebpf.io/linux/map-type/BPF_MAP_TYPE_TASK_STORAGE/) +- [BPF ring buffer documentation](https://docs.kernel.org/6.6/bpf/ringbuf.html) +- [raw_syscalls tracepoints](https://github.com/torvalds/linux/blob/master/include/trace/events/syscalls.h) +- [Task local storage commit](https://github.com/torvalds/linux/commit/4cf1bc1f1045) diff --git a/src/56-slow-syscall-index/README.zh.md b/src/56-slow-syscall-index/README.zh.md new file mode 100644 index 00000000..b022ef76 --- /dev/null +++ b/src/56-slow-syscall-index/README.zh.md @@ -0,0 +1,604 @@ +# eBPF 实战教程:慢系统调用延迟索引 + +应用程序中哪些系统调用耗时最长?本教程构建一个工具,使用 BPF 任务本地存储跟踪每个进程的系统调用延迟,按 TGID 和系统调用号聚合结果,并按总延迟排名,最终找出哪些慢系统调用占据了最多的观测延迟。 + +> 完整源代码: + +## eBPF 与系统调用跟踪 + +eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把选出的状态发送到用户态。本教程使用 `raw_syscalls/sys_enter` 和 `raw_syscalls/sys_exit` 跟踪点,它们在系统范围内每次系统调用进入和退出时触发。为了关联进入和退出,我们使用 Linux 5.11 引入的 BPF 任务本地存储,把状态直接附加到 task 结构上,并由内核在任务退出时清理。这里测量的是系统调用从进入到退出的挂钟延迟,而不是只统计调度器阻塞时间,因此本例需要 Linux 5.11 或更高版本。 + +## 实现原理 + +该工具附加到原始系统调用进入和退出跟踪点。在进入时,它在任务本地存储中记录开始时间戳和系统调用 ID。在退出时,它计算持续时间并报告超过配置阈值的事件。用户空间按 TGID 和系统调用聚合事件,跟踪计数、总延迟、最大延迟和错误计数。 + +## 头文件 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __SLOW_SYSCALL_INDEX_H +#define __SLOW_SYSCALL_INDEX_H + +#define SLOW_SYSCALL_COMM_LEN 16 + +struct slow_syscall_event { + unsigned long long timestamp_ns; + unsigned long long duration_ns; + long long return_value; + unsigned int tgid; + unsigned int tid; + unsigned int syscall_id; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +#endif /* __SLOW_SYSCALL_INDEX_H */ +``` + +头文件定义了通过 ring buffer 发送到用户空间的事件结构。每个事件包括时间戳、持续时间、返回值、TGID、TID、系统调用号和进程名。 + +## BPF 程序 + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include "slow_syscall_index.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; +const volatile __u32 target_tgid; +const volatile __u32 ignored_tgid; + +struct syscall_state { + __u64 started_ns; + __u32 syscall_id; + __u32 pad; +}; + +struct { + __uint(type, BPF_MAP_TYPE_TASK_STORAGE); + __uint(map_flags, BPF_F_NO_PREALLOC); + __type(key, int); + __type(value, struct syscall_state); +} active_syscalls SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 completed_syscalls; +__u64 slow_syscalls; +__u64 dropped_events; + +SEC("tp/raw_syscalls/sys_enter") +int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) +{ + struct task_struct *task; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tgid = pid_tgid >> 32; + + if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) + return 0; + task = bpf_get_current_task_btf(); + state = bpf_task_storage_get(&active_syscalls, task, 0, + BPF_LOCAL_STORAGE_GET_F_CREATE); + if (!state) + return 0; + state->started_ns = bpf_ktime_get_ns(); + state->syscall_id = ctx->id; + return 0; +} + +SEC("tp/raw_syscalls/sys_exit") +int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) +{ + struct task_struct *task = bpf_get_current_task_btf(); + struct slow_syscall_event *event; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u64 duration_ns; + + state = bpf_task_storage_get(&active_syscalls, task, 0, 0); + if (!state) + return 0; + duration_ns = bpf_ktime_get_ns() - state->started_ns; + __sync_fetch_and_add(&completed_syscalls, 1); + if (duration_ns < minimum_ns) + goto out; + + __sync_fetch_and_add(&slow_syscalls, 1); + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) { + __sync_fetch_and_add(&dropped_events, 1); + goto out; + } + event->timestamp_ns = bpf_ktime_get_ns(); + event->duration_ns = duration_ns; + event->return_value = ctx->ret; + event->tgid = pid_tgid >> 32; + event->tid = (__u32)pid_tgid; + event->syscall_id = state->syscall_id; + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); +out: + bpf_task_storage_delete(&active_syscalls, task); + return 0; +} +``` + +BPF 程序使用 `BPF_MAP_TYPE_TASK_STORAGE` 存储每个任务的系统调用状态。这种映射类型在 Linux 5.11 中引入,提供在任务退出时自动清理的存储。 + +`record_syscall_entry` 函数在系统调用进入时运行,存储开始时间戳和系统调用 ID。它按目标 TGID 过滤并忽略跟踪器进程本身。 + +`report_slow_syscall` 函数在系统调用退出时运行。它通过从当前时间减去开始时间戳来计算持续时间。如果持续时间超过阈值,它在 ring buffer 中预留空间,填写事件并提交。每次系统调用后删除任务存储以保持内存使用有界。 + +程序跟踪三个全局计数器:`completed_syscalls` 计数所有跟踪的系统调用,`slow_syscalls` 计数超过阈值的调用,`dropped_events` 计数 ring buffer 分配失败。 + +## 用户空间程序 + +```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 "slow_syscall_index.h" +#include "slow_syscall_index.skel.h" + +#define MAX_STATS 512 + +struct options { + unsigned int pid; + unsigned int minimum_ms; + unsigned int duration_seconds; + unsigned int top; + bool demo; +}; + +struct syscall_stats { + unsigned int tgid; + unsigned int syscall_id; + unsigned long long count; + unsigned long long total_ns; + unsigned long long maximum_ns; + unsigned long long errors; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +static struct syscall_stats stats[MAX_STATS]; +static size_t stats_count; +static volatile sig_atomic_t stop; +static unsigned int received_events; +static unsigned int unaggregated_events; + +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 const char *syscall_name(unsigned int id) +{ + switch (id) { +#ifdef __NR_read + case __NR_read: return "read"; +#endif +#ifdef __NR_write + case __NR_write: return "write"; +#endif +#ifdef __NR_openat + case __NR_openat: return "openat"; +#endif +#ifdef __NR_close + case __NR_close: return "close"; +#endif +#ifdef __NR_fsync + case __NR_fsync: return "fsync"; +#endif +#ifdef __NR_fdatasync + case __NR_fdatasync: return "fdatasync"; +#endif +#ifdef __NR_poll + case __NR_poll: return "poll"; +#endif +#ifdef __NR_ppoll + case __NR_ppoll: return "ppoll"; +#endif +#ifdef __NR_epoll_wait + case __NR_epoll_wait: return "epoll_wait"; +#endif +#ifdef __NR_epoll_pwait + case __NR_epoll_pwait: return "epoll_pwait"; +#endif +#ifdef __NR_futex + case __NR_futex: return "futex"; +#endif +#ifdef __NR_nanosleep + case __NR_nanosleep: return "nanosleep"; +#endif +#ifdef __NR_clock_nanosleep + case __NR_clock_nanosleep: return "clock_nanosleep"; +#endif +#ifdef __NR_connect + case __NR_connect: return "connect"; +#endif +#ifdef __NR_accept + case __NR_accept: return "accept"; +#endif +#ifdef __NR_accept4 + case __NR_accept4: return "accept4"; +#endif +#ifdef __NR_recvfrom + case __NR_recvfrom: return "recvfrom"; +#endif +#ifdef __NR_recvmsg + case __NR_recvmsg: return "recvmsg"; +#endif +#ifdef __NR_sendto + case __NR_sendto: return "sendto"; +#endif +#ifdef __NR_sendmsg + case __NR_sendmsg: return "sendmsg"; +#endif + default: return "unknown"; + } +} + +static struct syscall_stats *get_stats(const struct slow_syscall_event *event) +{ + struct syscall_stats *entry; + + for (size_t i = 0; i < stats_count; i++) + if (stats[i].tgid == event->tgid && + stats[i].syscall_id == event->syscall_id) + return &stats[i]; + if (stats_count == MAX_STATS) + return NULL; + entry = &stats[stats_count++]; + entry->tgid = event->tgid; + entry->syscall_id = event->syscall_id; + memcpy(entry->comm, event->comm, sizeof(entry->comm)); + entry->comm[sizeof(entry->comm) - 1] = '\0'; + return entry; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct slow_syscall_event *event = data; + struct syscall_stats *entry; + + (void)ctx; + if (size != sizeof(*event)) + return 0; + entry = get_stats(event); + if (entry) { + entry->count++; + entry->total_ns += event->duration_ns; + if (event->duration_ns > entry->maximum_ns) + entry->maximum_ns = event->duration_ns; + if (event->return_value < 0) + entry->errors++; + } else { + unaggregated_events++; + } + received_events++; + printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", + event->tgid, event->tid, event->comm, + syscall_name(event->syscall_id), event->syscall_id, + event->duration_ns / 1000000.0, event->return_value); + return 0; +} + +static int compare_stats(const void *left, const void *right) +{ + const struct syscall_stats *a = left; + const struct syscall_stats *b = right; + + return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; +} + +static void print_summary(unsigned int top) +{ + size_t limit; + + qsort(stats, stats_count, sizeof(stats[0]), compare_stats); + limit = stats_count < top ? stats_count : top; + printf("\nSlow syscall index, ranked by total latency:\n"); + printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", + "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); + for (size_t i = 0; i < limit; i++) { + const struct syscall_stats *entry = &stats[i]; + + printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", + entry->tgid, entry->comm, syscall_name(entry->syscall_id), + entry->syscall_id, entry->count, + entry->total_ns / 1000000.0, + entry->maximum_ns / 1000000.0, entry->errors); + } +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "pid", required_argument, NULL, 'p' }, + { "min-ms", required_argument, NULL, 'm' }, + { "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, "p:m:d:t:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'p': + if (parse_uint(optarg, 0xffffffffU, &options->pid)) + return -1; + break; + case 'm': + if (parse_uint(optarg, 60000, &options->minimum_ms)) + return -1; + break; + case 'd': + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 't': + if (parse_uint(optarg, MAX_STATS, &options->top)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->minimum_ms && options->top ? 0 : -1; +} + +static pid_t start_demo(int gate[2], int data_pipe[2]) +{ + pid_t child = fork(); + char byte; + + if (child) + return child; + close(gate[1]); + close(data_pipe[1]); + if (read(gate[0], &byte, 1) != 1) + _exit(2); + if (read(data_pipe[0], &byte, 1) != 1) + _exit(3); + _exit(0); +} + +int main(int argc, char **argv) +{ + struct options options = { .minimum_ms = 10, .top = 10 }; + struct slow_syscall_index_bpf *skel = NULL; + struct ring_buffer *ring = NULL; + unsigned long long deadline = 0; + int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; + pid_t demo_child = -1; + int demo_status = 0; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + if (options.demo) { + if (options.pid) { + fprintf(stderr, "--demo and --pid cannot be combined\n"); + return 2; + } + if (pipe(gate) || pipe(data_pipe)) { + perror("pipe"); + goto cleanup; + } + demo_child = start_demo(gate, data_pipe); + if (demo_child < 0) { + perror("fork"); + goto cleanup; + } + options.pid = demo_child; + } + + skel = slow_syscall_index_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; + skel->rodata->target_tgid = options.pid; + skel->rodata->ignored_tgid = getpid(); + if (slow_syscall_index_bpf__load(skel) || + slow_syscall_index_bpf__attach(skel)) { + fprintf(stderr, "failed to load and attach slow syscall tracer\n"); + goto cleanup; + } + ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, + NULL, NULL); + if (!ring) + goto cleanup; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (options.pid) + printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.pid, options.minimum_ms); + else + printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.minimum_ms); + if (options.duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)options.duration_seconds * 1000000000ULL; + + if (options.demo) { + char byte = 'x'; + struct timespec delay = { + .tv_sec = options.minimum_ms / 1000, + .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, + }; + + if (delay.tv_nsec >= 1000000000L) { + delay.tv_sec++; + delay.tv_nsec -= 1000000000L; + } + close(gate[0]); gate[0] = -1; + close(data_pipe[0]); data_pipe[0] = -1; + write(gate[1], &byte, 1); + nanosleep(&delay, NULL); + write(data_pipe[1], &byte, 1); + waitpid(demo_child, &demo_status, 0); + demo_child = -1; + for (int i = 0; i < 20 && !received_events; i++) + ring_buffer__poll(ring, 50); + } else { + 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; + } + } + } + + print_summary(options.top); + printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", + (unsigned long long)skel->bss->completed_syscalls, + (unsigned long long)skel->bss->slow_syscalls, + (unsigned long long)skel->bss->dropped_events, + unaggregated_events); + if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || + !received_events)) + goto cleanup; + err = 0; + +cleanup: + if (demo_child > 0) { + kill(demo_child, SIGKILL); + waitpid(demo_child, NULL, 0); + } + for (size_t i = 0; i < 2; i++) { + if (gate[i] >= 0) close(gate[i]); + if (data_pipe[i] >= 0) close(data_pipe[i]); + } + ring_buffer__free(ring); + slow_syscall_index_bpf__destroy(skel); + return err; +} +``` + +用户空间程序按 TGID 和系统调用号聚合事件。每个事件更新该组合的计数、总延迟、最大延迟和错误计数。在退出时,它按总延迟排序结果并打印前 N 个条目。 + +演示模式 fork 一个子进程,该进程阻塞在管道读取上。父进程延迟足够长的时间以超过阈值,然后写入以解除子进程阻塞。这会创建一个有保证的慢系统调用用于测试。 + +## 编译和执行 + +构建工具: + +```bash +cd src/56-slow-syscall-index +make +``` + +使用选项运行: + +```bash +sudo ./slow_syscall_index --pid 1234 --min-ms 10 --duration 60 --top 20 +``` + +或运行内置演示: + +```bash +sudo ./slow_syscall_index --demo --min-ms 10 --top 10 +``` + +示例输出: + +```text +Tracing PID 1255 syscalls slower than 10 ms. Press Ctrl-C to stop. +pid=1255 tid=1255 comm=slow_syscall_in syscall=read(0) latency_ms=30.080 return=1 + +Slow syscall index, ranked by total latency: +PID COMM SYSCALL COUNT TOTAL_MS MAX_MS ERRORS +1255 slow_syscall_in read ( 0) 1 30.080 30.080 0 +completed=1 slow=1 dropped=0 unaggregated=0 +``` + +## 环境要求 + +| 要求 | 详情 | +|------|------| +| 内核 | Linux 5.11+(BPF 任务本地存储) | +| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_DEBUG_INFO_BTF` | +| 权限 | Root | + +## 理解输出 + +索引显示: + +- **PID**:线程组 ID,也就是事件中携带的进程 ID +- **COMM**:进程名 +- **SYSCALL**:系统调用名和号 +- **COUNT**:慢调用次数 +- **TOTAL_MS**:所有慢系统调用持续时间之和 +- **MAX_MS**:单次最长调用 +- **ERRORS**:返回负值的调用次数 + +`completed` 计数器显示所有跟踪的系统调用,`slow` 显示超过阈值的调用,`dropped` 显示 ring buffer 分配失败。用户态最多保存 512 个 `(TGID, syscall)` 分组,超过表容量的事件仍会被接收,并计入 `unaggregated`。 + +## 总结 + +任务本地存储把每次系统调用退出与对应的入口关联起来,无需在用户态维护 PID 映射。内核中的阈值过滤让事件流保持集中,用户态聚合再把慢调用整理成延迟排名。这里得到的是系统调用的挂钟延迟,如果还需要区分 off-CPU 时间,可以继续关联调度器事件。 + +> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 + +## 参考资料 + +- [BPF 任务本地存储文档](https://docs.ebpf.io/linux/map-type/BPF_MAP_TYPE_TASK_STORAGE/) +- [BPF ring buffer 文档](https://docs.kernel.org/6.6/bpf/ringbuf.html) +- [raw_syscalls 跟踪点](https://github.com/torvalds/linux/blob/master/include/trace/events/syscalls.h) +- [任务本地存储 commit](https://github.com/torvalds/linux/commit/4cf1bc1f1045) diff --git a/src/56-slow-syscall-index/slow_syscall_index.bpf.c b/src/56-slow-syscall-index/slow_syscall_index.bpf.c new file mode 100644 index 00000000..5e06a8e1 --- /dev/null +++ b/src/56-slow-syscall-index/slow_syscall_index.bpf.c @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include "slow_syscall_index.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; +const volatile __u32 target_tgid; +const volatile __u32 ignored_tgid; + +struct syscall_state { + __u64 started_ns; + __u32 syscall_id; + __u32 pad; +}; + +struct { + __uint(type, BPF_MAP_TYPE_TASK_STORAGE); + __uint(map_flags, BPF_F_NO_PREALLOC); + __type(key, int); + __type(value, struct syscall_state); +} active_syscalls SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 completed_syscalls; +__u64 slow_syscalls; +__u64 dropped_events; + +SEC("tp/raw_syscalls/sys_enter") +int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) +{ + struct task_struct *task; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tgid = pid_tgid >> 32; + + if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) + return 0; + task = bpf_get_current_task_btf(); + state = bpf_task_storage_get(&active_syscalls, task, 0, + BPF_LOCAL_STORAGE_GET_F_CREATE); + if (!state) + return 0; + state->started_ns = bpf_ktime_get_ns(); + state->syscall_id = ctx->id; + return 0; +} + +SEC("tp/raw_syscalls/sys_exit") +int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) +{ + struct task_struct *task = bpf_get_current_task_btf(); + struct slow_syscall_event *event; + struct syscall_state *state; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u64 duration_ns; + + state = bpf_task_storage_get(&active_syscalls, task, 0, 0); + if (!state) + return 0; + duration_ns = bpf_ktime_get_ns() - state->started_ns; + __sync_fetch_and_add(&completed_syscalls, 1); + if (duration_ns < minimum_ns) + goto out; + + __sync_fetch_and_add(&slow_syscalls, 1); + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) { + __sync_fetch_and_add(&dropped_events, 1); + goto out; + } + event->timestamp_ns = bpf_ktime_get_ns(); + event->duration_ns = duration_ns; + event->return_value = ctx->ret; + event->tgid = pid_tgid >> 32; + event->tid = (__u32)pid_tgid; + event->syscall_id = state->syscall_id; + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); +out: + bpf_task_storage_delete(&active_syscalls, task); + return 0; +} diff --git a/src/56-slow-syscall-index/slow_syscall_index.c b/src/56-slow-syscall-index/slow_syscall_index.c new file mode 100644 index 00000000..e8b5cbc0 --- /dev/null +++ b/src/56-slow-syscall-index/slow_syscall_index.c @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "slow_syscall_index.h" +#include "slow_syscall_index.skel.h" + +#define MAX_STATS 512 + +struct options { + unsigned int pid; + unsigned int minimum_ms; + unsigned int duration_seconds; + unsigned int top; + bool demo; +}; + +struct syscall_stats { + unsigned int tgid; + unsigned int syscall_id; + unsigned long long count; + unsigned long long total_ns; + unsigned long long maximum_ns; + unsigned long long errors; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +static struct syscall_stats stats[MAX_STATS]; +static size_t stats_count; +static volatile sig_atomic_t stop; +static unsigned int received_events; +static unsigned int unaggregated_events; + +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 const char *syscall_name(unsigned int id) +{ + switch (id) { +#ifdef __NR_read + case __NR_read: return "read"; +#endif +#ifdef __NR_write + case __NR_write: return "write"; +#endif +#ifdef __NR_openat + case __NR_openat: return "openat"; +#endif +#ifdef __NR_close + case __NR_close: return "close"; +#endif +#ifdef __NR_fsync + case __NR_fsync: return "fsync"; +#endif +#ifdef __NR_fdatasync + case __NR_fdatasync: return "fdatasync"; +#endif +#ifdef __NR_poll + case __NR_poll: return "poll"; +#endif +#ifdef __NR_ppoll + case __NR_ppoll: return "ppoll"; +#endif +#ifdef __NR_epoll_wait + case __NR_epoll_wait: return "epoll_wait"; +#endif +#ifdef __NR_epoll_pwait + case __NR_epoll_pwait: return "epoll_pwait"; +#endif +#ifdef __NR_futex + case __NR_futex: return "futex"; +#endif +#ifdef __NR_nanosleep + case __NR_nanosleep: return "nanosleep"; +#endif +#ifdef __NR_clock_nanosleep + case __NR_clock_nanosleep: return "clock_nanosleep"; +#endif +#ifdef __NR_connect + case __NR_connect: return "connect"; +#endif +#ifdef __NR_accept + case __NR_accept: return "accept"; +#endif +#ifdef __NR_accept4 + case __NR_accept4: return "accept4"; +#endif +#ifdef __NR_recvfrom + case __NR_recvfrom: return "recvfrom"; +#endif +#ifdef __NR_recvmsg + case __NR_recvmsg: return "recvmsg"; +#endif +#ifdef __NR_sendto + case __NR_sendto: return "sendto"; +#endif +#ifdef __NR_sendmsg + case __NR_sendmsg: return "sendmsg"; +#endif + default: return "unknown"; + } +} + +static struct syscall_stats *get_stats(const struct slow_syscall_event *event) +{ + struct syscall_stats *entry; + + for (size_t i = 0; i < stats_count; i++) + if (stats[i].tgid == event->tgid && + stats[i].syscall_id == event->syscall_id) + return &stats[i]; + if (stats_count == MAX_STATS) + return NULL; + entry = &stats[stats_count++]; + entry->tgid = event->tgid; + entry->syscall_id = event->syscall_id; + memcpy(entry->comm, event->comm, sizeof(entry->comm)); + entry->comm[sizeof(entry->comm) - 1] = '\0'; + return entry; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct slow_syscall_event *event = data; + struct syscall_stats *entry; + + (void)ctx; + if (size != sizeof(*event)) + return 0; + entry = get_stats(event); + if (entry) { + entry->count++; + entry->total_ns += event->duration_ns; + if (event->duration_ns > entry->maximum_ns) + entry->maximum_ns = event->duration_ns; + if (event->return_value < 0) + entry->errors++; + } else { + unaggregated_events++; + } + received_events++; + printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", + event->tgid, event->tid, event->comm, + syscall_name(event->syscall_id), event->syscall_id, + event->duration_ns / 1000000.0, event->return_value); + return 0; +} + +static int compare_stats(const void *left, const void *right) +{ + const struct syscall_stats *a = left; + const struct syscall_stats *b = right; + + return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; +} + +static void print_summary(unsigned int top) +{ + size_t limit; + + qsort(stats, stats_count, sizeof(stats[0]), compare_stats); + limit = stats_count < top ? stats_count : top; + printf("\nSlow syscall index, ranked by total latency:\n"); + printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", + "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); + for (size_t i = 0; i < limit; i++) { + const struct syscall_stats *entry = &stats[i]; + + printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", + entry->tgid, entry->comm, syscall_name(entry->syscall_id), + entry->syscall_id, entry->count, + entry->total_ns / 1000000.0, + entry->maximum_ns / 1000000.0, entry->errors); + } +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "pid", required_argument, NULL, 'p' }, + { "min-ms", required_argument, NULL, 'm' }, + { "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, "p:m:d:t:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'p': + if (parse_uint(optarg, 0xffffffffU, &options->pid)) + return -1; + break; + case 'm': + if (parse_uint(optarg, 60000, &options->minimum_ms)) + return -1; + break; + case 'd': + if (parse_uint(optarg, 86400, &options->duration_seconds)) + return -1; + break; + case 't': + if (parse_uint(optarg, MAX_STATS, &options->top)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->minimum_ms && options->top ? 0 : -1; +} + +static pid_t start_demo(int gate[2], int data_pipe[2]) +{ + pid_t child = fork(); + char byte; + + if (child) + return child; + close(gate[1]); + close(data_pipe[1]); + if (read(gate[0], &byte, 1) != 1) + _exit(2); + if (read(data_pipe[0], &byte, 1) != 1) + _exit(3); + _exit(0); +} + +int main(int argc, char **argv) +{ + struct options options = { .minimum_ms = 10, .top = 10 }; + struct slow_syscall_index_bpf *skel = NULL; + struct ring_buffer *ring = NULL; + unsigned long long deadline = 0; + int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; + pid_t demo_child = -1; + int demo_status = 0; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + if (options.demo) { + if (options.pid) { + fprintf(stderr, "--demo and --pid cannot be combined\n"); + return 2; + } + if (pipe(gate) || pipe(data_pipe)) { + perror("pipe"); + goto cleanup; + } + demo_child = start_demo(gate, data_pipe); + if (demo_child < 0) { + perror("fork"); + goto cleanup; + } + options.pid = demo_child; + } + + skel = slow_syscall_index_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; + skel->rodata->target_tgid = options.pid; + skel->rodata->ignored_tgid = getpid(); + if (slow_syscall_index_bpf__load(skel) || + slow_syscall_index_bpf__attach(skel)) { + fprintf(stderr, "failed to load and attach slow syscall tracer\n"); + goto cleanup; + } + ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, + NULL, NULL); + if (!ring) + goto cleanup; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + if (options.pid) + printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.pid, options.minimum_ms); + else + printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", + options.minimum_ms); + if (options.duration_seconds) + deadline = monotonic_ns() + + (unsigned long long)options.duration_seconds * 1000000000ULL; + + if (options.demo) { + char byte = 'x'; + struct timespec delay = { + .tv_sec = options.minimum_ms / 1000, + .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, + }; + + if (delay.tv_nsec >= 1000000000L) { + delay.tv_sec++; + delay.tv_nsec -= 1000000000L; + } + close(gate[0]); gate[0] = -1; + close(data_pipe[0]); data_pipe[0] = -1; + write(gate[1], &byte, 1); + nanosleep(&delay, NULL); + write(data_pipe[1], &byte, 1); + waitpid(demo_child, &demo_status, 0); + demo_child = -1; + for (int i = 0; i < 20 && !received_events; i++) + ring_buffer__poll(ring, 50); + } else { + 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; + } + } + } + + print_summary(options.top); + printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", + (unsigned long long)skel->bss->completed_syscalls, + (unsigned long long)skel->bss->slow_syscalls, + (unsigned long long)skel->bss->dropped_events, + unaggregated_events); + if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || + !received_events)) + goto cleanup; + err = 0; + +cleanup: + if (demo_child > 0) { + kill(demo_child, SIGKILL); + waitpid(demo_child, NULL, 0); + } + for (size_t i = 0; i < 2; i++) { + if (gate[i] >= 0) close(gate[i]); + if (data_pipe[i] >= 0) close(data_pipe[i]); + } + ring_buffer__free(ring); + slow_syscall_index_bpf__destroy(skel); + return err; +} diff --git a/src/56-slow-syscall-index/slow_syscall_index.h b/src/56-slow-syscall-index/slow_syscall_index.h new file mode 100644 index 00000000..f4af45ad --- /dev/null +++ b/src/56-slow-syscall-index/slow_syscall_index.h @@ -0,0 +1,17 @@ +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __SLOW_SYSCALL_INDEX_H +#define __SLOW_SYSCALL_INDEX_H + +#define SLOW_SYSCALL_COMM_LEN 16 + +struct slow_syscall_event { + unsigned long long timestamp_ns; + unsigned long long duration_ns; + long long return_value; + unsigned int tgid; + unsigned int tid; + unsigned int syscall_id; + char comm[SLOW_SYSCALL_COMM_LEN]; +}; + +#endif /* __SLOW_SYSCALL_INDEX_H */ diff --git a/src/56-slow-syscall-index/tests/test_slow_syscall_index.py b/src/56-slow-syscall-index/tests/test_slow_syscall_index.py new file mode 100644 index 00000000..61d40135 --- /dev/null +++ b/src/56-slow-syscall-index/tests/test_slow_syscall_index.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import subprocess +import sys + + +def main(): + result = subprocess.run( + [sys.argv[1], "--demo", "--min-ms", "10"], + text=True, + capture_output=True, + check=False, + timeout=30, + ) + output = result.stdout + result.stderr + if result.returncode: + raise AssertionError(output) + assert "syscall=read(" in output, output + assert "Slow syscall index, ranked by total latency:" in output, output + assert "completed=" in output and "slow=1" in output, output + assert "unaggregated=0" in output, output + print("slow syscall index integration test: PASS") + + +if __name__ == "__main__": + main() diff --git a/src/57-oom-watch/.gitignore b/src/57-oom-watch/.gitignore new file mode 100644 index 00000000..a2940add --- /dev/null +++ b/src/57-oom-watch/.gitignore @@ -0,0 +1,2 @@ +.output/ +oom_watch diff --git a/src/57-oom-watch/Makefile b/src/57-oom-watch/Makefile new file mode 100644 index 00000000..5167f6dc --- /dev/null +++ b/src/57-oom-watch/Makefile @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +OUTPUT := .output +CLANG ?= clang +LIBBPF_SRC := $(abspath ../third_party/libbpf/src) +BPFTOOL_SRC := $(abspath ../third_party/bpftool/src) +LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a) +BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool) +BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool +ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \ + | sed 's/arm.*/arm/' \ + | sed 's/aarch64/arm64/' \ + | sed 's/ppc64le/powerpc/' \ + | sed 's/mips.*/mips/' \ + | sed 's/riscv64/riscv/' \ + | sed 's/loongarch64/loongarch/') +VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h +INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. +CFLAGS := -g -Wall -Wextra -D_GNU_SOURCE -pthread +ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) +APPS = oom_watch + +CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ + | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') + +ifeq ($(V),1) + Q = + msg = +else + Q = @ + msg = @printf ' %-8s %s%s\n' "$(1)" \ + "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \ + "$(if $(3), $(3))"; + MAKEFLAGS += --no-print-directory +endif + +define allow-override + $(if $(or $(findstring environment,$(origin $(1))),\ + $(findstring command line,$(origin $(1)))),,\ + $(eval $(1) = $(2))) +endef + +$(call allow-override,CC,$(CROSS_COMPILE)cc) + +.PHONY: all clean test +all: $(APPS) + +clean: + $(call msg,CLEAN) + $(Q)rm -rf $(OUTPUT) $(APPS) + +test: all + @if [ "$$(id -u)" -ne 0 ]; then \ + echo "test requires root; run inside the documented KVM guest" >&2; \ + exit 1; \ + fi + python3 tests/test_oom_watch.py ./oom_watch + +$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): + $(call msg,MKDIR,$@) + $(Q)mkdir -p $@ + +$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf + $(call msg,LIB,$@) + $(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \ + OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \ + INCLUDEDIR= LIBDIR= UAPIDIR= install + +$(BPFTOOL): | $(BPFTOOL_OUTPUT) + $(call msg,BPFTOOL,$@) + $(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap + +$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL) + $(call msg,BPF,$@) + $(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \ + $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \ + -c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + $(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + +$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL) + $(call msg,GEN-SKEL,$@) + $(Q)$(BPFTOOL) gen skeleton $< > $@ + +$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h + +$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT) + $(call msg,CC,$@) + $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@ + +$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT) + $(call msg,BINARY,$@) + $(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@ + +.DELETE_ON_ERROR: +.SECONDARY: diff --git a/src/57-oom-watch/README.md b/src/57-oom-watch/README.md new file mode 100644 index 00000000..6bf35759 --- /dev/null +++ b/src/57-oom-watch/README.md @@ -0,0 +1,759 @@ +# eBPF Tutorial by Example: OOM Victim Monitoring with Reclaim Profiling + +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. + +> Complete source code: + +## eBPF and OOM Tracepoints + +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. + +## How the Implementation Works + +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. + +## Header File + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __OOM_WATCH_H +#define __OOM_WATCH_H + +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; + unsigned long long reclaimed_pages; + unsigned long long cross_cgroup_reclaims; + unsigned long long last_reclaim_ns; + unsigned long long total_vm_kb; + unsigned long long anon_rss_kb; + unsigned long long file_rss_kb; + unsigned int type; + unsigned int victim_pid; + unsigned int victim_tid; + unsigned int triggering_tgid; + signed int exit_code; + char comm[16]; +}; + +#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. + +## BPF Program + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include +#include "oom_watch.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 target_cgroup_id; + +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 victim_state { + __u64 cgroup_id; + __u32 triggering_tgid; + __u32 victim_tgid; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, __u64); + __type(value, struct reclaim_profile); +} profiles SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct victim_state); +} victims SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 dropped_victim_states; + +static __always_inline bool selected_cgroup(__u64 cgroup_id) +{ + return !target_cgroup_id || cgroup_id == target_cgroup_id; +} + +static __always_inline __u64 victim_task_info(__u32 victim_pid, + __u32 *victim_tgid) +{ + struct task_struct *task; + __u64 cgroup_id = 0; + + task = bpf_task_from_pid(victim_pid); + if (!task) + return 0; + cgroup_id = BPF_CORE_READ(task, cgroups, dfl_cgrp, kn, id); + *victim_tgid = BPF_CORE_READ(task, tgid); + bpf_task_release(task); + return cgroup_id; +} + +static __always_inline __u64 memcg_cgroup_id(struct mem_cgroup *memcg) +{ + struct cgroup *cgroup; + + cgroup = BPF_CORE_READ(memcg, css.cgroup); + if (!cgroup) + return 0; + return BPF_CORE_READ(cgroup, kn, id); +} + +static __always_inline struct reclaim_profile *get_profile(__u64 cgroup_id) +{ + struct reclaim_profile initial = {}; + struct reclaim_profile *profile; + + profile = bpf_map_lookup_elem(&profiles, &cgroup_id); + if (profile) + return profile; + bpf_map_update_elem(&profiles, &cgroup_id, &initial, BPF_NOEXIST); + return bpf_map_lookup_elem(&profiles, &cgroup_id); +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_begin") +int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + (void)gfp_flags; + (void)order; + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + return 0; + __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(); + return 0; +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_end") +int BPF_PROG(track_reclaim_end, unsigned long reclaimed, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + 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(); + return 0; +} + +SEC("tracepoint/oom/mark_victim") +int capture_oom_victim(struct trace_event_raw_mark_victim *ctx) +{ + struct reclaim_profile *profile; + struct victim_state victim; + struct oom_watch_event *event; + __u64 cgroup_id; + __u32 victim_pid = ctx->pid; + __u32 victim_tgid = 0; + + cgroup_id = victim_task_info(victim_pid, &victim_tgid); + if (!victim_tgid || !selected_cgroup(cgroup_id)) + return 0; + victim.cgroup_id = cgroup_id; + victim.triggering_tgid = bpf_get_current_pid_tgid() >> 32; + victim.victim_tgid = victim_tgid; + if (bpf_map_update_elem(&victims, &victim_pid, &victim, BPF_ANY)) { + __sync_fetch_and_add(&dropped_victim_states, 1); + return 0; + } + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return 0; + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = cgroup_id; + event->type = OOM_VICTIM_MARKED; + event->victim_pid = victim_tgid; + event->victim_tid = victim_pid; + event->triggering_tgid = victim.triggering_tgid; + event->total_vm_kb = ctx->total_vm; + event->anon_rss_kb = ctx->anon_rss; + event->file_rss_kb = ctx->file_rss; + bpf_probe_read_kernel_str(event->comm, sizeof(event->comm), + (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; + } + bpf_ringbuf_submit(event, 0); + return 0; +} + +SEC("tracepoint/sched/sched_process_exit") +int capture_victim_exit(void *ctx) +{ + struct victim_state *victim; + struct task_struct *task; + struct oom_watch_event *event; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tid = pid_tgid; + + (void)ctx; + victim = bpf_map_lookup_elem(&victims, &tid); + if (!victim) + return 0; + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (event) { + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = victim->cgroup_id; + event->type = OOM_VICTIM_EXITED; + event->victim_pid = victim->victim_tgid; + event->victim_tid = tid; + event->triggering_tgid = victim->triggering_tgid; + task = (struct task_struct *)bpf_get_current_task_btf(); + event->exit_code = BPF_CORE_READ(task, exit_code); + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); + } + bpf_map_delete_elem(&victims, &tid); + return 0; +} +``` + +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. + +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). + +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. + +The `capture_victim_exit` function tracks when the victim actually exits, confirming the kill completed and reporting the exit signal. + +## User Space 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 +#include +#include +#include "oom_watch.h" +#include "oom_watch.skel.h" + +struct options { + const char *cgroup_path; + unsigned int duration_seconds; + bool demo; +}; + +static volatile sig_atomic_t stop; +static int victim_events; +static int exit_events; +static unsigned long long observed_cgroup_id; +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; + +struct allocation_context { + int ready_fd; + int continue_fd; +}; + +static struct allocation_context allocation_context; + +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 write_text(const char *path, const char *text) +{ + int fd = open(path, O_WRONLY | O_CLOEXEC); + ssize_t length = strlen(text); + int err = 0; + + if (fd < 0) + return -1; + if (write(fd, text, length) != length) + err = -1; + close(fd); + return err; +} + +static int memory_controller_enabled(bool *enabled) +{ + char controllers[4096]; + ssize_t length; + int fd; + + fd = open("/sys/fs/cgroup/cgroup.subtree_control", + O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + length = read(fd, controllers, sizeof(controllers) - 1); + close(fd); + if (length < 0) + return -1; + controllers[length] = '\0'; + *enabled = strstr(controllers, "memory") != NULL; + return 0; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct oom_watch_event *event = data; + + (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_victim_tid = event->victim_tid; + observed_cross_cgroup_reclaims = event->cross_cgroup_reclaims; + 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 " + "reclaimed_pages=%llu\n", + event->victim_pid, event->victim_tid, event->comm, + event->triggering_tgid, + (unsigned long long)event->cgroup_id, + (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); + } else if (event->type == OOM_VICTIM_EXITED) { + exit_events++; + printf("event=victim-exit pid=%u tid=%u cgroup_id=%llu exit_code=%d\n", + event->victim_pid, event->victim_tid, + (unsigned long long)event->cgroup_id, event->exit_code); + } + return 0; +} + +static int parse_uint(const char *text, unsigned int *value) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(text, &end, 10); + if (errno || !*text || *end || parsed > 86400) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--cgroup PATH] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +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' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "c:d:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'c': options->cgroup_path = optarg; break; + case 'd': + if (parse_uint(optarg, &options->duration_seconds)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && !(options->demo && options->cgroup_path) ? 0 : -1; +} + +static void *allocation_worker(void *argument) +{ + struct allocation_context *context = argument; + size_t first_stage = 24 * 1024 * 1024; + size_t length = 128 * 1024 * 1024; + unsigned char *memory; + char byte = 'x'; + + memory = mmap(NULL, length, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (memory == MAP_FAILED) + _exit(4); + for (size_t offset = 0; offset < first_stage; offset += 4096) + memory[offset] = 0xa5; + if (write(context->ready_fd, &byte, 1) != 1 || + read(context->continue_fd, &byte, 1) != 1) + _exit(5); + for (size_t offset = first_stage; offset < length; offset += 4096) + memory[offset] = 0xa5; + _exit(6); +} + +static void allocate_until_killed(const char *cgroup_path, int ready_fd, + int continue_fd) +{ + char procs_path[512]; + char pid_text[32]; + pthread_t worker; + + snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", cgroup_path); + snprintf(pid_text, sizeof(pid_text), "%d", getpid()); + if (write_text(procs_path, pid_text)) + _exit(3); + allocation_context.ready_fd = ready_fd; + allocation_context.continue_fd = continue_fd; + if (pthread_create(&worker, NULL, allocation_worker, + &allocation_context)) + _exit(4); + pthread_detach(worker); + pthread_exit(NULL); +} + +static int configure_demo_cgroup(const char *path, bool *created, + bool *enabled_by_demo) +{ + bool memory_enabled; + char file[512]; + + if (memory_controller_enabled(&memory_enabled)) + return -1; + if (!memory_enabled) { + if (write_text("/sys/fs/cgroup/cgroup.subtree_control", "+memory")) + return -1; + *enabled_by_demo = true; + } + if (mkdir(path, 0755)) + return -1; + *created = true; + snprintf(file, sizeof(file), "%s/memory.max", path); + if (write_text(file, "33554432")) + return -1; + snprintf(file, sizeof(file), "%s/memory.swap.max", path); + if (write_text(file, "0")) + return -1; + snprintf(file, sizeof(file), "%s/memory.oom.group", path); + return write_text(file, "1"); +} + +static int trigger_cross_cgroup_reclaim(const char *cgroup_path) +{ + char reclaim_path[512]; + + snprintf(reclaim_path, sizeof(reclaim_path), "%s/memory.reclaim", + cgroup_path); + if (!write_text(reclaim_path, "8388608") || errno == EAGAIN) + return 0; + return -1; +} + +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; + 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) + goto cleanup; + + if (selected_path) + printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", + selected_path, (unsigned long long)cgroup_stat.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); + 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; + 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. + +## Compilation and Execution + +Build the tool: + +```bash +cd src/57-oom-watch +make +``` + +Run with cgroup filtering: + +```bash +sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +``` + +Or watch all OOM events: + +```bash +sudo ./oom_watch --duration 60 +``` + +Run the built-in demo: + +```bash +sudo ./oom_watch --demo +``` + +Example output: + +```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 +demo workload signaled=1 signal=9 +demo result=matched-profile-to-victim +dropped_victim_states=0 +``` + +## 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 | + +## Understanding the Output + +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. + +## 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. + +> 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) diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md new file mode 100644 index 00000000..104c6697 --- /dev/null +++ b/src/57-oom-watch/README.zh.md @@ -0,0 +1,759 @@ +# eBPF 实战教程:带回收分析的 OOM 受害者监控 + +当内核因内存压力杀死进程时,你如何知道哪个 cgroup 处于压力之下,以及在 OOM killer 介入之前它为回收内存付出了多大努力?本教程构建一个工具,跟踪每个 cgroup 的内存回收活动,并在 OOM killer 选择受害者时捕获详细上下文。 + +> 完整源代码: + +## eBPF 与 OOM 跟踪点 + +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 或更高版本。 + +## 实现原理 + +该工具跟踪 OOM 生命周期的两个阶段。在任何 OOM 事件之前,内核会尝试内存回收。我们统计每个内存 cgroup 的回收周期,跟踪回收了多少页面,并记录何时从不同的 cgroup 触发了回收(跨 cgroup 回收)。当 OOM killer 选择受害者时,我们捕获受害者的内存统计信息,将其与累积的回收分析关联,并跟踪受害者直到它退出。 + +## 头文件 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __OOM_WATCH_H +#define __OOM_WATCH_H + +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; + unsigned long long reclaimed_pages; + unsigned long long cross_cgroup_reclaims; + unsigned long long last_reclaim_ns; + unsigned long long total_vm_kb; + unsigned long long anon_rss_kb; + unsigned long long file_rss_kb; + unsigned int type; + unsigned int victim_pid; + unsigned int victim_tid; + unsigned int triggering_tgid; + signed int exit_code; + char comm[16]; +}; + +#endif /* __OOM_WATCH_H */ +``` + +头文件定义了发送到用户空间的事件结构。每个事件包括 cgroup ID、回收统计(开始/结束计数、回收的页面、跨 cgroup 回收)、来自跟踪点的受害者内存统计,以及受害者终止时的退出代码。 + +## BPF 程序 + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include +#include "oom_watch.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 target_cgroup_id; + +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 victim_state { + __u64 cgroup_id; + __u32 triggering_tgid; + __u32 victim_tgid; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, __u64); + __type(value, struct reclaim_profile); +} profiles SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct victim_state); +} victims SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 dropped_victim_states; + +static __always_inline bool selected_cgroup(__u64 cgroup_id) +{ + return !target_cgroup_id || cgroup_id == target_cgroup_id; +} + +static __always_inline __u64 victim_task_info(__u32 victim_pid, + __u32 *victim_tgid) +{ + struct task_struct *task; + __u64 cgroup_id = 0; + + task = bpf_task_from_pid(victim_pid); + if (!task) + return 0; + cgroup_id = BPF_CORE_READ(task, cgroups, dfl_cgrp, kn, id); + *victim_tgid = BPF_CORE_READ(task, tgid); + bpf_task_release(task); + return cgroup_id; +} + +static __always_inline __u64 memcg_cgroup_id(struct mem_cgroup *memcg) +{ + struct cgroup *cgroup; + + cgroup = BPF_CORE_READ(memcg, css.cgroup); + if (!cgroup) + return 0; + return BPF_CORE_READ(cgroup, kn, id); +} + +static __always_inline struct reclaim_profile *get_profile(__u64 cgroup_id) +{ + struct reclaim_profile initial = {}; + struct reclaim_profile *profile; + + profile = bpf_map_lookup_elem(&profiles, &cgroup_id); + if (profile) + return profile; + bpf_map_update_elem(&profiles, &cgroup_id, &initial, BPF_NOEXIST); + return bpf_map_lookup_elem(&profiles, &cgroup_id); +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_begin") +int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + (void)gfp_flags; + (void)order; + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + return 0; + __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(); + return 0; +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_end") +int BPF_PROG(track_reclaim_end, unsigned long reclaimed, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + 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(); + return 0; +} + +SEC("tracepoint/oom/mark_victim") +int capture_oom_victim(struct trace_event_raw_mark_victim *ctx) +{ + struct reclaim_profile *profile; + struct victim_state victim; + struct oom_watch_event *event; + __u64 cgroup_id; + __u32 victim_pid = ctx->pid; + __u32 victim_tgid = 0; + + cgroup_id = victim_task_info(victim_pid, &victim_tgid); + if (!victim_tgid || !selected_cgroup(cgroup_id)) + return 0; + victim.cgroup_id = cgroup_id; + victim.triggering_tgid = bpf_get_current_pid_tgid() >> 32; + victim.victim_tgid = victim_tgid; + if (bpf_map_update_elem(&victims, &victim_pid, &victim, BPF_ANY)) { + __sync_fetch_and_add(&dropped_victim_states, 1); + return 0; + } + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return 0; + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = cgroup_id; + event->type = OOM_VICTIM_MARKED; + event->victim_pid = victim_tgid; + event->victim_tid = victim_pid; + event->triggering_tgid = victim.triggering_tgid; + event->total_vm_kb = ctx->total_vm; + event->anon_rss_kb = ctx->anon_rss; + event->file_rss_kb = ctx->file_rss; + bpf_probe_read_kernel_str(event->comm, sizeof(event->comm), + (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; + } + bpf_ringbuf_submit(event, 0); + return 0; +} + +SEC("tracepoint/sched/sched_process_exit") +int capture_victim_exit(void *ctx) +{ + struct victim_state *victim; + struct task_struct *task; + struct oom_watch_event *event; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tid = pid_tgid; + + (void)ctx; + victim = bpf_map_lookup_elem(&victims, &tid); + if (!victim) + return 0; + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (event) { + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = victim->cgroup_id; + event->type = OOM_VICTIM_EXITED; + event->victim_pid = victim->victim_tgid; + event->victim_tid = tid; + event->triggering_tgid = victim->triggering_tgid; + task = (struct task_struct *)bpf_get_current_task_btf(); + event->exit_code = BPF_CORE_READ(task, exit_code); + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); + } + bpf_map_delete_elem(&victims, &tid); + return 0; +} +``` + +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。 + +`track_reclaim_begin` 和 `track_reclaim_end` 函数附加到 memcg 回收跟踪点。它们统计回收周期并跟踪跨 cgroup 回收(当一个 cgroup 中的任务触发另一个 cgroup 内存空间的回收时)。 + +`capture_oom_victim` 函数在 OOM killer 选择受害者时运行。它使用 `bpf_task_from_pid()` 获取受害者的 cgroup ID 和 TGID,存储受害者状态以进行退出跟踪,并发送包含内存统计和累积回收分析的事件。 + +`capture_victim_exit` 函数跟踪受害者实际退出的时间,确认杀死完成并报告退出信号。 + +## 用户空间程序 + +```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 +#include +#include +#include "oom_watch.h" +#include "oom_watch.skel.h" + +struct options { + const char *cgroup_path; + unsigned int duration_seconds; + bool demo; +}; + +static volatile sig_atomic_t stop; +static int victim_events; +static int exit_events; +static unsigned long long observed_cgroup_id; +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; + +struct allocation_context { + int ready_fd; + int continue_fd; +}; + +static struct allocation_context allocation_context; + +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 write_text(const char *path, const char *text) +{ + int fd = open(path, O_WRONLY | O_CLOEXEC); + ssize_t length = strlen(text); + int err = 0; + + if (fd < 0) + return -1; + if (write(fd, text, length) != length) + err = -1; + close(fd); + return err; +} + +static int memory_controller_enabled(bool *enabled) +{ + char controllers[4096]; + ssize_t length; + int fd; + + fd = open("/sys/fs/cgroup/cgroup.subtree_control", + O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + length = read(fd, controllers, sizeof(controllers) - 1); + close(fd); + if (length < 0) + return -1; + controllers[length] = '\0'; + *enabled = strstr(controllers, "memory") != NULL; + return 0; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct oom_watch_event *event = data; + + (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_victim_tid = event->victim_tid; + observed_cross_cgroup_reclaims = event->cross_cgroup_reclaims; + 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 " + "reclaimed_pages=%llu\n", + event->victim_pid, event->victim_tid, event->comm, + event->triggering_tgid, + (unsigned long long)event->cgroup_id, + (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); + } else if (event->type == OOM_VICTIM_EXITED) { + exit_events++; + printf("event=victim-exit pid=%u tid=%u cgroup_id=%llu exit_code=%d\n", + event->victim_pid, event->victim_tid, + (unsigned long long)event->cgroup_id, event->exit_code); + } + return 0; +} + +static int parse_uint(const char *text, unsigned int *value) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(text, &end, 10); + if (errno || !*text || *end || parsed > 86400) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--cgroup PATH] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +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' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "c:d:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'c': options->cgroup_path = optarg; break; + case 'd': + if (parse_uint(optarg, &options->duration_seconds)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && !(options->demo && options->cgroup_path) ? 0 : -1; +} + +static void *allocation_worker(void *argument) +{ + struct allocation_context *context = argument; + size_t first_stage = 24 * 1024 * 1024; + size_t length = 128 * 1024 * 1024; + unsigned char *memory; + char byte = 'x'; + + memory = mmap(NULL, length, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (memory == MAP_FAILED) + _exit(4); + for (size_t offset = 0; offset < first_stage; offset += 4096) + memory[offset] = 0xa5; + if (write(context->ready_fd, &byte, 1) != 1 || + read(context->continue_fd, &byte, 1) != 1) + _exit(5); + for (size_t offset = first_stage; offset < length; offset += 4096) + memory[offset] = 0xa5; + _exit(6); +} + +static void allocate_until_killed(const char *cgroup_path, int ready_fd, + int continue_fd) +{ + char procs_path[512]; + char pid_text[32]; + pthread_t worker; + + snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", cgroup_path); + snprintf(pid_text, sizeof(pid_text), "%d", getpid()); + if (write_text(procs_path, pid_text)) + _exit(3); + allocation_context.ready_fd = ready_fd; + allocation_context.continue_fd = continue_fd; + if (pthread_create(&worker, NULL, allocation_worker, + &allocation_context)) + _exit(4); + pthread_detach(worker); + pthread_exit(NULL); +} + +static int configure_demo_cgroup(const char *path, bool *created, + bool *enabled_by_demo) +{ + bool memory_enabled; + char file[512]; + + if (memory_controller_enabled(&memory_enabled)) + return -1; + if (!memory_enabled) { + if (write_text("/sys/fs/cgroup/cgroup.subtree_control", "+memory")) + return -1; + *enabled_by_demo = true; + } + if (mkdir(path, 0755)) + return -1; + *created = true; + snprintf(file, sizeof(file), "%s/memory.max", path); + if (write_text(file, "33554432")) + return -1; + snprintf(file, sizeof(file), "%s/memory.swap.max", path); + if (write_text(file, "0")) + return -1; + snprintf(file, sizeof(file), "%s/memory.oom.group", path); + return write_text(file, "1"); +} + +static int trigger_cross_cgroup_reclaim(const char *cgroup_path) +{ + char reclaim_path[512]; + + snprintf(reclaim_path, sizeof(reclaim_path), "%s/memory.reclaim", + cgroup_path); + if (!write_text(reclaim_path, "8388608") || errno == EAGAIN) + return 0; + return -1; +} + +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; + 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) + goto cleanup; + + if (selected_path) + printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", + selected_path, (unsigned long long)cgroup_stat.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); + 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; + return err; +} +``` + +用户空间程序为 OOM 受害者及其退出创建事件。在演示模式下,它创建一个内存受限的 cgroup(32MB),fork 一个子进程加入该 cgroup 并分配内存直到被杀死,并触发跨 cgroup 回收以在 OOM 之前生成回收分析数据。 + +## 编译和执行 + +构建工具: + +```bash +cd src/57-oom-watch +make +``` + +使用 cgroup 过滤运行: + +```bash +sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +``` + +或监视所有 OOM 事件: + +```bash +sudo ./oom_watch --duration 60 +``` + +运行内置演示: + +```bash +sudo ./oom_watch --demo +``` + +示例输出: + +```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 +demo workload signaled=1 signal=9 +demo result=matched-profile-to-victim +dropped_victim_states=0 +``` + +## 环境要求 + +| 要求 | 详情 | +|------|------| +| 内核 | Linux 7.1+(vmscan 跟踪点提供目标 memcg) | +| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_MEMCG` | +| 权限 | Root | +| cgroup | 启用内存控制器的 cgroup v2 | + +## 理解输出 + +受害者事件包括: + +- **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 条目受害者映射的状态。 + +## 总结 + +`oom-watch` 把通常分开查看的三个时刻连在一起,包括内存 cgroup 回收、OOM 受害者选择以及受害者最终退出。事件同时保留 TGID 和 TID,记录受害者的内存占用,并带上工具运行期间积累的回收画像。 + +> 如果你想深入了解 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) diff --git a/src/57-oom-watch/oom_watch.bpf.c b/src/57-oom-watch/oom_watch.bpf.c new file mode 100644 index 00000000..fc9fefc7 --- /dev/null +++ b/src/57-oom-watch/oom_watch.bpf.c @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include +#include "oom_watch.h" + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u64 target_cgroup_id; + +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 victim_state { + __u64 cgroup_id; + __u32 triggering_tgid; + __u32 victim_tgid; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, __u64); + __type(value, struct reclaim_profile); +} profiles SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 1024); + __type(key, __u32); + __type(value, struct victim_state); +} victims SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 256 * 1024); +} events SEC(".maps"); + +__u64 dropped_victim_states; + +static __always_inline bool selected_cgroup(__u64 cgroup_id) +{ + return !target_cgroup_id || cgroup_id == target_cgroup_id; +} + +static __always_inline __u64 victim_task_info(__u32 victim_pid, + __u32 *victim_tgid) +{ + struct task_struct *task; + __u64 cgroup_id = 0; + + task = bpf_task_from_pid(victim_pid); + if (!task) + return 0; + cgroup_id = BPF_CORE_READ(task, cgroups, dfl_cgrp, kn, id); + *victim_tgid = BPF_CORE_READ(task, tgid); + bpf_task_release(task); + return cgroup_id; +} + +static __always_inline __u64 memcg_cgroup_id(struct mem_cgroup *memcg) +{ + struct cgroup *cgroup; + + cgroup = BPF_CORE_READ(memcg, css.cgroup); + if (!cgroup) + return 0; + return BPF_CORE_READ(cgroup, kn, id); +} + +static __always_inline struct reclaim_profile *get_profile(__u64 cgroup_id) +{ + struct reclaim_profile initial = {}; + struct reclaim_profile *profile; + + profile = bpf_map_lookup_elem(&profiles, &cgroup_id); + if (profile) + return profile; + bpf_map_update_elem(&profiles, &cgroup_id, &initial, BPF_NOEXIST); + return bpf_map_lookup_elem(&profiles, &cgroup_id); +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_begin") +int BPF_PROG(track_reclaim_begin, gfp_t gfp_flags, int order, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + (void)gfp_flags; + (void)order; + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + return 0; + __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(); + return 0; +} + +SEC("tp_btf/mm_vmscan_memcg_reclaim_end") +int BPF_PROG(track_reclaim_end, unsigned long reclaimed, + struct mem_cgroup *memcg) +{ + struct reclaim_profile *profile; + __u64 cgroup_id = memcg_cgroup_id(memcg); + + if (!selected_cgroup(cgroup_id)) + return 0; + profile = get_profile(cgroup_id); + if (!profile) + 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(); + return 0; +} + +SEC("tracepoint/oom/mark_victim") +int capture_oom_victim(struct trace_event_raw_mark_victim *ctx) +{ + struct reclaim_profile *profile; + struct victim_state victim; + struct oom_watch_event *event; + __u64 cgroup_id; + __u32 victim_pid = ctx->pid; + __u32 victim_tgid = 0; + + cgroup_id = victim_task_info(victim_pid, &victim_tgid); + if (!victim_tgid || !selected_cgroup(cgroup_id)) + return 0; + victim.cgroup_id = cgroup_id; + victim.triggering_tgid = bpf_get_current_pid_tgid() >> 32; + victim.victim_tgid = victim_tgid; + if (bpf_map_update_elem(&victims, &victim_pid, &victim, BPF_ANY)) { + __sync_fetch_and_add(&dropped_victim_states, 1); + return 0; + } + + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (!event) + return 0; + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = cgroup_id; + event->type = OOM_VICTIM_MARKED; + event->victim_pid = victim_tgid; + event->victim_tid = victim_pid; + event->triggering_tgid = victim.triggering_tgid; + event->total_vm_kb = ctx->total_vm; + event->anon_rss_kb = ctx->anon_rss; + event->file_rss_kb = ctx->file_rss; + bpf_probe_read_kernel_str(event->comm, sizeof(event->comm), + (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; + } + bpf_ringbuf_submit(event, 0); + return 0; +} + +SEC("tracepoint/sched/sched_process_exit") +int capture_victim_exit(void *ctx) +{ + struct victim_state *victim; + struct task_struct *task; + struct oom_watch_event *event; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tid = pid_tgid; + + (void)ctx; + victim = bpf_map_lookup_elem(&victims, &tid); + if (!victim) + return 0; + event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); + if (event) { + __builtin_memset(event, 0, sizeof(*event)); + event->timestamp_ns = bpf_ktime_get_ns(); + event->cgroup_id = victim->cgroup_id; + event->type = OOM_VICTIM_EXITED; + event->victim_pid = victim->victim_tgid; + event->victim_tid = tid; + event->triggering_tgid = victim->triggering_tgid; + task = (struct task_struct *)bpf_get_current_task_btf(); + event->exit_code = BPF_CORE_READ(task, exit_code); + bpf_get_current_comm(event->comm, sizeof(event->comm)); + bpf_ringbuf_submit(event, 0); + } + bpf_map_delete_elem(&victims, &tid); + return 0; +} diff --git a/src/57-oom-watch/oom_watch.c b/src/57-oom-watch/oom_watch.c new file mode 100644 index 00000000..b5eb5122 --- /dev/null +++ b/src/57-oom-watch/oom_watch.c @@ -0,0 +1,406 @@ +// 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 +#include +#include +#include "oom_watch.h" +#include "oom_watch.skel.h" + +struct options { + const char *cgroup_path; + unsigned int duration_seconds; + bool demo; +}; + +static volatile sig_atomic_t stop; +static int victim_events; +static int exit_events; +static unsigned long long observed_cgroup_id; +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; + +struct allocation_context { + int ready_fd; + int continue_fd; +}; + +static struct allocation_context allocation_context; + +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 write_text(const char *path, const char *text) +{ + int fd = open(path, O_WRONLY | O_CLOEXEC); + ssize_t length = strlen(text); + int err = 0; + + if (fd < 0) + return -1; + if (write(fd, text, length) != length) + err = -1; + close(fd); + return err; +} + +static int memory_controller_enabled(bool *enabled) +{ + char controllers[4096]; + ssize_t length; + int fd; + + fd = open("/sys/fs/cgroup/cgroup.subtree_control", + O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + length = read(fd, controllers, sizeof(controllers) - 1); + close(fd); + if (length < 0) + return -1; + controllers[length] = '\0'; + *enabled = strstr(controllers, "memory") != NULL; + return 0; +} + +static int handle_event(void *ctx, void *data, size_t size) +{ + const struct oom_watch_event *event = data; + + (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_victim_tid = event->victim_tid; + observed_cross_cgroup_reclaims = event->cross_cgroup_reclaims; + 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 " + "reclaimed_pages=%llu\n", + event->victim_pid, event->victim_tid, event->comm, + event->triggering_tgid, + (unsigned long long)event->cgroup_id, + (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); + } else if (event->type == OOM_VICTIM_EXITED) { + exit_events++; + printf("event=victim-exit pid=%u tid=%u cgroup_id=%llu exit_code=%d\n", + event->victim_pid, event->victim_tid, + (unsigned long long)event->cgroup_id, event->exit_code); + } + return 0; +} + +static int parse_uint(const char *text, unsigned int *value) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(text, &end, 10); + if (errno || !*text || *end || parsed > 86400) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s [--cgroup PATH] [--duration SEC]\n" + " %s --demo\n", program, program); +} + +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' }, + { "demo", no_argument, NULL, 'D' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "c:d:Dh", long_options, + NULL)) != -1) { + switch (option) { + case 'c': options->cgroup_path = optarg; break; + case 'd': + if (parse_uint(optarg, &options->duration_seconds)) + return -1; + break; + case 'D': options->demo = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && !(options->demo && options->cgroup_path) ? 0 : -1; +} + +static void *allocation_worker(void *argument) +{ + struct allocation_context *context = argument; + size_t first_stage = 24 * 1024 * 1024; + size_t length = 128 * 1024 * 1024; + unsigned char *memory; + char byte = 'x'; + + memory = mmap(NULL, length, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (memory == MAP_FAILED) + _exit(4); + for (size_t offset = 0; offset < first_stage; offset += 4096) + memory[offset] = 0xa5; + if (write(context->ready_fd, &byte, 1) != 1 || + read(context->continue_fd, &byte, 1) != 1) + _exit(5); + for (size_t offset = first_stage; offset < length; offset += 4096) + memory[offset] = 0xa5; + _exit(6); +} + +static void allocate_until_killed(const char *cgroup_path, int ready_fd, + int continue_fd) +{ + char procs_path[512]; + char pid_text[32]; + pthread_t worker; + + snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", cgroup_path); + snprintf(pid_text, sizeof(pid_text), "%d", getpid()); + if (write_text(procs_path, pid_text)) + _exit(3); + allocation_context.ready_fd = ready_fd; + allocation_context.continue_fd = continue_fd; + if (pthread_create(&worker, NULL, allocation_worker, + &allocation_context)) + _exit(4); + pthread_detach(worker); + pthread_exit(NULL); +} + +static int configure_demo_cgroup(const char *path, bool *created, + bool *enabled_by_demo) +{ + bool memory_enabled; + char file[512]; + + if (memory_controller_enabled(&memory_enabled)) + return -1; + if (!memory_enabled) { + if (write_text("/sys/fs/cgroup/cgroup.subtree_control", "+memory")) + return -1; + *enabled_by_demo = true; + } + if (mkdir(path, 0755)) + return -1; + *created = true; + snprintf(file, sizeof(file), "%s/memory.max", path); + if (write_text(file, "33554432")) + return -1; + snprintf(file, sizeof(file), "%s/memory.swap.max", path); + if (write_text(file, "0")) + return -1; + snprintf(file, sizeof(file), "%s/memory.oom.group", path); + return write_text(file, "1"); +} + +static int trigger_cross_cgroup_reclaim(const char *cgroup_path) +{ + char reclaim_path[512]; + + snprintf(reclaim_path, sizeof(reclaim_path), "%s/memory.reclaim", + cgroup_path); + if (!write_text(reclaim_path, "8388608") || errno == EAGAIN) + return 0; + return -1; +} + +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; + 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) + goto cleanup; + + if (selected_path) + printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", + selected_path, (unsigned long long)cgroup_stat.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); + 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; + return err; +} diff --git a/src/57-oom-watch/oom_watch.h b/src/57-oom-watch/oom_watch.h new file mode 100644 index 00000000..2288ed0e --- /dev/null +++ b/src/57-oom-watch/oom_watch.h @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __OOM_WATCH_H +#define __OOM_WATCH_H + +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; + unsigned long long reclaimed_pages; + unsigned long long cross_cgroup_reclaims; + unsigned long long last_reclaim_ns; + unsigned long long total_vm_kb; + unsigned long long anon_rss_kb; + unsigned long long file_rss_kb; + unsigned int type; + unsigned int victim_pid; + unsigned int victim_tid; + unsigned int triggering_tgid; + signed int exit_code; + char comm[16]; +}; + +#endif /* __OOM_WATCH_H */ diff --git a/src/57-oom-watch/tests/test_oom_watch.py b/src/57-oom-watch/tests/test_oom_watch.py new file mode 100644 index 00000000..07d134f3 --- /dev/null +++ b/src/57-oom-watch/tests/test_oom_watch.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import re +from pathlib import Path + + +def main(): + subtree_control = Path("/sys/fs/cgroup/cgroup.subtree_control") + before = subtree_control.read_text() + result = subprocess.run( + [sys.argv[1], "--demo"], + text=True, + capture_output=True, + check=False, + timeout=30, + ) + output = result.stdout + result.stderr + after = subtree_control.read_text() + if result.returncode: + raise AssertionError(output) + assert before == after, (before, after, output) + assert "event=oom-victim" in output, output + match = re.search(r"event=oom-victim pid=(\d+) tid=(\d+)", output) + assert match and match.group(1) != match.group(2), output + assert "reclaim_cycles=" in output, output + assert "cross_cgroup_reclaims=" in output, output + assert "event=victim-exit" in output, output + assert "demo workload signaled=1 signal=9" in output, output + assert "demo result=matched-profile-to-victim" in output, output + assert "dropped_victim_states=0" in output, output + print("OOM watch integration test: PASS") + + +if __name__ == "__main__": + main() diff --git a/src/60-afxdp-dump/.gitignore b/src/60-afxdp-dump/.gitignore new file mode 100644 index 00000000..6d312feb --- /dev/null +++ b/src/60-afxdp-dump/.gitignore @@ -0,0 +1,2 @@ +.output/ +afxdp_dump diff --git a/src/60-afxdp-dump/Makefile b/src/60-afxdp-dump/Makefile new file mode 100644 index 00000000..7379603d --- /dev/null +++ b/src/60-afxdp-dump/Makefile @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +OUTPUT := .output +CLANG ?= clang +LIBBPF_SRC := $(abspath ../third_party/libbpf/src) +BPFTOOL_SRC := $(abspath ../third_party/bpftool/src) +LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a) +BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool) +BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool +ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \ + | sed 's/arm.*/arm/' \ + | sed 's/aarch64/arm64/' \ + | sed 's/ppc64le/powerpc/' \ + | sed 's/mips.*/mips/' \ + | sed 's/riscv64/riscv/' \ + | sed 's/loongarch64/loongarch/') +VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h +INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. +CFLAGS := -g -Wall -Wextra -D_GNU_SOURCE +ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) +APPS = afxdp_dump + +CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ + | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') + +ifeq ($(V),1) + Q = + msg = +else + Q = @ + msg = @printf ' %-8s %s%s\n' "$(1)" \ + "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \ + "$(if $(3), $(3))"; + MAKEFLAGS += --no-print-directory +endif + +define allow-override + $(if $(or $(findstring environment,$(origin $(1))),\ + $(findstring command line,$(origin $(1)))),,\ + $(eval $(1) = $(2))) +endef + +$(call allow-override,CC,$(CROSS_COMPILE)cc) + +.PHONY: all clean test +all: $(APPS) + +clean: + $(call msg,CLEAN) + $(Q)rm -rf $(OUTPUT) $(APPS) + +test: all + @if [ "$$(id -u)" -ne 0 ]; then \ + echo "test requires root; run inside the documented KVM guest" >&2; \ + exit 1; \ + fi + python3 tests/test_afxdp_dump.py ./afxdp_dump + +$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): + $(call msg,MKDIR,$@) + $(Q)mkdir -p $@ + +$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf + $(call msg,LIB,$@) + $(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \ + OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \ + INCLUDEDIR= LIBDIR= UAPIDIR= install + +$(BPFTOOL): | $(BPFTOOL_OUTPUT) + $(call msg,BPFTOOL,$@) + $(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap + +$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL) + $(call msg,BPF,$@) + $(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \ + $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \ + -c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + $(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + +$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL) + $(call msg,GEN-SKEL,$@) + $(Q)$(BPFTOOL) gen skeleton $< > $@ + +$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h + +$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT) + $(call msg,CC,$@) + $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@ + +$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT) + $(call msg,BINARY,$@) + $(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@ + +.DELETE_ON_ERROR: +.SECONDARY: diff --git a/src/60-afxdp-dump/README.md b/src/60-afxdp-dump/README.md new file mode 100644 index 00000000..82632075 --- /dev/null +++ b/src/60-afxdp-dump/README.md @@ -0,0 +1,666 @@ +# eBPF Tutorial by Example: AF_XDP Packet Dump + +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. + +> Complete source code: + +## eBPF, XDP, and AF_XDP + +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. + +## How the Implementation Works + +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. + +## Header File + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __AFXDP_DUMP_H +#define __AFXDP_DUMP_H + +#define AFXDP_MAX_QUEUES 64 + +#endif /* __AFXDP_DUMP_H */ +``` + +The header defines the maximum number of RX queues supported by the XSKMAP. + +## BPF Program + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "afxdp_dump.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define ETH_P_IP 0x0800 +#define IPPROTO_UDP 17 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff + +const volatile __u16 capture_port = 8080; + +struct { + __uint(type, BPF_MAP_TYPE_XSKMAP); + __uint(max_entries, AFXDP_MAX_QUEUES); + __type(key, __u32); + __type(value, __u32); +} xsk_map SEC(".maps"); + +__u64 redirected_packets; + +SEC("xdp") +int redirect_udp(struct xdp_md *ctx) +{ + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; + struct ethhdr *ethernet = data; + struct iphdr *ip; + struct udphdr *udp; + __u32 queue = ctx->rx_queue_index; + __u32 ip_header_length; + __u32 ip_length; + __u32 udp_length; + + if ((void *)(ethernet + 1) > data_end || + ethernet->h_proto != bpf_htons(ETH_P_IP)) + return XDP_PASS; + ip = (void *)(ethernet + 1); + if ((void *)(ip + 1) > data_end || ip->version != 4 || + ip->protocol != IPPROTO_UDP || + ip->ihl < 5 || (bpf_ntohs(ip->frag_off) & (IP_MF | IP_OFFSET))) + return XDP_PASS; + ip_header_length = ip->ihl * 4; + ip_length = bpf_ntohs(ip->tot_len); + udp = (void *)ip + ip_header_length; + if (ip_length < ip_header_length + sizeof(*udp) || + (void *)ip + ip_length > data_end || + (void *)(udp + 1) > data_end) + return XDP_PASS; + udp_length = bpf_ntohs(udp->len); + if (udp_length < sizeof(*udp) || + udp_length > ip_length - ip_header_length || + udp->dest != bpf_htons(capture_port)) + return XDP_PASS; + if (!bpf_map_lookup_elem(&xsk_map, &queue)) + return XDP_PASS; + __sync_fetch_and_add(&redirected_packets, 1); + return bpf_redirect_map(&xsk_map, queue, XDP_PASS); +} +``` + +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. + +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. + +## User Space Program + +```c +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#define _FILE_OFFSET_BITS 64 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "afxdp_dump.h" +#include "afxdp_dump.skel.h" + +#ifndef SOL_XDP +#define SOL_XDP 283 +#endif +#ifndef AF_XDP +#define AF_XDP 44 +#endif +#ifndef PF_XDP +#define PF_XDP AF_XDP +#endif + +#define FRAME_SIZE 4096 +#define FRAME_COUNT 64 +#define RING_SIZE 64 +#define UMEM_SIZE ((size_t)FRAME_SIZE * FRAME_COUNT) +#define PAYLOAD_PREVIEW 32 +#define IPV4_MORE_FRAGMENTS 0x2000 +#define IPV4_FRAGMENT_OFFSET 0x1fff + +struct options { + const char *interface; + unsigned int queue; + unsigned int port; + unsigned int count; + bool skb_mode; +}; + +struct mapped_ring { + void *mapping; + size_t mapping_size; + unsigned int *producer; + unsigned int *consumer; + unsigned int *flags; + void *descriptors; + unsigned int mask; +}; + +struct xsk_state { + int fd; + void *umem; + struct mapped_ring fill; + struct mapped_ring completion; + struct mapped_ring rx; +}; + +static volatile sig_atomic_t stop; + +static void handle_signal(int signal_number) +{ + (void)signal_number; + stop = 1; +} + +static void map_ring_fields(struct mapped_ring *ring, void *mapping, + size_t mapping_size, + const struct xdp_ring_offset *offset) +{ + ring->mapping = mapping; + ring->mapping_size = mapping_size; + ring->producer = mapping + offset->producer; + ring->consumer = mapping + offset->consumer; + ring->flags = mapping + offset->flags; + ring->descriptors = mapping + offset->desc; + ring->mask = RING_SIZE - 1; +} + +static int map_xsk_ring(struct mapped_ring *ring, int fd, + const struct xdp_ring_offset *offset, + off_t page_offset, size_t descriptor_size) +{ + size_t size = offset->desc + RING_SIZE * descriptor_size; + void *mapping; + + mapping = mmap(NULL, size, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, fd, page_offset); + if (mapping == MAP_FAILED) + return -1; + map_ring_fields(ring, mapping, size, offset); + return 0; +} + +static void close_xsk(struct xsk_state *xsk) +{ + struct mapped_ring *rings[] = { + &xsk->rx, &xsk->completion, &xsk->fill, + }; + + for (size_t i = 0; i < sizeof(rings) / sizeof(rings[0]); i++) + if (rings[i]->mapping && rings[i]->mapping != MAP_FAILED) + munmap(rings[i]->mapping, rings[i]->mapping_size); + if (xsk->fd >= 0) + close(xsk->fd); + if (xsk->umem && xsk->umem != MAP_FAILED) + munmap(xsk->umem, UMEM_SIZE); + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; +} + +static int open_xsk(struct xsk_state *xsk, int ifindex, unsigned int queue) +{ + struct xdp_umem_reg registration = { + .len = UMEM_SIZE, + .chunk_size = FRAME_SIZE, + }; + struct sockaddr_xdp address = { + .sxdp_family = PF_XDP, + .sxdp_ifindex = ifindex, + .sxdp_queue_id = queue, + .sxdp_flags = XDP_COPY, + }; + struct xdp_mmap_offsets offsets; + socklen_t offsets_size = sizeof(offsets); + unsigned int ring_size = RING_SIZE; + unsigned long long *fill_addresses; + + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; + xsk->umem = mmap(NULL, UMEM_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0); + if (xsk->umem == MAP_FAILED) + return -1; + xsk->fd = socket(AF_XDP, SOCK_RAW | SOCK_CLOEXEC, 0); + if (xsk->fd < 0) + return -1; + registration.addr = (uintptr_t)xsk->umem; + if (setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_REG, ®istration, + sizeof(registration)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_FILL_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_COMPLETION_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_RX_RING, &ring_size, + sizeof(ring_size)) || + getsockopt(xsk->fd, SOL_XDP, XDP_MMAP_OFFSETS, &offsets, + &offsets_size)) + return -1; + + if (map_xsk_ring(&xsk->fill, xsk->fd, &offsets.fr, + XDP_UMEM_PGOFF_FILL_RING, sizeof(unsigned long long)) || + map_xsk_ring(&xsk->completion, xsk->fd, &offsets.cr, + XDP_UMEM_PGOFF_COMPLETION_RING, + sizeof(unsigned long long)) || + map_xsk_ring(&xsk->rx, xsk->fd, &offsets.rx, + XDP_PGOFF_RX_RING, sizeof(struct xdp_desc)) || + bind(xsk->fd, (struct sockaddr *)&address, sizeof(address))) + return -1; + + fill_addresses = xsk->fill.descriptors; + for (unsigned int i = 0; i < FRAME_COUNT; i++) + fill_addresses[i & xsk->fill.mask] = + (unsigned long long)i * FRAME_SIZE; + __atomic_store_n(xsk->fill.producer, FRAME_COUNT, __ATOMIC_RELEASE); + return 0; +} + +static unsigned long long data_address(unsigned long long address) +{ + return (address & XSK_UNALIGNED_BUF_ADDR_MASK) + + (address >> XSK_UNALIGNED_BUF_OFFSET_SHIFT); +} + +static int recycle_frame(struct xsk_state *xsk, unsigned long long address) +{ + unsigned long long *fill_addresses = xsk->fill.descriptors; + unsigned int producer, consumer; + + producer = __atomic_load_n(xsk->fill.producer, __ATOMIC_RELAXED); + consumer = __atomic_load_n(xsk->fill.consumer, __ATOMIC_ACQUIRE); + if (producer - consumer == RING_SIZE) + return -1; + fill_addresses[producer & xsk->fill.mask] = address; + __atomic_store_n(xsk->fill.producer, producer + 1, __ATOMIC_RELEASE); + return 0; +} + +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; + + if (length < sizeof(*ethernet) + sizeof(*ip) || + ethernet->h_proto != htons(ETH_P_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)) + 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++) + preview[i] = isprint(payload[i]) ? payload[i] : '.'; + preview[preview_length] = '\0'; + inet_ntop(AF_INET, &ip->saddr, source, sizeof(source)); + inet_ntop(AF_INET, &ip->daddr, destination, sizeof(destination)); + printf("packet=%u %s:%u -> %s:%u bytes=%u payload=\"%s\"\n", + packet_number, source, ntohs(udp->source), destination, + ntohs(udp->dest), length, preview); +} + +static int receive_packets(struct xsk_state *xsk, unsigned int count) +{ + struct pollfd poll_fd = { .fd = xsk->fd, .events = POLLIN }; + unsigned int received = 0; + + while (!stop && (!count || received < count)) { + unsigned int consumer, producer; + int poll_result = poll(&poll_fd, 1, 250); + + if (poll_result < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (!poll_result) + continue; + consumer = __atomic_load_n(xsk->rx.consumer, __ATOMIC_RELAXED); + producer = __atomic_load_n(xsk->rx.producer, __ATOMIC_ACQUIRE); + while (consumer != producer && (!count || received < count)) { + struct xdp_desc *descriptors = xsk->rx.descriptors; + struct xdp_desc descriptor = + descriptors[consumer & xsk->rx.mask]; + unsigned long long address = data_address(descriptor.addr); + + if (descriptor.options & XDP_PKT_CONTD) { + errno = EMSGSIZE; + return -1; + } + if (address + descriptor.len > UMEM_SIZE) { + errno = EFAULT; + return -1; + } + received++; + dump_packet(xsk->umem + address, descriptor.len, received); + consumer++; + __atomic_store_n(xsk->rx.consumer, consumer, + __ATOMIC_RELEASE); + if (recycle_frame(xsk, descriptor.addr)) { + errno = ENOBUFS; + return -1; + } + } + } + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --interface IFACE [--queue N] [--port PORT] [--count N] [--skb-mode]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "interface", required_argument, NULL, 'i' }, + { "queue", required_argument, NULL, 'q' }, + { "port", required_argument, NULL, 'p' }, + { "count", required_argument, NULL, 'n' }, + { "skb-mode", no_argument, NULL, 'S' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "i:q:p:n:Sh", long_options, + NULL)) != -1) { + switch (option) { + case 'i': options->interface = optarg; break; + case 'q': + if (parse_uint(optarg, AFXDP_MAX_QUEUES - 1, + &options->queue)) + return -1; + break; + case 'p': + if (parse_uint(optarg, 65535, &options->port) || + !options->port) + return -1; + break; + case 'n': + if (parse_uint(optarg, 1000000, &options->count)) + return -1; + break; + case 'S': options->skb_mode = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->interface ? 0 : -1; +} + +static int attach_xdp(int ifindex, int program_fd, bool skb_mode, + unsigned int *attached_flags) +{ + unsigned int flags = XDP_FLAGS_UPDATE_IF_NOEXIST | + (skb_mode ? XDP_FLAGS_SKB_MODE : XDP_FLAGS_DRV_MODE); + int err; + + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + if (err && !skb_mode && (err == -EOPNOTSUPP || err == -EINVAL)) { + flags = XDP_FLAGS_UPDATE_IF_NOEXIST | XDP_FLAGS_SKB_MODE; + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + } + if (!err) + *attached_flags = flags; + return err; +} + +int main(int argc, char **argv) +{ + struct options options = { .port = 8080 }; + struct afxdp_dump_bpf *skel = NULL; + struct xsk_state xsk; + unsigned int attached_flags = 0; + int ifindex; + int err = 1; + bool attached = false; + + setvbuf(stdout, NULL, _IONBF, 0); + memset(&xsk, 0, sizeof(xsk)); + xsk.fd = -1; + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + ifindex = if_nametoindex(options.interface); + if (!ifindex) { + fprintf(stderr, "unknown interface: %s\n", options.interface); + return 2; + } + if (open_xsk(&xsk, ifindex, options.queue)) { + fprintf(stderr, "failed to create AF_XDP socket: %s\n", + strerror(errno)); + goto cleanup; + } + skel = afxdp_dump_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->capture_port = options.port; + if (afxdp_dump_bpf__load(skel)) { + fprintf(stderr, "failed to load XDP program\n"); + goto cleanup; + } + if (bpf_map__update_elem(skel->maps.xsk_map, &options.queue, + sizeof(options.queue), &xsk.fd, sizeof(xsk.fd), + BPF_ANY)) { + fprintf(stderr, "failed to populate XSK map: %s\n", strerror(errno)); + goto cleanup; + } + { + int attach_error = attach_xdp(ifindex, + bpf_program__fd(skel->progs.redirect_udp), + options.skb_mode, &attached_flags); + + if (attach_error) { + fprintf(stderr, "failed to attach XDP program: %s\n", + strerror(-attach_error)); + goto cleanup; + } + } + attached = true; + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + printf("afxdp-dump ready interface=%s queue=%u port=%u mode=%s count=%u\n", + options.interface, options.queue, options.port, + attached_flags & XDP_FLAGS_SKB_MODE ? "skb" : "driver", + options.count); + + if (receive_packets(&xsk, options.count)) { + fprintf(stderr, "AF_XDP receive failed: %s\n", strerror(errno)); + goto cleanup; + } + printf("redirected=%llu\n", + (unsigned long long)skel->bss->redirected_packets); + err = 0; + +cleanup: + if (attached) { + LIBBPF_OPTS(bpf_xdp_attach_opts, detach_options, + .old_prog_fd = bpf_program__fd( + skel->progs.redirect_udp)); + int detach_error = bpf_xdp_detach( + ifindex, attached_flags & XDP_FLAGS_MODES, + &detach_options); + + if (detach_error) { + fprintf(stderr, "failed to detach XDP program: %s\n", + strerror(-detach_error)); + err = 1; + } + } + afxdp_dump_bpf__destroy(skel); + close_xsk(&xsk); + return err; +} +``` + +The user space program sets up AF_XDP with several components: + +**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. + +**Fill ring**: Pre-populated with all 64 frame addresses to tell the kernel where to place incoming packets. + +**RX ring**: Polled for packet descriptors. When a packet arrives, the kernel posts a descriptor here containing the frame address and length. + +**XSKMAP**: Updated with the AF_XDP socket file descriptor to enable packet redirection from XDP. + +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: + +```bash +cd src/60-afxdp-dump +make +``` + +Run with options: + +```bash +sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 +``` + +Options: + +- `--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: + +```bash +sudo make test +``` + +Representative test output: + +```text +afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 +packet=1 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" +packet=65 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" +redirected=65 +nonmatching-pass=verified +AF_XDP dump integration test: PASS +``` + +## 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 | + +## What Happens to Redirected Packets + +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); +} +``` + +## 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. + +> 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) +- [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) diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md new file mode 100644 index 00000000..28cba1b6 --- /dev/null +++ b/src/60-afxdp-dump/README.zh.md @@ -0,0 +1,666 @@ +# eBPF 实战教程:AF_XDP 数据包转储 + +如何将网络数据包直接接收到用户空间?AF_XDP 通过在内核和应用程序之间共享内存提供数据包处理路径。本教程构建一个数据包转储器,使用 XDP 将选定的 UDP 数据包重定向到 AF_XDP 套接字。此实现使用拷贝模式以获得兼容性。 + +> 完整源代码: + +## eBPF、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。 + +## 实现原理 + +我们的工具附加一个 XDP 程序,按目标端口过滤 UDP 数据包。匹配的数据包被重定向到 AF_XDP 套接字;其他所有数据包都传递到正常的网络栈。XDP 程序在重定向之前检查是否有 AF_XDP 套接字绑定到该队列。 + +## 头文件 + +```c +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __AFXDP_DUMP_H +#define __AFXDP_DUMP_H + +#define AFXDP_MAX_QUEUES 64 + +#endif /* __AFXDP_DUMP_H */ +``` + +头文件定义了 XSKMAP 支持的最大 RX 队列数。 + +## BPF 程序 + +```c +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "afxdp_dump.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define ETH_P_IP 0x0800 +#define IPPROTO_UDP 17 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff + +const volatile __u16 capture_port = 8080; + +struct { + __uint(type, BPF_MAP_TYPE_XSKMAP); + __uint(max_entries, AFXDP_MAX_QUEUES); + __type(key, __u32); + __type(value, __u32); +} xsk_map SEC(".maps"); + +__u64 redirected_packets; + +SEC("xdp") +int redirect_udp(struct xdp_md *ctx) +{ + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; + struct ethhdr *ethernet = data; + struct iphdr *ip; + struct udphdr *udp; + __u32 queue = ctx->rx_queue_index; + __u32 ip_header_length; + __u32 ip_length; + __u32 udp_length; + + if ((void *)(ethernet + 1) > data_end || + ethernet->h_proto != bpf_htons(ETH_P_IP)) + return XDP_PASS; + ip = (void *)(ethernet + 1); + if ((void *)(ip + 1) > data_end || ip->version != 4 || + ip->protocol != IPPROTO_UDP || + ip->ihl < 5 || (bpf_ntohs(ip->frag_off) & (IP_MF | IP_OFFSET))) + return XDP_PASS; + ip_header_length = ip->ihl * 4; + ip_length = bpf_ntohs(ip->tot_len); + udp = (void *)ip + ip_header_length; + if (ip_length < ip_header_length + sizeof(*udp) || + (void *)ip + ip_length > data_end || + (void *)(udp + 1) > data_end) + return XDP_PASS; + udp_length = bpf_ntohs(udp->len); + if (udp_length < sizeof(*udp) || + udp_length > ip_length - ip_header_length || + udp->dest != bpf_htons(capture_port)) + return XDP_PASS; + if (!bpf_map_lookup_elem(&xsk_map, &queue)) + return XDP_PASS; + __sync_fetch_and_add(&redirected_packets, 1); + return bpf_redirect_map(&xsk_map, queue, XDP_PASS); +} +``` + +XDP 程序在每个层级解析以太网、IP 和 UDP 头,并进行边界检查。它过滤发往配置端口的 IPv4 UDP 数据包,跳过分片数据包。`bpf_map_lookup_elem()` 检查确保我们只在此队列实际绑定了 AF_XDP 套接字时才重定向。`bpf_redirect_map()` 中的回退操作是 `XDP_PASS`,所以如果重定向失败,数据包正常传递。 + +XSKMAP 将队列 ID 与 AF_XDP 套接字文件描述符关联。当 `bpf_redirect_map()` 成功时,数据包直接传递到 AF_XDP 套接字而不是内核网络栈。 + +## 用户空间程序 + +```c +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#define _FILE_OFFSET_BITS 64 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "afxdp_dump.h" +#include "afxdp_dump.skel.h" + +#ifndef SOL_XDP +#define SOL_XDP 283 +#endif +#ifndef AF_XDP +#define AF_XDP 44 +#endif +#ifndef PF_XDP +#define PF_XDP AF_XDP +#endif + +#define FRAME_SIZE 4096 +#define FRAME_COUNT 64 +#define RING_SIZE 64 +#define UMEM_SIZE ((size_t)FRAME_SIZE * FRAME_COUNT) +#define PAYLOAD_PREVIEW 32 +#define IPV4_MORE_FRAGMENTS 0x2000 +#define IPV4_FRAGMENT_OFFSET 0x1fff + +struct options { + const char *interface; + unsigned int queue; + unsigned int port; + unsigned int count; + bool skb_mode; +}; + +struct mapped_ring { + void *mapping; + size_t mapping_size; + unsigned int *producer; + unsigned int *consumer; + unsigned int *flags; + void *descriptors; + unsigned int mask; +}; + +struct xsk_state { + int fd; + void *umem; + struct mapped_ring fill; + struct mapped_ring completion; + struct mapped_ring rx; +}; + +static volatile sig_atomic_t stop; + +static void handle_signal(int signal_number) +{ + (void)signal_number; + stop = 1; +} + +static void map_ring_fields(struct mapped_ring *ring, void *mapping, + size_t mapping_size, + const struct xdp_ring_offset *offset) +{ + ring->mapping = mapping; + ring->mapping_size = mapping_size; + ring->producer = mapping + offset->producer; + ring->consumer = mapping + offset->consumer; + ring->flags = mapping + offset->flags; + ring->descriptors = mapping + offset->desc; + ring->mask = RING_SIZE - 1; +} + +static int map_xsk_ring(struct mapped_ring *ring, int fd, + const struct xdp_ring_offset *offset, + off_t page_offset, size_t descriptor_size) +{ + size_t size = offset->desc + RING_SIZE * descriptor_size; + void *mapping; + + mapping = mmap(NULL, size, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, fd, page_offset); + if (mapping == MAP_FAILED) + return -1; + map_ring_fields(ring, mapping, size, offset); + return 0; +} + +static void close_xsk(struct xsk_state *xsk) +{ + struct mapped_ring *rings[] = { + &xsk->rx, &xsk->completion, &xsk->fill, + }; + + for (size_t i = 0; i < sizeof(rings) / sizeof(rings[0]); i++) + if (rings[i]->mapping && rings[i]->mapping != MAP_FAILED) + munmap(rings[i]->mapping, rings[i]->mapping_size); + if (xsk->fd >= 0) + close(xsk->fd); + if (xsk->umem && xsk->umem != MAP_FAILED) + munmap(xsk->umem, UMEM_SIZE); + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; +} + +static int open_xsk(struct xsk_state *xsk, int ifindex, unsigned int queue) +{ + struct xdp_umem_reg registration = { + .len = UMEM_SIZE, + .chunk_size = FRAME_SIZE, + }; + struct sockaddr_xdp address = { + .sxdp_family = PF_XDP, + .sxdp_ifindex = ifindex, + .sxdp_queue_id = queue, + .sxdp_flags = XDP_COPY, + }; + struct xdp_mmap_offsets offsets; + socklen_t offsets_size = sizeof(offsets); + unsigned int ring_size = RING_SIZE; + unsigned long long *fill_addresses; + + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; + xsk->umem = mmap(NULL, UMEM_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0); + if (xsk->umem == MAP_FAILED) + return -1; + xsk->fd = socket(AF_XDP, SOCK_RAW | SOCK_CLOEXEC, 0); + if (xsk->fd < 0) + return -1; + registration.addr = (uintptr_t)xsk->umem; + if (setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_REG, ®istration, + sizeof(registration)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_FILL_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_COMPLETION_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_RX_RING, &ring_size, + sizeof(ring_size)) || + getsockopt(xsk->fd, SOL_XDP, XDP_MMAP_OFFSETS, &offsets, + &offsets_size)) + return -1; + + if (map_xsk_ring(&xsk->fill, xsk->fd, &offsets.fr, + XDP_UMEM_PGOFF_FILL_RING, sizeof(unsigned long long)) || + map_xsk_ring(&xsk->completion, xsk->fd, &offsets.cr, + XDP_UMEM_PGOFF_COMPLETION_RING, + sizeof(unsigned long long)) || + map_xsk_ring(&xsk->rx, xsk->fd, &offsets.rx, + XDP_PGOFF_RX_RING, sizeof(struct xdp_desc)) || + bind(xsk->fd, (struct sockaddr *)&address, sizeof(address))) + return -1; + + fill_addresses = xsk->fill.descriptors; + for (unsigned int i = 0; i < FRAME_COUNT; i++) + fill_addresses[i & xsk->fill.mask] = + (unsigned long long)i * FRAME_SIZE; + __atomic_store_n(xsk->fill.producer, FRAME_COUNT, __ATOMIC_RELEASE); + return 0; +} + +static unsigned long long data_address(unsigned long long address) +{ + return (address & XSK_UNALIGNED_BUF_ADDR_MASK) + + (address >> XSK_UNALIGNED_BUF_OFFSET_SHIFT); +} + +static int recycle_frame(struct xsk_state *xsk, unsigned long long address) +{ + unsigned long long *fill_addresses = xsk->fill.descriptors; + unsigned int producer, consumer; + + producer = __atomic_load_n(xsk->fill.producer, __ATOMIC_RELAXED); + consumer = __atomic_load_n(xsk->fill.consumer, __ATOMIC_ACQUIRE); + if (producer - consumer == RING_SIZE) + return -1; + fill_addresses[producer & xsk->fill.mask] = address; + __atomic_store_n(xsk->fill.producer, producer + 1, __ATOMIC_RELEASE); + return 0; +} + +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; + + if (length < sizeof(*ethernet) + sizeof(*ip) || + ethernet->h_proto != htons(ETH_P_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)) + 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++) + preview[i] = isprint(payload[i]) ? payload[i] : '.'; + preview[preview_length] = '\0'; + inet_ntop(AF_INET, &ip->saddr, source, sizeof(source)); + inet_ntop(AF_INET, &ip->daddr, destination, sizeof(destination)); + printf("packet=%u %s:%u -> %s:%u bytes=%u payload=\"%s\"\n", + packet_number, source, ntohs(udp->source), destination, + ntohs(udp->dest), length, preview); +} + +static int receive_packets(struct xsk_state *xsk, unsigned int count) +{ + struct pollfd poll_fd = { .fd = xsk->fd, .events = POLLIN }; + unsigned int received = 0; + + while (!stop && (!count || received < count)) { + unsigned int consumer, producer; + int poll_result = poll(&poll_fd, 1, 250); + + if (poll_result < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (!poll_result) + continue; + consumer = __atomic_load_n(xsk->rx.consumer, __ATOMIC_RELAXED); + producer = __atomic_load_n(xsk->rx.producer, __ATOMIC_ACQUIRE); + while (consumer != producer && (!count || received < count)) { + struct xdp_desc *descriptors = xsk->rx.descriptors; + struct xdp_desc descriptor = + descriptors[consumer & xsk->rx.mask]; + unsigned long long address = data_address(descriptor.addr); + + if (descriptor.options & XDP_PKT_CONTD) { + errno = EMSGSIZE; + return -1; + } + if (address + descriptor.len > UMEM_SIZE) { + errno = EFAULT; + return -1; + } + received++; + dump_packet(xsk->umem + address, descriptor.len, received); + consumer++; + __atomic_store_n(xsk->rx.consumer, consumer, + __ATOMIC_RELEASE); + if (recycle_frame(xsk, descriptor.addr)) { + errno = ENOBUFS; + return -1; + } + } + } + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --interface IFACE [--queue N] [--port PORT] [--count N] [--skb-mode]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "interface", required_argument, NULL, 'i' }, + { "queue", required_argument, NULL, 'q' }, + { "port", required_argument, NULL, 'p' }, + { "count", required_argument, NULL, 'n' }, + { "skb-mode", no_argument, NULL, 'S' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "i:q:p:n:Sh", long_options, + NULL)) != -1) { + switch (option) { + case 'i': options->interface = optarg; break; + case 'q': + if (parse_uint(optarg, AFXDP_MAX_QUEUES - 1, + &options->queue)) + return -1; + break; + case 'p': + if (parse_uint(optarg, 65535, &options->port) || + !options->port) + return -1; + break; + case 'n': + if (parse_uint(optarg, 1000000, &options->count)) + return -1; + break; + case 'S': options->skb_mode = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->interface ? 0 : -1; +} + +static int attach_xdp(int ifindex, int program_fd, bool skb_mode, + unsigned int *attached_flags) +{ + unsigned int flags = XDP_FLAGS_UPDATE_IF_NOEXIST | + (skb_mode ? XDP_FLAGS_SKB_MODE : XDP_FLAGS_DRV_MODE); + int err; + + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + if (err && !skb_mode && (err == -EOPNOTSUPP || err == -EINVAL)) { + flags = XDP_FLAGS_UPDATE_IF_NOEXIST | XDP_FLAGS_SKB_MODE; + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + } + if (!err) + *attached_flags = flags; + return err; +} + +int main(int argc, char **argv) +{ + struct options options = { .port = 8080 }; + struct afxdp_dump_bpf *skel = NULL; + struct xsk_state xsk; + unsigned int attached_flags = 0; + int ifindex; + int err = 1; + bool attached = false; + + setvbuf(stdout, NULL, _IONBF, 0); + memset(&xsk, 0, sizeof(xsk)); + xsk.fd = -1; + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + ifindex = if_nametoindex(options.interface); + if (!ifindex) { + fprintf(stderr, "unknown interface: %s\n", options.interface); + return 2; + } + if (open_xsk(&xsk, ifindex, options.queue)) { + fprintf(stderr, "failed to create AF_XDP socket: %s\n", + strerror(errno)); + goto cleanup; + } + skel = afxdp_dump_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->capture_port = options.port; + if (afxdp_dump_bpf__load(skel)) { + fprintf(stderr, "failed to load XDP program\n"); + goto cleanup; + } + if (bpf_map__update_elem(skel->maps.xsk_map, &options.queue, + sizeof(options.queue), &xsk.fd, sizeof(xsk.fd), + BPF_ANY)) { + fprintf(stderr, "failed to populate XSK map: %s\n", strerror(errno)); + goto cleanup; + } + { + int attach_error = attach_xdp(ifindex, + bpf_program__fd(skel->progs.redirect_udp), + options.skb_mode, &attached_flags); + + if (attach_error) { + fprintf(stderr, "failed to attach XDP program: %s\n", + strerror(-attach_error)); + goto cleanup; + } + } + attached = true; + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + printf("afxdp-dump ready interface=%s queue=%u port=%u mode=%s count=%u\n", + options.interface, options.queue, options.port, + attached_flags & XDP_FLAGS_SKB_MODE ? "skb" : "driver", + options.count); + + if (receive_packets(&xsk, options.count)) { + fprintf(stderr, "AF_XDP receive failed: %s\n", strerror(errno)); + goto cleanup; + } + printf("redirected=%llu\n", + (unsigned long long)skel->bss->redirected_packets); + err = 0; + +cleanup: + if (attached) { + LIBBPF_OPTS(bpf_xdp_attach_opts, detach_options, + .old_prog_fd = bpf_program__fd( + skel->progs.redirect_udp)); + int detach_error = bpf_xdp_detach( + ifindex, attached_flags & XDP_FLAGS_MODES, + &detach_options); + + if (detach_error) { + fprintf(stderr, "failed to detach XDP program: %s\n", + strerror(-detach_error)); + err = 1; + } + } + afxdp_dump_bpf__destroy(skel); + close_xsk(&xsk); + return err; +} +``` + +用户空间程序设置 AF_XDP 涉及几个组件: + +**UMEM**:一个 256KB 区域(64 个 4KB 帧),使用 `mmap()` 分配并通过 `XDP_UMEM_REG` 注册到内核。数据包直接接收到这些帧中。 + +**Fill ring(填充环)**:预填充所有 64 个帧地址,告诉内核将传入数据包放在哪里。 + +**RX ring(接收环)**:轮询数据包描述符。当数据包到达时,内核在此处发布包含帧地址和长度的描述符。 + +**XSKMAP**:使用 AF_XDP 套接字文件描述符更新,以启用从 XDP 的数据包重定向。 + +绑定套接字时使用 `XDP_COPY` 标志,它把报文从驱动缓冲区复制到 UMEM,而不是使用基于 DMA 的零拷贝。拷贝模式可以覆盖更多驱动,也让这篇入门 AF_XDP 教程不依赖驱动的零拷贝支持。 + +## 编译和执行 + +构建工具: + +```bash +cd src/60-afxdp-dump +make +``` + +使用选项运行: + +```bash +sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 +``` + +选项: + +- `--interface IFACE`:要捕获的网络接口 +- `--queue N`:RX 队列索引(默认 0) +- `--port PORT`:要过滤的 UDP 目标端口(默认 8080) +- `--count N`:要捕获的数据包数量(0 表示无限) +- `--skb-mode`:强制使用 SKB 模式而不是驱动模式 + +运行确定性的集成测试,它会在最初投递的 64 个 frame 之外继续验证 frame 回收,并确认不匹配的 UDP 报文仍然进入普通网络栈: + +```bash +sudo make test +``` + +测试输出如下: + +```text +afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 +packet=1 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" +packet=65 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" +redirected=65 +nonmatching-pass=verified +AF_XDP dump integration test: PASS +``` + +## 环境要求 + +| 要求 | 详情 | +|------|------| +| 内核 | Linux 5.3+(XDP 中的 XSKMAP 查找) | +| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_XDP_SOCKETS` | +| 权限 | Root | +| 接口 | 具有所选 RX 队列的网络接口,工具会回退到 generic XDP,因此 native XDP 是可选项 | + +## 重定向数据包的去向 + +重定向到 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); +} +``` + +## 总结 + +这个例子走完了 AF_XDP 接收路径,XDP 程序选择一个 UDP 流,XSKMAP 把它重定向到已经绑定的队列,用户态从 RX ring 读取描述符,再把处理过的 frame 放回 fill ring。拷贝模式和 generic XDP 自动回退让工具可以运行在没有 native zero-copy 支持的系统上。 + +> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 + +## 参考资料 + +- [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) diff --git a/src/60-afxdp-dump/afxdp_dump.bpf.c b/src/60-afxdp-dump/afxdp_dump.bpf.c new file mode 100644 index 00000000..1ad73086 --- /dev/null +++ b/src/60-afxdp-dump/afxdp_dump.bpf.c @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include "afxdp_dump.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define ETH_P_IP 0x0800 +#define IPPROTO_UDP 17 +#define IP_MF 0x2000 +#define IP_OFFSET 0x1fff + +const volatile __u16 capture_port = 8080; + +struct { + __uint(type, BPF_MAP_TYPE_XSKMAP); + __uint(max_entries, AFXDP_MAX_QUEUES); + __type(key, __u32); + __type(value, __u32); +} xsk_map SEC(".maps"); + +__u64 redirected_packets; + +SEC("xdp") +int redirect_udp(struct xdp_md *ctx) +{ + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; + struct ethhdr *ethernet = data; + struct iphdr *ip; + struct udphdr *udp; + __u32 queue = ctx->rx_queue_index; + __u32 ip_header_length; + __u32 ip_length; + __u32 udp_length; + + if ((void *)(ethernet + 1) > data_end || + ethernet->h_proto != bpf_htons(ETH_P_IP)) + return XDP_PASS; + ip = (void *)(ethernet + 1); + if ((void *)(ip + 1) > data_end || ip->version != 4 || + ip->protocol != IPPROTO_UDP || + ip->ihl < 5 || (bpf_ntohs(ip->frag_off) & (IP_MF | IP_OFFSET))) + return XDP_PASS; + ip_header_length = ip->ihl * 4; + ip_length = bpf_ntohs(ip->tot_len); + udp = (void *)ip + ip_header_length; + if (ip_length < ip_header_length + sizeof(*udp) || + (void *)ip + ip_length > data_end || + (void *)(udp + 1) > data_end) + return XDP_PASS; + udp_length = bpf_ntohs(udp->len); + if (udp_length < sizeof(*udp) || + udp_length > ip_length - ip_header_length || + udp->dest != bpf_htons(capture_port)) + return XDP_PASS; + if (!bpf_map_lookup_elem(&xsk_map, &queue)) + return XDP_PASS; + __sync_fetch_and_add(&redirected_packets, 1); + return bpf_redirect_map(&xsk_map, queue, XDP_PASS); +} diff --git a/src/60-afxdp-dump/afxdp_dump.c b/src/60-afxdp-dump/afxdp_dump.c new file mode 100644 index 00000000..5176d9f8 --- /dev/null +++ b/src/60-afxdp-dump/afxdp_dump.c @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#define _FILE_OFFSET_BITS 64 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "afxdp_dump.h" +#include "afxdp_dump.skel.h" + +#ifndef SOL_XDP +#define SOL_XDP 283 +#endif +#ifndef AF_XDP +#define AF_XDP 44 +#endif +#ifndef PF_XDP +#define PF_XDP AF_XDP +#endif + +#define FRAME_SIZE 4096 +#define FRAME_COUNT 64 +#define RING_SIZE 64 +#define UMEM_SIZE ((size_t)FRAME_SIZE * FRAME_COUNT) +#define PAYLOAD_PREVIEW 32 +#define IPV4_MORE_FRAGMENTS 0x2000 +#define IPV4_FRAGMENT_OFFSET 0x1fff + +struct options { + const char *interface; + unsigned int queue; + unsigned int port; + unsigned int count; + bool skb_mode; +}; + +struct mapped_ring { + void *mapping; + size_t mapping_size; + unsigned int *producer; + unsigned int *consumer; + unsigned int *flags; + void *descriptors; + unsigned int mask; +}; + +struct xsk_state { + int fd; + void *umem; + struct mapped_ring fill; + struct mapped_ring completion; + struct mapped_ring rx; +}; + +static volatile sig_atomic_t stop; + +static void handle_signal(int signal_number) +{ + (void)signal_number; + stop = 1; +} + +static void map_ring_fields(struct mapped_ring *ring, void *mapping, + size_t mapping_size, + const struct xdp_ring_offset *offset) +{ + ring->mapping = mapping; + ring->mapping_size = mapping_size; + ring->producer = mapping + offset->producer; + ring->consumer = mapping + offset->consumer; + ring->flags = mapping + offset->flags; + ring->descriptors = mapping + offset->desc; + ring->mask = RING_SIZE - 1; +} + +static int map_xsk_ring(struct mapped_ring *ring, int fd, + const struct xdp_ring_offset *offset, + off_t page_offset, size_t descriptor_size) +{ + size_t size = offset->desc + RING_SIZE * descriptor_size; + void *mapping; + + mapping = mmap(NULL, size, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, fd, page_offset); + if (mapping == MAP_FAILED) + return -1; + map_ring_fields(ring, mapping, size, offset); + return 0; +} + +static void close_xsk(struct xsk_state *xsk) +{ + struct mapped_ring *rings[] = { + &xsk->rx, &xsk->completion, &xsk->fill, + }; + + for (size_t i = 0; i < sizeof(rings) / sizeof(rings[0]); i++) + if (rings[i]->mapping && rings[i]->mapping != MAP_FAILED) + munmap(rings[i]->mapping, rings[i]->mapping_size); + if (xsk->fd >= 0) + close(xsk->fd); + if (xsk->umem && xsk->umem != MAP_FAILED) + munmap(xsk->umem, UMEM_SIZE); + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; +} + +static int open_xsk(struct xsk_state *xsk, int ifindex, unsigned int queue) +{ + struct xdp_umem_reg registration = { + .len = UMEM_SIZE, + .chunk_size = FRAME_SIZE, + }; + struct sockaddr_xdp address = { + .sxdp_family = PF_XDP, + .sxdp_ifindex = ifindex, + .sxdp_queue_id = queue, + .sxdp_flags = XDP_COPY, + }; + struct xdp_mmap_offsets offsets; + socklen_t offsets_size = sizeof(offsets); + unsigned int ring_size = RING_SIZE; + unsigned long long *fill_addresses; + + memset(xsk, 0, sizeof(*xsk)); + xsk->fd = -1; + xsk->umem = mmap(NULL, UMEM_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0); + if (xsk->umem == MAP_FAILED) + return -1; + xsk->fd = socket(AF_XDP, SOCK_RAW | SOCK_CLOEXEC, 0); + if (xsk->fd < 0) + return -1; + registration.addr = (uintptr_t)xsk->umem; + if (setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_REG, ®istration, + sizeof(registration)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_FILL_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_UMEM_COMPLETION_RING, &ring_size, + sizeof(ring_size)) || + setsockopt(xsk->fd, SOL_XDP, XDP_RX_RING, &ring_size, + sizeof(ring_size)) || + getsockopt(xsk->fd, SOL_XDP, XDP_MMAP_OFFSETS, &offsets, + &offsets_size)) + return -1; + + if (map_xsk_ring(&xsk->fill, xsk->fd, &offsets.fr, + XDP_UMEM_PGOFF_FILL_RING, sizeof(unsigned long long)) || + map_xsk_ring(&xsk->completion, xsk->fd, &offsets.cr, + XDP_UMEM_PGOFF_COMPLETION_RING, + sizeof(unsigned long long)) || + map_xsk_ring(&xsk->rx, xsk->fd, &offsets.rx, + XDP_PGOFF_RX_RING, sizeof(struct xdp_desc)) || + bind(xsk->fd, (struct sockaddr *)&address, sizeof(address))) + return -1; + + fill_addresses = xsk->fill.descriptors; + for (unsigned int i = 0; i < FRAME_COUNT; i++) + fill_addresses[i & xsk->fill.mask] = + (unsigned long long)i * FRAME_SIZE; + __atomic_store_n(xsk->fill.producer, FRAME_COUNT, __ATOMIC_RELEASE); + return 0; +} + +static unsigned long long data_address(unsigned long long address) +{ + return (address & XSK_UNALIGNED_BUF_ADDR_MASK) + + (address >> XSK_UNALIGNED_BUF_OFFSET_SHIFT); +} + +static int recycle_frame(struct xsk_state *xsk, unsigned long long address) +{ + unsigned long long *fill_addresses = xsk->fill.descriptors; + unsigned int producer, consumer; + + producer = __atomic_load_n(xsk->fill.producer, __ATOMIC_RELAXED); + consumer = __atomic_load_n(xsk->fill.consumer, __ATOMIC_ACQUIRE); + if (producer - consumer == RING_SIZE) + return -1; + fill_addresses[producer & xsk->fill.mask] = address; + __atomic_store_n(xsk->fill.producer, producer + 1, __ATOMIC_RELEASE); + return 0; +} + +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; + + if (length < sizeof(*ethernet) + sizeof(*ip) || + ethernet->h_proto != htons(ETH_P_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)) + 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++) + preview[i] = isprint(payload[i]) ? payload[i] : '.'; + preview[preview_length] = '\0'; + inet_ntop(AF_INET, &ip->saddr, source, sizeof(source)); + inet_ntop(AF_INET, &ip->daddr, destination, sizeof(destination)); + printf("packet=%u %s:%u -> %s:%u bytes=%u payload=\"%s\"\n", + packet_number, source, ntohs(udp->source), destination, + ntohs(udp->dest), length, preview); +} + +static int receive_packets(struct xsk_state *xsk, unsigned int count) +{ + struct pollfd poll_fd = { .fd = xsk->fd, .events = POLLIN }; + unsigned int received = 0; + + while (!stop && (!count || received < count)) { + unsigned int consumer, producer; + int poll_result = poll(&poll_fd, 1, 250); + + if (poll_result < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (!poll_result) + continue; + consumer = __atomic_load_n(xsk->rx.consumer, __ATOMIC_RELAXED); + producer = __atomic_load_n(xsk->rx.producer, __ATOMIC_ACQUIRE); + while (consumer != producer && (!count || received < count)) { + struct xdp_desc *descriptors = xsk->rx.descriptors; + struct xdp_desc descriptor = + descriptors[consumer & xsk->rx.mask]; + unsigned long long address = data_address(descriptor.addr); + + if (descriptor.options & XDP_PKT_CONTD) { + errno = EMSGSIZE; + return -1; + } + if (address + descriptor.len > UMEM_SIZE) { + errno = EFAULT; + return -1; + } + received++; + dump_packet(xsk->umem + address, descriptor.len, received); + consumer++; + __atomic_store_n(xsk->rx.consumer, consumer, + __ATOMIC_RELEASE); + if (recycle_frame(xsk, descriptor.addr)) { + errno = ENOBUFS; + return -1; + } + } + } + return 0; +} + +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 > maximum) + return -1; + *value = parsed; + return 0; +} + +static void usage(const char *program) +{ + printf("Usage: %s --interface IFACE [--queue N] [--port PORT] [--count N] [--skb-mode]\n", + program); +} + +static int parse_options(int argc, char **argv, struct options *options) +{ + static const struct option long_options[] = { + { "interface", required_argument, NULL, 'i' }, + { "queue", required_argument, NULL, 'q' }, + { "port", required_argument, NULL, 'p' }, + { "count", required_argument, NULL, 'n' }, + { "skb-mode", no_argument, NULL, 'S' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "i:q:p:n:Sh", long_options, + NULL)) != -1) { + switch (option) { + case 'i': options->interface = optarg; break; + case 'q': + if (parse_uint(optarg, AFXDP_MAX_QUEUES - 1, + &options->queue)) + return -1; + break; + case 'p': + if (parse_uint(optarg, 65535, &options->port) || + !options->port) + return -1; + break; + case 'n': + if (parse_uint(optarg, 1000000, &options->count)) + return -1; + break; + case 'S': options->skb_mode = true; break; + case 'h': usage(argv[0]); exit(0); + default: return -1; + } + } + return optind == argc && options->interface ? 0 : -1; +} + +static int attach_xdp(int ifindex, int program_fd, bool skb_mode, + unsigned int *attached_flags) +{ + unsigned int flags = XDP_FLAGS_UPDATE_IF_NOEXIST | + (skb_mode ? XDP_FLAGS_SKB_MODE : XDP_FLAGS_DRV_MODE); + int err; + + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + if (err && !skb_mode && (err == -EOPNOTSUPP || err == -EINVAL)) { + flags = XDP_FLAGS_UPDATE_IF_NOEXIST | XDP_FLAGS_SKB_MODE; + err = bpf_xdp_attach(ifindex, program_fd, flags, NULL); + } + if (!err) + *attached_flags = flags; + return err; +} + +int main(int argc, char **argv) +{ + struct options options = { .port = 8080 }; + struct afxdp_dump_bpf *skel = NULL; + struct xsk_state xsk; + unsigned int attached_flags = 0; + int ifindex; + int err = 1; + bool attached = false; + + setvbuf(stdout, NULL, _IONBF, 0); + memset(&xsk, 0, sizeof(xsk)); + xsk.fd = -1; + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + ifindex = if_nametoindex(options.interface); + if (!ifindex) { + fprintf(stderr, "unknown interface: %s\n", options.interface); + return 2; + } + if (open_xsk(&xsk, ifindex, options.queue)) { + fprintf(stderr, "failed to create AF_XDP socket: %s\n", + strerror(errno)); + goto cleanup; + } + skel = afxdp_dump_bpf__open(); + if (!skel) + goto cleanup; + skel->rodata->capture_port = options.port; + if (afxdp_dump_bpf__load(skel)) { + fprintf(stderr, "failed to load XDP program\n"); + goto cleanup; + } + if (bpf_map__update_elem(skel->maps.xsk_map, &options.queue, + sizeof(options.queue), &xsk.fd, sizeof(xsk.fd), + BPF_ANY)) { + fprintf(stderr, "failed to populate XSK map: %s\n", strerror(errno)); + goto cleanup; + } + { + int attach_error = attach_xdp(ifindex, + bpf_program__fd(skel->progs.redirect_udp), + options.skb_mode, &attached_flags); + + if (attach_error) { + fprintf(stderr, "failed to attach XDP program: %s\n", + strerror(-attach_error)); + goto cleanup; + } + } + attached = true; + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + printf("afxdp-dump ready interface=%s queue=%u port=%u mode=%s count=%u\n", + options.interface, options.queue, options.port, + attached_flags & XDP_FLAGS_SKB_MODE ? "skb" : "driver", + options.count); + + if (receive_packets(&xsk, options.count)) { + fprintf(stderr, "AF_XDP receive failed: %s\n", strerror(errno)); + goto cleanup; + } + printf("redirected=%llu\n", + (unsigned long long)skel->bss->redirected_packets); + err = 0; + +cleanup: + if (attached) { + LIBBPF_OPTS(bpf_xdp_attach_opts, detach_options, + .old_prog_fd = bpf_program__fd( + skel->progs.redirect_udp)); + int detach_error = bpf_xdp_detach( + ifindex, attached_flags & XDP_FLAGS_MODES, + &detach_options); + + if (detach_error) { + fprintf(stderr, "failed to detach XDP program: %s\n", + strerror(-detach_error)); + err = 1; + } + } + afxdp_dump_bpf__destroy(skel); + close_xsk(&xsk); + return err; +} diff --git a/src/60-afxdp-dump/afxdp_dump.h b/src/60-afxdp-dump/afxdp_dump.h new file mode 100644 index 00000000..d5a20a6a --- /dev/null +++ b/src/60-afxdp-dump/afxdp_dump.h @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __AFXDP_DUMP_H +#define __AFXDP_DUMP_H + +#define AFXDP_MAX_QUEUES 64 + +#endif /* __AFXDP_DUMP_H */ diff --git a/src/60-afxdp-dump/tests/test_afxdp_dump.py b/src/60-afxdp-dump/tests/test_afxdp_dump.py new file mode 100644 index 00000000..a9c8f98b --- /dev/null +++ b/src/60-afxdp-dump/tests/test_afxdp_dump.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +import os +import pathlib +import select +import socket +import subprocess +import sys + + +SUFFIX = os.getpid() % 10000 +RX = f"axdp{SUFFIX}r" +TX = f"axdp{SUFFIX}t" +NETNS = f"afxdp-tx-ns-{os.getpid()}" + + +def run(*args, check=True): + return subprocess.run(args, text=True, capture_output=True, check=check) + + +def cleanup(): + run("ip", "link", "del", RX, check=False) + run("ip", "netns", "del", NETNS, check=False) + + +def setup(): + cleanup() + run("ip", "netns", "add", NETNS) + run("ip", "link", "add", RX, "type", "veth", "peer", "name", TX) + run("ip", "link", "set", TX, "netns", NETNS) + run("ip", "addr", "add", "10.77.0.2/24", "dev", RX) + run("ip", "link", "set", RX, "up") + run( + "ip", "netns", "exec", NETNS, + "ip", "addr", "add", "10.77.0.1/24", "dev", TX, + ) + run("ip", "netns", "exec", NETNS, "ip", "link", "set", TX, "up") + run("ip", "netns", "exec", NETNS, "ip", "link", "set", "lo", "up") + + +def send_packets(count): + code = ( + "import socket,time; " + "s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); " + f"[(s.sendto(b'hello-afxdp',('10.77.0.2',8080)),time.sleep(0.005)) " + f"for _ in range({count})]; s.close()" + ) + run("ip", "netns", "exec", NETNS, "python3", "-c", code) + + +def verify_nonmatching_passes(): + receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + receiver.bind(("10.77.0.2", 8081)) + receiver.settimeout(3) + code = ( + "import socket; " + "s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); " + "s.sendto(b'pass-afxdp',('10.77.0.2',8081)); s.close()" + ) + run("ip", "netns", "exec", NETNS, "python3", "-c", code) + payload, _ = receiver.recvfrom(64) + assert payload == b"pass-afxdp", payload + finally: + receiver.close() + + +def main(): + binary = str(pathlib.Path(sys.argv[1]).resolve()) + process = None + try: + setup() + command = [ + binary, + "--interface", RX, + "--queue", "0", + "--port", "8080", + "--count", "65", + ] + sys.argv[2:] + process = subprocess.Popen( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + readable, _, _ = select.select([process.stdout], [], [], 5) + if not readable: + process.kill() + stdout, stderr = process.communicate(timeout=5) + raise AssertionError("timed out waiting for ready line\n" + stdout + stderr) + ready = process.stdout.readline() + if "afxdp-dump ready" not in ready: + stdout, stderr = process.communicate(timeout=5) + raise AssertionError(ready + stdout + stderr) + verify_nonmatching_passes() + send_packets(65) + stdout, stderr = process.communicate(timeout=20) + output = ready + stdout + stderr + if process.returncode: + raise AssertionError(output) + assert "10.77.0.1:" in output and "-> 10.77.0.2:8080" in output, output + assert 'payload="hello-afxdp"' in output, output + assert "packet=65 " in output and "redirected=65" in output, output + lines = output.splitlines() + print(ready, end="") + print(next(line for line in lines if line.startswith("packet=1 "))) + print(next(line for line in lines if line.startswith("packet=65 "))) + print(next(line for line in lines if line == "redirected=65")) + print("nonmatching-pass=verified") + print("AF_XDP dump integration test: PASS") + finally: + if process and process.poll() is None: + process.kill() + process.wait() + cleanup() + + +if __name__ == "__main__": + main() From 9e37387803a05c729c64072eec8f1d20c0f013d5 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 01:35:47 -0700 Subject: [PATCH 03/13] feat: deepen advanced eBPF tutorial tools --- .github/workflows/test-libbpf.yml | 16 + src/55-dns-egress/.config | 10 + src/55-dns-egress/dns_egress.bpf.c | 166 +++-- src/55-dns-egress/dns_egress.c | 348 ++++++---- src/55-dns-egress/tests/test_dns_egress.py | 2 +- src/56-slow-syscall-index/.gitignore | 2 - src/56-slow-syscall-index/README.md | 604 ----------------- src/56-slow-syscall-index/README.zh.md | 604 ----------------- .../slow_syscall_index.bpf.c | 88 --- .../slow_syscall_index.c | 389 ----------- .../slow_syscall_index.h | 17 - .../tests/test_slow_syscall_index.py | 25 - src/56-tc-flow-index/.config | 10 + src/56-tc-flow-index/.gitignore | 2 + .../Makefile | 4 +- src/56-tc-flow-index/bpf_experimental.h | 40 ++ src/56-tc-flow-index/tc_flow_index.bpf.c | 286 ++++++++ src/56-tc-flow-index/tc_flow_index.c | 316 +++++++++ src/56-tc-flow-index/tc_flow_index.h | 32 + .../tests/test_tc_flow_index.py | 44 ++ src/57-oom-watch/.config | 10 + src/57-oom-watch/oom_watch.bpf.c | 143 +++- src/57-oom-watch/oom_watch.c | 621 ++++++++++++++---- src/57-oom-watch/oom_watch.h | 35 +- src/57-oom-watch/tests/test_oom_watch.py | 8 +- src/60-afxdp-dump/.config | 10 + src/60-afxdp-dump/afxdp_dump.c | 72 +- 27 files changed, 1777 insertions(+), 2127 deletions(-) create mode 100644 src/55-dns-egress/.config delete mode 100644 src/56-slow-syscall-index/.gitignore delete mode 100644 src/56-slow-syscall-index/README.md delete mode 100644 src/56-slow-syscall-index/README.zh.md delete mode 100644 src/56-slow-syscall-index/slow_syscall_index.bpf.c delete mode 100644 src/56-slow-syscall-index/slow_syscall_index.c delete mode 100644 src/56-slow-syscall-index/slow_syscall_index.h delete mode 100644 src/56-slow-syscall-index/tests/test_slow_syscall_index.py create mode 100644 src/56-tc-flow-index/.config create mode 100644 src/56-tc-flow-index/.gitignore rename src/{56-slow-syscall-index => 56-tc-flow-index}/Makefile (97%) create mode 100644 src/56-tc-flow-index/bpf_experimental.h create mode 100644 src/56-tc-flow-index/tc_flow_index.bpf.c create mode 100644 src/56-tc-flow-index/tc_flow_index.c create mode 100644 src/56-tc-flow-index/tc_flow_index.h create mode 100644 src/56-tc-flow-index/tests/test_tc_flow_index.py create mode 100644 src/57-oom-watch/.config create mode 100644 src/60-afxdp-dump/.config diff --git a/.github/workflows/test-libbpf.yml b/.github/workflows/test-libbpf.yml index 2c77e5c9..b1eaae25 100644 --- a/.github/workflows/test-libbpf.yml +++ b/.github/workflows/test-libbpf.yml @@ -197,6 +197,22 @@ jobs: run: | make -C src/54-exec-image-inspector + - name: test 55 DNS-derived allowlist + run: | + make -C src/55-dns-egress + + - name: test 56 TC flow index + run: | + make -C src/56-tc-flow-index + + - name: test 57 OOM reclaim profiler + run: | + make -C src/57-oom-watch + + - name: test 60 AF_XDP dump + run: | + make -C src/60-afxdp-dump + - name: test features bpf_token run: | make -C src/features/bpf_token diff --git a/src/55-dns-egress/.config b/src/55-dns-egress/.config new file mode 100644 index 00000000..159521c6 --- /dev/null +++ b/src/55-dns-egress/.config @@ -0,0 +1,10 @@ +level=Depth +type=Security +kernel_min=5.8 +kernel_min_basis=required-feature +architectures=x86_64 +btf=required +kernel_config=CONFIG_BPF=y,CONFIG_BPF_SYSCALL=y,CONFIG_BPF_JIT=y,CONFIG_CGROUP_BPF=y,CONFIG_DEBUG_INFO_BTF=y,CONFIG_INET=y +hardware=none +root=required +test_status=ci-build diff --git a/src/55-dns-egress/dns_egress.bpf.c b/src/55-dns-egress/dns_egress.bpf.c index b10bca58..c2baeedd 100644 --- a/src/55-dns-egress/dns_egress.bpf.c +++ b/src/55-dns-egress/dns_egress.bpf.c @@ -90,114 +90,164 @@ static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) return true; } -SEC("cgroup_skb/egress") -int record_dns_query(struct __sk_buff *skb) +static __always_inline bool parse_query_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *key) { - struct dns_query_state state = { - .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, - }; - struct dns_query_key key = {}; - struct dns_question question; - struct dns_header header; 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; + 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); + 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 1; + return false; flags = bpf_ntohs(header.flags); if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) - return 1; + 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; + key->transaction_id = header.id; + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + __u32 dns_offset; - key.server_ip = ip.daddr; - key.client_ip = ip.saddr; - key.client_port = udp.source; - key.transaction_id = header.id; + 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; } -SEC("cgroup_skb/ingress") -int learn_dns_answer(struct __sk_buff *skb) +static __always_inline bool parse_response_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *query_key) { - 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; 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.saddr != dns_server_ip) - return 1; + return false; 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); + 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 1; + return false; if (bpf_ktime_get_ns() >= query->expires_ns) { - bpf_map_delete_elem(&pending_queries, &query_key); - return 1; + 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 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 1; + 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; - dns_offset += sizeof(question); - if (bpf_skb_load_bytes(skb, dns_offset, &answer, sizeof(answer)) || + return false; + *answer_offset = dns_offset + sizeof(question); + return true; +} + +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 1; - bpf_map_delete_elem(&pending_queries, &query_key); + return false; + *key = answer.address; + *ttl = bpf_ntohl(answer.ttl); + return *ttl && *ttl <= 86400; +} - key = answer.address; - ttl = bpf_ntohl(answer.ttl); - if (!ttl || ttl > 86400) +SEC("cgroup_skb/ingress") +int learn_dns_answer(struct __sk_buff *skb) +{ + struct dns_query_key query_key = {}; + struct dns_state state = {}; + __u64 ttl_ns, expires; + __u32 dns_offset, answer_offset; + __u32 key, ttl; + + 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); ttl_ns = (__u64)ttl * 1000000000ULL; expires = bpf_ktime_get_ns() + ttl_ns; state.expires_ns = expires; diff --git a/src/55-dns-egress/dns_egress.c b/src/55-dns-egress/dns_egress.c index 8bd07f20..bfe09886 100644 --- a/src/55-dns-egress/dns_egress.c +++ b/src/55-dns-egress/dns_egress.c @@ -31,6 +31,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; @@ -87,6 +96,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[] = { @@ -103,45 +148,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], @@ -341,18 +351,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) { @@ -369,7 +425,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); @@ -378,22 +434,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, @@ -401,59 +449,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); @@ -463,78 +580,21 @@ 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; } diff --git a/src/55-dns-egress/tests/test_dns_egress.py b/src/55-dns-egress/tests/test_dns_egress.py index 02874aae..34065aef 100644 --- a/src/55-dns-egress/tests/test_dns_egress.py +++ b/src/55-dns-egress/tests/test_dns_egress.py @@ -22,7 +22,7 @@ def main(): assert "event=expired" in output, output assert "demo step=expired-answer result=blocked" in output, output assert output.count("event=denied") == 4, output - print("DNS-aware egress integration test: PASS") + print("DNS-derived allowlist integration test: PASS") if __name__ == "__main__": diff --git a/src/56-slow-syscall-index/.gitignore b/src/56-slow-syscall-index/.gitignore deleted file mode 100644 index 442bb931..00000000 --- a/src/56-slow-syscall-index/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.output/ -slow_syscall_index diff --git a/src/56-slow-syscall-index/README.md b/src/56-slow-syscall-index/README.md deleted file mode 100644 index 3dfb6b03..00000000 --- a/src/56-slow-syscall-index/README.md +++ /dev/null @@ -1,604 +0,0 @@ -# eBPF Tutorial by Example: Slow Syscall Latency Index - -Which syscalls are taking the longest in your application? This tutorial builds a tool that traces syscall latency per process using BPF task local storage, aggregates the results by TGID and syscall number, and ranks them by total latency. The result shows which recorded slow syscalls account for most of the observed syscall latency. - -> Complete source code: - -## eBPF and Syscall Tracing - -eBPF lets verified programs run at Linux kernel hooks and send selected state to user space. This tutorial uses the `raw_syscalls/sys_enter` and `raw_syscalls/sys_exit` tracepoints, which fire on every syscall entry and exit system-wide. To correlate entry with exit, we use BPF task local storage, a map type introduced in Linux 5.11 that attaches storage directly to each task struct. The kernel removes that storage when the task exits, which fits per-thread state with task lifetime. The measurement is syscall enter-to-exit wall-clock latency rather than scheduler-only blocked time. This implementation requires Linux 5.11 or later. - -## How the Implementation Works - -The tool attaches to the raw syscall entry and exit tracepoints. On entry, it records the start timestamp and syscall ID in task local storage. On exit, it calculates the duration and reports events exceeding the configured threshold. User space aggregates events by TGID and syscall, tracking count, total latency, maximum latency, and error count. - -## Header File - -```c -/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ -#ifndef __SLOW_SYSCALL_INDEX_H -#define __SLOW_SYSCALL_INDEX_H - -#define SLOW_SYSCALL_COMM_LEN 16 - -struct slow_syscall_event { - unsigned long long timestamp_ns; - unsigned long long duration_ns; - long long return_value; - unsigned int tgid; - unsigned int tid; - unsigned int syscall_id; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -#endif /* __SLOW_SYSCALL_INDEX_H */ -``` - -The header defines the event structure sent to user space via ring buffer. Each event includes the timestamp, duration, return value, TGID, TID, syscall number, and process name. - -## BPF Program - -```c -// SPDX-License-Identifier: GPL-2.0 -#include "vmlinux.h" -#include -#include "slow_syscall_index.h" - -char LICENSE[] SEC("license") = "GPL"; - -const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; -const volatile __u32 target_tgid; -const volatile __u32 ignored_tgid; - -struct syscall_state { - __u64 started_ns; - __u32 syscall_id; - __u32 pad; -}; - -struct { - __uint(type, BPF_MAP_TYPE_TASK_STORAGE); - __uint(map_flags, BPF_F_NO_PREALLOC); - __type(key, int); - __type(value, struct syscall_state); -} active_syscalls SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_RINGBUF); - __uint(max_entries, 256 * 1024); -} events SEC(".maps"); - -__u64 completed_syscalls; -__u64 slow_syscalls; -__u64 dropped_events; - -SEC("tp/raw_syscalls/sys_enter") -int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) -{ - struct task_struct *task; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u32 tgid = pid_tgid >> 32; - - if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) - return 0; - task = bpf_get_current_task_btf(); - state = bpf_task_storage_get(&active_syscalls, task, 0, - BPF_LOCAL_STORAGE_GET_F_CREATE); - if (!state) - return 0; - state->started_ns = bpf_ktime_get_ns(); - state->syscall_id = ctx->id; - return 0; -} - -SEC("tp/raw_syscalls/sys_exit") -int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) -{ - struct task_struct *task = bpf_get_current_task_btf(); - struct slow_syscall_event *event; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u64 duration_ns; - - state = bpf_task_storage_get(&active_syscalls, task, 0, 0); - if (!state) - return 0; - duration_ns = bpf_ktime_get_ns() - state->started_ns; - __sync_fetch_and_add(&completed_syscalls, 1); - if (duration_ns < minimum_ns) - goto out; - - __sync_fetch_and_add(&slow_syscalls, 1); - event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); - if (!event) { - __sync_fetch_and_add(&dropped_events, 1); - goto out; - } - event->timestamp_ns = bpf_ktime_get_ns(); - event->duration_ns = duration_ns; - event->return_value = ctx->ret; - event->tgid = pid_tgid >> 32; - event->tid = (__u32)pid_tgid; - event->syscall_id = state->syscall_id; - bpf_get_current_comm(event->comm, sizeof(event->comm)); - bpf_ringbuf_submit(event, 0); -out: - bpf_task_storage_delete(&active_syscalls, task); - return 0; -} -``` - -The BPF program uses `BPF_MAP_TYPE_TASK_STORAGE` to store syscall state per task. This map type was introduced in Linux 5.11 and provides storage that is automatically cleaned up when the task exits. - -The `record_syscall_entry` function runs on syscall entry, storing the start timestamp and syscall ID. It filters by target TGID and ignores the tracer process itself. - -The `report_slow_syscall` function runs on syscall exit. It calculates the duration by subtracting the start timestamp from the current time. If the duration exceeds the threshold, it reserves space in the ring buffer, fills in the event, and submits it. The task storage is deleted after each syscall to keep memory usage bounded. - -The program tracks three global counters: `completed_syscalls` counts all traced syscalls, `slow_syscalls` counts those exceeding the threshold, and `dropped_events` counts ring buffer allocation failures. - -## User Space 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 "slow_syscall_index.h" -#include "slow_syscall_index.skel.h" - -#define MAX_STATS 512 - -struct options { - unsigned int pid; - unsigned int minimum_ms; - unsigned int duration_seconds; - unsigned int top; - bool demo; -}; - -struct syscall_stats { - unsigned int tgid; - unsigned int syscall_id; - unsigned long long count; - unsigned long long total_ns; - unsigned long long maximum_ns; - unsigned long long errors; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -static struct syscall_stats stats[MAX_STATS]; -static size_t stats_count; -static volatile sig_atomic_t stop; -static unsigned int received_events; -static unsigned int unaggregated_events; - -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 const char *syscall_name(unsigned int id) -{ - switch (id) { -#ifdef __NR_read - case __NR_read: return "read"; -#endif -#ifdef __NR_write - case __NR_write: return "write"; -#endif -#ifdef __NR_openat - case __NR_openat: return "openat"; -#endif -#ifdef __NR_close - case __NR_close: return "close"; -#endif -#ifdef __NR_fsync - case __NR_fsync: return "fsync"; -#endif -#ifdef __NR_fdatasync - case __NR_fdatasync: return "fdatasync"; -#endif -#ifdef __NR_poll - case __NR_poll: return "poll"; -#endif -#ifdef __NR_ppoll - case __NR_ppoll: return "ppoll"; -#endif -#ifdef __NR_epoll_wait - case __NR_epoll_wait: return "epoll_wait"; -#endif -#ifdef __NR_epoll_pwait - case __NR_epoll_pwait: return "epoll_pwait"; -#endif -#ifdef __NR_futex - case __NR_futex: return "futex"; -#endif -#ifdef __NR_nanosleep - case __NR_nanosleep: return "nanosleep"; -#endif -#ifdef __NR_clock_nanosleep - case __NR_clock_nanosleep: return "clock_nanosleep"; -#endif -#ifdef __NR_connect - case __NR_connect: return "connect"; -#endif -#ifdef __NR_accept - case __NR_accept: return "accept"; -#endif -#ifdef __NR_accept4 - case __NR_accept4: return "accept4"; -#endif -#ifdef __NR_recvfrom - case __NR_recvfrom: return "recvfrom"; -#endif -#ifdef __NR_recvmsg - case __NR_recvmsg: return "recvmsg"; -#endif -#ifdef __NR_sendto - case __NR_sendto: return "sendto"; -#endif -#ifdef __NR_sendmsg - case __NR_sendmsg: return "sendmsg"; -#endif - default: return "unknown"; - } -} - -static struct syscall_stats *get_stats(const struct slow_syscall_event *event) -{ - struct syscall_stats *entry; - - for (size_t i = 0; i < stats_count; i++) - if (stats[i].tgid == event->tgid && - stats[i].syscall_id == event->syscall_id) - return &stats[i]; - if (stats_count == MAX_STATS) - return NULL; - entry = &stats[stats_count++]; - entry->tgid = event->tgid; - entry->syscall_id = event->syscall_id; - memcpy(entry->comm, event->comm, sizeof(entry->comm)); - entry->comm[sizeof(entry->comm) - 1] = '\0'; - return entry; -} - -static int handle_event(void *ctx, void *data, size_t size) -{ - const struct slow_syscall_event *event = data; - struct syscall_stats *entry; - - (void)ctx; - if (size != sizeof(*event)) - return 0; - entry = get_stats(event); - if (entry) { - entry->count++; - entry->total_ns += event->duration_ns; - if (event->duration_ns > entry->maximum_ns) - entry->maximum_ns = event->duration_ns; - if (event->return_value < 0) - entry->errors++; - } else { - unaggregated_events++; - } - received_events++; - printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", - event->tgid, event->tid, event->comm, - syscall_name(event->syscall_id), event->syscall_id, - event->duration_ns / 1000000.0, event->return_value); - return 0; -} - -static int compare_stats(const void *left, const void *right) -{ - const struct syscall_stats *a = left; - const struct syscall_stats *b = right; - - return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; -} - -static void print_summary(unsigned int top) -{ - size_t limit; - - qsort(stats, stats_count, sizeof(stats[0]), compare_stats); - limit = stats_count < top ? stats_count : top; - printf("\nSlow syscall index, ranked by total latency:\n"); - printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", - "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); - for (size_t i = 0; i < limit; i++) { - const struct syscall_stats *entry = &stats[i]; - - printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", - entry->tgid, entry->comm, syscall_name(entry->syscall_id), - entry->syscall_id, entry->count, - entry->total_ns / 1000000.0, - entry->maximum_ns / 1000000.0, entry->errors); - } -} - -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 > maximum) - return -1; - *value = parsed; - return 0; -} - -static void usage(const char *program) -{ - printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", - program); -} - -static int parse_options(int argc, char **argv, struct options *options) -{ - static const struct option long_options[] = { - { "pid", required_argument, NULL, 'p' }, - { "min-ms", required_argument, NULL, 'm' }, - { "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, "p:m:d:t:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'p': - if (parse_uint(optarg, 0xffffffffU, &options->pid)) - return -1; - break; - case 'm': - if (parse_uint(optarg, 60000, &options->minimum_ms)) - return -1; - break; - case 'd': - if (parse_uint(optarg, 86400, &options->duration_seconds)) - return -1; - break; - case 't': - if (parse_uint(optarg, MAX_STATS, &options->top)) - return -1; - break; - case 'D': options->demo = true; break; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - return optind == argc && options->minimum_ms && options->top ? 0 : -1; -} - -static pid_t start_demo(int gate[2], int data_pipe[2]) -{ - pid_t child = fork(); - char byte; - - if (child) - return child; - close(gate[1]); - close(data_pipe[1]); - if (read(gate[0], &byte, 1) != 1) - _exit(2); - if (read(data_pipe[0], &byte, 1) != 1) - _exit(3); - _exit(0); -} - -int main(int argc, char **argv) -{ - struct options options = { .minimum_ms = 10, .top = 10 }; - struct slow_syscall_index_bpf *skel = NULL; - struct ring_buffer *ring = NULL; - unsigned long long deadline = 0; - int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; - pid_t demo_child = -1; - int demo_status = 0; - int err = 1; - - setvbuf(stdout, NULL, _IONBF, 0); - if (parse_options(argc, argv, &options)) { - usage(argv[0]); - return 2; - } - if (options.demo) { - if (options.pid) { - fprintf(stderr, "--demo and --pid cannot be combined\n"); - return 2; - } - if (pipe(gate) || pipe(data_pipe)) { - perror("pipe"); - goto cleanup; - } - demo_child = start_demo(gate, data_pipe); - if (demo_child < 0) { - perror("fork"); - goto cleanup; - } - options.pid = demo_child; - } - - skel = slow_syscall_index_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; - skel->rodata->target_tgid = options.pid; - skel->rodata->ignored_tgid = getpid(); - if (slow_syscall_index_bpf__load(skel) || - slow_syscall_index_bpf__attach(skel)) { - fprintf(stderr, "failed to load and attach slow syscall tracer\n"); - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) - goto cleanup; - - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.pid) - printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.pid, options.minimum_ms); - else - printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.minimum_ms); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * 1000000000ULL; - - if (options.demo) { - char byte = 'x'; - struct timespec delay = { - .tv_sec = options.minimum_ms / 1000, - .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, - }; - - if (delay.tv_nsec >= 1000000000L) { - delay.tv_sec++; - delay.tv_nsec -= 1000000000L; - } - close(gate[0]); gate[0] = -1; - close(data_pipe[0]); data_pipe[0] = -1; - write(gate[1], &byte, 1); - nanosleep(&delay, NULL); - write(data_pipe[1], &byte, 1); - waitpid(demo_child, &demo_status, 0); - demo_child = -1; - for (int i = 0; i < 20 && !received_events; i++) - ring_buffer__poll(ring, 50); - } else { - 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; - } - } - } - - print_summary(options.top); - printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", - (unsigned long long)skel->bss->completed_syscalls, - (unsigned long long)skel->bss->slow_syscalls, - (unsigned long long)skel->bss->dropped_events, - unaggregated_events); - if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || - !received_events)) - goto cleanup; - err = 0; - -cleanup: - if (demo_child > 0) { - kill(demo_child, SIGKILL); - waitpid(demo_child, NULL, 0); - } - for (size_t i = 0; i < 2; i++) { - if (gate[i] >= 0) close(gate[i]); - if (data_pipe[i] >= 0) close(data_pipe[i]); - } - ring_buffer__free(ring); - slow_syscall_index_bpf__destroy(skel); - return err; -} -``` - -The user space program aggregates events by TGID and syscall number. Each event updates the count, total latency, maximum latency, and error count for that combination. On exit, it sorts the results by total latency and prints the top N entries. - -The demo mode forks a child process that blocks on a pipe read. The parent delays long enough to exceed the threshold, then writes to unblock the child. This creates a guaranteed slow syscall for testing. - -## Compilation and Execution - -Build the tool: - -```bash -cd src/56-slow-syscall-index -make -``` - -Run with options: - -```bash -sudo ./slow_syscall_index --pid 1234 --min-ms 10 --duration 60 --top 20 -``` - -Or run the built-in demo: - -```bash -sudo ./slow_syscall_index --demo --min-ms 10 --top 10 -``` - -Example output: - -```text -Tracing PID 1255 syscalls slower than 10 ms. Press Ctrl-C to stop. -pid=1255 tid=1255 comm=slow_syscall_in syscall=read(0) latency_ms=30.080 return=1 - -Slow syscall index, ranked by total latency: -PID COMM SYSCALL COUNT TOTAL_MS MAX_MS ERRORS -1255 slow_syscall_in read ( 0) 1 30.080 30.080 0 -completed=1 slow=1 dropped=0 unaggregated=0 -``` - -## Requirements - -| Requirement | Details | -|-------------|---------| -| Kernel | Linux 5.11+ (BPF task local storage) | -| Config | `CONFIG_BPF_SYSCALL`, `CONFIG_DEBUG_INFO_BTF` | -| Privileges | Root | - -## Understanding the Output - -The index shows: - -- **PID**: Thread group ID (the process ID carried in the event) -- **COMM**: Process name -- **SYSCALL**: Syscall name and number -- **COUNT**: Number of slow invocations -- **TOTAL_MS**: Sum of all slow syscall durations -- **MAX_MS**: Longest single invocation -- **ERRORS**: Count of invocations returning negative values - -The `completed` counter shows all traced syscalls, `slow` shows those exceeding the threshold, and `dropped` shows ring buffer allocation failures. User space keeps 512 `(TGID, syscall)` groups; events beyond that table capacity are still received and counted as `unaggregated`. - -## Summary - -Task local storage joins each syscall exit to its matching entry without a user-managed PID map. Kernel-side thresholding keeps the event stream focused, and user-space aggregation turns the remaining completions into a ranked latency index. The result measures wall-clock syscall latency; scheduler correlation can be added when off-CPU attribution is needed. - -> If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . - -## References - -- [BPF task local storage documentation](https://docs.ebpf.io/linux/map-type/BPF_MAP_TYPE_TASK_STORAGE/) -- [BPF ring buffer documentation](https://docs.kernel.org/6.6/bpf/ringbuf.html) -- [raw_syscalls tracepoints](https://github.com/torvalds/linux/blob/master/include/trace/events/syscalls.h) -- [Task local storage commit](https://github.com/torvalds/linux/commit/4cf1bc1f1045) diff --git a/src/56-slow-syscall-index/README.zh.md b/src/56-slow-syscall-index/README.zh.md deleted file mode 100644 index b022ef76..00000000 --- a/src/56-slow-syscall-index/README.zh.md +++ /dev/null @@ -1,604 +0,0 @@ -# eBPF 实战教程:慢系统调用延迟索引 - -应用程序中哪些系统调用耗时最长?本教程构建一个工具,使用 BPF 任务本地存储跟踪每个进程的系统调用延迟,按 TGID 和系统调用号聚合结果,并按总延迟排名,最终找出哪些慢系统调用占据了最多的观测延迟。 - -> 完整源代码: - -## eBPF 与系统调用跟踪 - -eBPF 让经过验证器检查的程序运行在 Linux 内核 hook 上,并把选出的状态发送到用户态。本教程使用 `raw_syscalls/sys_enter` 和 `raw_syscalls/sys_exit` 跟踪点,它们在系统范围内每次系统调用进入和退出时触发。为了关联进入和退出,我们使用 Linux 5.11 引入的 BPF 任务本地存储,把状态直接附加到 task 结构上,并由内核在任务退出时清理。这里测量的是系统调用从进入到退出的挂钟延迟,而不是只统计调度器阻塞时间,因此本例需要 Linux 5.11 或更高版本。 - -## 实现原理 - -该工具附加到原始系统调用进入和退出跟踪点。在进入时,它在任务本地存储中记录开始时间戳和系统调用 ID。在退出时,它计算持续时间并报告超过配置阈值的事件。用户空间按 TGID 和系统调用聚合事件,跟踪计数、总延迟、最大延迟和错误计数。 - -## 头文件 - -```c -/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ -#ifndef __SLOW_SYSCALL_INDEX_H -#define __SLOW_SYSCALL_INDEX_H - -#define SLOW_SYSCALL_COMM_LEN 16 - -struct slow_syscall_event { - unsigned long long timestamp_ns; - unsigned long long duration_ns; - long long return_value; - unsigned int tgid; - unsigned int tid; - unsigned int syscall_id; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -#endif /* __SLOW_SYSCALL_INDEX_H */ -``` - -头文件定义了通过 ring buffer 发送到用户空间的事件结构。每个事件包括时间戳、持续时间、返回值、TGID、TID、系统调用号和进程名。 - -## BPF 程序 - -```c -// SPDX-License-Identifier: GPL-2.0 -#include "vmlinux.h" -#include -#include "slow_syscall_index.h" - -char LICENSE[] SEC("license") = "GPL"; - -const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; -const volatile __u32 target_tgid; -const volatile __u32 ignored_tgid; - -struct syscall_state { - __u64 started_ns; - __u32 syscall_id; - __u32 pad; -}; - -struct { - __uint(type, BPF_MAP_TYPE_TASK_STORAGE); - __uint(map_flags, BPF_F_NO_PREALLOC); - __type(key, int); - __type(value, struct syscall_state); -} active_syscalls SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_RINGBUF); - __uint(max_entries, 256 * 1024); -} events SEC(".maps"); - -__u64 completed_syscalls; -__u64 slow_syscalls; -__u64 dropped_events; - -SEC("tp/raw_syscalls/sys_enter") -int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) -{ - struct task_struct *task; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u32 tgid = pid_tgid >> 32; - - if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) - return 0; - task = bpf_get_current_task_btf(); - state = bpf_task_storage_get(&active_syscalls, task, 0, - BPF_LOCAL_STORAGE_GET_F_CREATE); - if (!state) - return 0; - state->started_ns = bpf_ktime_get_ns(); - state->syscall_id = ctx->id; - return 0; -} - -SEC("tp/raw_syscalls/sys_exit") -int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) -{ - struct task_struct *task = bpf_get_current_task_btf(); - struct slow_syscall_event *event; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u64 duration_ns; - - state = bpf_task_storage_get(&active_syscalls, task, 0, 0); - if (!state) - return 0; - duration_ns = bpf_ktime_get_ns() - state->started_ns; - __sync_fetch_and_add(&completed_syscalls, 1); - if (duration_ns < minimum_ns) - goto out; - - __sync_fetch_and_add(&slow_syscalls, 1); - event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); - if (!event) { - __sync_fetch_and_add(&dropped_events, 1); - goto out; - } - event->timestamp_ns = bpf_ktime_get_ns(); - event->duration_ns = duration_ns; - event->return_value = ctx->ret; - event->tgid = pid_tgid >> 32; - event->tid = (__u32)pid_tgid; - event->syscall_id = state->syscall_id; - bpf_get_current_comm(event->comm, sizeof(event->comm)); - bpf_ringbuf_submit(event, 0); -out: - bpf_task_storage_delete(&active_syscalls, task); - return 0; -} -``` - -BPF 程序使用 `BPF_MAP_TYPE_TASK_STORAGE` 存储每个任务的系统调用状态。这种映射类型在 Linux 5.11 中引入,提供在任务退出时自动清理的存储。 - -`record_syscall_entry` 函数在系统调用进入时运行,存储开始时间戳和系统调用 ID。它按目标 TGID 过滤并忽略跟踪器进程本身。 - -`report_slow_syscall` 函数在系统调用退出时运行。它通过从当前时间减去开始时间戳来计算持续时间。如果持续时间超过阈值,它在 ring buffer 中预留空间,填写事件并提交。每次系统调用后删除任务存储以保持内存使用有界。 - -程序跟踪三个全局计数器:`completed_syscalls` 计数所有跟踪的系统调用,`slow_syscalls` 计数超过阈值的调用,`dropped_events` 计数 ring buffer 分配失败。 - -## 用户空间程序 - -```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 "slow_syscall_index.h" -#include "slow_syscall_index.skel.h" - -#define MAX_STATS 512 - -struct options { - unsigned int pid; - unsigned int minimum_ms; - unsigned int duration_seconds; - unsigned int top; - bool demo; -}; - -struct syscall_stats { - unsigned int tgid; - unsigned int syscall_id; - unsigned long long count; - unsigned long long total_ns; - unsigned long long maximum_ns; - unsigned long long errors; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -static struct syscall_stats stats[MAX_STATS]; -static size_t stats_count; -static volatile sig_atomic_t stop; -static unsigned int received_events; -static unsigned int unaggregated_events; - -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 const char *syscall_name(unsigned int id) -{ - switch (id) { -#ifdef __NR_read - case __NR_read: return "read"; -#endif -#ifdef __NR_write - case __NR_write: return "write"; -#endif -#ifdef __NR_openat - case __NR_openat: return "openat"; -#endif -#ifdef __NR_close - case __NR_close: return "close"; -#endif -#ifdef __NR_fsync - case __NR_fsync: return "fsync"; -#endif -#ifdef __NR_fdatasync - case __NR_fdatasync: return "fdatasync"; -#endif -#ifdef __NR_poll - case __NR_poll: return "poll"; -#endif -#ifdef __NR_ppoll - case __NR_ppoll: return "ppoll"; -#endif -#ifdef __NR_epoll_wait - case __NR_epoll_wait: return "epoll_wait"; -#endif -#ifdef __NR_epoll_pwait - case __NR_epoll_pwait: return "epoll_pwait"; -#endif -#ifdef __NR_futex - case __NR_futex: return "futex"; -#endif -#ifdef __NR_nanosleep - case __NR_nanosleep: return "nanosleep"; -#endif -#ifdef __NR_clock_nanosleep - case __NR_clock_nanosleep: return "clock_nanosleep"; -#endif -#ifdef __NR_connect - case __NR_connect: return "connect"; -#endif -#ifdef __NR_accept - case __NR_accept: return "accept"; -#endif -#ifdef __NR_accept4 - case __NR_accept4: return "accept4"; -#endif -#ifdef __NR_recvfrom - case __NR_recvfrom: return "recvfrom"; -#endif -#ifdef __NR_recvmsg - case __NR_recvmsg: return "recvmsg"; -#endif -#ifdef __NR_sendto - case __NR_sendto: return "sendto"; -#endif -#ifdef __NR_sendmsg - case __NR_sendmsg: return "sendmsg"; -#endif - default: return "unknown"; - } -} - -static struct syscall_stats *get_stats(const struct slow_syscall_event *event) -{ - struct syscall_stats *entry; - - for (size_t i = 0; i < stats_count; i++) - if (stats[i].tgid == event->tgid && - stats[i].syscall_id == event->syscall_id) - return &stats[i]; - if (stats_count == MAX_STATS) - return NULL; - entry = &stats[stats_count++]; - entry->tgid = event->tgid; - entry->syscall_id = event->syscall_id; - memcpy(entry->comm, event->comm, sizeof(entry->comm)); - entry->comm[sizeof(entry->comm) - 1] = '\0'; - return entry; -} - -static int handle_event(void *ctx, void *data, size_t size) -{ - const struct slow_syscall_event *event = data; - struct syscall_stats *entry; - - (void)ctx; - if (size != sizeof(*event)) - return 0; - entry = get_stats(event); - if (entry) { - entry->count++; - entry->total_ns += event->duration_ns; - if (event->duration_ns > entry->maximum_ns) - entry->maximum_ns = event->duration_ns; - if (event->return_value < 0) - entry->errors++; - } else { - unaggregated_events++; - } - received_events++; - printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", - event->tgid, event->tid, event->comm, - syscall_name(event->syscall_id), event->syscall_id, - event->duration_ns / 1000000.0, event->return_value); - return 0; -} - -static int compare_stats(const void *left, const void *right) -{ - const struct syscall_stats *a = left; - const struct syscall_stats *b = right; - - return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; -} - -static void print_summary(unsigned int top) -{ - size_t limit; - - qsort(stats, stats_count, sizeof(stats[0]), compare_stats); - limit = stats_count < top ? stats_count : top; - printf("\nSlow syscall index, ranked by total latency:\n"); - printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", - "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); - for (size_t i = 0; i < limit; i++) { - const struct syscall_stats *entry = &stats[i]; - - printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", - entry->tgid, entry->comm, syscall_name(entry->syscall_id), - entry->syscall_id, entry->count, - entry->total_ns / 1000000.0, - entry->maximum_ns / 1000000.0, entry->errors); - } -} - -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 > maximum) - return -1; - *value = parsed; - return 0; -} - -static void usage(const char *program) -{ - printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", - program); -} - -static int parse_options(int argc, char **argv, struct options *options) -{ - static const struct option long_options[] = { - { "pid", required_argument, NULL, 'p' }, - { "min-ms", required_argument, NULL, 'm' }, - { "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, "p:m:d:t:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'p': - if (parse_uint(optarg, 0xffffffffU, &options->pid)) - return -1; - break; - case 'm': - if (parse_uint(optarg, 60000, &options->minimum_ms)) - return -1; - break; - case 'd': - if (parse_uint(optarg, 86400, &options->duration_seconds)) - return -1; - break; - case 't': - if (parse_uint(optarg, MAX_STATS, &options->top)) - return -1; - break; - case 'D': options->demo = true; break; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - return optind == argc && options->minimum_ms && options->top ? 0 : -1; -} - -static pid_t start_demo(int gate[2], int data_pipe[2]) -{ - pid_t child = fork(); - char byte; - - if (child) - return child; - close(gate[1]); - close(data_pipe[1]); - if (read(gate[0], &byte, 1) != 1) - _exit(2); - if (read(data_pipe[0], &byte, 1) != 1) - _exit(3); - _exit(0); -} - -int main(int argc, char **argv) -{ - struct options options = { .minimum_ms = 10, .top = 10 }; - struct slow_syscall_index_bpf *skel = NULL; - struct ring_buffer *ring = NULL; - unsigned long long deadline = 0; - int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; - pid_t demo_child = -1; - int demo_status = 0; - int err = 1; - - setvbuf(stdout, NULL, _IONBF, 0); - if (parse_options(argc, argv, &options)) { - usage(argv[0]); - return 2; - } - if (options.demo) { - if (options.pid) { - fprintf(stderr, "--demo and --pid cannot be combined\n"); - return 2; - } - if (pipe(gate) || pipe(data_pipe)) { - perror("pipe"); - goto cleanup; - } - demo_child = start_demo(gate, data_pipe); - if (demo_child < 0) { - perror("fork"); - goto cleanup; - } - options.pid = demo_child; - } - - skel = slow_syscall_index_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; - skel->rodata->target_tgid = options.pid; - skel->rodata->ignored_tgid = getpid(); - if (slow_syscall_index_bpf__load(skel) || - slow_syscall_index_bpf__attach(skel)) { - fprintf(stderr, "failed to load and attach slow syscall tracer\n"); - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) - goto cleanup; - - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.pid) - printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.pid, options.minimum_ms); - else - printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.minimum_ms); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * 1000000000ULL; - - if (options.demo) { - char byte = 'x'; - struct timespec delay = { - .tv_sec = options.minimum_ms / 1000, - .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, - }; - - if (delay.tv_nsec >= 1000000000L) { - delay.tv_sec++; - delay.tv_nsec -= 1000000000L; - } - close(gate[0]); gate[0] = -1; - close(data_pipe[0]); data_pipe[0] = -1; - write(gate[1], &byte, 1); - nanosleep(&delay, NULL); - write(data_pipe[1], &byte, 1); - waitpid(demo_child, &demo_status, 0); - demo_child = -1; - for (int i = 0; i < 20 && !received_events; i++) - ring_buffer__poll(ring, 50); - } else { - 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; - } - } - } - - print_summary(options.top); - printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", - (unsigned long long)skel->bss->completed_syscalls, - (unsigned long long)skel->bss->slow_syscalls, - (unsigned long long)skel->bss->dropped_events, - unaggregated_events); - if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || - !received_events)) - goto cleanup; - err = 0; - -cleanup: - if (demo_child > 0) { - kill(demo_child, SIGKILL); - waitpid(demo_child, NULL, 0); - } - for (size_t i = 0; i < 2; i++) { - if (gate[i] >= 0) close(gate[i]); - if (data_pipe[i] >= 0) close(data_pipe[i]); - } - ring_buffer__free(ring); - slow_syscall_index_bpf__destroy(skel); - return err; -} -``` - -用户空间程序按 TGID 和系统调用号聚合事件。每个事件更新该组合的计数、总延迟、最大延迟和错误计数。在退出时,它按总延迟排序结果并打印前 N 个条目。 - -演示模式 fork 一个子进程,该进程阻塞在管道读取上。父进程延迟足够长的时间以超过阈值,然后写入以解除子进程阻塞。这会创建一个有保证的慢系统调用用于测试。 - -## 编译和执行 - -构建工具: - -```bash -cd src/56-slow-syscall-index -make -``` - -使用选项运行: - -```bash -sudo ./slow_syscall_index --pid 1234 --min-ms 10 --duration 60 --top 20 -``` - -或运行内置演示: - -```bash -sudo ./slow_syscall_index --demo --min-ms 10 --top 10 -``` - -示例输出: - -```text -Tracing PID 1255 syscalls slower than 10 ms. Press Ctrl-C to stop. -pid=1255 tid=1255 comm=slow_syscall_in syscall=read(0) latency_ms=30.080 return=1 - -Slow syscall index, ranked by total latency: -PID COMM SYSCALL COUNT TOTAL_MS MAX_MS ERRORS -1255 slow_syscall_in read ( 0) 1 30.080 30.080 0 -completed=1 slow=1 dropped=0 unaggregated=0 -``` - -## 环境要求 - -| 要求 | 详情 | -|------|------| -| 内核 | Linux 5.11+(BPF 任务本地存储) | -| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_DEBUG_INFO_BTF` | -| 权限 | Root | - -## 理解输出 - -索引显示: - -- **PID**:线程组 ID,也就是事件中携带的进程 ID -- **COMM**:进程名 -- **SYSCALL**:系统调用名和号 -- **COUNT**:慢调用次数 -- **TOTAL_MS**:所有慢系统调用持续时间之和 -- **MAX_MS**:单次最长调用 -- **ERRORS**:返回负值的调用次数 - -`completed` 计数器显示所有跟踪的系统调用,`slow` 显示超过阈值的调用,`dropped` 显示 ring buffer 分配失败。用户态最多保存 512 个 `(TGID, syscall)` 分组,超过表容量的事件仍会被接收,并计入 `unaggregated`。 - -## 总结 - -任务本地存储把每次系统调用退出与对应的入口关联起来,无需在用户态维护 PID 映射。内核中的阈值过滤让事件流保持集中,用户态聚合再把慢调用整理成延迟排名。这里得到的是系统调用的挂钟延迟,如果还需要区分 off-CPU 时间,可以继续关联调度器事件。 - -> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 - -## 参考资料 - -- [BPF 任务本地存储文档](https://docs.ebpf.io/linux/map-type/BPF_MAP_TYPE_TASK_STORAGE/) -- [BPF ring buffer 文档](https://docs.kernel.org/6.6/bpf/ringbuf.html) -- [raw_syscalls 跟踪点](https://github.com/torvalds/linux/blob/master/include/trace/events/syscalls.h) -- [任务本地存储 commit](https://github.com/torvalds/linux/commit/4cf1bc1f1045) diff --git a/src/56-slow-syscall-index/slow_syscall_index.bpf.c b/src/56-slow-syscall-index/slow_syscall_index.bpf.c deleted file mode 100644 index 5e06a8e1..00000000 --- a/src/56-slow-syscall-index/slow_syscall_index.bpf.c +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0 -#include "vmlinux.h" -#include -#include "slow_syscall_index.h" - -char LICENSE[] SEC("license") = "GPL"; - -const volatile __u64 minimum_ns = 10 * 1000 * 1000ULL; -const volatile __u32 target_tgid; -const volatile __u32 ignored_tgid; - -struct syscall_state { - __u64 started_ns; - __u32 syscall_id; - __u32 pad; -}; - -struct { - __uint(type, BPF_MAP_TYPE_TASK_STORAGE); - __uint(map_flags, BPF_F_NO_PREALLOC); - __type(key, int); - __type(value, struct syscall_state); -} active_syscalls SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_RINGBUF); - __uint(max_entries, 256 * 1024); -} events SEC(".maps"); - -__u64 completed_syscalls; -__u64 slow_syscalls; -__u64 dropped_events; - -SEC("tp/raw_syscalls/sys_enter") -int record_syscall_entry(struct trace_event_raw_sys_enter *ctx) -{ - struct task_struct *task; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u32 tgid = pid_tgid >> 32; - - if (tgid == ignored_tgid || (target_tgid && tgid != target_tgid)) - return 0; - task = bpf_get_current_task_btf(); - state = bpf_task_storage_get(&active_syscalls, task, 0, - BPF_LOCAL_STORAGE_GET_F_CREATE); - if (!state) - return 0; - state->started_ns = bpf_ktime_get_ns(); - state->syscall_id = ctx->id; - return 0; -} - -SEC("tp/raw_syscalls/sys_exit") -int report_slow_syscall(struct trace_event_raw_sys_exit *ctx) -{ - struct task_struct *task = bpf_get_current_task_btf(); - struct slow_syscall_event *event; - struct syscall_state *state; - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u64 duration_ns; - - state = bpf_task_storage_get(&active_syscalls, task, 0, 0); - if (!state) - return 0; - duration_ns = bpf_ktime_get_ns() - state->started_ns; - __sync_fetch_and_add(&completed_syscalls, 1); - if (duration_ns < minimum_ns) - goto out; - - __sync_fetch_and_add(&slow_syscalls, 1); - event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); - if (!event) { - __sync_fetch_and_add(&dropped_events, 1); - goto out; - } - event->timestamp_ns = bpf_ktime_get_ns(); - event->duration_ns = duration_ns; - event->return_value = ctx->ret; - event->tgid = pid_tgid >> 32; - event->tid = (__u32)pid_tgid; - event->syscall_id = state->syscall_id; - bpf_get_current_comm(event->comm, sizeof(event->comm)); - bpf_ringbuf_submit(event, 0); -out: - bpf_task_storage_delete(&active_syscalls, task); - return 0; -} diff --git a/src/56-slow-syscall-index/slow_syscall_index.c b/src/56-slow-syscall-index/slow_syscall_index.c deleted file mode 100644 index e8b5cbc0..00000000 --- a/src/56-slow-syscall-index/slow_syscall_index.c +++ /dev/null @@ -1,389 +0,0 @@ -// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "slow_syscall_index.h" -#include "slow_syscall_index.skel.h" - -#define MAX_STATS 512 - -struct options { - unsigned int pid; - unsigned int minimum_ms; - unsigned int duration_seconds; - unsigned int top; - bool demo; -}; - -struct syscall_stats { - unsigned int tgid; - unsigned int syscall_id; - unsigned long long count; - unsigned long long total_ns; - unsigned long long maximum_ns; - unsigned long long errors; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -static struct syscall_stats stats[MAX_STATS]; -static size_t stats_count; -static volatile sig_atomic_t stop; -static unsigned int received_events; -static unsigned int unaggregated_events; - -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 const char *syscall_name(unsigned int id) -{ - switch (id) { -#ifdef __NR_read - case __NR_read: return "read"; -#endif -#ifdef __NR_write - case __NR_write: return "write"; -#endif -#ifdef __NR_openat - case __NR_openat: return "openat"; -#endif -#ifdef __NR_close - case __NR_close: return "close"; -#endif -#ifdef __NR_fsync - case __NR_fsync: return "fsync"; -#endif -#ifdef __NR_fdatasync - case __NR_fdatasync: return "fdatasync"; -#endif -#ifdef __NR_poll - case __NR_poll: return "poll"; -#endif -#ifdef __NR_ppoll - case __NR_ppoll: return "ppoll"; -#endif -#ifdef __NR_epoll_wait - case __NR_epoll_wait: return "epoll_wait"; -#endif -#ifdef __NR_epoll_pwait - case __NR_epoll_pwait: return "epoll_pwait"; -#endif -#ifdef __NR_futex - case __NR_futex: return "futex"; -#endif -#ifdef __NR_nanosleep - case __NR_nanosleep: return "nanosleep"; -#endif -#ifdef __NR_clock_nanosleep - case __NR_clock_nanosleep: return "clock_nanosleep"; -#endif -#ifdef __NR_connect - case __NR_connect: return "connect"; -#endif -#ifdef __NR_accept - case __NR_accept: return "accept"; -#endif -#ifdef __NR_accept4 - case __NR_accept4: return "accept4"; -#endif -#ifdef __NR_recvfrom - case __NR_recvfrom: return "recvfrom"; -#endif -#ifdef __NR_recvmsg - case __NR_recvmsg: return "recvmsg"; -#endif -#ifdef __NR_sendto - case __NR_sendto: return "sendto"; -#endif -#ifdef __NR_sendmsg - case __NR_sendmsg: return "sendmsg"; -#endif - default: return "unknown"; - } -} - -static struct syscall_stats *get_stats(const struct slow_syscall_event *event) -{ - struct syscall_stats *entry; - - for (size_t i = 0; i < stats_count; i++) - if (stats[i].tgid == event->tgid && - stats[i].syscall_id == event->syscall_id) - return &stats[i]; - if (stats_count == MAX_STATS) - return NULL; - entry = &stats[stats_count++]; - entry->tgid = event->tgid; - entry->syscall_id = event->syscall_id; - memcpy(entry->comm, event->comm, sizeof(entry->comm)); - entry->comm[sizeof(entry->comm) - 1] = '\0'; - return entry; -} - -static int handle_event(void *ctx, void *data, size_t size) -{ - const struct slow_syscall_event *event = data; - struct syscall_stats *entry; - - (void)ctx; - if (size != sizeof(*event)) - return 0; - entry = get_stats(event); - if (entry) { - entry->count++; - entry->total_ns += event->duration_ns; - if (event->duration_ns > entry->maximum_ns) - entry->maximum_ns = event->duration_ns; - if (event->return_value < 0) - entry->errors++; - } else { - unaggregated_events++; - } - received_events++; - printf("pid=%u tid=%u comm=%s syscall=%s(%u) latency_ms=%.3f return=%lld\n", - event->tgid, event->tid, event->comm, - syscall_name(event->syscall_id), event->syscall_id, - event->duration_ns / 1000000.0, event->return_value); - return 0; -} - -static int compare_stats(const void *left, const void *right) -{ - const struct syscall_stats *a = left; - const struct syscall_stats *b = right; - - return a->total_ns < b->total_ns ? 1 : a->total_ns > b->total_ns ? -1 : 0; -} - -static void print_summary(unsigned int top) -{ - size_t limit; - - qsort(stats, stats_count, sizeof(stats[0]), compare_stats); - limit = stats_count < top ? stats_count : top; - printf("\nSlow syscall index, ranked by total latency:\n"); - printf("%-7s %-16s %-18s %8s %12s %12s %8s\n", - "PID", "COMM", "SYSCALL", "COUNT", "TOTAL_MS", "MAX_MS", "ERRORS"); - for (size_t i = 0; i < limit; i++) { - const struct syscall_stats *entry = &stats[i]; - - printf("%-7u %-16s %-12s(%3u) %8llu %12.3f %12.3f %8llu\n", - entry->tgid, entry->comm, syscall_name(entry->syscall_id), - entry->syscall_id, entry->count, - entry->total_ns / 1000000.0, - entry->maximum_ns / 1000000.0, entry->errors); - } -} - -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 > maximum) - return -1; - *value = parsed; - return 0; -} - -static void usage(const char *program) -{ - printf("Usage: %s [--pid PID] [--min-ms MS] [--duration SEC] [--top N] [--demo]\n", - program); -} - -static int parse_options(int argc, char **argv, struct options *options) -{ - static const struct option long_options[] = { - { "pid", required_argument, NULL, 'p' }, - { "min-ms", required_argument, NULL, 'm' }, - { "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, "p:m:d:t:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'p': - if (parse_uint(optarg, 0xffffffffU, &options->pid)) - return -1; - break; - case 'm': - if (parse_uint(optarg, 60000, &options->minimum_ms)) - return -1; - break; - case 'd': - if (parse_uint(optarg, 86400, &options->duration_seconds)) - return -1; - break; - case 't': - if (parse_uint(optarg, MAX_STATS, &options->top)) - return -1; - break; - case 'D': options->demo = true; break; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - return optind == argc && options->minimum_ms && options->top ? 0 : -1; -} - -static pid_t start_demo(int gate[2], int data_pipe[2]) -{ - pid_t child = fork(); - char byte; - - if (child) - return child; - close(gate[1]); - close(data_pipe[1]); - if (read(gate[0], &byte, 1) != 1) - _exit(2); - if (read(data_pipe[0], &byte, 1) != 1) - _exit(3); - _exit(0); -} - -int main(int argc, char **argv) -{ - struct options options = { .minimum_ms = 10, .top = 10 }; - struct slow_syscall_index_bpf *skel = NULL; - struct ring_buffer *ring = NULL; - unsigned long long deadline = 0; - int gate[2] = { -1, -1 }, data_pipe[2] = { -1, -1 }; - pid_t demo_child = -1; - int demo_status = 0; - int err = 1; - - setvbuf(stdout, NULL, _IONBF, 0); - if (parse_options(argc, argv, &options)) { - usage(argv[0]); - return 2; - } - if (options.demo) { - if (options.pid) { - fprintf(stderr, "--demo and --pid cannot be combined\n"); - return 2; - } - if (pipe(gate) || pipe(data_pipe)) { - perror("pipe"); - goto cleanup; - } - demo_child = start_demo(gate, data_pipe); - if (demo_child < 0) { - perror("fork"); - goto cleanup; - } - options.pid = demo_child; - } - - skel = slow_syscall_index_bpf__open(); - if (!skel) - goto cleanup; - skel->rodata->minimum_ns = (unsigned long long)options.minimum_ms * 1000000ULL; - skel->rodata->target_tgid = options.pid; - skel->rodata->ignored_tgid = getpid(); - if (slow_syscall_index_bpf__load(skel) || - slow_syscall_index_bpf__attach(skel)) { - fprintf(stderr, "failed to load and attach slow syscall tracer\n"); - goto cleanup; - } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) - goto cleanup; - - signal(SIGINT, handle_signal); - signal(SIGTERM, handle_signal); - if (options.pid) - printf("Tracing PID %u syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.pid, options.minimum_ms); - else - printf("Tracing all syscalls slower than %u ms. Press Ctrl-C to stop.\n", - options.minimum_ms); - if (options.duration_seconds) - deadline = monotonic_ns() + - (unsigned long long)options.duration_seconds * 1000000000ULL; - - if (options.demo) { - char byte = 'x'; - struct timespec delay = { - .tv_sec = options.minimum_ms / 1000, - .tv_nsec = (long)(options.minimum_ms % 1000 + 20) * 1000000L, - }; - - if (delay.tv_nsec >= 1000000000L) { - delay.tv_sec++; - delay.tv_nsec -= 1000000000L; - } - close(gate[0]); gate[0] = -1; - close(data_pipe[0]); data_pipe[0] = -1; - write(gate[1], &byte, 1); - nanosleep(&delay, NULL); - write(data_pipe[1], &byte, 1); - waitpid(demo_child, &demo_status, 0); - demo_child = -1; - for (int i = 0; i < 20 && !received_events; i++) - ring_buffer__poll(ring, 50); - } else { - 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; - } - } - } - - print_summary(options.top); - printf("completed=%llu slow=%llu dropped=%llu unaggregated=%u\n", - (unsigned long long)skel->bss->completed_syscalls, - (unsigned long long)skel->bss->slow_syscalls, - (unsigned long long)skel->bss->dropped_events, - unaggregated_events); - if (options.demo && (!WIFEXITED(demo_status) || WEXITSTATUS(demo_status) || - !received_events)) - goto cleanup; - err = 0; - -cleanup: - if (demo_child > 0) { - kill(demo_child, SIGKILL); - waitpid(demo_child, NULL, 0); - } - for (size_t i = 0; i < 2; i++) { - if (gate[i] >= 0) close(gate[i]); - if (data_pipe[i] >= 0) close(data_pipe[i]); - } - ring_buffer__free(ring); - slow_syscall_index_bpf__destroy(skel); - return err; -} diff --git a/src/56-slow-syscall-index/slow_syscall_index.h b/src/56-slow-syscall-index/slow_syscall_index.h deleted file mode 100644 index f4af45ad..00000000 --- a/src/56-slow-syscall-index/slow_syscall_index.h +++ /dev/null @@ -1,17 +0,0 @@ -/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ -#ifndef __SLOW_SYSCALL_INDEX_H -#define __SLOW_SYSCALL_INDEX_H - -#define SLOW_SYSCALL_COMM_LEN 16 - -struct slow_syscall_event { - unsigned long long timestamp_ns; - unsigned long long duration_ns; - long long return_value; - unsigned int tgid; - unsigned int tid; - unsigned int syscall_id; - char comm[SLOW_SYSCALL_COMM_LEN]; -}; - -#endif /* __SLOW_SYSCALL_INDEX_H */ diff --git a/src/56-slow-syscall-index/tests/test_slow_syscall_index.py b/src/56-slow-syscall-index/tests/test_slow_syscall_index.py deleted file mode 100644 index 61d40135..00000000 --- a/src/56-slow-syscall-index/tests/test_slow_syscall_index.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import sys - - -def main(): - result = subprocess.run( - [sys.argv[1], "--demo", "--min-ms", "10"], - text=True, - capture_output=True, - check=False, - timeout=30, - ) - output = result.stdout + result.stderr - if result.returncode: - raise AssertionError(output) - assert "syscall=read(" in output, output - assert "Slow syscall index, ranked by total latency:" in output, output - assert "completed=" in output and "slow=1" in output, output - assert "unaggregated=0" in output, output - print("slow syscall index integration test: PASS") - - -if __name__ == "__main__": - main() diff --git a/src/56-tc-flow-index/.config b/src/56-tc-flow-index/.config new file mode 100644 index 00000000..940e6f6b --- /dev/null +++ b/src/56-tc-flow-index/.config @@ -0,0 +1,10 @@ +level=Depth +type=Networking +kernel_min=6.16 +kernel_min_basis=required-feature +architectures=x86_64 +btf=required +kernel_config=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 +hardware=network-interface +root=required +test_status=ci-build diff --git a/src/56-tc-flow-index/.gitignore b/src/56-tc-flow-index/.gitignore new file mode 100644 index 00000000..ff08a9da --- /dev/null +++ b/src/56-tc-flow-index/.gitignore @@ -0,0 +1,2 @@ +.output/ +tc_flow_index diff --git a/src/56-slow-syscall-index/Makefile b/src/56-tc-flow-index/Makefile similarity index 97% rename from src/56-slow-syscall-index/Makefile rename to src/56-tc-flow-index/Makefile index 0f5c8c3a..7e0a404f 100644 --- a/src/56-slow-syscall-index/Makefile +++ b/src/56-tc-flow-index/Makefile @@ -17,7 +17,7 @@ VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. CFLAGS := -g -Wall -Wextra -D_GNU_SOURCE ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) -APPS = slow_syscall_index +APPS = tc_flow_index CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') @@ -53,7 +53,7 @@ test: all echo "test requires root; run inside the documented KVM guest" >&2; \ exit 1; \ fi - python3 tests/test_slow_syscall_index.py ./slow_syscall_index + python3 tests/test_tc_flow_index.py ./tc_flow_index $(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): $(call msg,MKDIR,$@) diff --git a/src/56-tc-flow-index/bpf_experimental.h b/src/56-tc-flow-index/bpf_experimental.h new file mode 100644 index 00000000..16cd8d28 --- /dev/null +++ b/src/56-tc-flow-index/bpf_experimental.h @@ -0,0 +1,40 @@ +/* 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 */ diff --git a/src/56-tc-flow-index/tc_flow_index.bpf.c b/src/56-tc-flow-index/tc_flow_index.bpf.c new file mode 100644 index 00000000..ede33f71 --- /dev/null +++ b/src/56-tc-flow-index/tc_flow_index.bpf.c @@ -0,0 +1,286 @@ +// 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; +} diff --git a/src/56-tc-flow-index/tc_flow_index.c b/src/56-tc-flow-index/tc_flow_index.c new file mode 100644 index 00000000..b64d2ccf --- /dev/null +++ b/src/56-tc-flow-index/tc_flow_index.c @@ -0,0 +1,316 @@ +// 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; +} diff --git a/src/56-tc-flow-index/tc_flow_index.h b/src/56-tc-flow-index/tc_flow_index.h new file mode 100644 index 00000000..1fc33cae --- /dev/null +++ b/src/56-tc-flow-index/tc_flow_index.h @@ -0,0 +1,32 @@ +/* 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 */ diff --git a/src/56-tc-flow-index/tests/test_tc_flow_index.py b/src/56-tc-flow-index/tests/test_tc_flow_index.py new file mode 100644 index 00000000..0de5a1a9 --- /dev/null +++ b/src/56-tc-flow-index/tests/test_tc_flow_index.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import re +import subprocess +import sys + + +def run_demo(): + result = subprocess.run( + [sys.argv[1], "--demo", "--top", "3"], + text=True, + capture_output=True, + check=False, + timeout=30, + ) + output = result.stdout + result.stderr + if result.returncode: + raise AssertionError(output) + assert "ranked in the BPF rbtree by bytes:" in output, output + assert "indexed_flows=3" in output, output + assert "allocation_failures=0" in output, output + assert "refcount_failures=0" in output, output + assert "rank_update_failures=0" in output, output + byte_counts = [int(value) for value in re.findall(r"UDP\s+\d+\s+(\d+)", output)] + assert len(byte_counts) == 3, output + assert byte_counts == sorted(byte_counts, reverse=True), output + + +def main(): + run_demo() + run_demo() + invalid = subprocess.run( + [sys.argv[1], "--interface", "no-such-interface"], + text=True, + capture_output=True, + check=False, + timeout=5, + ) + assert invalid.returncode == 2, invalid.stdout + invalid.stderr + assert "interface does not exist" in invalid.stderr, invalid.stderr + print("TC flow index integration test: PASS") + + +if __name__ == "__main__": + main() diff --git a/src/57-oom-watch/.config b/src/57-oom-watch/.config new file mode 100644 index 00000000..61fe7f78 --- /dev/null +++ b/src/57-oom-watch/.config @@ -0,0 +1,10 @@ +level=Depth +type=Tracing +kernel_min=7.1 +kernel_min_basis=required-feature +architectures=x86_64 +btf=required +kernel_config=CONFIG_BPF=y,CONFIG_BPF_SYSCALL=y,CONFIG_BPF_JIT=y,CONFIG_BPF_EVENTS=y,CONFIG_DEBUG_INFO_BTF=y,CONFIG_MEMCG=y +hardware=none +root=required +test_status=ci-build diff --git a/src/57-oom-watch/oom_watch.bpf.c b/src/57-oom-watch/oom_watch.bpf.c index fc9fefc7..d1c5d34b 100644 --- a/src/57-oom-watch/oom_watch.bpf.c +++ b/src/57-oom-watch/oom_watch.bpf.c @@ -8,16 +8,16 @@ 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 { @@ -33,6 +33,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); @@ -46,6 +67,7 @@ struct { } events SEC(".maps"); __u64 dropped_victim_states; +__u64 dropped_reclaim_states; static __always_inline bool selected_cgroup(__u64 cgroup_id) { @@ -89,12 +111,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; @@ -103,28 +181,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; } @@ -166,13 +272,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; } diff --git a/src/57-oom-watch/oom_watch.c b/src/57-oom-watch/oom_watch.c index b5eb5122..a33d7765 100644 --- a/src/57-oom-watch/oom_watch.c +++ b/src/57-oom-watch/oom_watch.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include "oom_watch.h" #include "oom_watch.skel.h" @@ -22,6 +24,7 @@ struct options { const char *cgroup_path; unsigned int duration_seconds; + unsigned int sample_every; bool demo; }; @@ -33,6 +36,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; @@ -88,20 +136,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 " @@ -112,9 +343,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", @@ -124,14 +356,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; @@ -139,8 +372,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) @@ -148,18 +381,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; @@ -249,158 +487,253 @@ static int trigger_cross_cgroup_reclaim(const char *cgroup_path) return -1; } -int main(int argc, char **argv) +static int select_cgroup(const struct options *options, + struct selected_cgroup *selected) { - 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; - 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), + if (options->demo) { + snprintf(selected->demo_path, sizeof(selected->demo_path), "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); - if (configure_demo_cgroup(demo_cgroup_path, - &demo_cgroup_created, - &memory_enabled_by_demo)) { + 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)); - goto cleanup; + return -1; } - selected_path = demo_cgroup_path; + selected->path = selected->demo_path; } else { - selected_path = options.cgroup_path; + 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; + 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; +} - skel = oom_watch_bpf__open(); - if (!skel) +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; - 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"); + 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; + } } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + print_live_profiles(&runtime->context); + return 0; +} + +int main(int argc, char **argv) +{ + struct options options = { .sample_every = 1 }; + struct selected_cgroup selected = {}; + struct oom_runtime runtime = {}; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + 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; } diff --git a/src/57-oom-watch/oom_watch.h b/src/57-oom-watch/oom_watch.h index 2288ed0e..f2a71f98 100644 --- a/src/57-oom-watch/oom_watch.h +++ b/src/57-oom-watch/oom_watch.h @@ -2,19 +2,44 @@ #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; diff --git a/src/57-oom-watch/tests/test_oom_watch.py b/src/57-oom-watch/tests/test_oom_watch.py index 07d134f3..3f198738 100644 --- a/src/57-oom-watch/tests/test_oom_watch.py +++ b/src/57-oom-watch/tests/test_oom_watch.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 +import re import subprocess import sys -import re from pathlib import Path @@ -25,10 +25,14 @@ def main(): assert match and match.group(1) != match.group(2), output assert "reclaim_cycles=" in output, output assert "cross_cgroup_reclaims=" in output, output + assert "reclaim_profile cgroup_id=" in output, output + assert "reclaim_latency_us=" in output, output + assert "reclaim_stack rank=1" in output, output + assert "try_to_free_mem_cgroup_pages" in output, output assert "event=victim-exit" in output, output assert "demo workload signaled=1 signal=9" in output, output assert "demo result=matched-profile-to-victim" in output, output - assert "dropped_victim_states=0" in output, output + assert "dropped_victim_states=0 dropped_reclaim_states=0" in output, output print("OOM watch integration test: PASS") diff --git a/src/60-afxdp-dump/.config b/src/60-afxdp-dump/.config new file mode 100644 index 00000000..ca6da83c --- /dev/null +++ b/src/60-afxdp-dump/.config @@ -0,0 +1,10 @@ +level=Depth +type=Networking +kernel_min=5.3 +kernel_min_basis=required-feature +architectures=x86_64 +btf=required +kernel_config=CONFIG_BPF=y,CONFIG_BPF_SYSCALL=y,CONFIG_BPF_JIT=y,CONFIG_DEBUG_INFO_BTF=y,CONFIG_XDP_SOCKETS=y +hardware=network-interface +root=required +test_status=ci-build diff --git a/src/60-afxdp-dump/afxdp_dump.c b/src/60-afxdp-dump/afxdp_dump.c index 5176d9f8..f4b1427c 100644 --- a/src/60-afxdp-dump/afxdp_dump.c +++ b/src/60-afxdp-dump/afxdp_dump.c @@ -199,43 +199,73 @@ static int recycle_frame(struct xsk_state *xsk, unsigned long long address) return 0; } -static void dump_packet(const void *packet, unsigned int length, - unsigned int packet_number) +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; - 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 available_ip; if (length < sizeof(*ethernet) + sizeof(*ip) || ethernet->h_proto != htons(ETH_P_IP)) - return; + return NULL; ip = packet + sizeof(*ethernet); - ip_header_length = ip->ihl * 4; + *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)) - return; - ip_length = ntohs(ip->tot_len); + *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(*udp) || - ip_length > available_ip) - return; - udp = packet + sizeof(*ethernet) + ip_header_length; - payload = (const unsigned char *)(udp + 1); + 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); + 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 iphdr *ip; + const struct udphdr *udp; + const unsigned char *payload; + char source[INET_ADDRSTRLEN], destination[INET_ADDRSTRLEN]; + char preview[PAYLOAD_PREVIEW + 1]; + unsigned int ip_header_length, ip_length, payload_length, preview_length; + + ip = parse_ipv4(packet, length, &ip_header_length, &ip_length); + if (!ip) + return; + udp = parse_udp(packet, length, ip_header_length, ip_length, &payload, + &payload_length); + if (!udp) 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++) From 2219cbc64fb886d07846ff6779245e92c137e75c Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 01:36:04 -0700 Subject: [PATCH 04/13] 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 | 598 ++++++----- src/55-dns-egress/README.zh.md | 600 ++++++----- src/56-tc-flow-index/README.md | 798 +++++++++++++++ src/56-tc-flow-index/README.zh.md | 798 +++++++++++++++ src/57-oom-watch/README.md | 926 ++++++++++++----- src/57-oom-watch/README.zh.md | 928 +++++++++++++----- src/60-afxdp-dump/README.md | 184 ++-- src/60-afxdp-dump/README.zh.md | 186 ++-- src/SUMMARY.md | 4 + src/SUMMARY.zh.md | 4 + src/compatibility.md | 4 + src/compatibility.zh.md | 4 + 19 files changed, 3960 insertions(+), 1104 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 e9c3673a..e08f9555 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 48df5dfc..d9360c50 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 9c24366b..18bfcfb4 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 4504b160..89d19bd1 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 ca7a886e..a874980a 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 9edbc077..2816f4f9 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 46093362..7b0c8ca5 100644 --- a/scripts/test_generate_toc.py +++ b/scripts/test_generate_toc.py @@ -16,6 +16,7 @@ "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 3e2ad216..4298ccd3 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 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. -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. +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. -## How the Implementation Works +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. -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. +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. -## Header File +## 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. + +## The Three BPF Hooks -## BPF Program +Here is the complete kernel-side program. ```c // SPDX-License-Identifier: GPL-2.0 @@ -160,114 +168,164 @@ static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) return true; } -SEC("cgroup_skb/egress") -int record_dns_query(struct __sk_buff *skb) +static __always_inline bool parse_query_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *key) { - struct dns_query_state state = { - .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, - }; - struct dns_query_key key = {}; - struct dns_question question; - struct dns_header header; 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; + 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); + 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 1; + return false; flags = bpf_ntohs(header.flags); if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) - return 1; + 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; + key->transaction_id = header.id; + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + __u32 dns_offset; - key.server_ip = ip.daddr; - key.client_ip = ip.saddr; - key.client_port = udp.source; - key.transaction_id = header.id; + 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; } -SEC("cgroup_skb/ingress") -int learn_dns_answer(struct __sk_buff *skb) +static __always_inline bool parse_response_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *query_key) { - 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; 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.saddr != dns_server_ip) - return 1; + return false; 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); + 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 1; + return false; if (bpf_ktime_get_ns() >= query->expires_ns) { - bpf_map_delete_elem(&pending_queries, &query_key); - return 1; + 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 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 1; + 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; - dns_offset += sizeof(question); - if (bpf_skb_load_bytes(skb, dns_offset, &answer, sizeof(answer)) || + return false; + *answer_offset = dns_offset + sizeof(question); + return true; +} + +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 1; - bpf_map_delete_elem(&pending_queries, &query_key); + return false; + *key = answer.address; + *ttl = bpf_ntohl(answer.ttl); + return *ttl && *ttl <= 86400; +} - key = answer.address; - ttl = bpf_ntohl(answer.ttl); - if (!ttl || ttl > 86400) +SEC("cgroup_skb/ingress") +int learn_dns_answer(struct __sk_buff *skb) +{ + struct dns_query_key query_key = {}; + struct dns_state state = {}; + __u64 ttl_ns, expires; + __u32 dns_offset, answer_offset; + __u32 key, ttl; + + 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); 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 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 `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. +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 `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. +`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. -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. +## Loading the Policy and Exercising Its Trust Chain -## User Space Program +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 c5e15478..290eca93 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 上,通过 map 保留状态,也可以把选出的事件交给用户态。cgroup BPF 让这些 hook 跟随一组工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在内核发起 IPv4 连接之前允许或拒绝这次请求。 -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 或更高版本。 +这个例子在同一个 cgroup 上挂载三个程序。`cgroup_skb/egress` 识别指定的 DNS 问题并记录待匹配查询,`cgroup_skb/ingress` 只从与查询对应的响应中接纳地址,`cgroup/connect4` 对受保护的 TCP 端口作最终判断。ring buffer 负责报告 learned、allowed、denied 和 expired 事件,并不参与策略结果。 -## 实现原理 +先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存解析器地址、客户端地址、客户端 UDP 端口和 DNS transaction ID,这份记录有效 5 秒。响应回来后,ingress hook 用反向报文重建同一个 key,检查响应标志与 question,再读取第一个直接 A 应答。通过检查的地址会和 TTL 推导出的过期时间一起进入允许列表,随后发往这个地址和受保护端口的 `connect()` 可以在 TTL 内成功。 -该工具将三个 BPF 程序附加到 cgroup。出口程序把事务 ID、客户端端口和服务器 IP 保存为待处理查询,用于记录出站 DNS 请求,5 秒内没有匹配响应的查询会过期。入口程序验证响应与待处理查询的关联,再提取 A 记录 IP 和 TTL。对于发往受保护端口的 TCP 连接,connect hook 检查目标 IP 是否在允许列表中并且仍然有效,其他目的端口保持原有行为。 +待匹配查询就是这里的信任边界。未经请求的响应没有 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 hook -## BPF 程序 +下面是完整的内核态程序。 ```c // SPDX-License-Identifier: GPL-2.0 @@ -160,114 +168,164 @@ static __noinline bool matches_qname(struct __sk_buff *skb, __u32 offset) return true; } -SEC("cgroup_skb/egress") -int record_dns_query(struct __sk_buff *skb) +static __always_inline bool parse_query_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *key) { - struct dns_query_state state = { - .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, - }; - struct dns_query_key key = {}; - struct dns_question question; - struct dns_header header; 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; + 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); + 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 1; + return false; flags = bpf_ntohs(header.flags); if ((flags & 0xf800) || bpf_ntohs(header.questions) != 1) - return 1; + 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; + key->transaction_id = header.id; + return true; +} + +SEC("cgroup_skb/egress") +int record_dns_query(struct __sk_buff *skb) +{ + struct dns_query_state state = { + .expires_ns = bpf_ktime_get_ns() + DNS_QUERY_LIFETIME_NS, + }; + struct dns_query_key key = {}; + __u32 dns_offset; - key.server_ip = ip.daddr; - key.client_ip = ip.saddr; - key.client_port = udp.source; - key.transaction_id = header.id; + 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; } -SEC("cgroup_skb/ingress") -int learn_dns_answer(struct __sk_buff *skb) +static __always_inline bool parse_response_transport( + struct __sk_buff *skb, __u32 *dns_offset, struct dns_query_key *query_key) { - 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; 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.saddr != dns_server_ip) - return 1; + return false; 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); + 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 1; + return false; if (bpf_ktime_get_ns() >= query->expires_ns) { - bpf_map_delete_elem(&pending_queries, &query_key); - return 1; + 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 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 1; + 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; - dns_offset += sizeof(question); - if (bpf_skb_load_bytes(skb, dns_offset, &answer, sizeof(answer)) || + return false; + *answer_offset = dns_offset + sizeof(question); + return true; +} + +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 1; - bpf_map_delete_elem(&pending_queries, &query_key); + return false; + *key = answer.address; + *ttl = bpf_ntohl(answer.ttl); + return *ttl && *ttl <= 86400; +} - key = answer.address; - ttl = bpf_ntohl(answer.ttl); - if (!ttl || ttl > 86400) +SEC("cgroup_skb/ingress") +int learn_dns_answer(struct __sk_buff *skb) +{ + struct dns_query_key query_key = {}; + struct dns_state state = {}; + __u64 ttl_ns, expires; + __u32 dns_offset, answer_offset; + __u32 key, ttl; + + 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); 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 个时,最近活跃的查询和地址也会得到优先保留。 + +egress 路径先验证 IPv4、UDP、分片状态、解析器地址和端口,再检查报文是不是指定名称的单 question A 查询。只有这些字段全部成立,`record_dns_query()` 才会插入四字段关联 key。程序返回 `1`,DNS 报文仍然按正常路径继续传输。 -`record_dns_query` 函数在 cgroup 出口运行,捕获发往配置的解析器的 DNS 查询。它验证数据包是发往正确服务器和端口的 IPv4 UDP,检查查询名称是否匹配,并存储带有 5 秒过期时间的查询。 +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 才获得连接资格。 -`learn_dns_answer` 函数在 cgroup 入口运行,解析 DNS 响应。它查找匹配的待处理查询,验证响应格式,从第一个 A 记录提取 IP 地址和 TTL,并将 IP 存储到允许列表中,设置基于 TTL 的过期时间。 +`enforce_dns_policy()` 的范围很窄。发往其他目的端口的 TCP 连接直接通过;发往受保护端口的连接使用 `ctx->user_ip4` 查表,有效条目返回 `1`,缺失或过期条目返回 `0`,应用会收到 `EPERM`。`expired_reported` 上的 compare-and-swap 让多个线程同时访问旧地址时只产生一次 expired 通知。 -`enforce_dns_policy` 函数在进程调用 `connect()` 时运行,发往受保护端口的 IPv4 TCP 连接会查询允许列表和过期时间。命中的条目返回 1 继续连接,缺失或过期的条目返回 0,让 `connect()` 以 `EPERM` 失败,其他端口则直接返回 1。 +## 加载策略并验证完整信任链 -## 用户空间程序 +用户态程序会配置只读 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 00000000..b91ed715 --- /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 00000000..8bb59d31 --- /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 6bf35759..94a8aef1 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 + +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. + +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 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. -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. +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. -## How the Implementation Works +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. -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. +## Profile and Event Layouts -## Header File +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. + +## Profiling Reclaim and Tracking the Victim -## BPF Program +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; } -int main(int argc, char **argv) +static int select_cgroup(const struct options *options, + struct selected_cgroup *selected) { - 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; - 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), + if (options->demo) { + snprintf(selected->demo_path, sizeof(selected->demo_path), "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); - if (configure_demo_cgroup(demo_cgroup_path, - &demo_cgroup_created, - &memory_enabled_by_demo)) { + 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)); - goto cleanup; + return -1; } - selected_path = demo_cgroup_path; + selected->path = selected->demo_path; } else { - selected_path = options.cgroup_path; + 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; + 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; +} - skel = oom_watch_bpf__open(); - if (!skel) +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; - 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"); + 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; + } } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + print_live_profiles(&runtime->context); + return 0; +} + +int main(int argc, char **argv) +{ + struct options options = { .sample_every = 1 }; + struct selected_cgroup selected = {}; + struct oom_runtime runtime = {}; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + 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. -```bash -cd src/57-oom-watch -make -``` +## Build and Run -Run with cgroup filtering: +Build the profiler: ```bash -sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +cd src/57-oom-watch +make ``` -Or watch all OOM events: +Profile one service cgroup for 60 seconds and sample one kernel stack for every ten reclaim intervals: ```bash -sudo ./oom_watch --duration 60 +sudo ./oom_watch \ + --cgroup /sys/fs/cgroup/my-service \ + --duration 60 \ + --sample-every 10 ``` -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 | - -## Understanding the Output - -The victim event includes: +|---|---| +| 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 | -- **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 +## Scope -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 104c6697..73e0bc25 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 之前的故事 + +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。 + +另一项依赖来自 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。 -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 或更高版本。 +跟着一次 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 之前的回收活动、内核标记的具体线程和之后的进程生命周期连在一起。 -该工具跟踪 OOM 生命周期的两个阶段。在任何 OOM 事件之前,内核会尝试内存回收。我们统计每个内存 cgroup 的回收周期,跟踪回收了多少页面,并记录何时从不同的 cgroup 触发了回收(跨 cgroup 回收)。当 OOM killer 选择受害者时,我们捕获受害者的内存统计信息,将其与累积的回收分析关联,并跟踪受害者直到它退出。 +## 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。 + +## 分析 reclaim 并跟踪 victim -## BPF 程序 +下面是完整 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; } -int main(int argc, char **argv) +static int select_cgroup(const struct options *options, + struct selected_cgroup *selected) { - 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; - 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), + if (options->demo) { + snprintf(selected->demo_path, sizeof(selected->demo_path), "/sys/fs/cgroup/ebpf-oom-watch-%d", getpid()); - if (configure_demo_cgroup(demo_cgroup_path, - &demo_cgroup_created, - &memory_enabled_by_demo)) { + 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)); - goto cleanup; + return -1; } - selected_path = demo_cgroup_path; + selected->path = selected->demo_path; } else { - selected_path = options.cgroup_path; + 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; + 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; +} - skel = oom_watch_bpf__open(); - if (!skel) +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; - 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"); + 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; + } } - ring = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, - NULL, NULL); - if (!ring) + print_live_profiles(&runtime->context); + return 0; +} + +int main(int argc, char **argv) +{ + struct options options = { .sample_every = 1 }; + struct selected_cgroup selected = {}; + struct oom_runtime runtime = {}; + int err = 1; + + setvbuf(stdout, NULL, _IONBF, 0); + if (parse_options(argc, argv, &options)) { + usage(argv[0]); + return 2; + } + 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 状态。 -```bash -cd src/57-oom-watch -make -``` +## 编译和运行 -使用 cgroup 过滤运行: +构建 profiler: ```bash -sudo ./oom_watch --cgroup /sys/fs/cgroup/my-service +cd src/57-oom-watch +make ``` -或监视所有 OOM 事件: +分析一个 service cgroup 60 秒,每 10 次 reclaim interval 采样一次 kernel stack: ```bash -sudo ./oom_watch --duration 60 +sudo ./oom_watch \ + --cgroup /sys/fs/cgroup/my-service \ + --duration 60 \ + --sample-every 10 ``` -运行内置演示: +省略 `--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 ``` -## 环境要求 - -| 要求 | 详情 | -|------|------| -| 内核 | Linux 7.1+(vmscan 跟踪点提供目标 memcg) | -| 配置 | `CONFIG_BPF_SYSCALL`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_MEMCG` | -| 权限 | Root | -| cgroup | 启用内存控制器的 cgroup v2 | +44 个 completed interval 与 44 个 begin event 一一对应,所有 histogram count 相加也是 44,其中一半由目标 cgroup 之外触发。两条排名路径分别显示 allocation charge reclaim 和显式 `memory.reclaim`,exit event 则确认被标记的 TID 最终收到 `SIGKILL`。 -## 理解输出 +## 环境要求 -受害者事件包括: +| 要求 | 说明 | +|---|---| +| 内核 | 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 82632075..5730abd9 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 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. + +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. -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. +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. -## How the Implementation Works +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. -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. +## Shared Queue Limit -## Header File +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 void dump_packet(const void *packet, unsigned int length, - unsigned int packet_number) +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; - 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 available_ip; if (length < sizeof(*ethernet) + sizeof(*ip) || ethernet->h_proto != htons(ETH_P_IP)) - return; + return NULL; ip = packet + sizeof(*ethernet); - ip_header_length = ip->ihl * 4; + *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)) - return; - ip_length = ntohs(ip->tot_len); + *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(*udp) || - ip_length > available_ip) - return; - udp = packet + sizeof(*ethernet) + ip_header_length; - payload = (const unsigned char *)(udp + 1); + 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); + 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 iphdr *ip; + const struct udphdr *udp; + const unsigned char *payload; + char source[INET_ADDRSTRLEN], destination[INET_ADDRSTRLEN]; + char preview[PAYLOAD_PREVIEW + 1]; + unsigned int ip_header_length, ip_length, payload_length, preview_length; + + ip = parse_ipv4(packet, length, &ip_header_length, &ip_length); + if (!ip) + return; + udp = parse_udp(packet, length, ip_header_length, ip_length, &payload, + &payload_length); + if (!udp) 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: - -**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. +`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. -**Fill ring**: Pre-populated with all 64 frame addresses to tell the kernel where to place incoming packets. +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. -**RX ring**: Polled for packet descriptors. When a packet arrives, the kernel posts a descriptor here containing the frame address and length. +`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. -**XSKMAP**: Updated with the AF_XDP socket file descriptor to enable packet redirection from XDP. +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. -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. +## Build and Run -## 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: - -- `--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 +`--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. -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 28cba1b6..eeba0618 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 可以让经过验证器检查的程序运行在内核 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。 -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。 +跟着一个报文和一个 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 已经走完完整的所有权循环。 -我们的工具附加一个 XDP 程序,按目标端口过滤 UDP 数据包。匹配的数据包被重定向到 AF_XDP 套接字;其他所有数据包都传递到正常的网络栈。XDP 程序在重定向之前检查是否有 AF_XDP 套接字绑定到该队列。 +## 共享的队列上限 -## 头文件 +共享头文件定义 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 void dump_packet(const void *packet, unsigned int length, - unsigned int packet_number) +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; - 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 available_ip; if (length < sizeof(*ethernet) + sizeof(*ip) || ethernet->h_proto != htons(ETH_P_IP)) - return; + return NULL; ip = packet + sizeof(*ethernet); - ip_header_length = ip->ihl * 4; + *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)) - return; - ip_length = ntohs(ip->tot_len); + *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(*udp) || - ip_length > available_ip) - return; - udp = packet + sizeof(*ethernet) + ip_header_length; - payload = (const unsigned char *)(udp + 1); + 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); + 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 iphdr *ip; + const struct udphdr *udp; + const unsigned char *payload; + char source[INET_ADDRSTRLEN], destination[INET_ADDRSTRLEN]; + char preview[PAYLOAD_PREVIEW + 1]; + unsigned int ip_header_length, ip_length, payload_length, preview_length; + + ip = parse_ipv4(packet, length, &ip_header_length, &ip_length); + if (!ip) + return; + udp = parse_udp(packet, length, ip_header_length, ip_length, &payload, + &payload_length); + if (!udp) 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 涉及几个组件: - -**UMEM**:一个 256KB 区域(64 个 4KB 帧),使用 `mmap()` 分配并通过 `XDP_UMEM_REG` 注册到内核。数据包直接接收到这些帧中。 +`open_xsk()` 创建 256 KiB UMEM,通过 `XDP_UMEM_REG` 注册内存,再向内核申请各 64 个 fill、completion 和 RX entry。`XDP_MMAP_OFFSETS` 描述每个 producer、consumer、flags 和 descriptor array 的位置,程序据此映射页面,并在 redirect 开始前发布全部 frame 地址。 -**Fill ring(填充环)**:预填充所有 64 个帧地址,告诉内核将传入数据包放在哪里。 +ring index 由内核和用户态共享,因此内存顺序很关键。producer 需要先写 descriptor,再用 release 语义发布新 index;consumer 则先 acquire producer index,再读取 descriptor。代码在消费 RX entry 和向 fill ring 归还地址时都遵循这组规则。 -**RX ring(接收环)**:轮询数据包描述符。当数据包到达时,内核在此处发布包含帧地址和长度的描述符。 +socket bind 使用的 `XDP_COPY` 与 XDP attach mode 解决的是两个问题。copy mode 决定报文如何进入 UMEM,不依赖驱动的 zero-copy 支持;XDP 程序本身会先尝试 native driver mode,如果网卡不支持,再回退到 generic SKB mode。`--skb-mode` 可以直接选择 generic 路径。 -**XSKMAP**:使用 AF_XDP 套接字文件描述符更新,以启用从 XDP 的数据包重定向。 +接收循环要求一个 descriptor 对应一个完整报文。遇到 `XDP_PKT_CONTD` 时会返回 `EMSGSIZE`,避免把 multi-buffer packet 的第一个 fragment 当成完整报文打印。地址和长度检查还会保证 descriptor 始终落在已注册 UMEM 内。退出时 compare-and-detach 把当前 program FD 作为 `old_prog_fd`,工具只会移除自己挂载的 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 ``` -选项: - -- `--interface IFACE`:要捕获的网络接口 -- `--queue N`:RX 队列索引(默认 0) -- `--port PORT`:要过滤的 UDP 目标端口(默认 8080) -- `--count N`:要捕获的数据包数量(0 表示无限) -- `--skb-mode`:强制使用 SKB 模式而不是驱动模式 +`--count 0` 会持续运行到收到信号,`--skb-mode` 用于 generic XDP。选中的 queue 必须实际接收这条流量,在多队列网卡上,这取决于 NIC 的 receive-side steering 配置。 -运行确定性的集成测试,它会在最初投递的 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 是可选项 | - -## 重定向数据包的去向 - -重定向到 AF_XDP 的数据包是被消费的,而不是被镜像。它们不会继续进入正常的内核网络栈。在常规套接字上监听的应用程序将看不到这些数据包。不匹配的数据包(错误的端口、错误的协议)正常传递并由内核处理。 - -## 实现细节 - -这是一个基本的单缓冲区仅接收 AF_XDP 转储器: - -- **拷贝模式**:使用 `XDP_COPY`,不依赖驱动的零拷贝支持 -- **单队列**:绑定到一个 RX 队列 -- **64 帧 UMEM**:使用固定大小控制内存占用 -- **帧回收**:处理后将帧返回 fill ring -- **安全附加**:使用 `UPDATE_IF_NOEXIST` 和比较-分离清理 - -## XDP 附加模式 - -该工具支持两种 XDP 模式: +## 环境要求 -- **驱动模式**(原生):需要驱动支持 XDP -- **SKB 模式**(通用):无需驱动提供 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 | -我们首先尝试驱动模式,然后回退到 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 96c9e73e..39d8dd63 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 46490ec4..a0eee1a3 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 07f6aa09..40a321cf 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 4d6353f8..11080929 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 构建测试 | From 05147a5cf33198de0d67d67d07f1d58f8de1e1bb Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 02:10:38 -0700 Subject: [PATCH 05/13] fix: align tutorial kernel requirements --- src/55-dns-egress/.config | 2 +- src/55-dns-egress/README.md | 138 +++++++++++++++++++++------------ src/55-dns-egress/README.zh.md | 138 +++++++++++++++++++++------------ src/55-dns-egress/dns_egress.c | 133 ++++++++++++++++++++----------- src/60-afxdp-dump/.config | 2 +- src/60-afxdp-dump/README.md | 5 +- src/60-afxdp-dump/README.zh.md | 5 +- src/compatibility.md | 4 +- src/compatibility.zh.md | 4 +- 9 files changed, 276 insertions(+), 155 deletions(-) diff --git a/src/55-dns-egress/.config b/src/55-dns-egress/.config index 159521c6..f24197e5 100644 --- a/src/55-dns-egress/.config +++ b/src/55-dns-egress/.config @@ -1,6 +1,6 @@ level=Depth type=Security -kernel_min=5.8 +kernel_min=5.12 kernel_min_basis=required-feature architectures=x86_64 btf=required diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md index 4298ccd3..b45373bc 100644 --- a/src/55-dns-egress/README.md +++ b/src/55-dns-egress/README.md @@ -373,7 +373,7 @@ The egress path first validates IPv4, UDP, fragmentation state, resolver address 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. -`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. +`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`. A 64-bit compare-and-swap on `expired_reported` keeps the expiry notification to one event even when several threads race on the stale address. BPF atomic compare-and-exchange arrived in Linux 5.12, which sets this tool's minimum kernel version. ## Loading the Policy and Exercising Its Trust Chain @@ -791,70 +791,109 @@ static int expected_demo_events(void) 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) +struct demo_context { + struct sockaddr_in server_address; + struct sockaddr_in response_client_address; + unsigned char dns_message[512]; + size_t query_length; + int dns_server; + int dns_client; + int listener; +}; + +static int open_demo_sockets(struct demo_context *demo, + const struct options *options) { - struct sockaddr_in server_address = { + struct sockaddr_in client_address = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), - .sin_port = htons(options->dns_port), }; - struct sockaddr_in client_address = { + + demo->server_address = (struct sockaddr_in) { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), }; - struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; - struct sockaddr_in response_client_address; - unsigned char dns_message[512]; - size_t query_length; - int dns_server = -1, dns_client = -1, listener = -1; - int err = -1; - - dns_server = bind_udp(&server_address); - dns_client = bind_udp(&client_address); - listener = create_tcp_listener(options->port); - if (dns_server < 0 || dns_client < 0 || listener < 0) - goto cleanup; + demo->dns_server = bind_udp(&demo->server_address); + demo->dns_client = bind_udp(&client_address); + demo->listener = create_tcp_listener(options->port); + return demo->dns_server < 0 || demo->dns_client < 0 || demo->listener < 0 ? + -1 : 0; +} +static int test_rejected_dns_answers(struct ring_buffer *ring, + const struct options *options, + const unsigned char *qname, + unsigned int qname_length, + struct demo_context *demo) +{ if (expect_blocked_connect(ring, options->port, "before-dns")) - goto cleanup; - - if (send_unsolicited_dns(dns_server, dns_client, qname, qname_length)) - goto cleanup; - if (poll_demo_events(ring) || - expect_blocked_connect(ring, options->port, "unsolicited-response")) - goto cleanup; - - if (begin_dns_exchange(dns_server, dns_client, &server_address, qname, - qname_length, dns_message, &query_length, - &response_client_address) || - send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID + 1, 30)) - goto cleanup; - if (poll_demo_events(ring) || + return -1; + if (send_unsolicited_dns(demo->dns_server, demo->dns_client, qname, + qname_length) || + poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, + "unsolicited-response")) + return -1; + if (begin_dns_exchange(demo->dns_server, demo->dns_client, + &demo->server_address, qname, qname_length, + demo->dns_message, &demo->query_length, + &demo->response_client_address) || + send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID + 1, 30) || + poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "wrong-transaction-id")) - goto cleanup; + return -1; + return 0; +} - if (send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID, 1)) - goto cleanup; - if (poll_demo_events(ring) || - expect_allowed_connect(ring, listener, options->port)) - goto cleanup; +static int test_live_and_expired_answer(struct ring_buffer *ring, + const struct options *options, + struct demo_context *demo) +{ + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + if (send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID, 1) || + poll_demo_events(ring) || + expect_allowed_connect(ring, demo->listener, options->port)) + return -1; nanosleep(&wait_time, NULL); if (poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "expired-answer")) - goto cleanup; + return -1; + return expected_demo_events(); +} - if (expected_demo_events()) - goto cleanup; - err = 0; +static void close_demo_sockets(struct demo_context *demo) +{ + if (demo->listener >= 0) + close(demo->listener); + if (demo->dns_client >= 0) + close(demo->dns_client); + if (demo->dns_server >= 0) + close(demo->dns_server); +} -cleanup: - if (listener >= 0) close(listener); - if (dns_client >= 0) close(dns_client); - if (dns_server >= 0) close(dns_server); +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct demo_context demo = { + .dns_server = -1, + .dns_client = -1, + .listener = -1, + }; + int err; + + err = open_demo_sockets(&demo, options); + if (!err) + err = test_rejected_dns_answers(ring, options, qname, + qname_length, &demo); + if (!err) + err = test_live_and_expired_answer(ring, options, &demo); + close_demo_sockets(&demo); return err; } @@ -1035,7 +1074,7 @@ The three early `denied` events show that neither receiving DNS-shaped traffic n | Requirement | Details | |---|---| -| Kernel | Linux 5.8 or newer; the newest dependency is BPF ring buffer support | +| Kernel | Linux 5.12 or newer; the newest dependency is BPF atomic compare-and-exchange | | 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 | @@ -1055,5 +1094,6 @@ This example turns an observed DNS result into a time-bounded connect policy. Th - [BPF ring buffer](https://docs.kernel.org/bpf/ringbuf.html) - [BPF LRU hash maps](https://docs.kernel.org/bpf/map_hash.html) +- [BPF atomic compare-and-exchange commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) - [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 290eca93..29a0fe9d 100644 --- a/src/55-dns-egress/README.zh.md +++ b/src/55-dns-egress/README.zh.md @@ -373,7 +373,7 @@ egress 路径先验证 IPv4、UDP、分片状态、解析器地址和端口, 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()` 的范围很窄。发往其他目的端口的 TCP 连接直接通过;发往受保护端口的连接使用 `ctx->user_ip4` 查表,有效条目返回 `1`,缺失或过期条目返回 `0`,应用会收到 `EPERM`。`expired_reported` 上的 compare-and-swap 让多个线程同时访问旧地址时只产生一次 expired 通知。 +`enforce_dns_policy()` 的范围很窄。发往其他目的端口的 TCP 连接直接通过;发往受保护端口的连接使用 `ctx->user_ip4` 查表,有效条目返回 `1`,缺失或过期条目返回 `0`,应用会收到 `EPERM`。`expired_reported` 上的 64 位 compare-and-swap 让多个线程同时访问旧地址时只产生一次 expired 通知。BPF atomic compare-and-exchange 在 Linux 5.12 引入,这也确定了工具的最低内核版本。 ## 加载策略并验证完整信任链 @@ -791,70 +791,109 @@ static int expected_demo_events(void) 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) +struct demo_context { + struct sockaddr_in server_address; + struct sockaddr_in response_client_address; + unsigned char dns_message[512]; + size_t query_length; + int dns_server; + int dns_client; + int listener; +}; + +static int open_demo_sockets(struct demo_context *demo, + const struct options *options) { - struct sockaddr_in server_address = { + struct sockaddr_in client_address = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), - .sin_port = htons(options->dns_port), }; - struct sockaddr_in client_address = { + + demo->server_address = (struct sockaddr_in) { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), }; - struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; - struct sockaddr_in response_client_address; - unsigned char dns_message[512]; - size_t query_length; - int dns_server = -1, dns_client = -1, listener = -1; - int err = -1; - - dns_server = bind_udp(&server_address); - dns_client = bind_udp(&client_address); - listener = create_tcp_listener(options->port); - if (dns_server < 0 || dns_client < 0 || listener < 0) - goto cleanup; + demo->dns_server = bind_udp(&demo->server_address); + demo->dns_client = bind_udp(&client_address); + demo->listener = create_tcp_listener(options->port); + return demo->dns_server < 0 || demo->dns_client < 0 || demo->listener < 0 ? + -1 : 0; +} +static int test_rejected_dns_answers(struct ring_buffer *ring, + const struct options *options, + const unsigned char *qname, + unsigned int qname_length, + struct demo_context *demo) +{ if (expect_blocked_connect(ring, options->port, "before-dns")) - goto cleanup; - - if (send_unsolicited_dns(dns_server, dns_client, qname, qname_length)) - goto cleanup; - if (poll_demo_events(ring) || - expect_blocked_connect(ring, options->port, "unsolicited-response")) - goto cleanup; - - if (begin_dns_exchange(dns_server, dns_client, &server_address, qname, - qname_length, dns_message, &query_length, - &response_client_address) || - send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID + 1, 30)) - goto cleanup; - if (poll_demo_events(ring) || + return -1; + if (send_unsolicited_dns(demo->dns_server, demo->dns_client, qname, + qname_length) || + poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, + "unsolicited-response")) + return -1; + if (begin_dns_exchange(demo->dns_server, demo->dns_client, + &demo->server_address, qname, qname_length, + demo->dns_message, &demo->query_length, + &demo->response_client_address) || + send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID + 1, 30) || + poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "wrong-transaction-id")) - goto cleanup; + return -1; + return 0; +} - if (send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID, 1)) - goto cleanup; - if (poll_demo_events(ring) || - expect_allowed_connect(ring, listener, options->port)) - goto cleanup; +static int test_live_and_expired_answer(struct ring_buffer *ring, + const struct options *options, + struct demo_context *demo) +{ + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + if (send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID, 1) || + poll_demo_events(ring) || + expect_allowed_connect(ring, demo->listener, options->port)) + return -1; nanosleep(&wait_time, NULL); if (poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "expired-answer")) - goto cleanup; + return -1; + return expected_demo_events(); +} - if (expected_demo_events()) - goto cleanup; - err = 0; +static void close_demo_sockets(struct demo_context *demo) +{ + if (demo->listener >= 0) + close(demo->listener); + if (demo->dns_client >= 0) + close(demo->dns_client); + if (demo->dns_server >= 0) + close(demo->dns_server); +} -cleanup: - if (listener >= 0) close(listener); - if (dns_client >= 0) close(dns_client); - if (dns_server >= 0) close(dns_server); +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct demo_context demo = { + .dns_server = -1, + .dns_client = -1, + .listener = -1, + }; + int err; + + err = open_demo_sockets(&demo, options); + if (!err) + err = test_rejected_dns_answers(ring, options, qname, + qname_length, &demo); + if (!err) + err = test_live_and_expired_answer(ring, options, &demo); + close_demo_sockets(&demo); return err; } @@ -1035,7 +1074,7 @@ demo step=expired-answer result=blocked | 要求 | 说明 | |---|---| -| 内核 | Linux 5.8 或更高版本,最新依赖来自 BPF ring buffer | +| 内核 | Linux 5.12 或更高版本,最新依赖来自 BPF atomic compare-and-exchange | | 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_INET` | | cgroup | cgroup v2,目标工作负载位于挂载目录之下 | | 权限 | root,或者等价的 BPF 与网络管理 capability | @@ -1055,5 +1094,6 @@ demo step=expired-answer result=blocked - [BPF ring buffer](https://docs.kernel.org/bpf/ringbuf.html) - [BPF LRU hash map](https://docs.kernel.org/bpf/map_hash.html) +- [BPF atomic compare-and-exchange 引入 commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) - [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/dns_egress.c b/src/55-dns-egress/dns_egress.c index bfe09886..88ec79fb 100644 --- a/src/55-dns-egress/dns_egress.c +++ b/src/55-dns-egress/dns_egress.c @@ -409,70 +409,109 @@ static int expected_demo_events(void) 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) +struct demo_context { + struct sockaddr_in server_address; + struct sockaddr_in response_client_address; + unsigned char dns_message[512]; + size_t query_length; + int dns_server; + int dns_client; + int listener; +}; + +static int open_demo_sockets(struct demo_context *demo, + const struct options *options) { - struct sockaddr_in server_address = { + struct sockaddr_in client_address = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), - .sin_port = htons(options->dns_port), }; - struct sockaddr_in client_address = { + + demo->server_address = (struct sockaddr_in) { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(options->dns_port), }; - struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; - struct sockaddr_in response_client_address; - unsigned char dns_message[512]; - size_t query_length; - int dns_server = -1, dns_client = -1, listener = -1; - int err = -1; - - dns_server = bind_udp(&server_address); - dns_client = bind_udp(&client_address); - listener = create_tcp_listener(options->port); - if (dns_server < 0 || dns_client < 0 || listener < 0) - goto cleanup; + demo->dns_server = bind_udp(&demo->server_address); + demo->dns_client = bind_udp(&client_address); + demo->listener = create_tcp_listener(options->port); + return demo->dns_server < 0 || demo->dns_client < 0 || demo->listener < 0 ? + -1 : 0; +} +static int test_rejected_dns_answers(struct ring_buffer *ring, + const struct options *options, + const unsigned char *qname, + unsigned int qname_length, + struct demo_context *demo) +{ if (expect_blocked_connect(ring, options->port, "before-dns")) - goto cleanup; - - if (send_unsolicited_dns(dns_server, dns_client, qname, qname_length)) - goto cleanup; - if (poll_demo_events(ring) || - expect_blocked_connect(ring, options->port, "unsolicited-response")) - goto cleanup; - - if (begin_dns_exchange(dns_server, dns_client, &server_address, qname, - qname_length, dns_message, &query_length, - &response_client_address) || - send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID + 1, 30)) - goto cleanup; - if (poll_demo_events(ring) || + return -1; + if (send_unsolicited_dns(demo->dns_server, demo->dns_client, qname, + qname_length) || + poll_demo_events(ring) || + expect_blocked_connect(ring, options->port, + "unsolicited-response")) + return -1; + if (begin_dns_exchange(demo->dns_server, demo->dns_client, + &demo->server_address, qname, qname_length, + demo->dns_message, &demo->query_length, + &demo->response_client_address) || + send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID + 1, 30) || + poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "wrong-transaction-id")) - goto cleanup; + return -1; + return 0; +} - if (send_dns_answer(dns_server, dns_client, &response_client_address, - dns_message, query_length, DNS_ID, 1)) - goto cleanup; - if (poll_demo_events(ring) || - expect_allowed_connect(ring, listener, options->port)) - goto cleanup; +static int test_live_and_expired_answer(struct ring_buffer *ring, + const struct options *options, + struct demo_context *demo) +{ + struct timespec wait_time = { .tv_sec = 1, .tv_nsec = 300000000 }; + if (send_dns_answer(demo->dns_server, demo->dns_client, + &demo->response_client_address, demo->dns_message, + demo->query_length, DNS_ID, 1) || + poll_demo_events(ring) || + expect_allowed_connect(ring, demo->listener, options->port)) + return -1; nanosleep(&wait_time, NULL); if (poll_demo_events(ring) || expect_blocked_connect(ring, options->port, "expired-answer")) - goto cleanup; + return -1; + return expected_demo_events(); +} - if (expected_demo_events()) - goto cleanup; - err = 0; +static void close_demo_sockets(struct demo_context *demo) +{ + if (demo->listener >= 0) + close(demo->listener); + if (demo->dns_client >= 0) + close(demo->dns_client); + if (demo->dns_server >= 0) + close(demo->dns_server); +} -cleanup: - if (listener >= 0) close(listener); - if (dns_client >= 0) close(dns_client); - if (dns_server >= 0) close(dns_server); +static int run_demo(struct ring_buffer *ring, const struct options *options, + const unsigned char *qname, unsigned int qname_length) +{ + struct demo_context demo = { + .dns_server = -1, + .dns_client = -1, + .listener = -1, + }; + int err; + + err = open_demo_sockets(&demo, options); + if (!err) + err = test_rejected_dns_answers(ring, options, qname, + qname_length, &demo); + if (!err) + err = test_live_and_expired_answer(ring, options, &demo); + close_demo_sockets(&demo); return err; } diff --git a/src/60-afxdp-dump/.config b/src/60-afxdp-dump/.config index ca6da83c..e5d9cf41 100644 --- a/src/60-afxdp-dump/.config +++ b/src/60-afxdp-dump/.config @@ -1,6 +1,6 @@ level=Depth type=Networking -kernel_min=5.3 +kernel_min=5.7 kernel_min_basis=required-feature architectures=x86_64 btf=required diff --git a/src/60-afxdp-dump/README.md b/src/60-afxdp-dump/README.md index 5730abd9..341e49ce 100644 --- a/src/60-afxdp-dump/README.md +++ b/src/60-afxdp-dump/README.md @@ -10,7 +10,7 @@ This tutorial builds that path from the AF_XDP ABI rather than hiding it behind 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. -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. +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 checks XSKMAP with `bpf_map_lookup_elem()` before redirecting, support added in Linux 5.3. Its compare-and-detach cleanup uses the expected-program FD added in Linux 5.7, which sets the complete tool's minimum kernel version. 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. @@ -647,7 +647,7 @@ AF_XDP dump integration test: PASS | Requirement | Details | |---|---| -| Kernel | Linux 5.3 or newer; AF_XDP arrived in 4.18 and this example also uses XSKMAP lookup from XDP | +| Kernel | Linux 5.7 or newer; AF_XDP arrived in 4.18, XSKMAP lookup from XDP in 5.3, and safe expected-FD detach in 5.7 | | 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 | @@ -668,3 +668,4 @@ This example exposes the complete AF_XDP receive contract. XDP selects one UDP f - [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 from XDP commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) +- [Expected-program FD for XDP replacement and detach](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md index eeba0618..8481274c 100644 --- a/src/60-afxdp-dump/README.zh.md +++ b/src/60-afxdp-dump/README.zh.md @@ -10,7 +10,7 @@ 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 程序只解析足够的信息,判断报文是否属于这个工具;AF_XDP socket 管理共享内存 ring,并把选中的字节交给用户态。本例还会在 redirect 之前用 `bpf_map_lookup_elem()` 检查 XSKMAP,这项能力从 Linux 5.3 开始可用。清理阶段的 compare-and-detach 使用 Linux 5.7 加入的 expected-program FD,因此完整工具的最低内核版本是 5.7。 跟着一个报文和一个 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。 @@ -647,7 +647,7 @@ AF_XDP dump integration test: PASS | 要求 | 说明 | |---|---| -| 内核 | Linux 5.3 或更高版本,AF_XDP 在 4.18 引入,本例还使用 XDP 对 XSKMAP 的 lookup | +| 内核 | Linux 5.7 或更高版本,AF_XDP 在 4.18 引入,XDP 对 XSKMAP 的 lookup 在 5.3 引入,安全的 expected-FD detach 在 5.7 引入 | | 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_XDP_SOCKETS`、`CONFIG_DEBUG_INFO_BTF` | | 权限 | root,或者等价的 BPF 与网络管理 capability | | 网络接口 | 接口包含所选 RX queue,native XDP 可选,工具也支持 generic XDP | @@ -668,3 +668,4 @@ AF_XDP dump integration test: PASS - [Linux AF_XDP 文档](https://docs.kernel.org/networking/af_xdp.html) - [AF_XDP 引入 commit](https://github.com/torvalds/linux/commit/c0c77d8fb787cfe0c3fca689c2a30d1dad4eaba7) - [XDP 支持 XSKMAP lookup 的 commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) +- [XDP replace 与 detach 的 expected-program FD](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) diff --git a/src/compatibility.md b/src/compatibility.md index 40a321cf..8f898bad 100644 --- a/src/compatibility.md +++ b/src/compatibility.md @@ -61,10 +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: Build a DNS-Derived IP Allowlist with cgroup BPF](55-dns-egress/README.md) | 5.12 | 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 by Example: Receive UDP Packets with AF_XDP](60-afxdp-dump/README.md) | 5.7 | 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 11080929..06aef5ad 100644 --- a/src/compatibility.zh.md +++ b/src/compatibility.zh.md @@ -61,10 +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 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表](55-dns-egress/README.zh.md) | 5.12 | 必需特性 | 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 实战教程:用 AF_XDP 在用户态接收 UDP 报文](60-afxdp-dump/README.zh.md) | 5.7 | 必需特性 | 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 构建测试 | From bf1fe5d9c09ddc7a2182662b13c6b23fea6b2b7b Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 14:36:50 -0700 Subject: [PATCH 06/13] docs: improve four advanced tutorial narratives --- src/55-dns-egress/README.md | 126 +++-- src/55-dns-egress/README.zh.md | 126 +++-- src/56-tc-flow-index/README.md | 114 ++-- src/56-tc-flow-index/README.zh.md | 110 ++-- src/57-oom-watch/README.md | 844 +++--------------------------- src/57-oom-watch/README.zh.md | 842 +++-------------------------- src/60-afxdp-dump/README.md | 141 +++-- src/60-afxdp-dump/README.zh.md | 131 +++-- 8 files changed, 664 insertions(+), 1770 deletions(-) diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md index b45373bc..bd95a522 100644 --- a/src/55-dns-egress/README.md +++ b/src/55-dns-egress/README.md @@ -1,24 +1,36 @@ -# eBPF Tutorial by Example: Build a DNS-Derived IP Allowlist with cgroup BPF +# eBPF Tutorial: Building a DNS-Derived IP Allowlist with cgroup BPF -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. +Ever tried to restrict a service so it can only connect to `api.example.com`? You might reach for an IP allowlist, but DNS answers change constantly and TTLs expire. By the time your application calls `connect()`, the kernel only sees an IP address—it has no idea which domain produced it. What's missing is a short-lived link between the DNS lookup and the subsequent connection. -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. +This tutorial builds that link. We'll watch DNS traffic for a specific domain, learn IP addresses only from valid query-response pairs, and allow connections until the TTL expires. It's a minimal but complete policy tool that shows how cgroup BPF can coordinate across multiple kernel hooks. > Complete source code: -## Where eBPF Enforces the Decision +## Understanding the Problem: DNS and Connect Are Disconnected -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. +When your application resolves `api.example.com`, the DNS response contains an IP address and a TTL. But this information lives only in userspace—the resolver library caches it, your application calls `connect()` with the IP, and the kernel has no idea where that IP came from. This creates a fundamental security gap. -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. +Consider a container that should only talk to your backend API. Traditional firewalls can block destination IPs, but they can't enforce "only connect to IPs that came from resolving api.example.com within the last 60 seconds." The kernel-level policy and the application-level DNS resolution operate in complete isolation. -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. +cgroup BPF bridges this gap. By attaching programs to packet hooks and connect hooks on the same cgroup, we can observe DNS traffic and later enforce connection policy based on what we learned. The key insight is that cgroup BPF programs share state through maps, creating a trust chain from DNS query through response to eventual connection. -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. +## How the Pieces Fit Together -## Shared Protocol and Event Types +eBPF lets us run verified programs at multiple kernel hooks and share state between them through maps. cgroup BPF makes these hooks follow a workload: packet programs can inspect traffic entering and leaving a cgroup, while socket-address programs can accept or reject connections before they're established. -The shared header contains the small DNS layouts parsed by the BPF programs and the fixed event format consumed in user space. +Our tool attaches three programs to the same cgroup: + +1. **`cgroup_skb/egress`** watches outgoing DNS queries and records which ones we're expecting answers for +2. **`cgroup_skb/ingress`** validates incoming DNS responses and learns IP addresses only from matching replies +3. **`cgroup/connect4`** makes the final decision—allowing connections only to IPs learned from valid DNS responses, and only while their TTL is still valid + +Let's walk through a successful exchange. Your application sends an A query for `lab.test`. The egress hook saves a correlation key containing the resolver address, client address, client UDP port, and DNS transaction ID. This record lives for five seconds. When the reply arrives, the ingress hook reconstructs the same key, verifies the response, and extracts the IP address along with its TTL. A later `connect()` to that address succeeds—but only while the TTL is valid. + +The query record is the trust boundary here. An unsolicited response has no pending key to match. A response with the wrong transaction ID looks for a different key and finds nothing. Neither can pollute the allowlist. And even if an entry lingers in the LRU map, the connect hook re-checks expiration time, so addresses naturally stop working when their DNS time runs out. + +## The Data Structures + +Before diving into code, let's understand the data structures that make this work. The shared header defines DNS protocol layouts and the events we report to userspace: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -69,11 +81,13 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -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. +The protocol structs use the `packed` attribute because they describe bytes on the wire—no padding allowed. The `dns_header` maps directly to the 12-byte DNS header that starts every query and response. The `dns_question` struct follows the question name (which uses length-prefixed labels). The `dns_a_answer` expects the common compressed format where the name pointer is `0xc00c`, pointing back to the question section. + +Each event we send to userspace carries both the DNS TTL and the absolute expiration timestamp. User space prints the human-readable TTL, while the BPF side uses the monotonic timestamp for its decisions. This separation keeps the kernel logic clean—no time format conversions in BPF code. -## The Three BPF Hooks +## The BPF Programs: Complete Implementation -Here is the complete kernel-side program. +Here's the complete kernel-side implementation. It's longer than some of our examples, but each piece has a clear job. Let's look at it in full, then walk through the key sections: ```c // SPDX-License-Identifier: GPL-2.0 @@ -367,17 +381,43 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -`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. +### Understanding the Map Design + +The program uses three BPF maps, each serving a specific purpose in the trust chain. The `pending_queries` map is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that uniquely identify a query/response pair. The value stores only the expiration timestamp because that's all we need to validate timing. LRU eviction ensures memory stays bounded even under DNS flood conditions. + +The `allowed_ips` map is also an LRU hash, but keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag to track whether we've already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry. + +The `events` ring buffer sends notifications to userspace. At 256KB, it can hold thousands of events without blocking the kernel path. Ring buffers are the modern replacement for perf buffers—they're more efficient and provide better ordering guarantees. + +### The Egress Path: Recording Pending Queries + +When a packet leaves the cgroup, `record_dns_query` runs. The function first validates the transport layer through `parse_query_transport`. This function loads the IP header, checks for IPv4 with UDP protocol, rejects fragmented packets (which would require reassembly we don't implement), and verifies the destination matches our configured resolver. If all checks pass, it calculates where DNS data begins and populates the correlation key. + +The `parse_dns_query` function then validates the DNS layer. It checks that the flags indicate a standard query (not a response), that exactly one question exists, and that the question name matches our configured domain. The question type and class must be A (address) and IN (internet). Only after all validation passes does it extract the transaction ID and insert into `pending_queries`. + +Notice that `record_dns_query` always returns 1. This tells the kernel to continue processing the packet normally—we're observing, not blocking. DNS queries flow through unchanged. + +### The Ingress Path: Learning from Responses -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 ingress program `learn_dns_answer` reverses the perspective. Now we're looking at packets arriving from the resolver, so `parse_response_transport` checks that the source (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint. -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 critical security check happens in `pending_query_is_live`. This function looks up the correlation key in `pending_queries`. If no entry exists—meaning we never saw a matching query—the response is rejected. If an entry exists but has expired, we delete it and reject the response. Only responses that match a live pending query proceed. -`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`. A 64-bit compare-and-swap on `expired_reported` keeps the expiry notification to one event even when several threads race on the stale address. BPF atomic compare-and-exchange arrived in Linux 5.12, which sets this tool's minimum kernel version. +After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It verifies this is a successful response (flags indicate "response" and "no error"), contains exactly one question matching our domain, and has at least one answer. `parse_direct_a_answer` then extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a sane TTL between 1 and 86400 seconds. -## Loading the Policy and Exercising Its Trust Chain +When validation passes, the pending query gets deleted (it's been consumed), and the IP address gets added to `allowed_ips` with an expiration based on the DNS TTL. The `emit_event` call sends a `DNS_LEARNED` notification to userspace. -The complete user-space program configures the read-only BPF data, attaches all three programs to one cgroup, and consumes ring-buffer events. +### The Connect Path: Enforcing Policy + +The `enforce_dns_policy` function attaches to `cgroup/connect4`, which runs before every IPv4 TCP connect. The function first applies filtering: if we're targeting a specific process and this isn't it, allow the connection. If it's not TCP or not the protected port, allow the connection. These early returns minimize overhead for irrelevant traffic. + +For connections that need policy enforcement, we look up the destination IP in `allowed_ips`. If found and not expired, we emit `DNS_ALLOWED` and return 1 (allow). If found but expired, we use an atomic compare-and-swap on `expired_reported` to emit exactly one `DNS_EXPIRED` event even under concurrent access. BPF atomic compare-and-exchange arrived in Linux 5.12, which sets this tool's minimum kernel version. + +If the IP isn't in the map or is expired, we emit `DNS_DENIED` and return 0. The kernel translates return value 0 into `EPERM`, and the application's connect() fails immediately. + +## The User-Space Program + +The user-space side configures the read-only BPF data, attaches all three programs to a cgroup, and processes ring-buffer events. It also includes a self-test demo mode that exercises the complete trust chain. ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -1021,20 +1061,38 @@ cleanup: } ``` -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. +### Understanding the User-Space Control Flow + +The user-space program follows a clear initialization sequence. First, `parse_options` handles command-line arguments, validating inputs and setting defaults. Demo mode automatically configures loopback addresses and non-standard ports to avoid conflicts with real DNS and web traffic. + +The `encode_qname` function converts a domain name like `lab.test` into DNS label format: `\x03lab\x04test\x00`. Each label starts with a length byte followed by the label content. The final zero byte terminates the name. This encoding happens once at startup and gets written into the BPF skeleton's `rodata` section, where the verifier treats it as a constant. + +The `prepare_runtime` function ties everything together. It opens the target cgroup directory, opens the BPF skeleton, configures all the `rodata` values (resolver IP, ports, domain name), loads the BPF programs, and attaches each program to the cgroup. The three separate links allow independent attachment and detachment. Finally, it creates a ring buffer consumer that calls `handle_event` for each notification from the kernel. + +Normal mode enters `poll_policy_events`, which loops on the ring buffer until the duration expires or a signal arrives. Each event gets printed with the IP address, PID, TTL, and event type. Demo mode instead runs `run_demo`, which exercises the complete trust chain with synthetic DNS traffic and TCP connections. + +### The Demo Mode: Proving the Security Properties + +Demo mode serves as both a functional test and a demonstration of the security model. It runs entirely on loopback, using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services. + +The test sequence starts by verifying that connections are blocked before any DNS traffic. Then it sends an unsolicited DNS response—a response that arrives without a preceding query. The BPF program should reject this because there's no matching entry in `pending_queries`. The demo verifies the connection is still blocked. + +Next, it sends a legitimate DNS query and receives a response with the wrong transaction ID. The BPF program should also reject this because the transaction ID is part of the correlation key. Again, the demo verifies the connection remains blocked. + +Finally, it sends a response with the correct transaction ID and a 1-second TTL. Now the connection should succeed. After waiting 1.3 seconds (longer than the TTL), the demo verifies the connection is blocked again. -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. +This sequence proves that the tool correctly implements query-response correlation, rejects spoofing attempts, honors TTLs, and properly expires allowlist entries. -## Build and Run +## Compilation and Execution -Build the example with the repository's vendored libbpf and bpftool: +Build the example: ```bash cd src/55-dns-egress make ``` -Attach it to a service cgroup, watch one domain through its actual resolver, and protect TCP port 443: +Attach it to a service cgroup and watch one domain through its resolver: ```bash sudo ./dns_egress \ @@ -1044,13 +1102,13 @@ sudo ./dns_egress \ --port 443 ``` -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: +The cgroup must contain the workload whose DNS packets and connections should share the policy state. TCP 443 and DNS 53 are 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 ``` -A real demo run looks like this: +Example output: ```text dns-egress attached cgroup=/sys/fs/cgroup domain=lab.test resolver=127.0.0.1 tcp_port=19090 dns_port=15353 @@ -1068,25 +1126,25 @@ 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. +The three early `denied` events show that merely receiving DNS-shaped traffic or seeing the right domain name isn't enough. `learned` appears only for the correlated response, `allowed` covers its live TTL, and `expired` is immediately followed by a denied connect. ## Requirements | Requirement | Details | |---|---| -| Kernel | Linux 5.12 or newer; the newest dependency is BPF atomic compare-and-exchange | +| Kernel | Linux 5.12+ (BPF atomic compare-and-exchange) | | 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 | +| cgroup | cgroup v2, with workload placed below the attached directory | +| Privileges | Root, or equivalent BPF and network capabilities | +| Architecture | x86-64 tested; no special network hardware required | -## Scope +## What This Example Doesn't Cover -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. +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 `0xc00c` compressed owner name. CNAME chains, alternate answer layouts, TCP DNS, IPv6, DoH, and DoT would need additional parsers or observation points. This compact scope keeps the important property visible: an IP enters the allowlist through a recent matching query and leaves through DNS time. ## Summary -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. +This example turns observed DNS results into a time-bounded connect policy. The egress and ingress hooks establish a trustworthy query-response relation, the TTL controls address lifetime, and the connect hook enforces the result for the protected port. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . diff --git a/src/55-dns-egress/README.zh.md b/src/55-dns-egress/README.zh.md index 29a0fe9d..4fb7380f 100644 --- a/src/55-dns-egress/README.zh.md +++ b/src/55-dns-egress/README.zh.md @@ -1,24 +1,36 @@ -# eBPF 实战教程:用 cgroup BPF 构建 DNS 派生的 IP 允许列表 +# eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表 -假设一个服务只能通过 HTTPS 访问 `api.example.com`,其余目的地址都需要拒绝。静态 IP 允许列表很难完成这件事,因为 DNS 应答会变化,也会过期;到了 `connect()` 阶段,内核看到的又只是一个 IP 地址,并不知道它来自哪个域名。这里缺少的是一条短期关联,把应用刚刚完成的 DNS 解析和随后的连接连起来。 +假设你想让一个服务只能连接 `api.example.com`,其他地址一律拒绝。静态 IP 白名单听起来可行,但 DNS 应答会变化,TTL 也会过期。到了 `connect()` 阶段,内核只看到一个 IP 地址,根本不知道它来自哪个域名。这里缺少的是一条短期关联,把应用的 DNS 查询和随后的连接联系起来。 -本课会把这条关联做成一个最小可运行的策略工具。它通过指定的 DNS 解析器观察一个域名,只从匹配的查询与响应中学习 IPv4 A 记录,并在 DNS TTL 有效期间允许访问一个 TCP 端口。 +本课就来构建这条关联。我们会观察特定域名的 DNS 流量,只从有效的查询响应对中学习 IP 地址,并在 TTL 有效期间允许连接。这是一个最小但完整的策略工具,展示了 cgroup BPF 如何在多个内核 hook 之间协调工作。 > 完整源代码: -## eBPF 在哪里执行策略 +## 理解问题:DNS 和 Connect 相互隔离 -eBPF 可以让经过验证器检查的程序运行在 Linux 内核 hook 上,通过 map 保留状态,也可以把选出的事件交给用户态。cgroup BPF 让这些 hook 跟随一组工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在内核发起 IPv4 连接之前允许或拒绝这次请求。 +当你的应用解析 `api.example.com` 时,DNS 响应包含一个 IP 地址和一个 TTL。但这些信息只存在于用户空间——解析器库缓存它,应用用这个 IP 调用 `connect()`,而内核完全不知道这个 IP 是怎么来的。这就形成了一个根本性的安全缺口。 -这个例子在同一个 cgroup 上挂载三个程序。`cgroup_skb/egress` 识别指定的 DNS 问题并记录待匹配查询,`cgroup_skb/ingress` 只从与查询对应的响应中接纳地址,`cgroup/connect4` 对受保护的 TCP 端口作最终判断。ring buffer 负责报告 learned、allowed、denied 和 expired 事件,并不参与策略结果。 +考虑一个只应该访问你后端 API 的容器。传统防火墙可以阻止目标 IP,但无法执行"只允许连接最近 60 秒内从 api.example.com 解析出来的 IP"这样的策略。内核级策略和应用级 DNS 解析完全隔离运行。 -先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存解析器地址、客户端地址、客户端 UDP 端口和 DNS transaction ID,这份记录有效 5 秒。响应回来后,ingress hook 用反向报文重建同一个 key,检查响应标志与 question,再读取第一个直接 A 应答。通过检查的地址会和 TTL 推导出的过期时间一起进入允许列表,随后发往这个地址和受保护端口的 `connect()` 可以在 TTL 内成功。 +cgroup BPF 弥合了这个缺口。通过在同一个 cgroup 上挂载 packet hook 和 connect hook,我们可以观察 DNS 流量,然后根据观察到的结果执行连接策略。关键洞察是:cgroup BPF 程序通过 map 共享状态,从 DNS 查询到响应再到最终连接形成一条信任链。 -待匹配查询就是这里的信任边界。未经请求的响应没有 pending key,transaction ID 不同的响应也会查到另一个 key,因此两者都无法写入允许列表。connect hook 还会再次比较过期时间,即使 LRU map 中仍保留着旧条目,地址也会按 DNS 时间自然失效。 +## 整体架构 -## DNS 协议与事件结构 +eBPF 可以在多个内核 hook 上运行经过验证的程序,并通过 map 在它们之间共享状态。cgroup BPF 让这些 hook 跟随工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在连接建立之前接受或拒绝请求。 -共享头文件包含 BPF 程序需要解析的少量 DNS 布局,以及用户态接收的固定事件格式。 +我们的工具在同一个 cgroup 上挂载三个程序: + +1. **`cgroup_skb/egress`** 监控出站 DNS 查询,记录我们正在等待响应的请求 +2. **`cgroup_skb/ingress`** 验证入站 DNS 响应,只从匹配的回复中学习 IP 地址 +3. **`cgroup/connect4`** 做最终决策——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内 + +先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这份记录有效期 5 秒。响应到达后,ingress hook 重建同一个 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。 + +待匹配查询就是这里的信任边界。未经请求的响应没有待匹配的 key。事务 ID 错误的响应会查找另一个 key 而找不到。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间,地址会随 DNS 时间自然失效。 + +## 数据结构详解 + +在深入代码之前,先理解让这一切工作的数据结构。共享头文件定义了 DNS 协议布局和我们报告给用户空间的事件: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -69,11 +81,13 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -这些协议结构直接描述线上字节,因此使用 packed 布局。事件同时携带 DNS TTL 和内核单调时钟下的绝对过期时间,命令行展示 TTL,BPF 程序则用时间戳作策略判断。 +协议结构使用 `packed` 属性,因为它们直接描述线上字节——不允许任何填充。`dns_header` 直接映射到每个查询和响应开头的 12 字节 DNS 头部。`dns_question` 结构跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。 + +我们发送给用户空间的每个事件同时携带 DNS TTL 和绝对过期时间戳。用户空间打印人类可读的 TTL,而 BPF 侧使用单调时间戳做决策。这种分离保持了内核逻辑的简洁——BPF 代码中不需要时间格式转换。 -## 三个 BPF hook +## BPF 程序:完整实现 -下面是完整的内核态程序。 +下面是完整的内核态实现。虽然比某些例子长,但每个部分都有明确的职责。让我们先看完整代码,然后逐步分析关键部分: ```c // SPDX-License-Identifier: GPL-2.0 @@ -367,17 +381,43 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -`pending_queries` 和 `allowed_ips` 都是固定容量的 LRU hash map。前者保存最多 5 秒的查询关联状态,后者保存处于 DNS 生命周期内的 IPv4 地址。LRU 淘汰把内存开销限制在确定范围内;当条目超过 1024 个时,最近活跃的查询和地址也会得到优先保留。 +### 理解 Map 设计 + +程序使用三个 BPF map,每个在信任链中都有特定用途。`pending_queries` map 是一个 LRU hash,保存 DNS 查询的关联状态。key 组合了四个字段——服务器 IP、客户端 IP、客户端端口和事务 ID——它们唯一标识一个查询/响应对。value 只存储过期时间戳,因为这就是我们验证时序所需的全部。LRU 淘汰确保即使在 DNS 洪水条件下内存也保持有界。 + +`allowed_ips` map 也是 LRU hash,但只用 IPv4 地址作为 key。value 包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已报告过期的 flag。这个 flag 防止多个线程在竞争访问过期条目时产生重复的 "expired" 事件。 + +`events` ring buffer 向用户空间发送通知。256KB 的大小可以容纳数千个事件而不会阻塞内核路径。Ring buffer 是 perf buffer 的现代替代品——它们更高效,提供更好的顺序保证。 + +### 出站路径:记录待处理查询 + +当报文离开 cgroup 时,`record_dns_query` 运行。函数首先通过 `parse_query_transport` 验证传输层。这个函数加载 IP 头,检查是否为使用 UDP 协议的 IPv4,拒绝分片报文(需要我们没有实现的重组),并验证目标是否匹配配置的解析器。如果所有检查通过,它计算 DNS 数据的起始位置并填充关联 key。 + +然后 `parse_dns_query` 函数验证 DNS 层。它检查 flags 是否表示标准查询(不是响应),是否恰好有一个问题,以及问题名是否匹配我们配置的域名。问题类型和类必须是 A(地址)和 IN(互联网)。只有所有验证都通过后,它才提取事务 ID 并插入 `pending_queries`。 + +注意 `record_dns_query` 始终返回 1。这告诉内核继续正常处理报文——我们是在观察,不是阻断。DNS 查询原样流过。 + +### 入站路径:从响应中学习 -egress 路径先验证 IPv4、UDP、分片状态、解析器地址和端口,再检查报文是不是指定名称的单 question A 查询。只有这些字段全部成立,`record_dns_query()` 才会插入四字段关联 key。程序返回 `1`,DNS 报文仍然按正常路径继续传输。 +入站程序 `learn_dns_answer` 反转了视角。现在我们看的是从解析器到达的报文,所以 `parse_response_transport` 检查源(不是目标)是否匹配解析器 IP 和端口。关联 key 用相同的字段填充,但从响应的视角来看。 -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 才获得连接资格。 +关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联 key。如果没有条目存在——意味着我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,我们删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。 -`enforce_dns_policy()` 的范围很窄。发往其他目的端口的 TCP 连接直接通过;发往受保护端口的连接使用 `ctx->user_ip4` 查表,有效条目返回 `1`,缺失或过期条目返回 `0`,应用会收到 `EPERM`。`expired_reported` 上的 64 位 compare-and-swap 让多个线程同时访问旧地址时只产生一次 expired 通知。BPF atomic compare-and-exchange 在 Linux 5.12 引入,这也确定了工具的最低内核版本。 +确认我们有合法响应后,`parse_response_question` 验证 DNS 头。它验证这是成功响应(flags 表示"响应"且"无错误"),恰好包含一个匹配我们域名的问题,且至少有一个应答。`parse_direct_a_answer` 然后提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的合理 TTL。 -## 加载策略并验证完整信任链 +验证通过后,待处理查询被删除(已被消费),IP 地址以基于 DNS TTL 的过期时间添加到 `allowed_ips`。`emit_event` 调用向用户空间发送 `DNS_LEARNED` 通知。 -用户态程序会配置只读 BPF 数据,把三个程序附加到一个 cgroup,再消费 ring buffer 事件。 +### 连接路径:执行策略 + +`enforce_dns_policy` 函数挂载到 `cgroup/connect4`,在每个 IPv4 TCP 连接之前运行。函数首先应用过滤:如果我们针对特定进程且这不是它,允许连接。如果不是 TCP 或不是受保护端口,允许连接。这些提前返回最小化了无关流量的开销。 + +对于需要策略执行的连接,我们在 `allowed_ips` 中查找目标 IP。如果找到且未过期,我们发出 `DNS_ALLOWED` 并返回 1(允许)。如果找到但已过期,我们在 `expired_reported` 上使用原子 compare-and-swap,即使在并发访问下也只发出一个 `DNS_EXPIRED` 事件。BPF atomic compare-and-exchange 在 Linux 5.12 引入,这也确定了工具的最低内核版本。 + +如果 IP 不在 map 中或已过期,我们发出 `DNS_DENIED` 并返回 0。内核将返回值 0 转换为 `EPERM`,应用的 connect() 立即失败。 + +## 用户态程序 + +用户态程序配置只读 BPF 数据,把三个程序附加到 cgroup,并处理 ring buffer 事件。它还包含一个自测的 demo 模式,验证完整的信任链。 ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -1021,20 +1061,38 @@ cleanup: } ``` -域名会在 BPF object 加载前编码为 DNS label 格式,例如 `lab.test` 会变成 `\x03lab\x04test\x00`,这正是 packet hook 逐字节比较的内容。解析器与端口写入 skeleton 的 `rodata`,验证器可以把它们视为常量。 +### 理解用户态控制流程 + +用户态程序遵循清晰的初始化序列。首先,`parse_options` 处理命令行参数,验证输入并设置默认值。Demo 模式自动配置 loopback 地址和非标准端口,以避免与真实 DNS 和 Web 流量冲突。 + +`encode_qname` 函数将域名如 `lab.test` 转换为 DNS label 格式:`\x03lab\x04test\x00`。每个 label 以长度字节开头,后跟 label 内容。最后的零字节终止名称。这个编码在启动时只发生一次,并写入 BPF skeleton 的 `rodata` 部分,验证器将其视为常量。 + +`prepare_runtime` 函数把一切串联起来。它打开目标 cgroup 目录,打开 BPF skeleton,配置所有 `rodata` 值(解析器 IP、端口、域名),加载 BPF 程序,并将每个程序附加到 cgroup。三个独立的 link 允许独立的附加和分离。最后,它创建一个 ring buffer 消费者,为每个内核通知调用 `handle_event`。 + +普通模式进入 `poll_policy_events`,在 ring buffer 上循环直到持续时间结束或收到信号。每个事件都会打印 IP 地址、PID、TTL 和事件类型。Demo 模式则运行 `run_demo`,用合成的 DNS 流量和 TCP 连接验证完整的信任链。 + +### Demo 模式:证明安全属性 + +Demo 模式既是功能测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。 + +测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序应该拒绝它,因为 `pending_queries` 中没有匹配的条目。Demo 验证连接仍然被阻止。 + +接下来,它发送一个合法的 DNS 查询并收到一个事务 ID 错误的响应。BPF 程序也应该拒绝它,因为事务 ID 是关联 key 的一部分。同样,demo 验证连接保持阻止状态。 + +最后,它发送一个事务 ID 正确、TTL 为 1 秒的响应。现在连接应该成功。等待 1.3 秒(超过 TTL)后,demo 验证连接再次被阻止。 -普通模式会保持三个 link 存活,直到运行时间结束或收到退出信号。demo 模式用 loopback socket 走完信任链:先证明初始连接会被拒绝,再分别发送未经请求的应答和错误 ID 应答,随后接纳一个 TTL 为 1 秒的正确应答,最后等待它过期。这些检查很重要,因为“解析所有 DNS 响应”的简单实现也能通过成功路径,却很容易被伪造响应污染。 +这个序列证明了工具正确实现了查询响应关联、拒绝欺骗尝试、遵守 TTL,并正确使允许列表条目过期。 ## 编译和运行 -使用仓库内置的 libbpf 与 bpftool 构建示例: +构建示例: ```bash cd src/55-dns-egress make ``` -将它挂到服务 cgroup,通过服务实际使用的解析器观察一个域名,并保护 TCP 443 端口: +将它挂到服务 cgroup,通过解析器观察一个域名: ```bash sudo ./dns_egress \ @@ -1044,13 +1102,13 @@ sudo ./dns_egress \ --port 443 ``` -这个 cgroup 需要包含目标工作负载,让它的 DNS 报文和连接共享同一份策略状态。TCP 443 和 DNS 53 是默认端口,`--dns-port` 可以选择其他解析器端口,`--duration` 则为运行时间设置上限。内置 demo 不依赖外部 DNS 服务器: +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 @@ -1068,25 +1126,25 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1 demo step=expired-answer result=blocked ``` -前三个 denied 说明,仅仅收到 DNS 格式的流量或者看到正确域名还不足以学习地址。只有关联正确的响应会产生 learned,allowed 覆盖它的有效 TTL,expired 之后的连接则立刻回到 denied。 +前三个 `denied` 说明仅仅收到 DNS 格式的流量或看到正确的域名还不够。只有关联正确的响应才会产生 `learned`,`allowed` 覆盖它的有效 TTL,`expired` 之后的连接立刻回到 denied。 ## 环境要求 | 要求 | 说明 | |---|---| -| 内核 | Linux 5.12 或更高版本,最新依赖来自 BPF atomic compare-and-exchange | +| 内核 | Linux 5.12+(BPF atomic compare-and-exchange) | | 内核配置 | `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,不需要特殊网卡 | +| cgroup | cgroup v2,工作负载位于挂载目录之下 | +| 权限 | root,或等价的 BPF 与网络 capability | +| 架构 | x86-64 已测试;不需要特殊网卡 | -## 实现范围 +## 本例未涵盖的内容 -这个工具处理一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答,并识别常见的 `0xc00c` owner name。CNAME 链、多种 answer 布局、TCP DNS、IPv6、DoH 和 DoT 需要更多解析逻辑或新的观察位置。这个紧凑范围保留了策略最关键的性质:IP 通过最近一次匹配查询进入允许列表,再按照 DNS 时间退出。 +工具刻意只实现一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答。它识别常见的 `0xc00c` 压缩 owner name。CNAME 链、其他 answer 布局、TCP DNS、IPv6、DoH 和 DoT 需要额外的解析器或新的观察点。这个紧凑范围保留了重要属性的可见性:IP 通过最近一次匹配查询进入允许列表,再通过 DNS 时间退出。 ## 总结 -这个例子把观察到的 DNS 结果变成有时间边界的 connect 策略。egress 与 ingress hook 建立可信的查询响应关联,TTL 管理地址生命周期,connect hook 则对工作负载的受保护端口执行结果。 +这个例子把观察到的 DNS 结果变成有时间边界的 connect 策略。egress 和 ingress hook 建立可信的查询响应关联,TTL 控制地址生命周期,connect hook 对受保护端口执行结果。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 @@ -1094,6 +1152,6 @@ demo step=expired-answer result=blocked - [BPF ring buffer](https://docs.kernel.org/bpf/ringbuf.html) - [BPF LRU hash map](https://docs.kernel.org/bpf/map_hash.html) -- [BPF atomic compare-and-exchange 引入 commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) +- [BPF atomic compare-and-exchange commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) - [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 index b91ed715..26f3f26a 100644 --- a/src/56-tc-flow-index/README.md +++ b/src/56-tc-flow-index/README.md @@ -1,24 +1,51 @@ -# eBPF Tutorial by Example: Index TC Flows in Two Refcounted rbtrees +# eBPF Tutorial: Building a Top-Flow Monitor with Dual Rbtree Indexing -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. +Ever tried building a network flow monitor and hit this wall? Every packet needs lightning-fast lookup to update counters, but at report time you want flows sorted by traffic volume. One index can't do both efficiently, and keeping two separate copies means counters drift apart. What if you could have both views of the same data, always in sync? -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. +This tutorial builds a TC egress flow monitor that solves exactly this problem. We'll index each IPv4 TCP or UDP flow in two red-black trees simultaneously: one keyed by five-tuple for fast packet lookup, another sorted by bytes for instant top-flow output. The magic ingredient is BPF refcounting, which lets both trees own the same flow record without duplication. > Complete source code: -## Why the Same Object Can Belong to Two Trees +## The Dual-Index Challenge -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. +Network monitoring tools face a fundamental tension. When a packet arrives, you need to find the matching flow record by its five-tuple (source IP, destination IP, source port, destination port, protocol) in microseconds. But when the user asks "show me the top 10 flows," you need those same records sorted by traffic volume. -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()`. +Traditional solutions either maintain two independent data structures (which can drift out of sync) or rebuild the ranking on demand (which burns CPU and delays output). Neither feels right. -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 approach we take here is different. Each flow record lives in memory exactly once, but it participates in two different orderings through two embedded tree nodes. When you update the byte counter, both views see the change immediately because they point to the same object. This is possible thanks to three recent eBPF features: -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. +- **BPF object allocation** (`bpf_obj_new`): Create dynamically allocated structures in BPF programs +- **BPF refcounting** (`bpf_refcount_acquire`): Give multiple owners shared access to one object +- **Rbtree traversal** (`bpf_rbtree_root/left/right`): Search and walk trees without removing nodes -## Shared Flow and Cursor Types +Linux 6.4 introduced refcounting, and 6.16 completed the picture with tree traversal. Together, they enable data structures that were previously impossible in BPF. -The shared header defines the five-tuple, the snapshot returned from BPF, and the cursor used to continue an ordered walk. +## How One Object Joins Two Trees + +Let's trace what happens when a new flow appears. The TC program sees a packet with five-tuple (10.0.0.1, 10.0.0.2, 50000, 80, TCP). It searches the identity tree by key and finds nothing. Time to create a new entry. + +First, `bpf_obj_new()` allocates a `flow_entry`. This structure embeds a `bpf_refcount` and two `bpf_rb_node` fields, one for each tree. Right after allocation, the object has exactly one owning reference. + +Next, `bpf_refcount_acquire()` creates a second owning reference to the same object. Now we have two references pointing to one piece of memory. The first `bpf_rbtree_add()` transfers one reference to the identity tree. The second call transfers the other to the traffic tree. At this point, both trees jointly own the flow entry, and the verifier knows no raw pointers escaped. + +When a subsequent packet for this flow arrives, we find the existing entry in the identity tree. But adding bytes changes the traffic ranking. The program removes only the traffic-tree node (which returns its owning reference), updates the counters, and reinserts at the new position. The identity node never moves, so lookup remains valid throughout. + +## Architecture Overview + +The implementation splits into three files: + +| File | Purpose | +|------|---------| +| `tc_flow_index.h` | Shared structures: five-tuple, snapshot, and cursor | +| `bpf_experimental.h` | Kfunc declarations for BPF graph APIs | +| `tc_flow_index.bpf.c` | BPF program: TC hook and snapshot syscall | +| `tc_flow_index.c` | User-space loader, demo traffic, and output | + +The BPF side handles packet parsing, tree maintenance, and cursor-based snapshot iteration. The user side attaches the TC program, optionally generates test traffic, and queries the index through `BPF_PROG_TEST_RUN`. + +## Shared Data Structures + +The header file defines the flow key, snapshot result, and traversal cursor: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -55,11 +82,11 @@ struct flow_cursor { #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. +The `flow_key` keeps addresses and ports in network byte order, so the BPF program can copy packet fields directly without conversion. User space converts to host order only when formatting output. -## Experimental kfunc Declarations +## 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. +BPF graph APIs use kfuncs rather than stable UAPI helpers. This compatibility header declares the functions our program uses: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -104,11 +131,11 @@ 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. +The `__contains(flow_entry, by_identity)` annotation tells the verifier which containing type and member belong to each root. Combined with the neighboring spin lock, this lets the verifier enforce ownership and critical-section rules. These interfaces may evolve between kernels, which is why we note a concrete minimum version below. -## Maintaining Both Indexes at TC Egress +## The BPF Program -Here is the complete BPF program. +Here's the complete BPF implementation. We'll walk through the key parts afterward. ```c // SPDX-License-Identifier: GPL-2.0 @@ -399,17 +426,23 @@ int snapshot_next(void *ctx) } ``` -`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. +### Understanding the Flow Entry + +The `flow_entry` structure is the heart of this design. It embeds one `bpf_refcount` for ownership tracking and two `bpf_rb_node` fields for participation in both trees. The lock, `identity_root`, and `traffic_root` share a private map value, ensuring all access is serialized. + +### The Packet Path -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. +When `index_egress_flow` runs, `parse_flow()` extracts the five-tuple from non-fragmented IPv4 TCP and UDP packets. The main function always returns `TC_ACT_OK` because we're observing, not filtering. -`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 `update_flow()` function first searches under lock. If the flow exists, it removes the traffic node, updates counters, and reinserts. If not, it allocates outside the lock, then locks again for a second lookup. This double-check handles two CPUs discovering the same new flow simultaneously: one wins the insertion race, the other updates the existing entry and drops its unused references. -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. +### The Snapshot Mechanism -## Attaching TC and Reading a Stable Snapshot +The `snapshot_next` syscall program reads the traffic tree under the same lock. With an empty cursor, it returns `bpf_rbtree_first()`. Subsequent calls search for the first entry after the previous position. Since the cursor and result live in BSS, user space can fetch one ranked entry at a time without receiving a per-packet event stream. -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`. +## The User-Space Program + +The loader attaches the TC program, optionally generates demo traffic, detaches before reading, then queries through `BPF_PROG_TEST_RUN`: ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -730,11 +763,9 @@ cleanup: } ``` -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. +Detaching before output freezes packet updates, so the cursor walks a stable traffic ordering. The demo mode creates three UDP flows over loopback: 2 packets at 100 bytes, 4 at 300 bytes, and 6 at 700 bytes. With headers, these become 284, 1368, and 4452 bytes total, producing a deterministic ranking. -## Build and Run +## Building and Running Build the tool: @@ -743,19 +774,19 @@ cd src/56-tc-flow-index make ``` -Index egress traffic on an interface for 30 seconds and print its ten busiest flows: +Monitor real egress traffic for 30 seconds: ```bash sudo ./tc_flow_index --interface eth0 --duration 30 --top 10 ``` -Run the deterministic loopback demo: +Run the deterministic demo: ```bash sudo ./tc_flow_index --demo --top 3 ``` -A real demo run shows the two-tree ordering directly: +Expected demo output: ```text Indexing IPv4 TCP/UDP egress flows on lo for 0 seconds. @@ -768,25 +799,30 @@ SOURCE DESTINATION PROTO PACKETS BYTES COMM 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. +The table arrives pre-sorted by bytes. Twelve packets created three identity entries, and all ownership counters remain zero, confirming correct dual-tree management. ## 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 | +|-------------|---------| +| Kernel | Linux 6.16+ (refcounted BPF objects + rbtree search kfuncs) | +| Config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_NET_SCHED`, `CONFIG_NET_CLS_BPF` | +| Privileges | Root or CAP_BPF + CAP_NET_ADMIN | +| Interface | Any interface supporting clsact egress | +| Architecture | Tested on x86-64 | + +## Extending the Design -## Scope +This example covers non-fragmented IPv4 TCP/UDP egress with a fixed 4096-entry capacity. For production use, consider: -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. +- **Idle expiry**: Remove flows that haven't seen traffic for N seconds, freeing both tree nodes before dropping references +- **Ingress indexing**: Add a second TC program for incoming packets +- **IPv6 support**: Extend the flow key structure +- **Real-time streaming**: Use a ring buffer alongside the trees for per-packet events ## 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. +This tutorial showed how BPF object ownership enables sophisticated data structures that weren't possible before. The identity tree handles fast five-tuple lookups, the traffic tree provides instant top-flow rankings, and refcounting lets both share one object without state duplication. The technique generalizes to any scenario where you need multiple orderings over the same dataset. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . diff --git a/src/56-tc-flow-index/README.zh.md b/src/56-tc-flow-index/README.zh.md index 8bb59d31..2c03043f 100644 --- a/src/56-tc-flow-index/README.zh.md +++ b/src/56-tc-flow-index/README.zh.md @@ -1,24 +1,51 @@ -# eBPF 实战教程:用两棵 refcounted rbtree 索引 TC 流量 +# eBPF 实战教程:用双红黑树索引构建 Top-Flow 监控器 -流量监控会遇到两种完全不同的查询。每个报文到来时,需要按五元组快速找到原有 flow 并更新计数;输出报告时,又希望同一批记录按流量大小排列,让最繁忙的 flow 出现在前面。一种排序很难同时高效回答两个问题,两份独立副本又会让计数和生命周期逐渐分叉。 +做过流量监控的人可能都遇到过这个问题:每个报文到达时,需要按五元组快速找到对应的 flow 记录更新计数器;但输出报告时,又希望同一批数据按流量大小排序,让最繁忙的 flow 排在前面。一份索引很难同时满足两种需求,而维护两份独立副本又会让计数器逐渐失去同步。有没有办法让两种视图共享同一份数据,始终保持一致? -本课围绕一个对象的两种视图构建 TC egress flow index。每条 IPv4 TCP 或 UDP 流会同时进入 identity rbtree 和按字节数排序的 traffic rbtree,BPF object allocator 创建记录,BPF refcount 让两棵树共同持有对象,rbtree traversal 则直接产出 top-flow 结果,用户态无需重建索引。 +本教程构建的 TC egress 流量监控器正是为了解决这个问题。我们把每条 IPv4 TCP 或 UDP 流同时索引到两棵红黑树中:一棵按五元组键值排序,用于快速报文查找;另一棵按字节数排序,用于即时输出 top-flow 结果。核心技术是 BPF 引用计数,它让两棵树能够共同拥有同一条 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 程序开始能够搜索并遍历有序树,也补齐了本例使用的整组能力。 +网络监控工具面临一个根本性的矛盾。报文到达时,需要在微秒级别内按五元组(源 IP、目的 IP、源端口、目的端口、协议)找到对应的 flow 记录。但当用户查询"显示前 10 条流量最大的 flow"时,又需要这些记录按流量大小排序。 -每个 `flow_entry` 内嵌一个 `bpf_refcount` 和两个不同的 `bpf_rb_node`。`identity_root` 通过 `by_identity` 按源地址、目的地址、源端口、目的端口和协议排序;`traffic_root` 通过 `by_traffic` 按 bytes、packets 和五元组排序,其中 bytes 与 packets 都是降序,五元组负责稳定处理并列项。两个 node 最终都能通过 `container_of()` 回到同一份计数和时间戳。 +传统方案要么维护两份独立的数据结构(可能出现不一致),要么在查询时重新排序(消耗 CPU 并延迟输出)。两种方案都不够理想。 -先看一条新 flow 的第一个报文。TC 程序搜索 identity tree,没有发现匹配项,于是分配一个 `flow_entry`,再调用 `bpf_refcount_acquire()` 取得第二个 owning reference。第一次 `bpf_rbtree_add()` 把一个 reference 交给 identity tree,第二次调用把另一个交给 traffic tree。临界区结束以后没有裸指针逃逸,验证器可以证明两棵 collection 都拥有这个对象。 +我们采用的方法完全不同。每条 flow 记录在内存中只存在一份,但通过两个内嵌的树节点参与两种不同的排序。更新字节计数器时,两种视图立即看到变化,因为它们指向同一个对象。这得益于三个最新的 eBPF 特性: -后续报文会在 identity tree 找到条目,但 bytes 增加后,原来的 traffic 顺序已经失效。程序只从 traffic tree 移除 `by_traffic`,remove 返回这棵树持有的 owning reference;计数更新以后,同一个 traffic node 会重新插入正确位置。identity node 始终留在原位,因此查找顺序在整个更新期间保持有效。 +- **BPF 对象分配**(`bpf_obj_new`):在 BPF 程序中创建动态分配的结构体 +- **BPF 引用计数**(`bpf_refcount_acquire`):让多个所有者共享同一个对象 +- **红黑树遍历**(`bpf_rbtree_root/left/right`):不移除节点也能搜索和遍历树 -## 共享的 flow 与 cursor 类型 +Linux 6.4 引入了引用计数,6.16 又添加了树遍历功能。两者结合,使得以前在 BPF 中不可能实现的数据结构成为现实。 -共享头文件定义五元组、BPF 返回的 snapshot,以及继续有序遍历所需的 cursor。 +## 一个对象如何加入两棵树 + +我们来追踪一条新 flow 出现时会发生什么。TC 程序看到一个五元组为 (10.0.0.1, 10.0.0.2, 50000, 80, TCP) 的报文。它在 identity 树中按键值搜索,没有找到匹配项。是时候创建新条目了。 + +首先,`bpf_obj_new()` 分配一个 `flow_entry`。这个结构体内嵌一个 `bpf_refcount` 和两个 `bpf_rb_node` 字段,分别对应两棵树。刚分配时,对象恰好有一个 owning reference。 + +接下来,`bpf_refcount_acquire()` 为同一个对象创建第二个 owning reference。现在我们有两个引用指向同一块内存。第一次 `bpf_rbtree_add()` 把一个引用交给 identity 树。第二次调用把另一个交给 traffic 树。此时,两棵树共同拥有这个 flow entry,验证器知道没有裸指针逃逸。 + +当这条 flow 的后续报文到达时,我们在 identity 树中找到已有条目。但增加字节数会改变 traffic 排名。程序只移除 traffic 树节点(返回其 owning reference),更新计数器,然后在新位置重新插入。identity 节点始终不动,所以查找在整个过程中保持有效。 + +## 架构概览 + +实现分为三个文件: + +| 文件 | 作用 | +|------|------| +| `tc_flow_index.h` | 共享结构体:五元组、快照、游标 | +| `bpf_experimental.h` | BPF graph API 的 kfunc 声明 | +| `tc_flow_index.bpf.c` | BPF 程序:TC hook 和快照系统调用 | +| `tc_flow_index.c` | 用户态加载器、演示流量和输出 | + +BPF 端负责报文解析、树维护和基于游标的快照迭代。用户端挂载 TC 程序,可选生成测试流量,并通过 `BPF_PROG_TEST_RUN` 查询索引。 + +## 共享数据结构 + +头文件定义 flow key、快照结果和遍历游标: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -55,11 +82,11 @@ struct flow_cursor { #endif /* __TC_FLOW_INDEX_H */ ``` -`flow_key` 中的地址和端口保留 network byte order,BPF 侧可以直接复制报文字段,只有用户态格式化表格时才作转换。 +`flow_key` 中的地址和端口保留网络字节序,BPF 程序可以直接从报文复制字段。只有用户态格式化输出时才转换为主机字节序。 ## 实验性 kfunc 声明 -BPF graph API 使用 kfunc,而不是稳定 UAPI helper。这个兼容头文件声明程序实际调用的函数,并用本地 BTF type ID 包装 object allocator。 +BPF graph API 使用 kfunc 而非稳定的 UAPI helper。这个兼容头文件声明了程序使用的函数: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -104,11 +131,11 @@ 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 接口可能随内核演进,因此本课明确记录最低版本,并把声明放在例子旁边。 +`__contains(flow_entry, by_identity)` 注解告诉验证器每个 root 对应的 containing type 和 member。结合相邻的 spin lock,验证器可以强制检查 ownership 和临界区规则。这些接口可能随内核版本演进,因此我们在下文注明了最低版本要求。 -## 在 TC egress 维护两份索引 +## BPF 程序 -下面是完整 BPF 程序。 +下面是完整的 BPF 实现,之后我们会逐一讲解关键部分。 ```c // SPDX-License-Identifier: GPL-2.0 @@ -399,17 +426,23 @@ int snapshot_next(void *ctx) } ``` -`index_lock`、`identity_root` 和 `traffic_root` 位于同一个 private map value,所有搜索与修改都持有这把锁。二叉树搜索最多执行 32 步,让验证器看到确定的控制流;索引最多保存 4096 个 entry,平衡 rbtree 的实际深度远小于这个上限。 +### 理解 Flow Entry + +`flow_entry` 结构体是整个设计的核心。它内嵌一个 `bpf_refcount` 用于 ownership 跟踪,以及两个 `bpf_rb_node` 字段用于参与两棵树。lock、`identity_root` 和 `traffic_root` 共享一个 private map value,确保所有访问都是串行化的。 + +### 报文处理路径 -第一次加锁查找失败以后,程序会在锁外分配对象,再次加锁并重新查找。这次 double check 用于处理两个 CPU 同时发现新 flow 的情况:胜者把对象交给两棵树,另一方更新已有条目,并释放自己刚分配的两个 reference。allocation、refcount、capacity 和 re-ranking failure 都有独立计数,ownership 问题可以直接从输出中看到。 +当 `index_egress_flow` 运行时,`parse_flow()` 从非分片的 IPv4 TCP 和 UDP 报文中提取五元组。主函数始终返回 `TC_ACT_OK`,因为我们只是观察,不是过滤。 -`parse_flow()` 接收未分片的 IPv4 TCP 与 UDP 报文,从 Ethernet、IP 和 transport header 组装 key。`index_egress_flow()` 始终返回 `TC_ACT_OK`,工具只观察并建立索引,不会改变报文传输。 +`update_flow()` 函数首先在锁内搜索。如果 flow 存在,它移除 traffic 节点、更新计数器并重新插入。如果不存在,则在锁外分配,然后重新加锁做第二次查找。这个 double-check 处理了两个 CPU 同时发现新 flow 的情况:一个赢得插入竞争,另一个更新已有条目并释放自己未使用的引用。 -`snapshot_next` 程序在同一把锁下读取 traffic tree。空 cursor 从 `bpf_rbtree_first()` 开始,之后则搜索前一个 `(bytes, packets, key)` 之后的第一项。result 与 cursor 都放在 BSS,用户态每次取一个有序 entry,不需要接收每报文事件流。 +### 快照机制 -## 挂载 TC 并读取稳定 snapshot +`snapshot_next` syscall 程序在同一把锁下读取 traffic 树。空游标时返回 `bpf_rbtree_first()`,后续调用则搜索上一个位置之后的第一个条目。由于游标和结果都在 BSS 中,用户态可以逐个获取排序好的条目,无需接收每报文事件流。 -用户态程序挂载 classifier,按需生成 demo 流量,在 snapshot 之前解除挂载,再通过 `BPF_PROG_TEST_RUN` 调用 snapshot program。 +## 用户态程序 + +加载器挂载 TC 程序,可选生成演示流量,读取前先解挂,然后通过 `BPF_PROG_TEST_RUN` 查询: ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -730,9 +763,7 @@ cleanup: } ``` -输出前解除挂载会冻结所有 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。 +输出前解挂会冻结报文更新,让游标可以遍历一份稳定的 traffic 排序。演示模式创建三条 loopback 上的 UDP 流:2 个报文 100 字节、4 个报文 300 字节、6 个报文 700 字节。加上协议头后,分别是 284、1368 和 4452 字节,产生确定性的排名。 ## 编译和运行 @@ -743,19 +774,19 @@ cd src/56-tc-flow-index make ``` -在指定接口上索引 30 秒 egress 流量,并打印最繁忙的 10 条 flow: +监控真实 egress 流量 30 秒: ```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. @@ -768,25 +799,30 @@ SOURCE DESTINATION PROTO PACKETS BYTES COMM 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。 +表格到达用户态时已按字节数降序排列。12 个报文创建了 3 个 identity 条目,所有 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,普通网络接口即可 | +|------|------| +| 内核 | Linux 6.16+(需要 refcounted BPF object + rbtree search kfunc) | +| 配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_NET_SCHED`、`CONFIG_NET_CLS_BPF` | +| 权限 | root 或 CAP_BPF + CAP_NET_ADMIN | +| 网络接口 | 支持 clsact egress 的任意接口 | +| 架构 | 已在 x86-64 上测试 | + +## 扩展方向 -## 实现范围 +本示例覆盖非分片的 IPv4 TCP/UDP egress 流量,容量固定为 4096 条。对于生产环境,可以考虑: -这个索引覆盖未分片的 IPv4 TCP 与 UDP egress 流量,并保留 flow 首次创建时观察到的 command name。容量固定为 4096 条 flow,entry 会一直保留到程序退出,适合有边界的实验和短时间观察。长期运行的服务可以继续加入 idle expiry,在释放 reference 之前从两棵树移除对应 node。 +- **空闲过期**:移除 N 秒内没有流量的 flow,先释放两棵树的节点再 drop reference +- **入口索引**:为入站报文添加第二个 TC 程序 +- **IPv6 支持**:扩展 flow key 结构 +- **实时流式输出**:在树之外添加 ring buffer 用于每报文事件 ## 总结 -这个例子用 BPF object ownership 在同一条 flow 记录上维护两种有用顺序。identity tree 负责每报文更新,traffic tree 提供 top-flow traversal,refcount 则让两棵 collection 共享对象,同时保持状态只有一份。 +本教程展示了 BPF object ownership 如何实现以前不可能的复杂数据结构。identity 树负责快速的五元组查找,traffic 树提供即时的 top-flow 排名,而引用计数让两者共享同一个对象,无需状态复制。这个技术可以推广到任何需要对同一数据集维护多种排序的场景。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 diff --git a/src/57-oom-watch/README.md b/src/57-oom-watch/README.md index 94a8aef1..b1d1ffa5 100644 --- a/src/57-oom-watch/README.md +++ b/src/57-oom-watch/README.md @@ -1,26 +1,52 @@ -# eBPF Tutorial by Example: Profile memcg Reclaim Before an OOM Kill +# eBPF Tutorial: Profiling Memory Reclaim Before an OOM Kill -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. +Have you ever had a container or service suddenly killed by the Linux OOM killer, leaving you with nothing but a cryptic log message? The kernel tells you *which* process it chose as a victim, but says almost nothing about the memory pressure that led there. How many times did the system try to reclaim memory? How long did each attempt take? Which kernel code paths consumed all that time? -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. +This tutorial builds `oom-watch`, an eBPF tool that captures what happens *before* the kill. It profiles every memcg reclaim attempt as a latency histogram and a set of sampled kernel stacks, then attaches this accumulated profile to the OOM victim and tracks the process until it exits. > Complete source code: -## Reclaim Is the Story Before the Kill +## The Missing Piece in OOM Debugging -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. +When a memory cgroup approaches its limit, any allocation might trigger reclaim. The kernel scans the cgroup for pages it can free. Sometimes a few short attempts succeed. Other times, reclaim keeps running with little progress until the OOM killer finally steps in. -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. +Looking only at the `oom/mark_victim` tracepoint loses all this context. You see the victim, but not the struggle that came before. Operators need answers to questions like: -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. +- How many reclaim cycles happened before the kill? +- Were they quick microsecond scans, or long millisecond stalls? +- Did reclaim come from allocations inside the cgroup, or from external proactive reclaim via `memory.reclaim`? +- Which kernel functions dominated the reclaim time? -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. +`oom-watch` answers all of these. It hooks into the kernel's vmscan tracepoints to measure every reclaim interval, samples kernel stacks to show where time was spent, and when OOM selects a victim, it snapshots the accumulated profile right alongside the victim information. -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. +## Why eBPF for Memory Profiling? -## Profile and Event Layouts +Traditional monitoring approaches have serious limitations for this use case. Polling `/proc/meminfo` or cgroup stats misses short-lived reclaim events. `perf` can capture stack traces but requires careful setup and post-processing. Neither approach easily connects reclaim activity to a specific OOM event. -The shared header defines the cgroup-level histogram, per-stack aggregate, and the event that carries an OOM snapshot. +eBPF changes the game. Programs run directly in the kernel, triggered by events with nanosecond precision. Maps carry state between events, letting us build histograms and correlate begin/end pairs. The ring buffer delivers events to userspace with minimal overhead. And because eBPF programs are verified before loading, there's no risk of crashing the kernel. + +For `oom-watch`, we use several tracepoints: + +- `mm_vmscan_memcg_reclaim_begin`: Fires when reclaim starts for a specific memory cgroup +- `mm_vmscan_memcg_reclaim_end`: Fires when that reclaim interval completes +- `oom/mark_victim`: Fires when the OOM killer selects a victim +- `sched/sched_process_exit`: Fires when the victim process exits + +Linux 7.1 added a crucial feature: the vmscan tracepoints now include the target `mem_cgroup` being scanned. This matters because a process in one cgroup can trigger reclaim in a different cgroup through `memory.reclaim`. With the target cgroup in the tracepoint, we can correctly attribute the work. + +## Architecture Overview + +The tool has three main components working together. + +The **header file** defines shared data structures: a per-cgroup reclaim profile containing latency buckets and counters, a per-stack aggregate tracking samples and timing, and an event structure for OOM notifications. + +The **BPF program** runs in the kernel. On reclaim begin, it records the start time and optionally captures a kernel stack. On reclaim end, it computes duration, updates the histogram, and stores the stack aggregate. When OOM marks a victim, it looks up the victim's cgroup, copies the accumulated profile into an event, and sends it to userspace. It also saves victim state so it can report when the process exits. + +The **userspace program** loads kernel symbols for stack symbolization, manages the optional cgroup filter, processes events from the ring buffer, and includes a self-contained demo mode that triggers an OOM to verify everything works. + +## The Profile Data Structures + +The shared header defines what we're measuring: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -79,11 +105,13 @@ struct oom_watch_event { #endif /* __OOM_WATCH_H */ ``` -`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. +The `latency_slots` array holds a power-of-two histogram with 20 buckets. Bucket 0 covers 0-1 microseconds, bucket 1 covers 2-3 microseconds, then 4-7 microseconds, and so on. The final bucket catches everything over half a second. This logarithmic distribution captures both fast reclaim cycles and rare slow ones efficiently. + +When an OOM event fires, it embeds a complete copy of the cgroup's profile. The numbers printed with the victim show exactly how things stood at the moment of selection. -## Profiling Reclaim and Tracking the Victim +## The BPF Program -Here is the complete BPF program. +Here's the complete BPF code. We'll walk through how it works after the listing: ```c // SPDX-License-Identifier: GPL-2.0 @@ -398,776 +426,34 @@ int capture_victim_exit(void *ctx) } ``` -`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 program uses several BPF maps to maintain state. The `active_reclaims` map joins begin and end events by storing the start timestamp keyed by `pid_tgid`. The `profiles` map accumulates per-cgroup statistics. The `stack_traces` map stores deduplicated kernel stacks, while `stack_profiles` aggregates timing data for each unique stack. -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. +At reclaim begin, we extract the target cgroup ID from the `mem_cgroup` parameter, not from the current task's cgroup. This distinction matters for cross-cgroup reclaim. If someone calls `memory.reclaim` on a cgroup from outside, we correctly attribute the work to the target. The `cross_cgroup_reclaims` counter tracks how often this happens. -`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. +Stack sampling uses the `--sample-every` setting. With the default value of 1, we capture every interval. Higher values reduce overhead while still counting all intervals in the histogram. The `BPF_F_FAST_STACK_CMP` flag speeds up stack deduplication, and the `2` in the flags skips two tracing frames to get cleaner stacks. -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. +The OOM handler has to resolve process identity carefully. The tracepoint gives us a thread ID, but we also need the thread group ID (PID from userspace perspective) and the cgroup. We use `bpf_task_from_pid()` to look up the task, read what we need, then release the reference. Victim state is keyed by TID because `sched_process_exit` fires in that thread's context. -## Symbolizing and Presenting the Profile +## Userspace: Symbols and Presentation -The user-space program loads kernel symbols, ranks stack aggregates, manages the optional cgroup filter, and provides a self-contained OOM demonstration. +The userspace code handles several jobs: loading kernel symbols for stack symbolization, setting up the BPF program with any cgroup filter, processing events from the ring buffer, and ranking stacks by total time spent. It also includes a demo mode that creates a memory-limited cgroup and triggers an OOM to verify the tool works. -```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 -#include -#include -#include -#include -#include "oom_watch.h" -#include "oom_watch.skel.h" - -struct options { - const char *cgroup_path; - unsigned int duration_seconds; - unsigned int sample_every; - bool demo; -}; +The full userspace code is quite long, so we'll highlight the key parts. On startup, it reads `/proc/kallsyms`, sorts symbols by address, and later uses binary search to resolve each stack frame. When symbol addresses are restricted (common on production systems), it still prints raw addresses - the measurements stay accurate. -static volatile sig_atomic_t stop; -static int victim_events; -static int exit_events; -static unsigned long long observed_cgroup_id; -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; -}; +Stack groups are ranked by cumulative reclaim time. This ranking surfaces both frequently-called paths and rare slow intervals. After an OOM event, the top five stacks are printed with their timing statistics. -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; -}; +The demo mode creates a cgroup with `memory.max=32 MiB`, no swap, and grouped OOM behavior. A worker process faults 24 MiB then pauses, letting the parent request 8 MiB through `memory.reclaim` from outside the cgroup. This exercises cross-cgroup attribution. The worker then continues faulting a 128 MiB mapping until OOM kills it. The process leader exits before the second stage, testing that we handle TGID and victim TID independently. -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; - int continue_fd; -}; - -static struct allocation_context allocation_context; - -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 write_text(const char *path, const char *text) -{ - int fd = open(path, O_WRONLY | O_CLOEXEC); - ssize_t length = strlen(text); - int err = 0; - - if (fd < 0) - return -1; - if (write(fd, text, length) != length) - err = -1; - close(fd); - return err; -} - -static int memory_controller_enabled(bool *enabled) -{ - char controllers[4096]; - ssize_t length; - int fd; - - fd = open("/sys/fs/cgroup/cgroup.subtree_control", - O_RDONLY | O_CLOEXEC); - if (fd < 0) - return -1; - length = read(fd, controllers, sizeof(controllers) - 1); - close(fd); - if (length < 0) - return -1; - controllers[length] = '\0'; - *enabled = strstr(controllers, "memory") != NULL; - return 0; -} - -static int compare_symbols(const void *left, const void *right) -{ - const struct kernel_symbol *a = left; - const struct kernel_symbol *b = right; +## Compilation and Execution - 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; - - 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->profile.begin_count; - observed_victim_tid = event->victim_tid; - 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 " - "reclaimed_pages=%llu\n", - event->victim_pid, event->victim_tid, event->comm, - event->triggering_tgid, - (unsigned long long)event->cgroup_id, - (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->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", - event->victim_pid, event->victim_tid, - (unsigned long long)event->cgroup_id, event->exit_code); - } - return 0; -} - -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 [--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) -{ - 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:s:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'c': options->cgroup_path = optarg; break; - case 'd': - 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; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - return optind == argc && !(options->demo && options->cgroup_path) ? 0 : -1; -} - -static void *allocation_worker(void *argument) -{ - struct allocation_context *context = argument; - size_t first_stage = 24 * 1024 * 1024; - size_t length = 128 * 1024 * 1024; - unsigned char *memory; - char byte = 'x'; - - memory = mmap(NULL, length, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (memory == MAP_FAILED) - _exit(4); - for (size_t offset = 0; offset < first_stage; offset += 4096) - memory[offset] = 0xa5; - if (write(context->ready_fd, &byte, 1) != 1 || - read(context->continue_fd, &byte, 1) != 1) - _exit(5); - for (size_t offset = first_stage; offset < length; offset += 4096) - memory[offset] = 0xa5; - _exit(6); -} - -static void allocate_until_killed(const char *cgroup_path, int ready_fd, - int continue_fd) -{ - char procs_path[512]; - char pid_text[32]; - pthread_t worker; - - snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", cgroup_path); - snprintf(pid_text, sizeof(pid_text), "%d", getpid()); - if (write_text(procs_path, pid_text)) - _exit(3); - allocation_context.ready_fd = ready_fd; - allocation_context.continue_fd = continue_fd; - if (pthread_create(&worker, NULL, allocation_worker, - &allocation_context)) - _exit(4); - pthread_detach(worker); - pthread_exit(NULL); -} - -static int configure_demo_cgroup(const char *path, bool *created, - bool *enabled_by_demo) -{ - bool memory_enabled; - char file[512]; - - if (memory_controller_enabled(&memory_enabled)) - return -1; - if (!memory_enabled) { - if (write_text("/sys/fs/cgroup/cgroup.subtree_control", "+memory")) - return -1; - *enabled_by_demo = true; - } - if (mkdir(path, 0755)) - return -1; - *created = true; - snprintf(file, sizeof(file), "%s/memory.max", path); - if (write_text(file, "33554432")) - return -1; - snprintf(file, sizeof(file), "%s/memory.swap.max", path); - if (write_text(file, "0")) - return -1; - snprintf(file, sizeof(file), "%s/memory.oom.group", path); - return write_text(file, "1"); -} - -static int trigger_cross_cgroup_reclaim(const char *cgroup_path) -{ - char reclaim_path[512]; - - snprintf(reclaim_path, sizeof(reclaim_path), "%s/memory.reclaim", - cgroup_path); - if (!write_text(reclaim_path, "8388608") || errno == EAGAIN) - return 0; - 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 = { .sample_every = 1 }; - struct selected_cgroup selected = {}; - struct oom_runtime runtime = {}; - int err = 1; - - setvbuf(stdout, NULL, _IONBF, 0); - if (parse_options(argc, argv, &options)) { - usage(argv[0]); - return 2; - } - if (select_cgroup(&options, &selected) || - prepare_runtime(&runtime, &options, &selected)) - goto cleanup; - - if (selected.path) - printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", - selected.path, - (unsigned long long)selected.metadata.st_ino); - else - printf("oom-watch tracing all cgroups\n"); - - 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: - destroy_runtime(&runtime); - cleanup_selected_cgroup(&selected, &err); - return err; -} -``` - -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. - -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. - -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: +Build the tool: ```bash cd src/57-oom-watch make ``` -Profile one service cgroup for 60 seconds and sample one kernel stack for every ten reclaim intervals: +Profile a specific cgroup for 60 seconds, sampling one kernel stack for every ten reclaim intervals: ```bash sudo ./oom_watch \ @@ -1182,7 +468,7 @@ Omit `--cgroup` to watch all cgroups. Omit `--duration` to run until interrupted sudo ./oom_watch --demo ``` -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: +Here's output from a real demo run. PIDs, cgroup IDs, addresses, and timings vary between runs: ```text oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1262 cgroup_id=151 @@ -1206,25 +492,27 @@ demo result=matched-profile-to-victim 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`. +Look at what this tells us: 44 reclaim cycles completed before the kill, with half triggered from outside the target cgroup. The histogram shows most cycles were fast (under 8 microseconds), but a few took longer. The two ranked stacks separate allocation-triggered reclaim from explicit `memory.reclaim` requests. The exit event confirms the victim received `SIGKILL`. ## Requirements | Requirement | Details | |---|---| -| 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 | +| Kernel | Linux 7.1 or newer (needs 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 symbol output | +| cgroup | cgroup v2 with memory controller; demo mode needs cgroup admin access | +| Privileges | Root or equivalent BPF and tracing capabilities | +| Architecture | x86-64 tested; no special hardware needed | -## Scope +## Implementation Scope -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. +Profiles accumulate from program attach until exit, using bounded LRU maps: 4096 cgroup profiles, 4096 active intervals, 8192 stack aggregates, and 1024 unique stacks. One active interval is retained per `pid_tgid`, matching the begin/end tracing pattern. The tool captures kernel stacks rather than user stacks, and treats symbolization as presentation - restricted `kallsyms` changes names to addresses without affecting measurements. ## Summary -`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. +`oom-watch` turns the chaos before an OOM kill into evidence you can examine. It measures every memcg reclaim interval, samples and ranks the kernel paths that consumed time, correctly attributes work to the target cgroup (even for cross-cgroup reclaim), and connects this profile to the victim selection and exit. + +The next time a container dies and someone asks "what happened?", you'll have more than a one-line kernel log to show them. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md index 73e0bc25..cd951c64 100644 --- a/src/57-oom-watch/README.zh.md +++ b/src/57-oom-watch/README.zh.md @@ -1,26 +1,52 @@ -# eBPF 实战教程:在 OOM kill 之前分析 memcg reclaim +# eBPF 实战教程:在 OOM Kill 之前分析内存回收 -一条 OOM 日志回答了内存故障的最后一个问题:内核选择了哪个 task 作为 victim。此前发生的工作却很难从这条记录里看出来,排查时仍然需要知道目标 memory cgroup 进入了多少次 reclaim、每次花了多久、时间主要消耗在哪些内核路径,以及 victim 最终是否退出。 +你是否遇到过容器或服务突然被 Linux OOM killer 杀掉,只留下一行不明所以的内核日志?内核会告诉你它选择了*哪个*进程作为 victim,但对于导致这一结果的内存压力几乎只字未提。系统尝试了多少次内存回收?每次花了多久?哪些内核路径消耗了这些时间? -本课构建 `oom-watch` 保存这段缺失的历史。它把 memcg reclaim 聚合为延迟分布和采样内核栈,再把积累得到的 profile 关联到 OOM victim,并继续跟踪这条 victim 记录直到进程退出。 +本教程构建 `oom-watch`,一个 eBPF 工具,用于捕获 OOM kill *之前*发生的事情。它将每次 memcg 回收尝试记录为延迟直方图和采样的内核调用栈,然后将积累的 profile 附加到 OOM victim 上,并跟踪进程直到它退出。 > 完整源代码: -## reclaim 是 OOM kill 之前的故事 +## OOM 调试中缺失的一环 -memory cgroup 接近上限时,一次内存分配可能进入 reclaim,扫描这个 memcg 中可以释放的页面。多次短暂尝试有时只能回收少量内存,也可能几乎没有进展,最终由 OOM killer 选择 victim。只观察 `oom/mark_victim` 会同时丢失这些尝试的延迟分布和调用路径。 +当 memory cgroup 接近其限制时,任何内存分配都可能触发回收。内核会扫描该 cgroup 中可释放的页面。有时几次短暂的尝试就能成功。有时,回收反复运行却进展甚微,直到 OOM killer 最终介入。 -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/mark_victim` tracepoint,你会丢失所有这些上下文。你看到了 victim,却看不到之前的挣扎过程。运维人员需要这些问题的答案: -另一项依赖来自 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。 +- OOM kill 之前发生了多少次回收周期? +- 它们是微秒级的快速扫描,还是毫秒级的长时间停顿? +- 回收是由 cgroup 内部的分配触发的,还是通过 `memory.reclaim` 从外部主动触发的? +- 哪些内核函数占用了回收时间? -跟着一次 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-watch` 可以回答所有这些问题。它通过 hook 内核的 vmscan tracepoint 来测量每个回收间隔,采样内核调用栈以展示时间花在了哪里,当 OOM 选择 victim 时,它会将累积的 profile 快照与 victim 信息一起输出。 -OOM 选中 victim 后,程序把 TID 解析为 TGID 和 cgroup,把这个 cgroup 已积累的 reclaim profile 复制到 ring buffer event,并按 TID 保存 victim state。随后 `sched_process_exit` 消费这份状态并报告 exit code。最终结果会把 kill 之前的回收活动、内核标记的具体线程和之后的进程生命周期连在一起。 +## 为什么用 eBPF 做内存分析? -## profile 与 event 布局 +传统监控方法在这个场景下有严重的局限性。轮询 `/proc/meminfo` 或 cgroup 统计会错过短暂的回收事件。`perf` 可以捕获调用栈,但需要仔细配置和后处理。两种方法都不容易将回收活动与特定的 OOM 事件关联起来。 -共享头文件定义 cgroup 级直方图、每 stack aggregate 和携带 OOM snapshot 的事件。 +eBPF 改变了这一切。程序直接在内核中运行,以纳秒级精度响应事件。Map 在事件之间传递状态,让我们可以构建直方图并关联 begin/end 对。Ring buffer 以最小的开销将事件传递给用户空间。而且因为 eBPF 程序在加载前经过验证,不会有崩溃内核的风险。 + +对于 `oom-watch`,我们使用几个 tracepoint: + +- `mm_vmscan_memcg_reclaim_begin`:当特定 memory cgroup 的回收开始时触发 +- `mm_vmscan_memcg_reclaim_end`:当该回收间隔完成时触发 +- `oom/mark_victim`:当 OOM killer 选择 victim 时触发 +- `sched/sched_process_exit`:当 victim 进程退出时触发 + +Linux 7.1 添加了一个关键特性:vmscan tracepoint 现在包含被扫描的目标 `mem_cgroup`。这很重要,因为一个 cgroup 中的进程可以通过 `memory.reclaim` 触发另一个 cgroup 的回收。有了 tracepoint 中的目标 cgroup,我们可以正确归因这些工作。 + +## 架构概述 + +这个工具有三个主要组件协同工作。 + +**头文件**定义共享数据结构:包含延迟桶和计数器的 per-cgroup 回收 profile,跟踪采样和时间的 per-stack aggregate,以及 OOM 通知的事件结构。 + +**BPF 程序**在内核中运行。在回收开始时,它记录开始时间并可选地捕获内核调用栈。在回收结束时,它计算持续时间,更新直方图,并存储 stack aggregate。当 OOM 标记 victim 时,它查找 victim 的 cgroup,将累积的 profile 复制到事件中,并发送给用户空间。它还保存 victim 状态,以便在进程退出时进行报告。 + +**用户空间程序**加载内核符号用于调用栈符号化,管理可选的 cgroup 过滤器,处理来自 ring buffer 的事件,并包含一个自包含的 demo 模式,可以触发 OOM 来验证一切正常工作。 + +## Profile 数据结构 + +共享头文件定义了我们测量的内容: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -79,11 +105,13 @@ struct oom_watch_event { #endif /* __OOM_WATCH_H */ ``` -`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。 +`latency_slots` 数组是一个 20 个桶的 2 次幂直方图。桶 0 覆盖 0-1 微秒,桶 1 覆盖 2-3 微秒,然后是 4-7 微秒,依此类推。最后一个桶捕获超过半秒的所有内容。这种对数分布可以高效地捕获快速回收周期和罕见的慢速周期。 + +当 OOM 事件触发时,它会嵌入 cgroup profile 的完整副本。与 victim 一起打印的数字准确显示了选择时的状态。 -## 分析 reclaim 并跟踪 victim +## BPF 程序 -下面是完整 BPF 程序。 +下面是完整的 BPF 代码。我们将在代码之后解释它的工作原理: ```c // SPDX-License-Identifier: GPL-2.0 @@ -398,776 +426,34 @@ int capture_victim_exit(void *ctx) } ``` -`active_reclaims` 按 `pid_tgid` 关联 begin 与 end。begin callback 从 `memcg->css.cgroup->kn->id` 取得目标 cgroup ID,而不是读取当前 task 的 cgroup。两者比较产生 `cross_cgroup_reclaims`,另一个 cgroup 发起的 proactive reclaim 因此可以被直接观察。 +程序使用多个 BPF map 来维护状态。`active_reclaims` map 通过以 `pid_tgid` 为 key 存储开始时间戳来关联 begin 和 end 事件。`profiles` map 累积每个 cgroup 的统计信息。`stack_traces` map 存储去重后的内核调用栈,而 `stack_profiles` 为每个唯一调用栈聚合时间数据。 -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。 +在回收开始时,我们从 `mem_cgroup` 参数而不是当前 task 的 cgroup 中提取目标 cgroup ID。这个区别对于跨 cgroup 回收很重要。如果有人从外部对某个 cgroup 调用 `memory.reclaim`,我们可以正确地将工作归因到目标。`cross_cgroup_reclaims` 计数器跟踪这种情况发生的频率。 -`profile_reclaim_end()` 完成统计后删除 active state。它会更新总延迟和最大延迟,选择 histogram bucket,再更新对应的 sampled stack aggregate。active-state 插入失败和 stack-capture 失败都有独立计数,begin 与 end count 的差值也能反映未完成配对的 interval。 +调用栈采样使用 `--sample-every` 设置。默认值为 1 时,我们捕获每个间隔。更大的值可以减少开销,同时仍然在直方图中计算所有间隔。`BPF_F_FAST_STACK_CMP` 标志加速调用栈去重,标志中的 `2` 跳过两个 tracing 帧以获得更清晰的调用栈。 -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 不同,这条关联仍然有效。 +OOM 处理程序必须仔细解析进程身份。tracepoint 给我们一个线程 ID,但我们还需要线程组 ID(用户空间视角的 PID)和 cgroup。我们使用 `bpf_task_from_pid()` 查找 task,读取所需信息,然后释放引用。victim 状态以 TID 为 key,因为 `sched_process_exit` 在该线程的上下文中触发。 -## 符号化并展示 profile +## 用户空间:符号化和展示 -用户态程序加载 kernel symbol、排序 stack aggregate、管理可选 cgroup filter,并提供可重复的 OOM demo。 +用户空间代码负责几项工作:加载内核符号用于调用栈符号化,使用任何 cgroup 过滤器设置 BPF 程序,处理来自 ring buffer 的事件,以及按总耗时对调用栈进行排名。它还包含一个 demo 模式,可以创建一个内存受限的 cgroup 并触发 OOM 来验证工具是否正常工作。 -```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 -#include -#include -#include -#include -#include "oom_watch.h" -#include "oom_watch.skel.h" - -struct options { - const char *cgroup_path; - unsigned int duration_seconds; - unsigned int sample_every; - bool demo; -}; +完整的用户空间代码相当长,所以我们重点介绍关键部分。启动时,它读取 `/proc/kallsyms`,按地址排序符号,然后使用二分查找解析每个栈帧。当符号地址受限时(在生产系统上很常见),它仍然打印原始地址——测量结果保持准确。 -static volatile sig_atomic_t stop; -static int victim_events; -static int exit_events; -static unsigned long long observed_cgroup_id; -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; -}; +调用栈组按累计回收时间排名。这种排名可以展示频繁调用的路径和罕见的慢速间隔。在 OOM 事件之后,打印前五个调用栈及其时间统计。 -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; - int continue_fd; -}; - -static struct allocation_context allocation_context; - -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 write_text(const char *path, const char *text) -{ - int fd = open(path, O_WRONLY | O_CLOEXEC); - ssize_t length = strlen(text); - int err = 0; - - if (fd < 0) - return -1; - if (write(fd, text, length) != length) - err = -1; - close(fd); - return err; -} - -static int memory_controller_enabled(bool *enabled) -{ - char controllers[4096]; - ssize_t length; - int fd; - - fd = open("/sys/fs/cgroup/cgroup.subtree_control", - O_RDONLY | O_CLOEXEC); - if (fd < 0) - return -1; - length = read(fd, controllers, sizeof(controllers) - 1); - close(fd); - if (length < 0) - return -1; - controllers[length] = '\0'; - *enabled = strstr(controllers, "memory") != NULL; - 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; - - 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->profile.begin_count; - observed_victim_tid = event->victim_tid; - 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 " - "reclaimed_pages=%llu\n", - event->victim_pid, event->victim_tid, event->comm, - event->triggering_tgid, - (unsigned long long)event->cgroup_id, - (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->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", - event->victim_pid, event->victim_tid, - (unsigned long long)event->cgroup_id, event->exit_code); - } - return 0; -} - -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 [--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) -{ - 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:s:Dh", long_options, - NULL)) != -1) { - switch (option) { - case 'c': options->cgroup_path = optarg; break; - case 'd': - 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; - case 'h': usage(argv[0]); exit(0); - default: return -1; - } - } - return optind == argc && !(options->demo && options->cgroup_path) ? 0 : -1; -} - -static void *allocation_worker(void *argument) -{ - struct allocation_context *context = argument; - size_t first_stage = 24 * 1024 * 1024; - size_t length = 128 * 1024 * 1024; - unsigned char *memory; - char byte = 'x'; - - memory = mmap(NULL, length, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (memory == MAP_FAILED) - _exit(4); - for (size_t offset = 0; offset < first_stage; offset += 4096) - memory[offset] = 0xa5; - if (write(context->ready_fd, &byte, 1) != 1 || - read(context->continue_fd, &byte, 1) != 1) - _exit(5); - for (size_t offset = first_stage; offset < length; offset += 4096) - memory[offset] = 0xa5; - _exit(6); -} - -static void allocate_until_killed(const char *cgroup_path, int ready_fd, - int continue_fd) -{ - char procs_path[512]; - char pid_text[32]; - pthread_t worker; - - snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", cgroup_path); - snprintf(pid_text, sizeof(pid_text), "%d", getpid()); - if (write_text(procs_path, pid_text)) - _exit(3); - allocation_context.ready_fd = ready_fd; - allocation_context.continue_fd = continue_fd; - if (pthread_create(&worker, NULL, allocation_worker, - &allocation_context)) - _exit(4); - pthread_detach(worker); - pthread_exit(NULL); -} - -static int configure_demo_cgroup(const char *path, bool *created, - bool *enabled_by_demo) -{ - bool memory_enabled; - char file[512]; - - if (memory_controller_enabled(&memory_enabled)) - return -1; - if (!memory_enabled) { - if (write_text("/sys/fs/cgroup/cgroup.subtree_control", "+memory")) - return -1; - *enabled_by_demo = true; - } - if (mkdir(path, 0755)) - return -1; - *created = true; - snprintf(file, sizeof(file), "%s/memory.max", path); - if (write_text(file, "33554432")) - return -1; - snprintf(file, sizeof(file), "%s/memory.swap.max", path); - if (write_text(file, "0")) - return -1; - snprintf(file, sizeof(file), "%s/memory.oom.group", path); - return write_text(file, "1"); -} - -static int trigger_cross_cgroup_reclaim(const char *cgroup_path) -{ - char reclaim_path[512]; - - snprintf(reclaim_path, sizeof(reclaim_path), "%s/memory.reclaim", - cgroup_path); - if (!write_text(reclaim_path, "8388608") || errno == EAGAIN) - return 0; - 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 = { .sample_every = 1 }; - struct selected_cgroup selected = {}; - struct oom_runtime runtime = {}; - int err = 1; - - setvbuf(stdout, NULL, _IONBF, 0); - if (parse_options(argc, argv, &options)) { - usage(argv[0]); - return 2; - } - if (select_cgroup(&options, &selected) || - prepare_runtime(&runtime, &options, &selected)) - goto cleanup; - - if (selected.path) - printf("oom-watch tracing cgroup=%s cgroup_id=%llu\n", - selected.path, - (unsigned long long)selected.metadata.st_ino); - else - printf("oom-watch tracing all cgroups\n"); - - 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: - destroy_runtime(&runtime); - cleanup_selected_cgroup(&selected, &err); - return err; -} -``` - -启动时,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 状态。 +demo 模式创建一个 `memory.max=32 MiB`、无 swap、启用分组 OOM 行为的 cgroup。一个 worker 进程首先 fault 24 MiB 然后暂停,让父进程从 cgroup 外部通过 `memory.reclaim` 请求 8 MiB。这会测试跨 cgroup 归因。然后 worker 继续 fault 一个 128 MiB 的映射直到 OOM 杀死它。进程 leader 在第二阶段之前退出,测试我们是否正确地独立处理 TGID 和 victim TID。 ## 编译和运行 -构建 profiler: +构建工具: ```bash cd src/57-oom-watch make ``` -分析一个 service cgroup 60 秒,每 10 次 reclaim interval 采样一次 kernel stack: +分析一个特定的 cgroup 60 秒,每十个回收间隔采样一次内核调用栈: ```bash sudo ./oom_watch \ @@ -1176,13 +462,13 @@ sudo ./oom_watch \ --sample-every 10 ``` -省略 `--cgroup` 会观察全部 cgroup,省略 `--duration` 则持续运行到收到中断。内置 demo 会捕获每次 reclaim stack: +省略 `--cgroup` 可以观察所有 cgroup。省略 `--duration` 可以持续运行直到中断。内置 demo 会捕获每次回收调用栈: ```bash sudo ./oom_watch --demo ``` -PID、cgroup ID、地址和延迟会随运行变化。下面是一次真实运行的节选,保留完整 profile 总计,并缩短 stack frame 便于阅读: +下面是一次真实 demo 运行的输出。PID、cgroup ID、地址和时间在不同运行之间会有变化: ```text oom-watch tracing cgroup=/sys/fs/cgroup/ebpf-oom-watch-1262 cgroup_id=151 @@ -1206,25 +492,27 @@ demo result=matched-profile-to-victim 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`。 +看看这告诉我们什么:在 kill 之前完成了 44 个回收周期,其中一半是从目标 cgroup 外部触发的。直方图显示大多数周期很快(不到 8 微秒),但有几个花了更长时间。两个排名的调用栈将分配触发的回收与显式的 `memory.reclaim` 请求区分开来。exit 事件确认 victim 收到了 `SIGKILL`。 ## 环境要求 | 要求 | 说明 | |---|---| -| 内核 | 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,不需要特殊硬件 | +| 内核 | 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 | 带 memory controller 的 cgroup v2;demo 模式需要 cgroup 管理权限 | +| 权限 | root 或等效的 BPF 和 tracing capability | +| 架构 | 已在 x86-64 上测试;不需要特殊硬件 | ## 实现范围 -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` 会把名称换成地址,并不会改变测量结果。 +Profile 从程序附加开始累积直到退出,使用有界的 LRU map:4096 个 cgroup profile、4096 个活动间隔、8192 个调用栈聚合和 1024 个唯一调用栈。每个 `pid_tgid` 保留一个活动间隔,与 begin/end 跟踪模式相匹配。工具捕获内核调用栈而不是用户调用栈,并将符号化视为展示——受限的 `kallsyms` 会将名称变为地址,但不影响测量结果。 ## 总结 -`oom-watch` 把 OOM kill 之前的阶段变成可以检查的证据。它测量每个匹配的 memcg reclaim interval,采样并排序内核路径,把工作归因到目标 cgroup,再将 profile 与 victim selection 和 exit 关联起来。 +`oom-watch` 将 OOM kill 之前的混乱变成可以检查的证据。它测量每个 memcg 回收间隔,采样并排名消耗时间的内核路径,正确归因工作到目标 cgroup(即使对于跨 cgroup 回收),并将此 profile 与 victim 选择和退出关联起来。 + +下次容器死掉有人问"发生了什么?"时,你将不仅仅有一行内核日志可以展示。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 diff --git a/src/60-afxdp-dump/README.md b/src/60-afxdp-dump/README.md index 34e20264..bb907e71 100644 --- a/src/60-afxdp-dump/README.md +++ b/src/60-afxdp-dump/README.md @@ -1,24 +1,40 @@ -# eBPF Tutorial by Example: Receive UDP Packets with AF_XDP +# eBPF Tutorial: High-Performance UDP Packet Capture with AF_XDP -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. +Have you ever wondered how packet capture tools like Suricata or high-frequency trading systems receive millions of packets per second without drowning in kernel overhead? The secret is bypassing most of the network stack entirely. AF_XDP lets you intercept packets at the driver boundary, copy them directly into your application's memory, and process them without system calls for every 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. +This tutorial builds a complete packet capture tool from scratch using the raw AF_XDP interface. No helper libraries, no magic abstractions. You'll see exactly how UMEM registration, ring buffers, and XDP redirection work together. The result is `afxdp-dump`, a tool that captures IPv4 UDP packets for a specific port, prints a payload preview, and properly recycles every frame to keep receiving indefinitely. > Complete source code: -## XDP Selects; AF_XDP Delivers +## Why AF_XDP? -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. +Traditional packet capture with `libpcap` or raw sockets has a fundamental problem: every packet crosses the kernel-userspace boundary through expensive system calls. When you're capturing 10 Gbps of traffic, this overhead becomes the bottleneck, not your processing code. -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 checks XSKMAP with `bpf_map_lookup_elem()` before redirecting, support added in Linux 5.3. Its compare-and-detach cleanup uses the expected-program FD added in Linux 5.7, which sets the complete tool's minimum kernel version. +AF_XDP solves this by establishing shared memory between kernel and userspace. The kernel writes packets directly into memory your application can read. You communicate through lock-free ring buffers instead of system calls. A single `poll()` can wake you for hundreds of packets. This architecture enables packet rates of millions per second on commodity hardware. -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. +The technology has real production use. Meta runs AF_XDP in their load balancers. Cilium uses it for Kubernetes networking. High-frequency trading firms use it to shave microseconds off their latency. Even if you never build a trading system, understanding AF_XDP teaches you patterns that appear throughout high-performance systems: shared memory, lock-free data structures, and explicit ownership transfer. -That last step is what keeps the receiver alive. Without recycling, the initial 64 addresses would be exhausted after 64 packets. A 65-packet run proves that at least one frame has completed the entire ownership cycle. +## The AF_XDP Architecture -## Shared Queue Limit +AF_XDP works through four components that must coordinate precisely: -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. +**UMEM (User Memory)** is a region of memory you allocate that both kernel and userspace can access. You divide it into fixed-size frames, typically 4096 bytes each. Every packet the kernel delivers arrives in one of these frames. + +**The Fill Ring** is how you tell the kernel which frames are available for incoming packets. You post frame addresses here. The kernel consumes these addresses when it needs somewhere to put a packet. + +**The RX Ring** is where the kernel tells you about received packets. Each entry contains a frame address and packet length. When you see an entry here, you own that frame until you return it. + +**XSKMAP** is a BPF map that connects XDP programs to AF_XDP sockets. The XDP program decides which packets to redirect, looks up the socket for the current RX queue in this map, and calls `bpf_redirect_map()` to deliver the packet. + +The flow works like this: you post 64 frame addresses to the Fill Ring. A UDP packet arrives. Your XDP program checks the destination port, looks up the socket in XSKMAP, and redirects. The kernel copies the packet into one of your frames and publishes a descriptor on the RX Ring. You read the descriptor, process the packet, and post the frame address back to the Fill Ring. The cycle continues indefinitely. + +This ownership model is critical. A frame starts with you. You lend it to the kernel via the Fill Ring. The kernel borrows it to receive a packet. You reclaim it from the RX Ring. You must return it to the Fill Ring or you'll run out of frames after 64 packets. Our tool proves this works by successfully capturing 65 packets, which requires at least one frame to complete the full ownership cycle. + +## The XDP Program: Filtering and Redirecting + +The kernel-side BPF program is compact because it only handles filtering and redirection. All the complexity of buffer management lives in userspace. + +First, we define a shared header that sets the XSKMAP capacity. Queue IDs are map keys, so this example can address queues 0 through 63: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -30,9 +46,7 @@ The shared header sets the XSKMAP capacity. Queue IDs are the map keys, so this #endif /* __AFXDP_DUMP_H */ ``` -## Filtering and Redirecting in XDP - -The complete BPF program is small because buffer management belongs to the AF_XDP side. +Now the XDP program itself: ```c // SPDX-License-Identifier: GPL-2.0 @@ -99,13 +113,19 @@ int redirect_udp(struct xdp_md *ctx) } ``` -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 program parses packets layer by layer, validating boundaries at each step. This careful bounds checking is required by the BPF verifier and also ensures we don't misinterpret truncated or malformed packets. -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. +The parsing starts with Ethernet, checking that there's room for the header and that the EtherType indicates IPv4. Then it validates the IPv4 header: correct version, UDP protocol, minimum header length, and no fragmentation (fragmented packets would need reassembly, which is beyond our scope). The program computes the actual IP header length from the IHL field and uses it to locate the UDP header. -## Building an AF_XDP Socket from the ABI +The UDP validation ensures the length field is sane and that the destination port matches our target. Only then does the program check if there's actually a socket registered for this queue. This ordering is deliberate: most packets will fail earlier checks, so we avoid the map lookup cost for packets we won't capture anyway. -The user-space side below performs the UMEM registration, maps the rings, binds the socket, loads XDP, and runs the receive loop. +The XSKMAP lookup is also a safety check. If userspace hasn't registered a socket for this queue, the lookup returns NULL and we pass the packet to the normal stack. When everything checks out, `bpf_redirect_map()` sends the packet to AF_XDP. The second argument is the queue index, which becomes the map key. The third argument is the fallback action if something goes wrong. + +One important detail: once a packet is redirected, it's consumed by AF_XDP. The regular socket stack will never see it. This is exactly what we want for a capture tool, but it means you need to be careful about what you redirect. + +## The Userspace Application + +The userspace code handles everything AF_XDP needs: memory allocation, ring setup, socket binding, XDP loading, and the receive loop. Here's the complete implementation: ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -599,15 +619,43 @@ cleanup: } ``` -`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. +### Understanding UMEM and Ring Setup + +The `open_xsk()` function creates the AF_XDP socket and all its supporting infrastructure. It starts by mapping 256 KiB of anonymous memory for UMEM, divided into 64 frames of 4096 bytes each. This memory will be shared with the kernel after registration. + +After creating the socket with `AF_XDP`, it registers the UMEM with `XDP_UMEM_REG`, telling the kernel where our packet buffers live. Then it requests fill, completion, and RX rings of 64 entries each with `setsockopt`. The `XDP_MMAP_OFFSETS` getsockopt reveals where each ring's producer index, consumer index, flags, and descriptor array are located within pages that can be mmap'd. + +The ring mapping is tricky because the kernel lays out each ring at a fixed page offset. The Fill Ring lives at `XDP_UMEM_PGOFF_FILL_RING`, the Completion Ring at `XDP_UMEM_PGOFF_COMPLETION_RING`, and the RX Ring at `XDP_PGOFF_RX_RING`. Each ring contains pointers to the producer and consumer indices, which are the synchronization points between kernel and userspace. + +The final step before binding is posting all 64 frame addresses to the Fill Ring. We write each address into the descriptor array and then publish the producer index with release semantics. This tells the kernel it has 64 frames available for receiving packets. + +### Memory Ordering in Ring Operations + +Ring indices are shared between kernel and userspace, which makes memory ordering critical. The pattern is consistent throughout: a producer writes descriptors before publishing its index with release semantics, and a consumer acquires the producer index before reading descriptors. -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. +In `recycle_frame()`, we read our own producer index (which only we modify) with relaxed ordering, but we acquire the consumer index because the kernel writes it. If there's room in the ring, we write the address and publish with release. The kernel will eventually acquire our producer update and see the address we wrote. -`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. +The same pattern appears in the receive loop. We acquire the producer index written by the kernel, read descriptors, and publish our consumer update with release. This ensures the kernel knows we're done with those frames before it reuses them. -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. +### Copy Mode vs Zero-Copy -## Build and Run +The `XDP_COPY` flag in the socket address tells AF_XDP to copy packets into UMEM rather than doing true zero-copy. Copy mode works on any interface without driver support, making it the right choice for a learning example. The kernel allocates its own memory for incoming packets and copies them into our UMEM frames. + +The XDP program attach mode is different. We first try native driver mode (`XDP_FLAGS_DRV_MODE`), which runs the XDP program in the driver before SKB allocation. If that fails because the driver doesn't support XDP, we fall back to generic SKB mode (`XDP_FLAGS_SKB_MODE`), which runs after the SKB is created but still lets us redirect to AF_XDP. The `--skb-mode` flag forces generic mode directly. + +### The Receive Loop + +The receive loop polls for packets, processes them in batches, and recycles frames immediately. The 250ms timeout ensures we can respond to signals even when no packets arrive. + +When poll indicates data, we check the RX ring for new descriptors. For each one, we compute the actual data address (the descriptor address might be encoded with offset information), verify it's within UMEM bounds, and check that this isn't a multi-buffer packet (which would have `XDP_PKT_CONTD` set). Multi-buffer support would require accumulating fragments, which is beyond this example's scope. + +After printing the packet, we immediately return the frame to the Fill Ring via `recycle_frame()`. This is critical: without recycling, we'd run out of frames after 64 packets. The tool proves recycling works by successfully capturing 65 packets. + +### Cleanup and Safe Detach + +On exit, we use compare-and-detach to remove only our XDP program. The `old_prog_fd` option tells `bpf_xdp_detach` to only detach if the currently attached program matches ours. This prevents accidentally detaching someone else's XDP program if they attached one while we were running. + +## Compilation and Execution Build the executable: @@ -616,48 +664,65 @@ cd src/60-afxdp-dump make ``` -Capture five UDP packets arriving on queue 0 and destination port 8080: +Capture five UDP packets arriving on queue 0 with destination port 8080: ```bash sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 ``` -`--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. +Use `--count 0` to run until interrupted with Ctrl+C. Add `--skb-mode` to force generic XDP mode. The selected queue must actually receive the traffic; on multi-queue NICs, this depends on RSS (receive-side scaling) configuration. -A longer run with `--count 65` makes frame recycling visible. One real run produced: +Send test traffic from another machine or terminal: + +```bash +echo "hello-afxdp" | nc -u target-ip 8080 +``` + +A longer run with `--count 65` demonstrates that frame recycling works: ```text -afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 +afxdp-dump ready interface=eth0 queue=0 port=8080 mode=driver count=65 packet=1 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" packet=65 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" redirected=65 ``` -`packet=65` demonstrates frame reuse beyond the 64 initially posted frames. `redirected=65` comes from the BPF-side counter. The XDP path returns `XDP_PASS` for other destination ports, so they remain on the normal network path. +Packet 65 proves that at least one frame completed the full ownership cycle: posted to Fill, used for receive, consumed from RX, and posted back to Fill. The `redirected=65` counter comes from the BPF program and matches our receive count. ## Requirements | Requirement | Details | |---|---| -| Kernel | Linux 5.7 or newer; AF_XDP arrived in 4.18, XSKMAP lookup from XDP in 5.3, and safe expected-FD detach in 5.7 | +| Kernel | Linux 5.7 or newer. AF_XDP arrived in 4.18, XSKMAP lookup from XDP in 5.3, and safe expected-FD detach in 5.7 | | 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 | +| Privileges | Root or equivalent BPF and network-admin capabilities | +| Interface | Any interface with the selected RX queue. Native XDP is optional since generic mode works everywhere | +| Architecture | x86-64 is tested. Copy mode works without driver zero-copy support | + +## What's Next -## Scope +This example is deliberately minimal: receive-only, single-queue, single-buffer packets, copy mode. Real production AF_XDP applications extend this foundation in several directions: -`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. +**TX rings** let you send packets with the same zero-overhead model. You'd add a TX ring, post frame addresses with packet data, and poll for completion notifications. + +**Zero-copy mode** eliminates the copy into UMEM. The driver uses your UMEM directly, but this requires driver support and careful buffer alignment. + +**Multi-buffer packets** handle jumbo frames or when UMEM frames are smaller than the MTU. You'd accumulate fragments marked with `XDP_PKT_CONTD` until the final fragment. + +**Shared UMEM** lets multiple sockets share the same memory, useful for load-balancing across queues or between RX and TX paths. ## Summary -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 run closes the loop by proving that ownership really comes back to the fill ring. +AF_XDP gives you kernel-bypass packet reception with eBPF's safety guarantees. The XDP program selects traffic at the driver boundary, the XSKMAP routes packets to your socket, and lock-free ring buffers transfer data without system calls. This example showed the complete receive contract: post frames to Fill, receive descriptors on RX, process packets, recycle frames back to Fill. + +Understanding this flow is valuable beyond packet capture. The patterns here, shared memory between kernel and userspace, explicit ownership transfer, lock-free synchronization, appear throughout high-performance systems from databases to GPU drivers. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . ## References -- [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 from XDP commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) -- [Expected-program FD for XDP replacement and detach](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) +- [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 from XDP](https://github.com/torvalds/linux/commit/fada7fdc83c0) +- [Expected-Program FD for XDP Detach](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) +- [libxdp Library](https://github.com/xdp-project/xdp-tools) - Higher-level AF_XDP helpers if you want to skip the raw ABI diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md index 819fb84e..c121cfb0 100644 --- a/src/60-afxdp-dump/README.zh.md +++ b/src/60-afxdp-dump/README.zh.md @@ -1,24 +1,40 @@ -# eBPF 实战教程:用 AF_XDP 在用户态接收 UDP 报文 +# eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 -有些报文需要在进入普通 socket stack 之前交给用户态程序。抓包工具、协议原型或专用转发器通常需要一条清晰的接收路径:在驱动入口筛选流量,把报文放入共享内存,完成检查,再把 buffer 交还给下一次接收。 +你有没有好奇过 Suricata 这样的抓包工具或者高频交易系统是如何每秒接收数百万个报文而不被内核开销拖垮的?秘密在于绕过大部分网络协议栈。AF_XDP 让你在网卡驱动入口拦截报文,直接复制到应用程序的内存空间,处理时几乎不需要为每个报文做系统调用。 -本课直接使用 AF_XDP ABI 构建这条路径,不用库隐藏 ring 和 UMEM 的细节。最终得到的 `afxdp-dump` 会捕获一个队列上发往指定端口的 IPv4 UDP 报文,打印 payload 预览并循环使用每个 frame。它采用单队列、single-buffer packet 和 copy mode,适合作为 zero-copy 与 multi-buffer 之前的第一个 AF_XDP 例子。 +本教程从零开始,使用原始的 AF_XDP 接口构建一个完整的抓包工具。没有辅助库,没有魔法抽象。你会清楚看到 UMEM 注册、ring buffer 和 XDP 重定向是如何协同工作的。最终成果是 `afxdp-dump`,一个捕获指定端口 IPv4 UDP 报文、打印 payload 预览、并正确回收每个 frame 以持续接收的工具。 > 完整源代码: -## XDP 选择报文,AF_XDP 完成交付 +## 为什么选择 AF_XDP? -eBPF 可以让经过验证器检查的程序运行在内核 hook 上,XDP 则把其中一个 hook 放在 Linux 网络接收路径的最前端。Linux 4.18 引入的 AF_XDP 把 XDP redirect 连接到由用户态注册内存支撑的 socket,XSKMAP 再建立 RX queue number 与对应 AF_XDP socket 之间的关系。 +传统的 `libpcap` 或 raw socket 抓包有一个根本问题:每个报文都要通过昂贵的系统调用跨越内核与用户态的边界。当你抓取 10 Gbps 流量时,这些开销会成为瓶颈,而不是你的处理代码。 -两边的职责很明确。XDP 程序只解析足够的信息,判断报文是否属于这个工具;AF_XDP socket 管理共享内存 ring,并把选中的字节交给用户态。本例还会在 redirect 之前用 `bpf_map_lookup_elem()` 检查 XSKMAP,这项能力从 Linux 5.3 开始可用。清理阶段的 compare-and-detach 使用 Linux 5.7 加入的 expected-program FD,因此完整工具的最低内核版本是 5.7。 +AF_XDP 通过在内核和用户态之间建立共享内存来解决这个问题。内核把报文直接写入你的应用程序可以读取的内存,双方通过无锁的 ring buffer 通信,而不是系统调用。一次 `poll()` 就能唤醒你处理数百个报文。这种架构在普通硬件上就能达到每秒数百万报文的接收速率。 -跟着一个报文和一个 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。 +这项技术已经在生产环境中使用。Meta 的负载均衡器运行 AF_XDP,Cilium 用它做 Kubernetes 网络,高频交易公司用它来缩短微秒级延迟。即使你永远不会构建交易系统,理解 AF_XDP 也能教会你高性能系统中反复出现的模式:共享内存、无锁数据结构、显式的所有权转移。 -最后一步决定了接收循环能否持续。如果 frame 没有回收,最初的 64 个地址会在 64 个报文之后耗尽。一次接收 65 个报文,就可以用第 65 个报文证明至少有一个 frame 已经走完完整的所有权循环。 +## AF_XDP 架构 -## 共享的队列上限 +AF_XDP 通过四个必须精确协调的组件工作: -共享头文件定义 XSKMAP 容量。queue ID 是 map key,因此例子可以表示 0 到 63 号队列,每个进程绑定其中一个。 +**UMEM(User Memory)** 是你分配的、内核和用户态都能访问的内存区域。你把它划分为固定大小的 frame,通常每个 4096 字节。内核送达的每个报文都会放进这些 frame 之一。 + +**Fill Ring** 是你告诉内核哪些 frame 可用于接收报文的方式。你把 frame 地址发布到这里,内核需要存放报文时就会消费这些地址。 + +**RX Ring** 是内核通知你已接收报文的地方。每个 entry 包含 frame 地址和报文长度。当你在这里看到 entry 时,你就拥有那个 frame 的所有权,直到你归还它。 + +**XSKMAP** 是一个 BPF map,用于连接 XDP 程序和 AF_XDP socket。XDP 程序决定重定向哪些报文,在这个 map 中查找当前 RX queue 对应的 socket,然后调用 `bpf_redirect_map()` 来投递报文。 + +流程是这样的:你向 Fill Ring 发布 64 个 frame 地址。一个 UDP 报文到达。你的 XDP 程序检查目的端口,在 XSKMAP 中查找 socket,然后重定向。内核把报文复制到你的某个 frame 中,并在 RX Ring 上发布一个 descriptor。你读取 descriptor、处理报文,再把这个 frame 地址放回 Fill Ring。循环继续。 + +这个所有权模型至关重要。一个 frame 开始时属于你。你通过 Fill Ring 借给内核。内核借用它来接收报文。你从 RX Ring 回收它。你必须把它归还到 Fill Ring,否则 64 个报文之后就会耗尽 frame。我们的工具通过成功捕获 65 个报文来证明这个机制有效——这需要至少一个 frame 完成完整的所有权周期。 + +## XDP 程序:过滤和重定向 + +内核侧的 BPF 程序很精简,因为它只负责过滤和重定向。buffer 管理的所有复杂性都在用户态。 + +首先,我们定义一个共享头文件来设置 XSKMAP 容量。queue ID 是 map 的 key,所以这个例子可以寻址 0 到 63 号队列: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -30,9 +46,7 @@ eBPF 可以让经过验证器检查的程序运行在内核 hook 上,XDP 则 #endif /* __AFXDP_DUMP_H */ ``` -## 在 XDP 中筛选并重定向 - -完整 BPF 程序很短,因为 buffer 管理属于 AF_XDP 一侧。 +现在是 XDP 程序本身: ```c // SPDX-License-Identifier: GPL-2.0 @@ -99,13 +113,19 @@ int redirect_udp(struct xdp_md *ctx) } ``` -程序在每次 header 访问之前都检查边界或协议长度。它接收 Ethernet IPv4 报文,排除分片,根据 IPv4 header length 找到 UDP,再确认 UDP length 位于 IP payload 内。只有指定目的端口会进入 redirect 路径。 +程序逐层解析报文,在每一步都验证边界。这种仔细的边界检查是 BPF 验证器的要求,同时也确保我们不会误解截断或畸形的报文。 -XSKMAP lookup 也是正确性的一部分。队列没有 socket entry 时返回 `XDP_PASS`,无关报文和格式异常的报文同样沿普通网络路径继续;`bpf_redirect_map()` 的 fallback action 也是 pass。一旦 redirect 成功,报文会由 AF_XDP 消费,而不是复制一份镜像,因此普通 UDP socket 不会再收到这条被选中的报文。 +解析从 Ethernet 开始,检查是否有足够空间容纳头部,以及 EtherType 是否表示 IPv4。然后验证 IPv4 头部:正确的版本、UDP 协议、最小头部长度、没有分片(分片报文需要重组,超出了我们的范围)。程序从 IHL 字段计算实际的 IP 头部长度,并用它来定位 UDP 头部。 -## 直接使用 ABI 构建 AF_XDP socket +UDP 验证确保长度字段合理,目的端口与目标匹配。只有这时程序才检查这个 queue 是否真的注册了 socket。这个顺序是刻意的:大多数报文会在更早的检查中失败,所以对不会捕获的报文我们避免了 map lookup 的开销。 -下面的用户态程序完成 UMEM 注册、ring 映射、socket bind、XDP 加载和接收循环。 +XSKMAP lookup 也是一个安全检查。如果用户态没有为这个 queue 注册 socket,lookup 返回 NULL,我们就把报文传递给普通协议栈。当所有检查都通过时,`bpf_redirect_map()` 把报文发送到 AF_XDP。第二个参数是 queue index,会成为 map 的 key。第三个参数是出错时的回退 action。 + +一个重要细节:一旦报文被重定向,它就被 AF_XDP 消费了。普通的 socket 协议栈永远不会看到它。这正是抓包工具需要的,但这意味着你需要小心选择重定向什么。 + +## 用户态应用程序 + +用户态代码处理 AF_XDP 需要的一切:内存分配、ring 设置、socket 绑定、XDP 加载和接收循环。以下是完整实现: ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -599,13 +619,41 @@ cleanup: } ``` -`open_xsk()` 创建 256 KiB UMEM,通过 `XDP_UMEM_REG` 注册内存,再向内核申请各 64 个 fill、completion 和 RX entry。`XDP_MMAP_OFFSETS` 描述每个 producer、consumer、flags 和 descriptor array 的位置,程序据此映射页面,并在 redirect 开始前发布全部 frame 地址。 +### 理解 UMEM 和 Ring 设置 + +`open_xsk()` 函数创建 AF_XDP socket 及其所有支撑基础设施。它首先映射 256 KiB 的匿名内存作为 UMEM,划分为 64 个 4096 字节的 frame。这块内存在注册后会与内核共享。 + +用 `AF_XDP` 创建 socket 后,它通过 `XDP_UMEM_REG` 注册 UMEM,告诉内核我们的报文 buffer 在哪里。然后用 `setsockopt` 请求各 64 个 entry 的 fill、completion 和 RX ring。`XDP_MMAP_OFFSETS` getsockopt 揭示了每个 ring 的 producer index、consumer index、flags 和 descriptor 数组在可 mmap 的页面中的位置。 + +Ring 映射有些技巧,因为内核把每个 ring 放在固定的页面偏移。Fill Ring 在 `XDP_UMEM_PGOFF_FILL_RING`,Completion Ring 在 `XDP_UMEM_PGOFF_COMPLETION_RING`,RX Ring 在 `XDP_PGOFF_RX_RING`。每个 ring 包含指向 producer 和 consumer index 的指针,它们是内核和用户态之间的同步点。 + +bind 之前的最后一步是把所有 64 个 frame 地址发布到 Fill Ring。我们把每个地址写入 descriptor 数组,然后用 release 语义发布 producer index。这告诉内核有 64 个 frame 可用于接收报文。 + +### Ring 操作中的内存顺序 + +Ring index 由内核和用户态共享,这使得内存顺序至关重要。模式是一致的:producer 在用 release 语义发布 index 之前先写 descriptor,consumer 在读取 descriptor 之前先 acquire producer index。 -ring index 由内核和用户态共享,因此内存顺序很关键。producer 需要先写 descriptor,再用 release 语义发布新 index;consumer 则先 acquire producer index,再读取 descriptor。代码在消费 RX entry 和向 fill ring 归还地址时都遵循这组规则。 +在 `recycle_frame()` 中,我们用 relaxed 顺序读取自己的 producer index(只有我们修改它),但要 acquire consumer index,因为内核会写它。如果 ring 有空间,我们写入地址并用 release 发布。内核最终会 acquire 我们的 producer 更新并看到我们写的地址。 -socket bind 使用的 `XDP_COPY` 与 XDP attach mode 解决的是两个问题。copy mode 决定报文如何进入 UMEM,不依赖驱动的 zero-copy 支持;XDP 程序本身会先尝试 native driver mode,如果网卡不支持,再回退到 generic SKB mode。`--skb-mode` 可以直接选择 generic 路径。 +同样的模式出现在接收循环中。我们 acquire 内核写的 producer index,读取 descriptor,用 release 发布我们的 consumer 更新。这确保内核知道我们已经完成这些 frame,然后才能复用它们。 -接收循环要求一个 descriptor 对应一个完整报文。遇到 `XDP_PKT_CONTD` 时会返回 `EMSGSIZE`,避免把 multi-buffer packet 的第一个 fragment 当成完整报文打印。地址和长度检查还会保证 descriptor 始终落在已注册 UMEM 内。退出时 compare-and-detach 把当前 program FD 作为 `old_prog_fd`,工具只会移除自己挂载的 XDP 程序。 +### Copy Mode 与 Zero-Copy + +Socket 地址中的 `XDP_COPY` 标志告诉 AF_XDP 把报文复制到 UMEM,而不是真正的 zero-copy。Copy mode 在任何接口上都能工作,不需要驱动支持,是学习示例的正确选择。内核为传入报文分配自己的内存,然后复制到我们的 UMEM frame。 + +XDP 程序的 attach mode 是另一回事。我们首先尝试 native driver mode(`XDP_FLAGS_DRV_MODE`),它在 SKB 分配之前在驱动中运行 XDP 程序。如果因为驱动不支持 XDP 而失败,我们回退到 generic SKB mode(`XDP_FLAGS_SKB_MODE`),它在 SKB 创建之后运行,但仍然让我们重定向到 AF_XDP。`--skb-mode` 标志直接强制使用 generic mode。 + +### 接收循环 + +接收循环轮询报文,批量处理它们,并立即回收 frame。250ms 超时确保即使没有报文到达,我们也能响应信号。 + +当 poll 指示有数据时,我们检查 RX ring 是否有新 descriptor。对于每一个,我们计算实际的数据地址(descriptor 地址可能编码了 offset 信息),验证它在 UMEM 边界内,并检查这不是一个 multi-buffer 报文(那会设置 `XDP_PKT_CONTD`)。Multi-buffer 支持需要累积分片,超出了这个例子的范围。 + +打印报文后,我们立即通过 `recycle_frame()` 把 frame 归还到 Fill Ring。这至关重要:不回收的话,64 个报文之后就会耗尽 frame。工具通过成功捕获 65 个报文来证明回收有效。 + +### 清理和安全卸载 + +退出时,我们使用 compare-and-detach 只移除我们自己的 XDP 程序。`old_prog_fd` 选项告诉 `bpf_xdp_detach` 只在当前挂载的程序与我们的匹配时才卸载。这防止了在我们运行期间如果有人挂载了其他 XDP 程序时,意外卸载别人的程序。 ## 编译和运行 @@ -622,36 +670,52 @@ make sudo ./afxdp_dump --interface eth0 --queue 0 --port 8080 --count 5 ``` -`--count 0` 会持续运行到收到信号,`--skb-mode` 用于 generic XDP。选中的 queue 必须实际接收这条流量,在多队列网卡上,这取决于 NIC 的 receive-side steering 配置。 +使用 `--count 0` 持续运行直到 Ctrl+C 中断。添加 `--skb-mode` 强制使用 generic XDP mode。选中的 queue 必须实际接收到流量;在多队列网卡上,这取决于 RSS(receive-side scaling)配置。 -使用 `--count 65` 运行更长时间,可以直接看到 frame 已经完成复用。一次真实运行的输出如下: +从另一台机器或另一个终端发送测试流量: + +```bash +echo "hello-afxdp" | nc -u target-ip 8080 +``` + +使用 `--count 65` 运行更长时间可以证明 frame 回收有效: ```text -afxdp-dump ready interface=axdp1267r queue=0 port=8080 mode=driver count=65 +afxdp-dump ready interface=eth0 queue=0 port=8080 mode=driver count=65 packet=1 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" packet=65 10.77.0.1:60414 -> 10.77.0.2:8080 bytes=53 payload="hello-afxdp" redirected=65 ``` -`packet=65` 证明 frame 已经在最初的 64 个地址之外完成复用,`redirected=65` 来自 BPF 侧计数器。XDP 路径会对其他目的端口返回 `XDP_PASS`,让这些流量继续走普通网络路径。 +Packet 65 证明至少有一个 frame 完成了完整的所有权周期:发布到 Fill,用于接收,从 RX 消费,再发布回 Fill。`redirected=65` 计数器来自 BPF 程序,与我们的接收计数一致。 ## 环境要求 | 要求 | 说明 | |---|---| -| 内核 | Linux 5.7 或更高版本,AF_XDP 在 4.18 引入,XDP 对 XSKMAP 的 lookup 在 5.3 引入,安全的 expected-FD detach 在 5.7 引入 | +| 内核 | Linux 5.7 或更高版本。AF_XDP 在 4.18 引入,XDP 对 XSKMAP 的 lookup 在 5.3 引入,安全的 expected-FD detach 在 5.7 引入 | | 内核配置 | `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 | +| 权限 | root 或等价的 BPF 与网络管理 capability | +| 网络接口 | 任何包含所选 RX queue 的接口。native XDP 可选,generic mode 处处可用 | +| 架构 | x86-64 已测试。copy mode 不需要驱动 zero-copy 支持 | + +## 后续扩展 -## 实现范围 +这个例子有意做得最小化:只接收、单队列、single-buffer 报文、copy mode。真正的生产 AF_XDP 应用会在这个基础上向多个方向扩展: -`afxdp-dump` 是一个 receive-only、单队列、single-buffer 的 IPv4 UDP 工具,使用固定的 64-frame UMEM,最多打印 32 个 payload 字符。它保留了一个实用接收器需要的 buffer 生命周期,TX ring、shared UMEM、zero-copy、multi-buffer 重组和 RX metadata 可以在后续例子中继续展开。 +**TX ring** 让你用同样的零开销模型发送报文。你需要添加 TX ring,发布带有报文数据的 frame 地址,然后轮询 completion 通知。 + +**Zero-copy mode** 消除了复制到 UMEM 的过程。驱动直接使用你的 UMEM,但这需要驱动支持和仔细的 buffer 对齐。 + +**Multi-buffer 报文** 处理巨型帧或 UMEM frame 小于 MTU 的情况。你需要累积标记了 `XDP_PKT_CONTD` 的分片,直到最后一个分片。 + +**Shared UMEM** 让多个 socket 共享同一块内存,对于跨 queue 负载均衡或 RX 和 TX 路径之间共享很有用。 ## 总结 -这个例子展示了完整的 AF_XDP 接收契约:XDP 选择一条 UDP 流,XSKMAP 把 RX queue 解析到 socket,内核发布 UMEM descriptor,用户态检查完成后归还 frame。一次接收 65 个报文,进一步证明所有权确实回到了 fill ring。 +AF_XDP 给你提供了具有 eBPF 安全保证的内核旁路报文接收。XDP 程序在驱动边界选择流量,XSKMAP 把报文路由到你的 socket,无锁的 ring buffer 不用系统调用就能传输数据。这个例子展示了完整的接收契约:向 Fill 发布 frame,在 RX 上接收 descriptor,处理报文,把 frame 回收到 Fill。 + +理解这个流程的价值超越了抓包本身。这里的模式——内核与用户态之间的共享内存、显式的所有权转移、无锁同步——在从数据库到 GPU 驱动的各种高性能系统中反复出现。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 @@ -660,4 +724,5 @@ redirected=65 - [Linux AF_XDP 文档](https://docs.kernel.org/networking/af_xdp.html) - [AF_XDP 引入 commit](https://github.com/torvalds/linux/commit/c0c77d8fb787cfe0c3fca689c2a30d1dad4eaba7) - [XDP 支持 XSKMAP lookup 的 commit](https://github.com/torvalds/linux/commit/fada7fdc83c0) -- [XDP replace 与 detach 的 expected-program FD](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) +- [XDP detach 的 expected-program FD](https://github.com/torvalds/linux/commit/92234c8f15c8d96ad7e52afdc5994cba6be68eb9) +- [libxdp 库](https://github.com/xdp-project/xdp-tools) - 如果想跳过原始 ABI,这里有更高层的 AF_XDP 辅助函数 From aa361fae5f975ab760793b69e23cfc48f1f4ff26 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 14:36:50 -0700 Subject: [PATCH 07/13] docs: simplify tutorial writing workflow --- .../bpf-tutorial-writing-style/SKILL.md | 14 ++- .../write-bpf-production-tutorial/SKILL.md | 93 +++---------------- .../agents/openai.yaml | 4 +- scripts/guideline_advance.md | 6 +- 4 files changed, 28 insertions(+), 89 deletions(-) diff --git a/.agents/skills/bpf-tutorial-writing-style/SKILL.md b/.agents/skills/bpf-tutorial-writing-style/SKILL.md index 61c15c12..fb7966c7 100644 --- a/.agents/skills/bpf-tutorial-writing-style/SKILL.md +++ b/.agents/skills/bpf-tutorial-writing-style/SKILL.md @@ -5,6 +5,8 @@ description: Style checklist for English and Chinese bpf-developer-tutorial READ # BPF Tutorial Writing Style +This checklist is reference material, not a mandatory second pass after `$write-bpf-production-tutorial`. When Claude is the delegated writer, do not use this skill to review or rewrite Claude's result, do not send it to Claude as an additional checklist, and do not ask Claude for another revision. Codex may make local word-choice and punctuation edits after the delegated writing pass while preserving sentence meaning and paragraph structure. + Read the complete guidelines first: - [Advanced tutorial guideline](https://github.com/eunomia-bpf/bpf-developer-tutorial/blob/main/scripts/guideline_advance.md) for tutorials 40+ - [Basic tutorial guideline](https://github.com/eunomia-bpf/bpf-developer-tutorial/blob/main/scripts/guideline_basic.md) for tutorials 0-39 @@ -41,12 +43,14 @@ Use this Chinese paragraph only as a voice reference for connected rhythm, restr > libbpf 是一个 C/C++ 的 eBPF 用户态加载和控制库,随着内核一起分发,几乎已经成为 eBPF 用户态事实上的 API 标准,libbpf 也支持 CO-RE(Compile Once – Run Everywhere) 的解决方案,即预编译的 bpf 代码可以在不同内核版本上正常工作,而无需为每个特定内核重新编译。 -## Teach from complete source +## Teach from the code that matters -- Introduce one component, show its complete source in an ordinary Markdown fence, then explain the important logic before moving to the next component. -- Keep every core source file byte-exact and complete, including the user-space loader. Preserve comments and commands; use focused excerpts only after the complete block when they help explain a specific mechanism. +- Introduce one component, show the code needed to understand it in an ordinary Markdown fence, then explain the important logic before moving to the next component. +- Show the core kernel-side eBPF program in full when its complete control flow is the lesson. Large or secondary kernel files may use focused excerpts when the omitted parts do not carry the mechanism being taught. +- Keep the user-space discussion compact. Show the loader, configuration, event loop, and cleanup functions only when they help explain the interaction with BPF. A complete user-space loader is optional, and omitting boilerplate is not a tutorial failure. +- Keep every included code excerpt faithful to the repository source. Link to the tutorial directory once so readers can inspect the complete implementation. - Use neither `
` nor HTML synchronization markers. -- Link to the complete lesson once through its GitHub directory. Avoid an opening catalog of individual files. +- Avoid an opening catalog of individual files. - Every Markdown link uses a stable absolute `https://` target. GitHub, kernel.org, and authoritative documentation sites are all valid; relative links are prohibited. - Public prose contains no local path, shared test repository, VM name, copy route, cache, prompt, model, agent, or trace detail. @@ -57,4 +61,4 @@ Use this Chinese paragraph only as a voice reference for connected rhythm, restr - End with a compact summary, repository invitation, and primary references. - Keep the English and Chinese files aligned on structure, facts, source, commands, output, limits, and references while allowing each language to sound natural. -The final read should answer: what problem is solved, how one event moves through the system, which eBPF mechanism makes it possible, which code matters, how to run it, what success looks like, and where the example stops. +The final read should answer: what problem is solved, how one event moves through the system, which eBPF mechanism makes it possible, which code matters, how to run it, what success looks like, and where the example stops. It does not need to reproduce every line of user-space implementation. diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index 43cdda8a..5caf2580 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -1,97 +1,32 @@ --- name: write-bpf-production-tutorial -description: Design, write, or rewrite one or more practical bilingual bpf-developer-tutorial lessons with pinned Claude Opus 4.5, then verify the public workflow, complete source, and prose against the implementation. Use when creating README.md and README.zh.md, checking whether a tutorial tool has a realistic CLI and lifecycle, testing the example, or preparing tutorial changes for review. +description: Write or rewrite bilingual bpf-developer-tutorial lessons by assigning each tutorial's English and Chinese README pair to its own pinned Claude Opus 4.5 process. Use when creating or revising README.md and README.zh.md while requiring Claude to edit one tutorial paragraph by paragraph from the reader's perspective, allowing Codex to make local word and punctuation edits only and forbidding an additional prose review or rewrite request. --- # Write a BPF Tutorial -Use `$bpf-tutorial-writing-style` for the finished prose. Keep this workflow small: prepare reliable source material, let one pinned writer complete both languages, then check what it actually wrote. +Keep one writer for the complete reader-facing text. One Claude Opus process performs one free, paragraph-by-paragraph pass over one tutorial's English and Chinese README pair. Codex coordinates the invocation and performs mechanical checks without becoming a second writer or reviewer. -## 1. Prepare the lesson +## 1. Give Opus the complete task once -Read these inputs before writing: +Use the exact model ID `claude-opus-4-5-20251101`. Stop when that model is unavailable instead of substituting another model. -- `scripts/guideline_advance.md` for tutorials 40+, `scripts/guideline_basic.md` for tutorials 0-39; -- both README files from `src/47-cuda-events`, `src/48-energy`, and `src/49-hid` as style references; -- the lesson's implementation, headers, Makefile, fixtures, and tests; -- the current README pair when revising an existing lesson; -- primary upstream sources for versions and feature semantics. +Run one non-interactive invocation from the repository root for one tutorial directory. Name that tutorial's English and Chinese README pair, the applicable `scripts/guideline_advance.md` or `scripts/guideline_basic.md`, and existing tutorials as general references. Ask Claude to revise every paragraph freely from the reader's perspective, improve readability, adjust content or structure wherever useful, finish both files before returning, and ask no questions. -Collect the facts the reader needs: the problem, useful alternatives, the kernel/user-space flow, feature versions, requirements, intended public commands, real output, concurrency or admission behavior, cleanup, limits, and references. Keep every claim grounded in the code, tests, captured output, or a primary source. Preserve an existing draft before a from-scratch rewrite. +Start a separate Claude process for every additional tutorial. Never batch README pairs from different tutorial directories into one process. -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. +Keep the prompt to that request. Do not add a paragraph plan, fact inventory, style checklist, defect list, review rubric, acceptance criteria, or instructions for a later revision. Give Claude permission to read the repository and write only the target README files. Claude does not commit or push. -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. +## 2. Preserve single-writer ownership -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. +Treat Claude as the sole author of sentences, headings, paragraph order, explanations, and translations. After Claude returns, Codex may make local word-choice and punctuation edits. Keep those edits within the existing sentence meaning and paragraph structure. Codex must not add or remove information, rewrite sentences, reorder paragraphs, change headings, shorten or expand explanations, or alter translations substantively. -## 2. Pass the practical-design gate +Use exactly one Claude writing pass per tutorial. Do not ask Claude to review its result, respond to a defect list, polish selected paragraphs, or rewrite the tutorial again. Do not invoke another model, subagent, or independent reviewer for the prose. When the result needs another writing pass, report that fact to the user and wait for an explicit request. -Validate the tool as an operator before writing prose. Keep its public workflow separate from the deterministic fixture: +## 3. Perform mechanical checks only -- State the operational question, the exact command a reader would run, the independent workload or target, the useful output, and how the tool stops. -- Classify the lifecycle. A scan or atomic control action may be one-shot; a tracer or monitor must attach before the workload and remain active until a signal, duration, or real completion condition. -- Do not turn a blocked toy child or `/bin/true` fixture into the public CLI merely because it removes a test race. Launch-scoped tracing is valid only when it is the intended real workflow. -- Emit and test an explicit readiness signal before starting an independent workload. Do not use a fixed sleep as proof that setup or attachment completed. -- Check target scope and filters, concurrent state, admission bounds, drop and failure counters, exit status, normal cleanup, signal cleanup, and destructive-action safety in proportion to the lesson. -- For asynchronous work, stop admission first, wait for completed work rather than merely entered callbacks, drain output, report stable health, and only then destroy resources. -- Execute the documented command as written. The test must prove the real lifecycle plus one relevant failure or cleanup path, not only that the BPF program loaded. -- Describe the current public workflow directly. Do not narrate removed flags, old child-command modes, or other migration history unless backward compatibility is itself the lesson. -- Keep repository tests out of the reader-facing tutorial path. Use them as private validation evidence, but teach the normal command, independent workload, useful tool output, and shutdown sequence. -- Show only output emitted by the documented tool in public examples. Never include harness lines such as `TEST-*` or `PASS`, fixture setup, test assertions, or local test-infrastructure provenance. +Check only that the intended files exist, remain nonempty, stay within the requested file scope, contain no merge markers, and expose no local paths, usernames, secrets, prompts, or private infrastructure. Do not count lines or code fences as a quality proxy. Never run `sync-source-blocks.py` or compare README code fences against a complete repository source inventory as writing acceptance. Do not require the README to contain a byte-exact copy of every source file, and do not treat an omitted complete user-space loader as a failure. These checks establish basic file integrity; they are not a prose, style, structure, factual, or technical review. -If the implementation only demonstrates a helper but is awkward or misleading as a tool, revise the implementation and test before asking the writer to explain it. Do not let polished prose overclaim an impractical design. +Run repository formatting or documentation validation only when it does not rewrite the text. Report mechanical failures without repairing words or asking Claude to revise them. Preserve prompts, responses, drafts, and failed runs; never delete real conversation or agent history. -## 3. Shape the lesson - -Follow `scripts/guideline_advance.md`, then organize the article around the example rather than a fixed heading template. A complete lesson normally has: - -- a direct title and a short introduction that says what the tool answers; -- enough eBPF, kernel-subsystem, and feature background to make the mechanism understandable, including the feature's kernel version when relevant; -- the complete kernel and user-space flow before detailed code; -- every core source file in a normal Markdown fence, followed by focused explanation; -- the normal public command, target, lifecycle, useful output, and shutdown path; -- copyable build and run commands, real output, requirements, a compact ending, and primary references. - -Use headings that fit the topic. Keep compilation after the code discussion. Explain alternatives, limits, diagrams, and extra concepts only when they materially improve the lesson. - -## 4. Let Opus write - -Claude Opus writes all reader-facing tutorial prose. Use the exact model ID `claude-opus-4-5-20251101`. Stop if that model is unavailable instead of substituting another model. - -Use one non-interactive invocation for the complete requested set. A single lesson means one English and Chinese pair; a batch means every requested pair is finished in the same invocation before Opus returns. The prompt stays short and names only: - -- every target README pair; -- `scripts/guideline_advance.md` or `scripts/guideline_basic.md`; -- `$bpf-tutorial-writing-style`; -- `src/47-cuda-events`, `src/48-energy`, and `src/49-hid` as style references; -- the request to read the implementation, rewrite each paragraph in place, and finish both files before returning without questions. - -Do not paste a second checklist, paragraph plan, fact inventory, or review rubric into the prompt. Add a technical fact only when it is unavailable in the repository. - -Run Claude from the repository root with the pinned model and permission to read the repository and write only the target README files. Opus does not commit or push. - -## 5. Check the result - -Inspect both files and the diff instead of trusting the model's final message. Confirm that: - -- both languages are complete and tell the same technical story; -- the documented command, target, lifecycle, signals, concurrency bounds, cleanup, and health output match the practical design gate; -- 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; -- every core source file, including the user-space loader, appears once in a complete ordinary Markdown fence; -- code, commands, output, versions, requirements, cleanup, and limits agree with the repository; -- sample output comes from the normal public workflow and contains no test-harness or migration/deprecation narration; -- the opening source link points only to the lesson directory; -- every published link uses a stable absolute `https://` URL; GitHub, kernel.org, and authoritative documentation sites are valid, while relative links are prohibited; -- no local infrastructure, prompt, model, agent, or trace detail appears in public text. - -Check every core header, kernel/BPF source, and user-space loader with the bundled `sync-source-blocks.py`. Then run `git diff --check`, the lesson build, its tests, the exact documented public command, and the relevant runtime test. Treat a functional run as a functional run rather than a benchmark. - -Read the finished pair once as an intermediate eBPF developer. Remove template-like detours, repeated setup, unexplained jargon, and details that belong only to the test harness. If a concrete problem remains, give Opus a short defect list in the same session and let it revise the affected paragraphs before returning. Keep prompts, responses, partial drafts, and failed runs; never delete real conversation or agent history. - -Stop after the reviewed local result unless the user asks to commit, push, or update a PR. +Stop with the local result unless the user explicitly asks to commit, push, or update a PR. diff --git a/.agents/skills/write-bpf-production-tutorial/agents/openai.yaml b/.agents/skills/write-bpf-production-tutorial/agents/openai.yaml index ad3f247f..1888061a 100644 --- a/.agents/skills/write-bpf-production-tutorial/agents/openai.yaml +++ b/.agents/skills/write-bpf-production-tutorial/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "BPF Tutorial Writer" - short_description: "Design and test practical bilingual eBPF lessons" - default_prompt: "Use $write-bpf-production-tutorial to turn this eBPF example into a practical, tested bilingual tutorial." + short_description: "Let one Opus process revise one bilingual lesson" + default_prompt: "Use $write-bpf-production-tutorial to let one pinned Claude Opus process revise this tutorial's English and Chinese README pair paragraph by paragraph from the reader's perspective." diff --git a/scripts/guideline_advance.md b/scripts/guideline_advance.md index 02ad761b..edc0a2ee 100644 --- a/scripts/guideline_advance.md +++ b/scripts/guideline_advance.md @@ -28,7 +28,7 @@ Provide an overview of the specific eBPF programs, tools, or features you'll dis Dive into the kernel-mode eBPF code and user-space code, focusing on high-level concepts rather than basic syntax. -Always include the full code as it is first. then break down the key parts. +Show the core kernel-mode eBPF program in full when it is central to the lesson, then break down the key parts. For user-space code, include only the functions and excerpts needed to understand loading, configuration, event handling, and cleanup. Link to the tutorial directory for the complete implementation. Try to avoid using too much list, make it more like a story. @@ -43,7 +43,7 @@ Follow the steps: Do not make them a list, make them some paragraphs, you can also quote some code snippets to explain the key parts of the code if needed, focus on the logic and features used in advanced eBPF development. Don't make it too long, but make sure it is informative enough and you explain everything a advanced eBPF developer wants to know. -3. Then briefly explain the user-space code +3. Then briefly explain the user-space code, using focused excerpts when they make the control flow easier to follow Aim to help readers grasp how the code works without getting bogged down in basic details. @@ -76,6 +76,6 @@ You should include the important references and resources that used in the tutor - **Focus on Advanced Concepts:** Assume readers have basic eBPF knowledge; skip elementary explanations. - **Engagement:** Encourage readers to think critically and engage with the material. - **Consistency:** Keep a consistent style and formatting throughout. -- **Code Formatting:** Ensure code snippets are well-formatted and highlight key parts. Do not change or simplify any of the code and commands, keep them as they are. +- **Code Formatting:** Ensure code snippets are well-formatted and highlight key parts. Keep every included excerpt and command faithful to the implementation. The tutorial does not need to embed the complete user-space loader. - **Proofreading:** Double-check for errors and ensure technical accuracy. - **Accessibility:** Make the content valuable for readers with advanced expertise, avoiding unnecessary simplifications. From 10c020acc6d86d0f7f644958ec4f370997978ce4 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 14:38:20 -0700 Subject: [PATCH 08/13] docs: refresh generated tutorial indexes --- README.md | 8 ++++---- README.zh.md | 8 ++++---- src/SUMMARY.md | 8 ++++---- src/SUMMARY.zh.md | 8 ++++---- src/compatibility.md | 8 ++++---- src/compatibility.zh.md | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 89d19bd1..32b11f9a 100644 --- a/README.md +++ b/README.md @@ -78,8 +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 +- [lesson 56-tc-flow-index](src/56-tc-flow-index/README.md) Building a Top-Flow Monitor with Dual Rbtree Indexing +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.md) High-Performance UDP Packet Capture with AF_XDP Tracing: @@ -92,7 +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 +- [lesson 57-oom-watch](src/57-oom-watch/README.md) Profiling Memory Reclaim Before an OOM Kill Security: @@ -105,7 +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 +- [lesson 55-dns-egress](src/55-dns-egress/README.md) Building a DNS-Derived IP Allowlist with cgroup BPF Features: diff --git a/README.zh.md b/README.zh.md index a874980a..01f5c831 100644 --- a/README.zh.md +++ b/README.zh.md @@ -77,8 +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 报文 +- [lesson 56-tc-flow-index](src/56-tc-flow-index/README.zh.md) eBPF 实战教程:用双红黑树索引构建 Top-Flow 监控器 +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 追踪: @@ -91,7 +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 +- [lesson 57-oom-watch](src/57-oom-watch/README.zh.md) eBPF 实战教程:在 OOM Kill 之前分析内存回收 安全: @@ -104,7 +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 允许列表 +- [lesson 55-dns-egress](src/55-dns-egress/README.zh.md) eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表 特性: diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 39d8dd63..e19034b4 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -69,8 +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 +- [lesson 56-tc-flow-index](56-tc-flow-index/README.md) Building a Top-Flow Monitor with Dual Rbtree Indexing +- [lesson 60-afxdp-dump](60-afxdp-dump/README.md) High-Performance UDP Packet Capture with AF_XDP Tracing: @@ -83,7 +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 +- [lesson 57-oom-watch](57-oom-watch/README.md) Profiling Memory Reclaim Before an OOM Kill Security: @@ -96,7 +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 +- [lesson 55-dns-egress](55-dns-egress/README.md) Building a DNS-Derived IP Allowlist with cgroup BPF Features: diff --git a/src/SUMMARY.zh.md b/src/SUMMARY.zh.md index a0eee1a3..0a6d8b2a 100644 --- a/src/SUMMARY.zh.md +++ b/src/SUMMARY.zh.md @@ -69,8 +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 报文 +- [lesson 56-tc-flow-index](56-tc-flow-index/README.zh.md) eBPF 实战教程:用双红黑树索引构建 Top-Flow 监控器 +- [lesson 60-afxdp-dump](60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 追踪: @@ -83,7 +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 +- [lesson 57-oom-watch](57-oom-watch/README.zh.md) eBPF 实战教程:在 OOM Kill 之前分析内存回收 安全: @@ -96,7 +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 允许列表 +- [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 8f898bad..a226f337 100644 --- a/src/compatibility.md +++ b/src/compatibility.md @@ -61,10 +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.12 | 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.7 | 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: Building a DNS-Derived IP Allowlist with cgroup BPF](55-dns-egress/README.md) | 5.12 | 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: Building a Top-Flow Monitor with Dual Rbtree Indexing](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: Profiling Memory 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: High-Performance UDP Packet Capture with AF_XDP](60-afxdp-dump/README.md) | 5.7 | 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 06aef5ad..34f6b1b4 100644 --- a/src/compatibility.zh.md +++ b/src/compatibility.zh.md @@ -61,10 +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.12 | 必需特性 | 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.7 | 必需特性 | 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 BPF 构建基于 DNS 的 IP 允许列表](55-dns-egress/README.zh.md) | 5.12 | 必需特性 | 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 实战教程:用双红黑树索引构建 Top-Flow 监控器](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 之前分析内存回收](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.7 | 必需特性 | 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 构建测试 | From 2abd1d386ad9321434ea3e8cc096182eb5134162 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 16:58:15 -0700 Subject: [PATCH 09/13] docs: improve DNS allowlist tutorial prose --- src/55-dns-egress/README.md | 110 +++++++++++++++++---------------- src/55-dns-egress/README.zh.md | 106 ++++++++++++++++--------------- 2 files changed, 112 insertions(+), 104 deletions(-) diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md index bd95a522..7e00fefb 100644 --- a/src/55-dns-egress/README.md +++ b/src/55-dns-egress/README.md @@ -1,36 +1,36 @@ # eBPF Tutorial: Building a DNS-Derived IP Allowlist with cgroup BPF -Ever tried to restrict a service so it can only connect to `api.example.com`? You might reach for an IP allowlist, but DNS answers change constantly and TTLs expire. By the time your application calls `connect()`, the kernel only sees an IP address—it has no idea which domain produced it. What's missing is a short-lived link between the DNS lookup and the subsequent connection. +Suppose you want a containerized service to communicate only with `api.example.com`. The obvious approach is an IP allowlist, but DNS answers change constantly and TTLs expire. By the time your application calls `connect()`, the kernel sees only a raw IP address with no trace of the domain that produced it. The missing piece is a short-lived correlation between the DNS lookup and the subsequent TCP connection. -This tutorial builds that link. We'll watch DNS traffic for a specific domain, learn IP addresses only from valid query-response pairs, and allow connections until the TTL expires. It's a minimal but complete policy tool that shows how cgroup BPF can coordinate across multiple kernel hooks. +This tutorial builds that correlation. We watch DNS traffic for a specific domain, extract IP addresses only from valid query-response pairs, and permit connections until the TTL expires. The result is a minimal but complete policy tool that demonstrates how cgroup BPF programs can share state across multiple kernel hooks. > Complete source code: -## Understanding the Problem: DNS and Connect Are Disconnected +## The Problem: DNS and Connect Live in Different Worlds -When your application resolves `api.example.com`, the DNS response contains an IP address and a TTL. But this information lives only in userspace—the resolver library caches it, your application calls `connect()` with the IP, and the kernel has no idea where that IP came from. This creates a fundamental security gap. +When your application resolves `api.example.com`, the DNS response contains an IP address and a TTL. But this information lives only in userspace: the resolver library caches it, your application calls `connect()` with the IP, and the kernel has no idea where that IP came from. This disconnect creates a fundamental security gap. -Consider a container that should only talk to your backend API. Traditional firewalls can block destination IPs, but they can't enforce "only connect to IPs that came from resolving api.example.com within the last 60 seconds." The kernel-level policy and the application-level DNS resolution operate in complete isolation. +Consider a container that should only talk to your backend API. Traditional firewalls can block destination IPs, but they cannot enforce a policy like "only connect to IPs that came from resolving api.example.com within the last 60 seconds." Kernel-level networking and application-level DNS resolution operate in complete isolation from each other. -cgroup BPF bridges this gap. By attaching programs to packet hooks and connect hooks on the same cgroup, we can observe DNS traffic and later enforce connection policy based on what we learned. The key insight is that cgroup BPF programs share state through maps, creating a trust chain from DNS query through response to eventual connection. +cgroup BPF bridges this gap. By attaching programs to both packet hooks and socket-address hooks on the same cgroup, we can observe DNS traffic and later enforce connection policy based on what we learned. The key insight: cgroup BPF programs share state through maps, enabling a trust chain from DNS query through response to eventual connection. -## How the Pieces Fit Together +## Architecture Overview -eBPF lets us run verified programs at multiple kernel hooks and share state between them through maps. cgroup BPF makes these hooks follow a workload: packet programs can inspect traffic entering and leaving a cgroup, while socket-address programs can accept or reject connections before they're established. +eBPF lets us run verified programs at multiple kernel hooks and share state between them through maps. cgroup BPF ties these hooks to a workload: packet programs inspect traffic entering and leaving a cgroup, while socket-address programs can accept or reject connections before they are established. Our tool attaches three programs to the same cgroup: -1. **`cgroup_skb/egress`** watches outgoing DNS queries and records which ones we're expecting answers for -2. **`cgroup_skb/ingress`** validates incoming DNS responses and learns IP addresses only from matching replies -3. **`cgroup/connect4`** makes the final decision—allowing connections only to IPs learned from valid DNS responses, and only while their TTL is still valid +1. **`cgroup_skb/egress`** watches outgoing DNS queries and records which responses we expect +2. **`cgroup_skb/ingress`** validates incoming DNS responses and extracts IP addresses only from replies that match a pending query +3. **`cgroup/connect4`** makes the final decision, allowing connections only to IPs learned from valid DNS responses and only while their TTL remains valid -Let's walk through a successful exchange. Your application sends an A query for `lab.test`. The egress hook saves a correlation key containing the resolver address, client address, client UDP port, and DNS transaction ID. This record lives for five seconds. When the reply arrives, the ingress hook reconstructs the same key, verifies the response, and extracts the IP address along with its TTL. A later `connect()` to that address succeeds—but only while the TTL is valid. +Here is how a successful resolution flows through the system. Your application sends an A query for `lab.test`. The egress hook saves a correlation key containing four fields: resolver address, client address, client UDP port, and DNS transaction ID. This record expires after five seconds. When the reply arrives, the ingress hook reconstructs the same key, verifies the response, and extracts the IP address along with its TTL. A subsequent `connect()` to that address succeeds, but only while the TTL is valid. -The query record is the trust boundary here. An unsolicited response has no pending key to match. A response with the wrong transaction ID looks for a different key and finds nothing. Neither can pollute the allowlist. And even if an entry lingers in the LRU map, the connect hook re-checks expiration time, so addresses naturally stop working when their DNS time runs out. +The pending-query record establishes the trust boundary. An unsolicited response finds no matching key in the map. A response with the wrong transaction ID looks for a different key and finds nothing. Neither can pollute the allowlist. Even if an entry lingers in the LRU map, the connect hook re-checks the expiration timestamp, so addresses naturally stop working when their DNS TTL runs out. -## The Data Structures +## Data Structures -Before diving into code, let's understand the data structures that make this work. The shared header defines DNS protocol layouts and the events we report to userspace: +Before looking at the code, let's examine the data structures that enable this design. The shared header defines DNS protocol layouts and the events we report to userspace: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -81,13 +81,13 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -The protocol structs use the `packed` attribute because they describe bytes on the wire—no padding allowed. The `dns_header` maps directly to the 12-byte DNS header that starts every query and response. The `dns_question` struct follows the question name (which uses length-prefixed labels). The `dns_a_answer` expects the common compressed format where the name pointer is `0xc00c`, pointing back to the question section. +The protocol structs use the `packed` attribute because they map directly to bytes on the wire, with no padding allowed. `dns_header` corresponds to the 12-byte DNS header that starts every query and response. `dns_question` follows the question name, which uses length-prefixed labels. `dns_a_answer` expects the common compressed format where the name pointer is `0xc00c`, pointing back to the question section. -Each event we send to userspace carries both the DNS TTL and the absolute expiration timestamp. User space prints the human-readable TTL, while the BPF side uses the monotonic timestamp for its decisions. This separation keeps the kernel logic clean—no time format conversions in BPF code. +Each event sent to userspace carries both the DNS TTL in seconds and the absolute expiration timestamp in nanoseconds. Userspace prints the human-readable TTL for logging, while the BPF programs use the monotonic timestamp for decisions. This separation keeps kernel logic simple: no time format conversions in BPF code. -## The BPF Programs: Complete Implementation +## BPF Programs: Complete Implementation -Here's the complete kernel-side implementation. It's longer than some of our examples, but each piece has a clear job. Let's look at it in full, then walk through the key sections: +Here is the complete kernel-side implementation. It is longer than some of our examples, but each piece has a well-defined responsibility: ```c // SPDX-License-Identifier: GPL-2.0 @@ -381,43 +381,45 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -### Understanding the Map Design +### Map Design -The program uses three BPF maps, each serving a specific purpose in the trust chain. The `pending_queries` map is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that uniquely identify a query/response pair. The value stores only the expiration timestamp because that's all we need to validate timing. LRU eviction ensures memory stays bounded even under DNS flood conditions. +The program uses three BPF maps, each serving a distinct role in the trust chain. -The `allowed_ips` map is also an LRU hash, but keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag to track whether we've already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry. +**`pending_queries`** is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that together uniquely identify a query-response pair. The value stores only the expiration timestamp, which is all we need for timing validation. LRU eviction keeps memory bounded even under heavy DNS traffic. -The `events` ring buffer sends notifications to userspace. At 256KB, it can hold thousands of events without blocking the kernel path. Ring buffers are the modern replacement for perf buffers—they're more efficient and provide better ordering guarantees. +**`allowed_ips`** is also an LRU hash, keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag indicating whether we have already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry. + +**`events`** is a 256 KB ring buffer for sending notifications to userspace. At this size, it can hold thousands of events without blocking the kernel path. Ring buffers are the modern replacement for perf buffers: more efficient and with better ordering guarantees. ### The Egress Path: Recording Pending Queries -When a packet leaves the cgroup, `record_dns_query` runs. The function first validates the transport layer through `parse_query_transport`. This function loads the IP header, checks for IPv4 with UDP protocol, rejects fragmented packets (which would require reassembly we don't implement), and verifies the destination matches our configured resolver. If all checks pass, it calculates where DNS data begins and populates the correlation key. +When a packet leaves the cgroup, `record_dns_query` executes. The function first validates the transport layer through `parse_query_transport`, which loads the IP header, confirms IPv4 with UDP protocol, rejects fragmented packets (which would require reassembly logic we don't implement), and verifies the destination matches our configured resolver. If all checks pass, it calculates where DNS data begins and populates the correlation key. -The `parse_dns_query` function then validates the DNS layer. It checks that the flags indicate a standard query (not a response), that exactly one question exists, and that the question name matches our configured domain. The question type and class must be A (address) and IN (internet). Only after all validation passes does it extract the transaction ID and insert into `pending_queries`. +Next, `parse_dns_query` validates the DNS layer. It checks that the flags indicate a standard query (not a response), that exactly one question exists, and that the question name matches our configured domain. The question type and class must be A (address) and IN (internet). Only after all validation passes does it extract the transaction ID and insert the record into `pending_queries`. -Notice that `record_dns_query` always returns 1. This tells the kernel to continue processing the packet normally—we're observing, not blocking. DNS queries flow through unchanged. +Note that `record_dns_query` always returns 1, telling the kernel to continue processing the packet normally. We are observing, not blocking. DNS queries flow through unchanged. ### The Ingress Path: Learning from Responses -The ingress program `learn_dns_answer` reverses the perspective. Now we're looking at packets arriving from the resolver, so `parse_response_transport` checks that the source (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint. +The ingress program `learn_dns_answer` reverses the perspective. Now we examine packets arriving from the resolver, so `parse_response_transport` checks that the *source* (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint. The critical security check happens in `pending_query_is_live`. This function looks up the correlation key in `pending_queries`. If no entry exists—meaning we never saw a matching query—the response is rejected. If an entry exists but has expired, we delete it and reject the response. Only responses that match a live pending query proceed. -After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It verifies this is a successful response (flags indicate "response" and "no error"), contains exactly one question matching our domain, and has at least one answer. `parse_direct_a_answer` then extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a sane TTL between 1 and 86400 seconds. +After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It checks that flags indicate a successful response with no errors, that exactly one question matches our domain, and that at least one answer exists. Then `parse_direct_a_answer` extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a TTL between 1 and 86400 seconds. -When validation passes, the pending query gets deleted (it's been consumed), and the IP address gets added to `allowed_ips` with an expiration based on the DNS TTL. The `emit_event` call sends a `DNS_LEARNED` notification to userspace. +When validation passes, the pending query is deleted (it has been consumed), and the IP address is added to `allowed_ips` with an expiration based on the DNS TTL. An `emit_event` call sends a `DNS_LEARNED` notification to userspace. ### The Connect Path: Enforcing Policy -The `enforce_dns_policy` function attaches to `cgroup/connect4`, which runs before every IPv4 TCP connect. The function first applies filtering: if we're targeting a specific process and this isn't it, allow the connection. If it's not TCP or not the protected port, allow the connection. These early returns minimize overhead for irrelevant traffic. +`enforce_dns_policy` attaches to `cgroup/connect4`, running before every IPv4 TCP connection attempt. The function first applies filtering: if we are targeting a specific process and this is not it, allow the connection. If it is not TCP or not the protected port, allow it. These early returns minimize overhead for irrelevant traffic. -For connections that need policy enforcement, we look up the destination IP in `allowed_ips`. If found and not expired, we emit `DNS_ALLOWED` and return 1 (allow). If found but expired, we use an atomic compare-and-swap on `expired_reported` to emit exactly one `DNS_EXPIRED` event even under concurrent access. BPF atomic compare-and-exchange arrived in Linux 5.12, which sets this tool's minimum kernel version. +For connections requiring policy enforcement, we look up the destination IP in `allowed_ips`. If found and not expired, we emit `DNS_ALLOWED` and return 1 (allow). If found but expired, we use an atomic compare-and-swap on `expired_reported` to emit exactly one `DNS_EXPIRED` event even under concurrent access. This atomic operation arrived in Linux 5.12, which sets the tool's minimum kernel version. -If the IP isn't in the map or is expired, we emit `DNS_DENIED` and return 0. The kernel translates return value 0 into `EPERM`, and the application's connect() fails immediately. +If the IP is not in the map or is expired, we emit `DNS_DENIED` and return 0. The kernel translates a return value of 0 into `EPERM`, and the application's `connect()` fails immediately. -## The User-Space Program +## User-Space Program -The user-space side configures the read-only BPF data, attaches all three programs to a cgroup, and processes ring-buffer events. It also includes a self-test demo mode that exercises the complete trust chain. +The user-space program configures the read-only BPF data, attaches all three programs to a cgroup, and processes ring-buffer events. It also includes a self-test demo mode that exercises the complete trust chain. ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -1061,29 +1063,29 @@ cleanup: } ``` -### Understanding the User-Space Control Flow +### User-Space Control Flow -The user-space program follows a clear initialization sequence. First, `parse_options` handles command-line arguments, validating inputs and setting defaults. Demo mode automatically configures loopback addresses and non-standard ports to avoid conflicts with real DNS and web traffic. +The user-space program follows a clear initialization sequence. First, `parse_options` handles command-line arguments, validates inputs, and sets defaults. Demo mode automatically configures loopback addresses and non-standard ports to avoid conflicts with real DNS and web traffic. -The `encode_qname` function converts a domain name like `lab.test` into DNS label format: `\x03lab\x04test\x00`. Each label starts with a length byte followed by the label content. The final zero byte terminates the name. This encoding happens once at startup and gets written into the BPF skeleton's `rodata` section, where the verifier treats it as a constant. +The `encode_qname` function converts a human-readable domain name like `lab.test` into DNS wire format: `\x03lab\x04test\x00`. Each label starts with a length byte followed by the label content, terminated by a zero byte. This encoding happens once at startup and is written into the BPF skeleton's `rodata` section, where the verifier treats it as a constant. -The `prepare_runtime` function ties everything together. It opens the target cgroup directory, opens the BPF skeleton, configures all the `rodata` values (resolver IP, ports, domain name), loads the BPF programs, and attaches each program to the cgroup. The three separate links allow independent attachment and detachment. Finally, it creates a ring buffer consumer that calls `handle_event` for each notification from the kernel. +`prepare_runtime` ties everything together. It opens the target cgroup directory, opens the BPF skeleton, configures all `rodata` values (resolver IP, ports, domain name), loads the BPF programs, and attaches each program to the cgroup. The three separate links allow independent attachment and detachment. Finally, it creates a ring buffer consumer that calls `handle_event` for each kernel notification. -Normal mode enters `poll_policy_events`, which loops on the ring buffer until the duration expires or a signal arrives. Each event gets printed with the IP address, PID, TTL, and event type. Demo mode instead runs `run_demo`, which exercises the complete trust chain with synthetic DNS traffic and TCP connections. +In normal mode, `poll_policy_events` loops on the ring buffer until the duration expires or a signal arrives. Each event is printed with its IP address, PID, TTL, and type. Demo mode runs `run_demo` instead, exercising the complete trust chain with synthetic DNS traffic and TCP connections. -### The Demo Mode: Proving the Security Properties +### Demo Mode: Validating Security Properties -Demo mode serves as both a functional test and a demonstration of the security model. It runs entirely on loopback, using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services. +Demo mode functions as both an integration test and a demonstration of the security model. It runs entirely on loopback using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services. -The test sequence starts by verifying that connections are blocked before any DNS traffic. Then it sends an unsolicited DNS response—a response that arrives without a preceding query. The BPF program should reject this because there's no matching entry in `pending_queries`. The demo verifies the connection is still blocked. +The test sequence begins by verifying that connections are blocked before any DNS traffic occurs. It then sends an unsolicited DNS response—one that arrives without a preceding query. The BPF program rejects this because `pending_queries` contains no matching entry. The test confirms the connection remains blocked. -Next, it sends a legitimate DNS query and receives a response with the wrong transaction ID. The BPF program should also reject this because the transaction ID is part of the correlation key. Again, the demo verifies the connection remains blocked. +Next, it sends a legitimate DNS query but responds with the wrong transaction ID. The BPF program rejects this too, because the transaction ID is part of the correlation key. Again, the test confirms the connection stays blocked. -Finally, it sends a response with the correct transaction ID and a 1-second TTL. Now the connection should succeed. After waiting 1.3 seconds (longer than the TTL), the demo verifies the connection is blocked again. +Finally, it sends a response with the correct transaction ID and a 1-second TTL. Now the connection succeeds. After waiting 1.3 seconds (longer than the TTL), the test confirms the connection is blocked again. This sequence proves that the tool correctly implements query-response correlation, rejects spoofing attempts, honors TTLs, and properly expires allowlist entries. -## Compilation and Execution +## Building and Running Build the example: @@ -1092,7 +1094,7 @@ cd src/55-dns-egress make ``` -Attach it to a service cgroup and watch one domain through its resolver: +Attach to a service cgroup and monitor a specific domain: ```bash sudo ./dns_egress \ @@ -1102,7 +1104,7 @@ sudo ./dns_egress \ --port 443 ``` -The cgroup must contain the workload whose DNS packets and connections should share the policy state. TCP 443 and DNS 53 are defaults; `--dns-port` selects another resolver port, and `--duration` adds a time limit. The built-in demo needs no external DNS server: +The specified cgroup must contain the workload whose DNS packets and connections should share policy state. TCP port 443 and DNS port 53 are the defaults; `--dns-port` selects an alternate resolver port, and `--duration` sets a time limit. The built-in demo requires no external DNS server: ```bash sudo ./dns_egress --demo @@ -1126,7 +1128,7 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1 demo step=expired-answer result=blocked ``` -The three early `denied` events show that merely receiving DNS-shaped traffic or seeing the right domain name isn't enough. `learned` appears only for the correlated response, `allowed` covers its live TTL, and `expired` is immediately followed by a denied connect. +The first three `denied` events show that merely receiving DNS-shaped traffic or seeing the correct domain name is not enough. `learned` appears only for a properly correlated response, `allowed` covers its live TTL window, and once the TTL expires the next connect is immediately denied. ## Requirements @@ -1136,17 +1138,19 @@ The three early `denied` events show that merely receiving DNS-shaped traffic or | Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_CGROUP_BPF`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_INET` | | cgroup | cgroup v2, with workload placed below the attached directory | | Privileges | Root, or equivalent BPF and network capabilities | -| Architecture | x86-64 tested; no special network hardware required | +| Architecture | Tested on x86-64; no special network hardware required | + +## Scope and Limitations -## What This Example Doesn't Cover +This tool deliberately implements a narrow scope: one exact domain, one resolver, one protected TCP port, IPv4 UDP DNS, and the first direct A answer. It recognizes the common `0xc00c` compressed owner name format. Supporting CNAME chains, alternate answer layouts, TCP DNS, IPv6, DoH, or DoT would require additional parsers or observation points. -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 `0xc00c` compressed owner name. CNAME chains, alternate answer layouts, TCP DNS, IPv6, DoH, and DoT would need additional parsers or observation points. This compact scope keeps the important property visible: an IP enters the allowlist through a recent matching query and leaves through DNS time. +This compact scope keeps the central property visible: an IP enters the allowlist through a recent matching DNS query and exits when the DNS TTL expires. ## Summary -This example turns observed DNS results into a time-bounded connect policy. The egress and ingress hooks establish a trustworthy query-response relation, the TTL controls address lifetime, and the connect hook enforces the result for the protected port. +This example transforms observed DNS results into a time-bounded connection policy. The egress and ingress hooks establish a trustworthy query-response correlation, the TTL controls address lifetime, and the connect hook enforces the policy for the protected port. -> If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . +> For more eBPF tutorials, visit our repository at or our website at . ## References diff --git a/src/55-dns-egress/README.zh.md b/src/55-dns-egress/README.zh.md index 4fb7380f..8f87300f 100644 --- a/src/55-dns-egress/README.zh.md +++ b/src/55-dns-egress/README.zh.md @@ -1,36 +1,36 @@ # eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表 -假设你想让一个服务只能连接 `api.example.com`,其他地址一律拒绝。静态 IP 白名单听起来可行,但 DNS 应答会变化,TTL 也会过期。到了 `connect()` 阶段,内核只看到一个 IP 地址,根本不知道它来自哪个域名。这里缺少的是一条短期关联,把应用的 DNS 查询和随后的连接联系起来。 +假设你希望一个容器化服务只能访问 `api.example.com`,拒绝所有其他连接。最直接的方案是 IP 白名单,但 DNS 应答会变、TTL 会过期。到了 `connect()` 阶段,内核只看到一个裸 IP,完全不知道它源自哪个域名。缺少的是一条短期关联——把 DNS 查询和随后的 TCP 连接串起来。 -本课就来构建这条关联。我们会观察特定域名的 DNS 流量,只从有效的查询响应对中学习 IP 地址,并在 TTL 有效期间允许连接。这是一个最小但完整的策略工具,展示了 cgroup BPF 如何在多个内核 hook 之间协调工作。 +本教程就来构建这条关联。我们监控特定域名的 DNS 流量,只从有效的查询与响应对中提取 IP 地址,并在 TTL 有效期内允许连接。最终是一个简洁但完整的策略工具,展示 cgroup BPF 程序如何跨多个内核 hook 共享状态。 > 完整源代码: -## 理解问题:DNS 和 Connect 相互隔离 +## 问题的本质:DNS 和 connect 相互隔离 -当你的应用解析 `api.example.com` 时,DNS 响应包含一个 IP 地址和一个 TTL。但这些信息只存在于用户空间——解析器库缓存它,应用用这个 IP 调用 `connect()`,而内核完全不知道这个 IP 是怎么来的。这就形成了一个根本性的安全缺口。 +当应用解析 `api.example.com` 时,DNS 响应包含 IP 地址和 TTL。但这些信息只存在于用户空间:解析器库把它缓存起来,应用拿着这个 IP 调用 `connect()`,而内核完全不知道这个 IP 从何而来。这种割裂造成了根本性的安全缺口。 -考虑一个只应该访问你后端 API 的容器。传统防火墙可以阻止目标 IP,但无法执行"只允许连接最近 60 秒内从 api.example.com 解析出来的 IP"这样的策略。内核级策略和应用级 DNS 解析完全隔离运行。 +考虑一个只应该访问后端 API 的容器。传统防火墙可以按目标 IP 过滤,却无法执行“只允许连接最近 60 秒内从 api.example.com 解析出来的 IP”这类策略。内核网络栈和应用层 DNS 解析彼此孤立,互不感知。 -cgroup BPF 弥合了这个缺口。通过在同一个 cgroup 上挂载 packet hook 和 connect hook,我们可以观察 DNS 流量,然后根据观察到的结果执行连接策略。关键洞察是:cgroup BPF 程序通过 map 共享状态,从 DNS 查询到响应再到最终连接形成一条信任链。 +cgroup BPF 弥合了这个缺口。在同一个 cgroup 上同时挂载 packet hook 和 socket-address hook,就能先观察 DNS 流量,再据此执行连接策略。关键洞察是:cgroup BPF 程序通过 map 共享状态,从而把 DNS 查询、响应和最终连接串成一条信任链。 ## 整体架构 -eBPF 可以在多个内核 hook 上运行经过验证的程序,并通过 map 在它们之间共享状态。cgroup BPF 让这些 hook 跟随工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在连接建立之前接受或拒绝请求。 +eBPF 允许在多个内核 hook 上运行经过验证的程序,并通过 map 在它们之间共享状态。cgroup BPF 让这些 hook 跟随工作负载:packet 程序检查进出 cgroup 的流量,socket-address 程序则能在连接建立之前决定放行或拦截。 -我们的工具在同一个 cgroup 上挂载三个程序: +本工具在同一个 cgroup 上挂载三个程序: -1. **`cgroup_skb/egress`** 监控出站 DNS 查询,记录我们正在等待响应的请求 -2. **`cgroup_skb/ingress`** 验证入站 DNS 响应,只从匹配的回复中学习 IP 地址 -3. **`cgroup/connect4`** 做最终决策——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内 +1. **`cgroup_skb/egress`**:监控出站 DNS 查询,记录哪些响应是我们期待的 +2. **`cgroup_skb/ingress`**:验证入站 DNS 响应,只从匹配待处理查询的回复中提取 IP 地址 +3. **`cgroup/connect4`**:做最终裁决——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内 -先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这份记录有效期 5 秒。响应到达后,ingress hook 重建同一个 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。 +下面跟着一次成功的解析走完整个流程。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含四个字段:解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这条记录 5 秒后过期。响应到达后,ingress hook 用相同字段重建 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。 -待匹配查询就是这里的信任边界。未经请求的响应没有待匹配的 key。事务 ID 错误的响应会查找另一个 key 而找不到。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间,地址会随 DNS 时间自然失效。 +待处理查询记录就是信任边界。主动推送的伪造响应在 map 中找不到匹配的键;事务 ID 错误的响应会查找另一个键而落空。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间戳,所以地址会随 DNS TTL 自然失效。 -## 数据结构详解 +## 数据结构 -在深入代码之前,先理解让这一切工作的数据结构。共享头文件定义了 DNS 协议布局和我们报告给用户空间的事件: +先看让这一切运转的数据结构。共享头文件定义了 DNS 协议布局和上报给用户空间的事件: ```c /* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ @@ -81,13 +81,13 @@ struct dns_egress_event { #endif /* __DNS_EGRESS_H */ ``` -协议结构使用 `packed` 属性,因为它们直接描述线上字节——不允许任何填充。`dns_header` 直接映射到每个查询和响应开头的 12 字节 DNS 头部。`dns_question` 结构跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。 +协议结构使用 `packed` 属性,因为它们直接映射网络报文字节,不允许填充。`dns_header` 对应每个查询和响应开头的 12 字节 DNS 头。`dns_question` 跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。 -我们发送给用户空间的每个事件同时携带 DNS TTL 和绝对过期时间戳。用户空间打印人类可读的 TTL,而 BPF 侧使用单调时间戳做决策。这种分离保持了内核逻辑的简洁——BPF 代码中不需要时间格式转换。 +发给用户空间的每个事件同时携带以秒为单位的 DNS TTL 和以纳秒为单位的绝对过期时间戳。用户空间打印人类可读的 TTL 用于日志,而 BPF 程序用单调时间戳做决策。这种分离让内核逻辑保持简洁——BPF 代码中无需时间格式转换。 ## BPF 程序:完整实现 -下面是完整的内核态实现。虽然比某些例子长,但每个部分都有明确的职责。让我们先看完整代码,然后逐步分析关键部分: +下面是完整的内核态实现。虽然比某些例子长,但每个部分都有明确的职责: ```c // SPDX-License-Identifier: GPL-2.0 @@ -381,43 +381,45 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) } ``` -### 理解 Map 设计 +### Map 设计 -程序使用三个 BPF map,每个在信任链中都有特定用途。`pending_queries` map 是一个 LRU hash,保存 DNS 查询的关联状态。key 组合了四个字段——服务器 IP、客户端 IP、客户端端口和事务 ID——它们唯一标识一个查询/响应对。value 只存储过期时间戳,因为这就是我们验证时序所需的全部。LRU 淘汰确保即使在 DNS 洪水条件下内存也保持有界。 +程序使用三个 BPF map,各自在信任链中承担不同角色。 -`allowed_ips` map 也是 LRU hash,但只用 IPv4 地址作为 key。value 包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已报告过期的 flag。这个 flag 防止多个线程在竞争访问过期条目时产生重复的 "expired" 事件。 +**`pending_queries`** 是一个 LRU hash,保存 DNS 查询的关联状态。键由四个字段组成——服务器 IP、客户端 IP、客户端端口和事务 ID——它们共同唯一标识一个查询与响应对。值只存储过期时间戳,这就是时序验证所需的全部信息。LRU 淘汰机制确保即使 DNS 流量很大,内存占用也保持有界。 -`events` ring buffer 向用户空间发送通知。256KB 的大小可以容纳数千个事件而不会阻塞内核路径。Ring buffer 是 perf buffer 的现代替代品——它们更高效,提供更好的顺序保证。 +**`allowed_ips`** 也是 LRU hash,但只用 IPv4 地址作为键。值包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已上报过期的标志位。这个标志位可以防止多线程竞争访问过期条目时产生重复的 `expired` 事件。 + +**`events`** 是一个 256 KB 的 ring buffer,用于向用户空间发送通知。这个容量可以容纳数千个事件而不会阻塞内核路径。ring buffer 是 perf buffer 的现代替代方案:效率更高,顺序保证也更清晰。 ### 出站路径:记录待处理查询 -当报文离开 cgroup 时,`record_dns_query` 运行。函数首先通过 `parse_query_transport` 验证传输层。这个函数加载 IP 头,检查是否为使用 UDP 协议的 IPv4,拒绝分片报文(需要我们没有实现的重组),并验证目标是否匹配配置的解析器。如果所有检查通过,它计算 DNS 数据的起始位置并填充关联 key。 +当报文离开 cgroup 时,`record_dns_query` 开始执行。函数首先通过 `parse_query_transport` 验证传输层:加载 IP 头,确认这是 IPv4 UDP 报文,拒绝分片报文(需要我们没有实现的重组逻辑),并验证目标是否匹配配置的解析器。所有检查通过后,计算 DNS 数据的起始位置并填充关联键。 -然后 `parse_dns_query` 函数验证 DNS 层。它检查 flags 是否表示标准查询(不是响应),是否恰好有一个问题,以及问题名是否匹配我们配置的域名。问题类型和类必须是 A(地址)和 IN(互联网)。只有所有验证都通过后,它才提取事务 ID 并插入 `pending_queries`。 +接着 `parse_dns_query` 验证 DNS 层。它检查 flags 是否表示标准查询(而非响应)、是否恰好有一个问题、以及问题名是否匹配配置的域名。问题类型和类必须是 A(地址)和 IN(互联网)。只有所有验证都通过后,才提取事务 ID 并将记录插入 `pending_queries`。 -注意 `record_dns_query` 始终返回 1。这告诉内核继续正常处理报文——我们是在观察,不是阻断。DNS 查询原样流过。 +注意 `record_dns_query` 始终返回 1,告诉内核继续正常处理报文。我们是在观察,不是阻断。DNS 查询原样流过。 ### 入站路径:从响应中学习 -入站程序 `learn_dns_answer` 反转了视角。现在我们看的是从解析器到达的报文,所以 `parse_response_transport` 检查源(不是目标)是否匹配解析器 IP 和端口。关联 key 用相同的字段填充,但从响应的视角来看。 +入站程序 `learn_dns_answer` 反转了视角。现在我们检查的是从解析器到达的报文,所以 `parse_response_transport` 检查*源*(而非目标)是否匹配解析器 IP 和端口。关联键使用相同的字段填充,但改从响应的视角取值。 -关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联 key。如果没有条目存在——意味着我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,我们删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。 +关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联键。如果没有条目——说明我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。 -确认我们有合法响应后,`parse_response_question` 验证 DNS 头。它验证这是成功响应(flags 表示"响应"且"无错误"),恰好包含一个匹配我们域名的问题,且至少有一个应答。`parse_direct_a_answer` 然后提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的合理 TTL。 +确认响应合法后,`parse_response_question` 验证 DNS 头。它检查 flags 是否表示成功响应且无错误、是否恰好包含一个匹配我们域名的问题、以及是否至少有一个应答。然后 `parse_direct_a_answer` 提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的 TTL。 验证通过后,待处理查询被删除(已被消费),IP 地址以基于 DNS TTL 的过期时间添加到 `allowed_ips`。`emit_event` 调用向用户空间发送 `DNS_LEARNED` 通知。 ### 连接路径:执行策略 -`enforce_dns_policy` 函数挂载到 `cgroup/connect4`,在每个 IPv4 TCP 连接之前运行。函数首先应用过滤:如果我们针对特定进程且这不是它,允许连接。如果不是 TCP 或不是受保护端口,允许连接。这些提前返回最小化了无关流量的开销。 +`enforce_dns_policy` 挂载到 `cgroup/connect4`,在每个 IPv4 TCP 连接之前执行。函数首先应用过滤:如果我们针对特定进程且这不是它,放行;如果不是 TCP 或不是受保护端口,放行。这些提前返回将无关流量的开销降到最低。 -对于需要策略执行的连接,我们在 `allowed_ips` 中查找目标 IP。如果找到且未过期,我们发出 `DNS_ALLOWED` 并返回 1(允许)。如果找到但已过期,我们在 `expired_reported` 上使用原子 compare-and-swap,即使在并发访问下也只发出一个 `DNS_EXPIRED` 事件。BPF atomic compare-and-exchange 在 Linux 5.12 引入,这也确定了工具的最低内核版本。 +对于需要策略执行的连接,我们在 `allowed_ips` 中查找目标 IP。如果找到且未过期,发出 `DNS_ALLOWED` 并返回 1(允许)。如果找到但已过期,使用原子 compare-and-swap 操作 `expired_reported`,确保即使并发访问也只发出一个 `DNS_EXPIRED` 事件。这个原子操作在 Linux 5.12 引入,也确定了本工具的最低内核版本。 -如果 IP 不在 map 中或已过期,我们发出 `DNS_DENIED` 并返回 0。内核将返回值 0 转换为 `EPERM`,应用的 connect() 立即失败。 +如果 IP 不在 map 中或已过期,发出 `DNS_DENIED` 并返回 0。内核将返回值 0 转换为 `EPERM`,应用的 `connect()` 立即失败。 ## 用户态程序 -用户态程序配置只读 BPF 数据,把三个程序附加到 cgroup,并处理 ring buffer 事件。它还包含一个自测的 demo 模式,验证完整的信任链。 +用户态程序配置 BPF 只读数据区,把三个程序挂载到 cgroup,并处理 ring buffer 事件。它还包含一个自测的 demo 模式,用于验证完整的信任链。 ```c // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) @@ -1061,27 +1063,27 @@ cleanup: } ``` -### 理解用户态控制流程 +### 用户态控制流程 -用户态程序遵循清晰的初始化序列。首先,`parse_options` 处理命令行参数,验证输入并设置默认值。Demo 模式自动配置 loopback 地址和非标准端口,以避免与真实 DNS 和 Web 流量冲突。 +用户态程序遵循清晰的初始化序列。首先 `parse_options` 处理命令行参数,验证输入并设置默认值。Demo 模式自动配置 loopback 地址和非标准端口,以避免与真实 DNS 和 Web 流量冲突。 -`encode_qname` 函数将域名如 `lab.test` 转换为 DNS label 格式:`\x03lab\x04test\x00`。每个 label 以长度字节开头,后跟 label 内容。最后的零字节终止名称。这个编码在启动时只发生一次,并写入 BPF skeleton 的 `rodata` 部分,验证器将其视为常量。 +`encode_qname` 函数将人类可读的域名如 `lab.test` 转换为 DNS 报文格式:`\x03lab\x04test\x00`。每个标签以长度字节开头,后跟标签内容,以零字节结尾。这个编码在启动时只执行一次,写入 BPF skeleton 的 `rodata` 部分,验证器将其视为常量。 -`prepare_runtime` 函数把一切串联起来。它打开目标 cgroup 目录,打开 BPF skeleton,配置所有 `rodata` 值(解析器 IP、端口、域名),加载 BPF 程序,并将每个程序附加到 cgroup。三个独立的 link 允许独立的附加和分离。最后,它创建一个 ring buffer 消费者,为每个内核通知调用 `handle_event`。 +`prepare_runtime` 把一切串联起来。它打开目标 cgroup 目录、打开 BPF skeleton、配置所有 `rodata` 值(解析器 IP、端口、域名)、加载 BPF 程序,并将每个程序挂载到 cgroup。三个独立的 link 允许独立地挂载和卸载。最后,它创建一个 ring buffer 消费者,为每个内核通知调用 `handle_event`。 -普通模式进入 `poll_policy_events`,在 ring buffer 上循环直到持续时间结束或收到信号。每个事件都会打印 IP 地址、PID、TTL 和事件类型。Demo 模式则运行 `run_demo`,用合成的 DNS 流量和 TCP 连接验证完整的信任链。 +在普通模式下,`poll_policy_events` 在 ring buffer 上循环,直到持续时间结束或收到信号。每个事件都会打印其 IP 地址、PID、TTL 和类型。Demo 模式则运行 `run_demo`,用合成的 DNS 流量和 TCP 连接验证完整的信任链。 -### Demo 模式:证明安全属性 +### Demo 模式:验证安全属性 -Demo 模式既是功能测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。 +Demo 模式既是集成测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。 -测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序应该拒绝它,因为 `pending_queries` 中没有匹配的条目。Demo 验证连接仍然被阻止。 +测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序拒绝它,因为 `pending_queries` 中没有匹配的条目。测试确认连接仍然被阻止。 -接下来,它发送一个合法的 DNS 查询并收到一个事务 ID 错误的响应。BPF 程序也应该拒绝它,因为事务 ID 是关联 key 的一部分。同样,demo 验证连接保持阻止状态。 +接下来,发送一个合法的 DNS 查询,但响应使用错误的事务 ID。BPF 程序同样拒绝它,因为事务 ID 是关联键的一部分。测试再次确认连接仍处于阻断状态。 -最后,它发送一个事务 ID 正确、TTL 为 1 秒的响应。现在连接应该成功。等待 1.3 秒(超过 TTL)后,demo 验证连接再次被阻止。 +最后,发送一个事务 ID 正确、TTL 为 1 秒的响应。现在连接成功了。等待 1.3 秒(超过 TTL)后,测试确认连接再次被阻止。 -这个序列证明了工具正确实现了查询响应关联、拒绝欺骗尝试、遵守 TTL,并正确使允许列表条目过期。 +这个序列证明了工具正确实现了查询-响应关联、拒绝欺骗尝试、遵守 TTL,并正确使允许列表条目过期。 ## 编译和运行 @@ -1092,7 +1094,7 @@ cd src/55-dns-egress make ``` -将它挂到服务 cgroup,通过解析器观察一个域名: +挂载到服务 cgroup 并监控特定域名: ```bash sudo ./dns_egress \ @@ -1102,7 +1104,7 @@ sudo ./dns_egress \ --port 443 ``` -cgroup 需要包含目标工作负载,让它的 DNS 报文和连接共享同一份策略状态。TCP 443 和 DNS 53 是默认端口;`--dns-port` 选择其他解析器端口,`--duration` 设置时间上限。内置 demo 不需要外部 DNS 服务器: +指定的 cgroup 必须包含目标工作负载,让它的 DNS 报文和连接共享策略状态。TCP 443 和 DNS 53 是默认端口;`--dns-port` 选择其他解析器端口,`--duration` 设置时间上限。内置 demo 不需要外部 DNS 服务器: ```bash sudo ./dns_egress --demo @@ -1126,7 +1128,7 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1 demo step=expired-answer result=blocked ``` -前三个 `denied` 说明仅仅收到 DNS 格式的流量或看到正确的域名还不够。只有关联正确的响应才会产生 `learned`,`allowed` 覆盖它的有效 TTL,`expired` 之后的连接立刻回到 denied。 +前三个 `denied` 事件说明,仅仅收到 DNS 格式的流量或看到正确的域名是不够的。只有正确关联的响应才产生 `learned`,`allowed` 覆盖其有效 TTL 窗口,TTL 一过期,下一次连接立即被拒绝。 ## 环境要求 @@ -1135,18 +1137,20 @@ demo step=expired-answer result=blocked | 内核 | Linux 5.12+(BPF atomic compare-and-exchange) | | 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_INET` | | cgroup | cgroup v2,工作负载位于挂载目录之下 | -| 权限 | root,或等价的 BPF 与网络 capability | +| 权限 | root 权限,或等价的 BPF 与网络能力 | | 架构 | x86-64 已测试;不需要特殊网卡 | -## 本例未涵盖的内容 +## 范围与限制 + +本工具刻意实现了一个紧凑的范围:一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答。它识别常见的 `0xc00c` 压缩 owner name 格式。支持 CNAME 链、其他应答布局、TCP DNS、IPv6、DoH 或 DoT 需要额外的解析器或新的观察点。 -工具刻意只实现一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答。它识别常见的 `0xc00c` 压缩 owner name。CNAME 链、其他 answer 布局、TCP DNS、IPv6、DoH 和 DoT 需要额外的解析器或新的观察点。这个紧凑范围保留了重要属性的可见性:IP 通过最近一次匹配查询进入允许列表,再通过 DNS 时间退出。 +这个紧凑范围保留了核心属性的可见性:IP 通过最近一次匹配的 DNS 查询进入允许列表,在 DNS TTL 过期时退出。 ## 总结 -这个例子把观察到的 DNS 结果变成有时间边界的 connect 策略。egress 和 ingress hook 建立可信的查询响应关联,TTL 控制地址生命周期,connect hook 对受保护端口执行结果。 +本示例把观察到的 DNS 结果转化为有时间边界的连接策略。egress 和 ingress hook 建立可信的查询与响应关联,TTL 控制地址生命周期,connect hook 对受保护端口执行策略。 -> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 +> 更多 eBPF 教程,请访问我们的代码仓库 或网站 。 ## 参考资料 From a49943aaf3cf07b314914366a3e145ed888e6b51 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 17:45:27 -0700 Subject: [PATCH 10/13] docs: remove dash punctuation from new tutorials --- src/55-dns-egress/README.md | 8 ++++---- src/55-dns-egress/README.zh.md | 16 ++++++++-------- src/57-oom-watch/README.zh.md | 4 ++-- src/60-afxdp-dump/README.zh.md | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/55-dns-egress/README.md b/src/55-dns-egress/README.md index 7e00fefb..c2d51ae5 100644 --- a/src/55-dns-egress/README.md +++ b/src/55-dns-egress/README.md @@ -385,7 +385,7 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) The program uses three BPF maps, each serving a distinct role in the trust chain. -**`pending_queries`** is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that together uniquely identify a query-response pair. The value stores only the expiration timestamp, which is all we need for timing validation. LRU eviction keeps memory bounded even under heavy DNS traffic. +**`pending_queries`** is an LRU hash that holds correlation state for DNS queries. The key combines four fields: server IP, client IP, client port, and transaction ID. Together, they uniquely identify a query-response pair. The value stores only the expiration timestamp, which is all we need for timing validation. LRU eviction keeps memory bounded even under heavy DNS traffic. **`allowed_ips`** is also an LRU hash, keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag indicating whether we have already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry. @@ -403,7 +403,7 @@ Note that `record_dns_query` always returns 1, telling the kernel to continue pr The ingress program `learn_dns_answer` reverses the perspective. Now we examine packets arriving from the resolver, so `parse_response_transport` checks that the *source* (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint. -The critical security check happens in `pending_query_is_live`. This function looks up the correlation key in `pending_queries`. If no entry exists—meaning we never saw a matching query—the response is rejected. If an entry exists but has expired, we delete it and reject the response. Only responses that match a live pending query proceed. +The critical security check happens in `pending_query_is_live`. This function looks up the correlation key in `pending_queries`. If no entry exists, meaning we never saw a matching query, the response is rejected. If an entry exists but has expired, we delete it and reject the response. Only responses that match a live pending query proceed. After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It checks that flags indicate a successful response with no errors, that exactly one question matches our domain, and that at least one answer exists. Then `parse_direct_a_answer` extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a TTL between 1 and 86400 seconds. @@ -1077,7 +1077,7 @@ In normal mode, `poll_policy_events` loops on the ring buffer until the duration Demo mode functions as both an integration test and a demonstration of the security model. It runs entirely on loopback using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services. -The test sequence begins by verifying that connections are blocked before any DNS traffic occurs. It then sends an unsolicited DNS response—one that arrives without a preceding query. The BPF program rejects this because `pending_queries` contains no matching entry. The test confirms the connection remains blocked. +The test sequence begins by verifying that connections are blocked before any DNS traffic occurs. It then sends an unsolicited DNS response, one that arrives without a preceding query. The BPF program rejects this because `pending_queries` contains no matching entry. The test confirms the connection remains blocked. Next, it sends a legitimate DNS query but responds with the wrong transaction ID. The BPF program rejects this too, because the transaction ID is part of the correlation key. Again, the test confirms the connection stays blocked. @@ -1158,4 +1158,4 @@ This example transforms observed DNS results into a time-bounded connection poli - [BPF LRU hash maps](https://docs.kernel.org/bpf/map_hash.html) - [BPF atomic compare-and-exchange commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) - [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) +- [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 8f87300f..15161c60 100644 --- a/src/55-dns-egress/README.zh.md +++ b/src/55-dns-egress/README.zh.md @@ -1,6 +1,6 @@ # eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表 -假设你希望一个容器化服务只能访问 `api.example.com`,拒绝所有其他连接。最直接的方案是 IP 白名单,但 DNS 应答会变、TTL 会过期。到了 `connect()` 阶段,内核只看到一个裸 IP,完全不知道它源自哪个域名。缺少的是一条短期关联——把 DNS 查询和随后的 TCP 连接串起来。 +假设你希望一个容器化服务只能访问 `api.example.com`,拒绝所有其他连接。最直接的方案是 IP 白名单,但 DNS 应答会变、TTL 会过期。到了 `connect()` 阶段,内核只看到一个裸 IP,完全不知道它源自哪个域名。缺少的是一条短期关联:把 DNS 查询和随后的 TCP 连接串起来。 本教程就来构建这条关联。我们监控特定域名的 DNS 流量,只从有效的查询与响应对中提取 IP 地址,并在 TTL 有效期内允许连接。最终是一个简洁但完整的策略工具,展示 cgroup BPF 程序如何跨多个内核 hook 共享状态。 @@ -22,9 +22,9 @@ eBPF 允许在多个内核 hook 上运行经过验证的程序,并通过 map 1. **`cgroup_skb/egress`**:监控出站 DNS 查询,记录哪些响应是我们期待的 2. **`cgroup_skb/ingress`**:验证入站 DNS 响应,只从匹配待处理查询的回复中提取 IP 地址 -3. **`cgroup/connect4`**:做最终裁决——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内 +3. **`cgroup/connect4`**:做最终裁决,只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内 -下面跟着一次成功的解析走完整个流程。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含四个字段:解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这条记录 5 秒后过期。响应到达后,ingress hook 用相同字段重建 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。 +下面跟着一次成功的解析走完整个流程。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含四个字段:解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这条记录 5 秒后过期。响应到达后,ingress hook 用相同字段重建 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功,但只在 TTL 有效期内。 待处理查询记录就是信任边界。主动推送的伪造响应在 map 中找不到匹配的键;事务 ID 错误的响应会查找另一个键而落空。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间戳,所以地址会随 DNS TTL 自然失效。 @@ -83,7 +83,7 @@ struct dns_egress_event { 协议结构使用 `packed` 属性,因为它们直接映射网络报文字节,不允许填充。`dns_header` 对应每个查询和响应开头的 12 字节 DNS 头。`dns_question` 跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。 -发给用户空间的每个事件同时携带以秒为单位的 DNS TTL 和以纳秒为单位的绝对过期时间戳。用户空间打印人类可读的 TTL 用于日志,而 BPF 程序用单调时间戳做决策。这种分离让内核逻辑保持简洁——BPF 代码中无需时间格式转换。 +发给用户空间的每个事件同时携带以秒为单位的 DNS TTL 和以纳秒为单位的绝对过期时间戳。用户空间打印人类可读的 TTL 用于日志,而 BPF 程序用单调时间戳做决策。这种分离让内核逻辑保持简洁,BPF 代码中无需时间格式转换。 ## BPF 程序:完整实现 @@ -385,7 +385,7 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) 程序使用三个 BPF map,各自在信任链中承担不同角色。 -**`pending_queries`** 是一个 LRU hash,保存 DNS 查询的关联状态。键由四个字段组成——服务器 IP、客户端 IP、客户端端口和事务 ID——它们共同唯一标识一个查询与响应对。值只存储过期时间戳,这就是时序验证所需的全部信息。LRU 淘汰机制确保即使 DNS 流量很大,内存占用也保持有界。 +**`pending_queries`** 是一个 LRU hash,保存 DNS 查询的关联状态。键由四个字段组成:服务器 IP、客户端 IP、客户端端口和事务 ID。这些字段共同唯一标识一个查询与响应对。值只存储过期时间戳,这就是时序验证所需的全部信息。LRU 淘汰机制确保即使 DNS 流量很大,内存占用也保持有界。 **`allowed_ips`** 也是 LRU hash,但只用 IPv4 地址作为键。值包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已上报过期的标志位。这个标志位可以防止多线程竞争访问过期条目时产生重复的 `expired` 事件。 @@ -403,7 +403,7 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx) 入站程序 `learn_dns_answer` 反转了视角。现在我们检查的是从解析器到达的报文,所以 `parse_response_transport` 检查*源*(而非目标)是否匹配解析器 IP 和端口。关联键使用相同的字段填充,但改从响应的视角取值。 -关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联键。如果没有条目——说明我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。 +关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联键。如果没有条目,说明我们从未看到匹配的查询,响应会被拒绝。如果条目存在但已过期,删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。 确认响应合法后,`parse_response_question` 验证 DNS 头。它检查 flags 是否表示成功响应且无错误、是否恰好包含一个匹配我们域名的问题、以及是否至少有一个应答。然后 `parse_direct_a_answer` 提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的 TTL。 @@ -1077,7 +1077,7 @@ cleanup: Demo 模式既是集成测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。 -测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序拒绝它,因为 `pending_queries` 中没有匹配的条目。测试确认连接仍然被阻止。 +测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应,也就是一个没有前置查询就到达的响应。BPF 程序拒绝它,因为 `pending_queries` 中没有匹配的条目。测试确认连接仍然被阻止。 接下来,发送一个合法的 DNS 查询,但响应使用错误的事务 ID。BPF 程序同样拒绝它,因为事务 ID 是关联键的一部分。测试再次确认连接仍处于阻断状态。 @@ -1158,4 +1158,4 @@ demo step=expired-answer result=blocked - [BPF LRU hash map](https://docs.kernel.org/bpf/map_hash.html) - [BPF atomic compare-and-exchange commit](https://github.com/torvalds/linux/commit/5ffa25502b5ab3d639829a2d1e316cff7f59a41e) - [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) +- [RFC 1035:Domain Names: Implementation and Specification](https://www.rfc-editor.org/rfc/rfc1035.html) diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md index cd951c64..05b7c62d 100644 --- a/src/57-oom-watch/README.zh.md +++ b/src/57-oom-watch/README.zh.md @@ -438,7 +438,7 @@ OOM 处理程序必须仔细解析进程身份。tracepoint 给我们一个线 用户空间代码负责几项工作:加载内核符号用于调用栈符号化,使用任何 cgroup 过滤器设置 BPF 程序,处理来自 ring buffer 的事件,以及按总耗时对调用栈进行排名。它还包含一个 demo 模式,可以创建一个内存受限的 cgroup 并触发 OOM 来验证工具是否正常工作。 -完整的用户空间代码相当长,所以我们重点介绍关键部分。启动时,它读取 `/proc/kallsyms`,按地址排序符号,然后使用二分查找解析每个栈帧。当符号地址受限时(在生产系统上很常见),它仍然打印原始地址——测量结果保持准确。 +完整的用户空间代码相当长,所以我们重点介绍关键部分。启动时,它读取 `/proc/kallsyms`,按地址排序符号,然后使用二分查找解析每个栈帧。当符号地址受限时(在生产系统上很常见),它仍然打印原始地址,测量结果保持准确。 调用栈组按累计回收时间排名。这种排名可以展示频繁调用的路径和罕见的慢速间隔。在 OOM 事件之后,打印前五个调用栈及其时间统计。 @@ -506,7 +506,7 @@ dropped_victim_states=0 dropped_reclaim_states=0 ## 实现范围 -Profile 从程序附加开始累积直到退出,使用有界的 LRU map:4096 个 cgroup profile、4096 个活动间隔、8192 个调用栈聚合和 1024 个唯一调用栈。每个 `pid_tgid` 保留一个活动间隔,与 begin/end 跟踪模式相匹配。工具捕获内核调用栈而不是用户调用栈,并将符号化视为展示——受限的 `kallsyms` 会将名称变为地址,但不影响测量结果。 +Profile 从程序附加开始累积直到退出,使用有界的 LRU map:4096 个 cgroup profile、4096 个活动间隔、8192 个调用栈聚合和 1024 个唯一调用栈。每个 `pid_tgid` 保留一个活动间隔,与 begin/end 跟踪模式相匹配。工具捕获内核调用栈而不是用户调用栈,并将符号化视为展示环节:受限的 `kallsyms` 会将名称变为地址,但不影响测量结果。 ## 总结 diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md index c121cfb0..84085c94 100644 --- a/src/60-afxdp-dump/README.zh.md +++ b/src/60-afxdp-dump/README.zh.md @@ -28,7 +28,7 @@ AF_XDP 通过四个必须精确协调的组件工作: 流程是这样的:你向 Fill Ring 发布 64 个 frame 地址。一个 UDP 报文到达。你的 XDP 程序检查目的端口,在 XSKMAP 中查找 socket,然后重定向。内核把报文复制到你的某个 frame 中,并在 RX Ring 上发布一个 descriptor。你读取 descriptor、处理报文,再把这个 frame 地址放回 Fill Ring。循环继续。 -这个所有权模型至关重要。一个 frame 开始时属于你。你通过 Fill Ring 借给内核。内核借用它来接收报文。你从 RX Ring 回收它。你必须把它归还到 Fill Ring,否则 64 个报文之后就会耗尽 frame。我们的工具通过成功捕获 65 个报文来证明这个机制有效——这需要至少一个 frame 完成完整的所有权周期。 +这个所有权模型至关重要。一个 frame 开始时属于你。你通过 Fill Ring 借给内核。内核借用它来接收报文。你从 RX Ring 回收它。你必须把它归还到 Fill Ring,否则 64 个报文之后就会耗尽 frame。我们的工具通过成功捕获 65 个报文来证明这个机制有效,这需要至少一个 frame 完成完整的所有权周期。 ## XDP 程序:过滤和重定向 @@ -715,7 +715,7 @@ Packet 65 证明至少有一个 frame 完成了完整的所有权周期:发布 AF_XDP 给你提供了具有 eBPF 安全保证的内核旁路报文接收。XDP 程序在驱动边界选择流量,XSKMAP 把报文路由到你的 socket,无锁的 ring buffer 不用系统调用就能传输数据。这个例子展示了完整的接收契约:向 Fill 发布 frame,在 RX 上接收 descriptor,处理报文,把 frame 回收到 Fill。 -理解这个流程的价值超越了抓包本身。这里的模式——内核与用户态之间的共享内存、显式的所有权转移、无锁同步——在从数据库到 GPU 驱动的各种高性能系统中反复出现。 +理解这个流程的价值超越了抓包本身。这里的模式包括内核与用户态之间的共享内存、显式的所有权转移和无锁同步,这些模式在从数据库到 GPU 驱动的各种高性能系统中反复出现。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 From da2f405a33939c9e49b98ba780d15c2d641dc765 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Wed, 22 Jul 2026 17:45:27 -0700 Subject: [PATCH 11/13] docs: require dash-free tutorial prose --- .agents/skills/bpf-tutorial-writing-style/SKILL.md | 5 +++-- .agents/skills/write-bpf-production-tutorial/SKILL.md | 6 ++++-- scripts/guideline_advance.md | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agents/skills/bpf-tutorial-writing-style/SKILL.md b/.agents/skills/bpf-tutorial-writing-style/SKILL.md index fb7966c7..fee850b1 100644 --- a/.agents/skills/bpf-tutorial-writing-style/SKILL.md +++ b/.agents/skills/bpf-tutorial-writing-style/SKILL.md @@ -5,7 +5,7 @@ description: Style checklist for English and Chinese bpf-developer-tutorial READ # BPF Tutorial Writing Style -This checklist is reference material, not a mandatory second pass after `$write-bpf-production-tutorial`. When Claude is the delegated writer, do not use this skill to review or rewrite Claude's result, do not send it to Claude as an additional checklist, and do not ask Claude for another revision. Codex may make local word-choice and punctuation edits after the delegated writing pass while preserving sentence meaning and paragraph structure. +This checklist is reference material, not a mandatory second pass after `$write-bpf-production-tutorial`. When Claude is the delegated writer, do not use this skill to review or rewrite Claude's result, do not send it to Claude as an additional checklist, and do not ask Claude for another revision. Codex must make local word-choice and punctuation edits after the delegated writing pass while preserving sentence meaning and paragraph structure. Read the complete guidelines first: - [Advanced tutorial guideline](https://github.com/eunomia-bpf/bpf-developer-tutorial/blob/main/scripts/guideline_advance.md) for tutorials 40+ @@ -35,13 +35,14 @@ Introduce information at the point where the reader needs it. Compilation follow - Use prose for the main explanation and lists for genuinely parallel items. - 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. +- Do not use em dashes, doubled Chinese em dashes, or en dashes as sentence punctuation in either language. Use commas, semicolons, colons, parentheses, or separate sentences instead. Codex checks every target README pair after Claude returns. - 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: -> libbpf 是一个 C/C++ 的 eBPF 用户态加载和控制库,随着内核一起分发,几乎已经成为 eBPF 用户态事实上的 API 标准,libbpf 也支持 CO-RE(Compile Once – Run Everywhere) 的解决方案,即预编译的 bpf 代码可以在不同内核版本上正常工作,而无需为每个特定内核重新编译。 +> libbpf 是一个 C/C++ 的 eBPF 用户态加载和控制库,随着内核一起分发,几乎已经成为 eBPF 用户态事实上的 API 标准,libbpf 也支持 CO-RE(Compile Once, Run Everywhere) 的解决方案,即预编译的 bpf 代码可以在不同内核版本上正常工作,而无需为每个特定内核重新编译。 ## Teach from the code that matters diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index 5caf2580..cf575a9b 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -19,13 +19,15 @@ Keep the prompt to that request. Do not add a paragraph plan, fact inventory, st ## 2. Preserve single-writer ownership -Treat Claude as the sole author of sentences, headings, paragraph order, explanations, and translations. After Claude returns, Codex may make local word-choice and punctuation edits. Keep those edits within the existing sentence meaning and paragraph structure. Codex must not add or remove information, rewrite sentences, reorder paragraphs, change headings, shorten or expand explanations, or alter translations substantively. +Treat Claude as the sole author of sentences, headings, paragraph order, explanations, and translations. After Claude returns, Codex must make a local word-choice and punctuation pass. Keep those edits within the existing sentence meaning and paragraph structure. Codex must not add or remove information, rewrite sentences, reorder paragraphs, change headings, shorten or expand explanations, or alter translations substantively. + +Reader-facing prose must not use em dashes, doubled Chinese em dashes, or en dashes as sentence punctuation. After every Claude writing pass, run `rg -n '[—–]'` on the target README pair and inspect every match outside source code. Replace prose matches with commas, semicolons, colons, parentheses, or separate sentences. Hyphens that belong to code identifiers, command options, URLs, or established technical names remain unchanged. Use exactly one Claude writing pass per tutorial. Do not ask Claude to review its result, respond to a defect list, polish selected paragraphs, or rewrite the tutorial again. Do not invoke another model, subagent, or independent reviewer for the prose. When the result needs another writing pass, report that fact to the user and wait for an explicit request. ## 3. Perform mechanical checks only -Check only that the intended files exist, remain nonempty, stay within the requested file scope, contain no merge markers, and expose no local paths, usernames, secrets, prompts, or private infrastructure. Do not count lines or code fences as a quality proxy. Never run `sync-source-blocks.py` or compare README code fences against a complete repository source inventory as writing acceptance. Do not require the README to contain a byte-exact copy of every source file, and do not treat an omitted complete user-space loader as a failure. These checks establish basic file integrity; they are not a prose, style, structure, factual, or technical review. +Check only that the intended files exist, remain nonempty, stay within the requested file scope, contain no merge markers, and expose no local paths, usernames, secrets, prompts, or private infrastructure. The mandatory dash scan in the previous section is part of Codex's punctuation pass. Do not count lines or code fences as a quality proxy. Never run `sync-source-blocks.py` or compare README code fences against a complete repository source inventory as writing acceptance. Do not require the README to contain a byte-exact copy of every source file, and do not treat an omitted complete user-space loader as a failure. These checks establish basic file integrity; they are not a prose, style, structure, factual, or technical review. Run repository formatting or documentation validation only when it does not rewrite the text. Report mechanical failures without repairing words or asking Claude to revise them. Preserve prompts, responses, drafts, and failed runs; never delete real conversation or agent history. diff --git a/scripts/guideline_advance.md b/scripts/guideline_advance.md index edc0a2ee..c0f55e1a 100644 --- a/scripts/guideline_advance.md +++ b/scripts/guideline_advance.md @@ -76,6 +76,7 @@ You should include the important references and resources that used in the tutor - **Focus on Advanced Concepts:** Assume readers have basic eBPF knowledge; skip elementary explanations. - **Engagement:** Encourage readers to think critically and engage with the material. - **Consistency:** Keep a consistent style and formatting throughout. +- **Punctuation:** Do not use em dashes, doubled Chinese em dashes, or en dashes as sentence punctuation. Use commas, semicolons, colons, parentheses, or separate sentences. - **Code Formatting:** Ensure code snippets are well-formatted and highlight key parts. Keep every included excerpt and command faithful to the implementation. The tutorial does not need to embed the complete user-space loader. - **Proofreading:** Double-check for errors and ensure technical accuracy. - **Accessibility:** Make the content valuable for readers with advanced expertise, avoiding unnecessary simplifications. From 562a6c42405d0a5a8d8be270a35d8bcf66465cf9 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Sun, 26 Jul 2026 09:27:20 -0700 Subject: [PATCH 12/13] docs: restore tutorial fidelity checks --- .../skills/write-bpf-production-tutorial/SKILL.md | 12 +++++++----- README.md | 2 +- README.zh.md | 2 +- src/56-tc-flow-index/README.md | 2 +- src/56-tc-flow-index/README.zh.md | 2 +- src/57-oom-watch/README.md | 8 ++++---- src/57-oom-watch/README.zh.md | 8 ++++---- src/60-afxdp-dump/README.md | 14 +++++++------- src/60-afxdp-dump/README.zh.md | 14 +++++++------- src/SUMMARY.md | 2 +- src/SUMMARY.zh.md | 2 +- src/compatibility.md | 2 +- src/compatibility.zh.md | 2 +- 13 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index cf575a9b..cd00fb2a 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -5,13 +5,13 @@ description: Write or rewrite bilingual bpf-developer-tutorial lessons by assign # Write a BPF Tutorial -Keep one writer for the complete reader-facing text. One Claude Opus process performs one free, paragraph-by-paragraph pass over one tutorial's English and Chinese README pair. Codex coordinates the invocation and performs mechanical checks without becoming a second writer or reviewer. +Keep one writer for the complete reader-facing text. One Claude Opus process performs one free, paragraph-by-paragraph pass over one tutorial's English and Chinese README pair. Codex coordinates the invocation and performs integrity and technical-fidelity checks without becoming a second prose writer or reviewer. ## 1. Give Opus the complete task once Use the exact model ID `claude-opus-4-5-20251101`. Stop when that model is unavailable instead of substituting another model. -Run one non-interactive invocation from the repository root for one tutorial directory. Name that tutorial's English and Chinese README pair, the applicable `scripts/guideline_advance.md` or `scripts/guideline_basic.md`, and existing tutorials as general references. Ask Claude to revise every paragraph freely from the reader's perspective, improve readability, adjust content or structure wherever useful, finish both files before returning, and ask no questions. +Run one non-interactive invocation from the repository root for one tutorial directory. Name that tutorial's English and Chinese README pair, its implementation, headers, Makefile, tests, the applicable `scripts/guideline_advance.md` or `scripts/guideline_basic.md`, and existing tutorials as general references. Ask Claude to read those sources, keep technical claims grounded in them, revise every paragraph freely from the reader's perspective, improve readability, adjust content or structure wherever useful, finish both files before returning, and ask no questions. Start a separate Claude process for every additional tutorial. Never batch README pairs from different tutorial directories into one process. @@ -25,10 +25,12 @@ Reader-facing prose must not use em dashes, doubled Chinese em dashes, or en das Use exactly one Claude writing pass per tutorial. Do not ask Claude to review its result, respond to a defect list, polish selected paragraphs, or rewrite the tutorial again. Do not invoke another model, subagent, or independent reviewer for the prose. When the result needs another writing pass, report that fact to the user and wait for an explicit request. -## 3. Perform mechanical checks only +## 3. Check integrity and technical fidelity -Check only that the intended files exist, remain nonempty, stay within the requested file scope, contain no merge markers, and expose no local paths, usernames, secrets, prompts, or private infrastructure. The mandatory dash scan in the previous section is part of Codex's punctuation pass. Do not count lines or code fences as a quality proxy. Never run `sync-source-blocks.py` or compare README code fences against a complete repository source inventory as writing acceptance. Do not require the README to contain a byte-exact copy of every source file, and do not treat an omitted complete user-space loader as a failure. These checks establish basic file integrity; they are not a prose, style, structure, factual, or technical review. +Check that the intended files exist, remain nonempty, stay within the requested file scope, contain no merge markers, and expose no local paths, usernames, secrets, prompts, or private infrastructure. The mandatory dash scan in the previous section is part of Codex's punctuation pass. Do not count lines or code fences as a quality proxy. Do not require the README to contain a byte-exact copy of every source file, and do not treat an omitted complete user-space loader as a failure. -Run repository formatting or documentation validation only when it does not rewrite the text. Report mechanical failures without repairing words or asking Claude to revise them. Preserve prompts, responses, drafts, and failed runs; never delete real conversation or agent history. +Verify every included source excerpt, command, output sample, version, requirement, and behavior claim against the implementation, tests, captured evidence, or a primary source. Use `sync-source-blocks.py` for blocks intended to reproduce complete repository files when the script supports the lesson, but do not compare focused excerpts against a required inventory of every source file. Run non-rewriting repository documentation validation. When technical commands, runtime behavior, requirements, or output claims changed, run the smallest relevant build or test, or identify exact current validation evidence for the unchanged implementation. + +These are content-fidelity checks, not a second prose-writing pass. Codex may make the smallest factual correction needed to match the implementation or evidence, but must not ask another model, subagent, or independent reviewer to rewrite or polish content-only changes. Preserve prompts, responses, drafts, and failed runs; never delete real conversation or agent history. Stop with the local result unless the user explicitly asks to commit, push, or update a PR. diff --git a/README.md b/README.md index 32b11f9a..bd53ce24 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Networking: - [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) Building a Top-Flow Monitor with Dual Rbtree Indexing -- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.md) High-Performance UDP Packet Capture with AF_XDP +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.md) High-Performance UDP Receiving with AF_XDP Tracing: diff --git a/README.zh.md b/README.zh.md index 01f5c831..31d34b60 100644 --- a/README.zh.md +++ b/README.zh.md @@ -78,7 +78,7 @@ GPU: - [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 实战教程:用双红黑树索引构建 Top-Flow 监控器 -- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 +- [lesson 60-afxdp-dump](src/60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 高性能接收 UDP 报文 追踪: diff --git a/src/56-tc-flow-index/README.md b/src/56-tc-flow-index/README.md index 26f3f26a..6f27720d 100644 --- a/src/56-tc-flow-index/README.md +++ b/src/56-tc-flow-index/README.md @@ -32,7 +32,7 @@ When a subsequent packet for this flow arrives, we find the existing entry in th ## Architecture Overview -The implementation splits into three files: +The implementation splits into four files: | File | Purpose | |------|---------| diff --git a/src/56-tc-flow-index/README.zh.md b/src/56-tc-flow-index/README.zh.md index 2c03043f..6c855794 100644 --- a/src/56-tc-flow-index/README.zh.md +++ b/src/56-tc-flow-index/README.zh.md @@ -32,7 +32,7 @@ Linux 6.4 引入了引用计数,6.16 又添加了树遍历功能。两者结 ## 架构概览 -实现分为三个文件: +实现分为四个文件: | 文件 | 作用 | |------|------| diff --git a/src/57-oom-watch/README.md b/src/57-oom-watch/README.md index b1d1ffa5..daee1693 100644 --- a/src/57-oom-watch/README.md +++ b/src/57-oom-watch/README.md @@ -2,7 +2,7 @@ Have you ever had a container or service suddenly killed by the Linux OOM killer, leaving you with nothing but a cryptic log message? The kernel tells you *which* process it chose as a victim, but says almost nothing about the memory pressure that led there. How many times did the system try to reclaim memory? How long did each attempt take? Which kernel code paths consumed all that time? -This tutorial builds `oom-watch`, an eBPF tool that captures what happens *before* the kill. It profiles every memcg reclaim attempt as a latency histogram and a set of sampled kernel stacks, then attaches this accumulated profile to the OOM victim and tracks the process until it exits. +This tutorial builds `oom-watch`, an eBPF tool that captures what happens *before* the kill. It profiles recorded memcg reclaim intervals as a latency histogram and a set of sampled kernel stacks, then attaches this accumulated profile to the OOM victim and tracks the process until it exits. > Complete source code: @@ -17,13 +17,13 @@ Looking only at the `oom/mark_victim` tracepoint loses all this context. You see - Did reclaim come from allocations inside the cgroup, or from external proactive reclaim via `memory.reclaim`? - Which kernel functions dominated the reclaim time? -`oom-watch` answers all of these. It hooks into the kernel's vmscan tracepoints to measure every reclaim interval, samples kernel stacks to show where time was spent, and when OOM selects a victim, it snapshots the accumulated profile right alongside the victim information. +`oom-watch` answers all of these. It hooks into the kernel's vmscan tracepoints to measure matched reclaim intervals, samples kernel stacks to show where time was spent, and when OOM selects a victim, it snapshots the accumulated profile right alongside the victim information. Its drop counters expose intervals that could not be retained in the bounded state maps. ## Why eBPF for Memory Profiling? Traditional monitoring approaches have serious limitations for this use case. Polling `/proc/meminfo` or cgroup stats misses short-lived reclaim events. `perf` can capture stack traces but requires careful setup and post-processing. Neither approach easily connects reclaim activity to a specific OOM event. -eBPF changes the game. Programs run directly in the kernel, triggered by events with nanosecond precision. Maps carry state between events, letting us build histograms and correlate begin/end pairs. The ring buffer delivers events to userspace with minimal overhead. And because eBPF programs are verified before loading, there's no risk of crashing the kernel. +eBPF changes the game. Programs run directly in the kernel, triggered by events with nanosecond precision. Maps carry state between events, letting us build histograms and correlate begin/end pairs. The ring buffer delivers events to userspace with minimal overhead. The verifier rejects unsafe programs before loading, substantially reducing the risk of kernel memory corruption compared with an unchecked kernel module. For `oom-watch`, we use several tracepoints: @@ -510,7 +510,7 @@ Profiles accumulate from program attach until exit, using bounded LRU maps: 4096 ## Summary -`oom-watch` turns the chaos before an OOM kill into evidence you can examine. It measures every memcg reclaim interval, samples and ranks the kernel paths that consumed time, correctly attributes work to the target cgroup (even for cross-cgroup reclaim), and connects this profile to the victim selection and exit. +`oom-watch` turns the chaos before an OOM kill into evidence you can examine. It measures recorded memcg reclaim intervals, samples and ranks the kernel paths that consumed time, correctly attributes work to the target cgroup (even for cross-cgroup reclaim), and connects this profile to the victim selection and exit. The next time a container dies and someone asks "what happened?", you'll have more than a one-line kernel log to show them. diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md index 05b7c62d..6dbc2397 100644 --- a/src/57-oom-watch/README.zh.md +++ b/src/57-oom-watch/README.zh.md @@ -2,7 +2,7 @@ 你是否遇到过容器或服务突然被 Linux OOM killer 杀掉,只留下一行不明所以的内核日志?内核会告诉你它选择了*哪个*进程作为 victim,但对于导致这一结果的内存压力几乎只字未提。系统尝试了多少次内存回收?每次花了多久?哪些内核路径消耗了这些时间? -本教程构建 `oom-watch`,一个 eBPF 工具,用于捕获 OOM kill *之前*发生的事情。它将每次 memcg 回收尝试记录为延迟直方图和采样的内核调用栈,然后将积累的 profile 附加到 OOM victim 上,并跟踪进程直到它退出。 +本教程构建 `oom-watch`,一个 eBPF 工具,用于捕获 OOM kill *之前*发生的事情。它将记录到的 memcg 回收间隔汇总为延迟直方图和采样的内核调用栈,然后将积累的 profile 附加到 OOM victim 上,并跟踪进程直到它退出。 > 完整源代码: @@ -17,13 +17,13 @@ - 回收是由 cgroup 内部的分配触发的,还是通过 `memory.reclaim` 从外部主动触发的? - 哪些内核函数占用了回收时间? -`oom-watch` 可以回答所有这些问题。它通过 hook 内核的 vmscan tracepoint 来测量每个回收间隔,采样内核调用栈以展示时间花在了哪里,当 OOM 选择 victim 时,它会将累积的 profile 快照与 victim 信息一起输出。 +`oom-watch` 可以回答所有这些问题。它通过 hook 内核的 vmscan tracepoint 来测量成功匹配的回收间隔,采样内核调用栈以展示时间花在了哪里,当 OOM 选择 victim 时,它会将累积的 profile 快照与 victim 信息一起输出。对于有界状态 map 未能保留的间隔,工具会通过 drop 计数器明确报告。 ## 为什么用 eBPF 做内存分析? 传统监控方法在这个场景下有严重的局限性。轮询 `/proc/meminfo` 或 cgroup 统计会错过短暂的回收事件。`perf` 可以捕获调用栈,但需要仔细配置和后处理。两种方法都不容易将回收活动与特定的 OOM 事件关联起来。 -eBPF 改变了这一切。程序直接在内核中运行,以纳秒级精度响应事件。Map 在事件之间传递状态,让我们可以构建直方图并关联 begin/end 对。Ring buffer 以最小的开销将事件传递给用户空间。而且因为 eBPF 程序在加载前经过验证,不会有崩溃内核的风险。 +eBPF 改变了这一切。程序直接在内核中运行,以纳秒级精度响应事件。Map 在事件之间传递状态,让我们可以构建直方图并关联 begin/end 对。Ring buffer 以最小的开销将事件传递给用户空间。验证器会在加载前拒绝不安全的程序,与未经检查的内核模块相比,这显著降低了破坏内核内存的风险。 对于 `oom-watch`,我们使用几个 tracepoint: @@ -510,7 +510,7 @@ Profile 从程序附加开始累积直到退出,使用有界的 LRU map:4096 ## 总结 -`oom-watch` 将 OOM kill 之前的混乱变成可以检查的证据。它测量每个 memcg 回收间隔,采样并排名消耗时间的内核路径,正确归因工作到目标 cgroup(即使对于跨 cgroup 回收),并将此 profile 与 victim 选择和退出关联起来。 +`oom-watch` 将 OOM kill 之前的混乱变成可以检查的证据。它测量记录到的 memcg 回收间隔,采样并排名消耗时间的内核路径,正确归因工作到目标 cgroup(即使对于跨 cgroup 回收),并将此 profile 与 victim 选择和退出关联起来。 下次容器死掉有人问"发生了什么?"时,你将不仅仅有一行内核日志可以展示。 diff --git a/src/60-afxdp-dump/README.md b/src/60-afxdp-dump/README.md index bb907e71..268f4719 100644 --- a/src/60-afxdp-dump/README.md +++ b/src/60-afxdp-dump/README.md @@ -1,8 +1,8 @@ -# eBPF Tutorial: High-Performance UDP Packet Capture with AF_XDP +# eBPF Tutorial: High-Performance UDP Receiving with AF_XDP -Have you ever wondered how packet capture tools like Suricata or high-frequency trading systems receive millions of packets per second without drowning in kernel overhead? The secret is bypassing most of the network stack entirely. AF_XDP lets you intercept packets at the driver boundary, copy them directly into your application's memory, and process them without system calls for every packet. +Have you ever wondered how high-performance network applications receive millions of packets per second without drowning in kernel overhead? The key is bypassing most of the network stack. AF_XDP lets you select packets at the driver boundary, copy them directly into your application's memory, and process them without a system call for every packet. -This tutorial builds a complete packet capture tool from scratch using the raw AF_XDP interface. No helper libraries, no magic abstractions. You'll see exactly how UMEM registration, ring buffers, and XDP redirection work together. The result is `afxdp-dump`, a tool that captures IPv4 UDP packets for a specific port, prints a payload preview, and properly recycles every frame to keep receiving indefinitely. +This tutorial builds an exclusive packet receiver from scratch using the raw AF_XDP interface. No helper libraries, no magic abstractions. You'll see exactly how UMEM registration, ring buffers, and XDP redirection work together. The result is `afxdp-dump`, a tool that redirects IPv4 UDP packets for a specific port away from the normal socket stack, prints a payload preview, and properly recycles every frame to keep receiving indefinitely. > Complete source code: @@ -12,7 +12,7 @@ Traditional packet capture with `libpcap` or raw sockets has a fundamental probl AF_XDP solves this by establishing shared memory between kernel and userspace. The kernel writes packets directly into memory your application can read. You communicate through lock-free ring buffers instead of system calls. A single `poll()` can wake you for hundreds of packets. This architecture enables packet rates of millions per second on commodity hardware. -The technology has real production use. Meta runs AF_XDP in their load balancers. Cilium uses it for Kubernetes networking. High-frequency trading firms use it to shave microseconds off their latency. Even if you never build a trading system, understanding AF_XDP teaches you patterns that appear throughout high-performance systems: shared memory, lock-free data structures, and explicit ownership transfer. +Understanding AF_XDP also teaches patterns that appear throughout high-performance systems: shared memory, lock-free data structures, and explicit ownership transfer. ## The AF_XDP Architecture @@ -121,7 +121,7 @@ The UDP validation ensures the length field is sane and that the destination por The XSKMAP lookup is also a safety check. If userspace hasn't registered a socket for this queue, the lookup returns NULL and we pass the packet to the normal stack. When everything checks out, `bpf_redirect_map()` sends the packet to AF_XDP. The second argument is the queue index, which becomes the map key. The third argument is the fallback action if something goes wrong. -One important detail: once a packet is redirected, it's consumed by AF_XDP. The regular socket stack will never see it. This is exactly what we want for a capture tool, but it means you need to be careful about what you redirect. +One important detail: once a packet is redirected, it's consumed by AF_XDP. The regular socket stack will never see it. This is expected for this exclusive receiver, but it means you need to be careful about what you redirect. ## The Userspace Application @@ -713,9 +713,9 @@ This example is deliberately minimal: receive-only, single-queue, single-buffer ## Summary -AF_XDP gives you kernel-bypass packet reception with eBPF's safety guarantees. The XDP program selects traffic at the driver boundary, the XSKMAP routes packets to your socket, and lock-free ring buffers transfer data without system calls. This example showed the complete receive contract: post frames to Fill, receive descriptors on RX, process packets, recycle frames back to Fill. +AF_XDP lets packet reception bypass the normal network stack while retaining eBPF's safety checks. The XDP program selects traffic at the driver boundary, the XSKMAP routes packets to your socket, and lock-free ring buffers transfer data without system calls. This example showed the complete receive contract: post frames to Fill, receive descriptors on RX, process packets, recycle frames back to Fill. -Understanding this flow is valuable beyond packet capture. The patterns here, shared memory between kernel and userspace, explicit ownership transfer, lock-free synchronization, appear throughout high-performance systems from databases to GPU drivers. +Understanding this flow is valuable beyond packet reception. The patterns here, shared memory between kernel and userspace, explicit ownership transfer, lock-free synchronization, appear throughout high-performance systems from databases to GPU drivers. > If you'd like to dive deeper into eBPF, check out our tutorial repository at or visit our website at . diff --git a/src/60-afxdp-dump/README.zh.md b/src/60-afxdp-dump/README.zh.md index 84085c94..c21a4a22 100644 --- a/src/60-afxdp-dump/README.zh.md +++ b/src/60-afxdp-dump/README.zh.md @@ -1,8 +1,8 @@ -# eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 +# eBPF 实战教程:用 AF_XDP 高性能接收 UDP 报文 -你有没有好奇过 Suricata 这样的抓包工具或者高频交易系统是如何每秒接收数百万个报文而不被内核开销拖垮的?秘密在于绕过大部分网络协议栈。AF_XDP 让你在网卡驱动入口拦截报文,直接复制到应用程序的内存空间,处理时几乎不需要为每个报文做系统调用。 +你有没有好奇过高性能网络应用是如何每秒接收数百万个报文而不被内核开销拖垮的?关键在于绕过大部分网络协议栈。AF_XDP 让你在网卡驱动入口选择报文,直接复制到应用程序的内存空间,处理时不需要为每个报文做一次系统调用。 -本教程从零开始,使用原始的 AF_XDP 接口构建一个完整的抓包工具。没有辅助库,没有魔法抽象。你会清楚看到 UMEM 注册、ring buffer 和 XDP 重定向是如何协同工作的。最终成果是 `afxdp-dump`,一个捕获指定端口 IPv4 UDP 报文、打印 payload 预览、并正确回收每个 frame 以持续接收的工具。 +本教程从零开始,使用原始的 AF_XDP 接口构建一个独占式报文接收器。没有辅助库,没有魔法抽象。你会清楚看到 UMEM 注册、ring buffer 和 XDP 重定向是如何协同工作的。最终成果是 `afxdp-dump`,它把指定端口的 IPv4 UDP 报文从普通 socket 协议栈重定向到用户态,打印 payload 预览,并正确回收每个 frame 以持续接收。 > 完整源代码: @@ -12,7 +12,7 @@ AF_XDP 通过在内核和用户态之间建立共享内存来解决这个问题。内核把报文直接写入你的应用程序可以读取的内存,双方通过无锁的 ring buffer 通信,而不是系统调用。一次 `poll()` 就能唤醒你处理数百个报文。这种架构在普通硬件上就能达到每秒数百万报文的接收速率。 -这项技术已经在生产环境中使用。Meta 的负载均衡器运行 AF_XDP,Cilium 用它做 Kubernetes 网络,高频交易公司用它来缩短微秒级延迟。即使你永远不会构建交易系统,理解 AF_XDP 也能教会你高性能系统中反复出现的模式:共享内存、无锁数据结构、显式的所有权转移。 +理解 AF_XDP 也能让你掌握高性能系统中反复出现的模式:共享内存、无锁数据结构和显式的所有权转移。 ## AF_XDP 架构 @@ -121,7 +121,7 @@ UDP 验证确保长度字段合理,目的端口与目标匹配。只有这时 XSKMAP lookup 也是一个安全检查。如果用户态没有为这个 queue 注册 socket,lookup 返回 NULL,我们就把报文传递给普通协议栈。当所有检查都通过时,`bpf_redirect_map()` 把报文发送到 AF_XDP。第二个参数是 queue index,会成为 map 的 key。第三个参数是出错时的回退 action。 -一个重要细节:一旦报文被重定向,它就被 AF_XDP 消费了。普通的 socket 协议栈永远不会看到它。这正是抓包工具需要的,但这意味着你需要小心选择重定向什么。 +一个重要细节:一旦报文被重定向,它就被 AF_XDP 消费了。普通的 socket 协议栈永远不会看到它。这符合这个独占式接收器的设计,但也意味着你需要小心选择重定向哪些报文。 ## 用户态应用程序 @@ -713,9 +713,9 @@ Packet 65 证明至少有一个 frame 完成了完整的所有权周期:发布 ## 总结 -AF_XDP 给你提供了具有 eBPF 安全保证的内核旁路报文接收。XDP 程序在驱动边界选择流量,XSKMAP 把报文路由到你的 socket,无锁的 ring buffer 不用系统调用就能传输数据。这个例子展示了完整的接收契约:向 Fill 发布 frame,在 RX 上接收 descriptor,处理报文,把 frame 回收到 Fill。 +AF_XDP 让报文接收绕过普通网络协议栈,同时保留 eBPF 的安全检查。XDP 程序在驱动边界选择流量,XSKMAP 把报文路由到你的 socket,无锁的 ring buffer 不用系统调用就能传输数据。这个例子展示了完整的接收契约:向 Fill 发布 frame,在 RX 上接收 descriptor,处理报文,把 frame 回收到 Fill。 -理解这个流程的价值超越了抓包本身。这里的模式包括内核与用户态之间的共享内存、显式的所有权转移和无锁同步,这些模式在从数据库到 GPU 驱动的各种高性能系统中反复出现。 +理解这个流程的价值超越了报文接收本身。这里的模式包括内核与用户态之间的共享内存、显式的所有权转移和无锁同步,这些模式在从数据库到 GPU 驱动的各种高性能系统中反复出现。 > 如果你想深入了解 eBPF,请查看我们的教程代码仓库 或访问我们的网站 。 diff --git a/src/SUMMARY.md b/src/SUMMARY.md index e19034b4..e110c203 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -70,7 +70,7 @@ Networking: - [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) Building a Top-Flow Monitor with Dual Rbtree Indexing -- [lesson 60-afxdp-dump](60-afxdp-dump/README.md) High-Performance UDP Packet Capture with AF_XDP +- [lesson 60-afxdp-dump](60-afxdp-dump/README.md) High-Performance UDP Receiving with AF_XDP Tracing: diff --git a/src/SUMMARY.zh.md b/src/SUMMARY.zh.md index 0a6d8b2a..5e483f51 100644 --- a/src/SUMMARY.zh.md +++ b/src/SUMMARY.zh.md @@ -70,7 +70,7 @@ GPU: - [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 实战教程:用双红黑树索引构建 Top-Flow 监控器 -- [lesson 60-afxdp-dump](60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 实现高性能 UDP 抓包 +- [lesson 60-afxdp-dump](60-afxdp-dump/README.zh.md) eBPF 实战教程:用 AF_XDP 高性能接收 UDP 报文 追踪: diff --git a/src/compatibility.md b/src/compatibility.md index a226f337..6570ec38 100644 --- a/src/compatibility.md +++ b/src/compatibility.md @@ -64,7 +64,7 @@ This table is generated from each tutorial's `.config` metadata. `Minimum kernel | [eBPF Tutorial: Building a DNS-Derived IP Allowlist with cgroup BPF](55-dns-egress/README.md) | 5.12 | 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: Building a Top-Flow Monitor with Dual Rbtree Indexing](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: Profiling Memory 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: High-Performance UDP Packet Capture with AF_XDP](60-afxdp-dump/README.md) | 5.7 | 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: High-Performance UDP Receiving with AF_XDP](60-afxdp-dump/README.md) | 5.7 | 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 34f6b1b4..eaf59c89 100644 --- a/src/compatibility.zh.md +++ b/src/compatibility.zh.md @@ -64,7 +64,7 @@ | [eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表](55-dns-egress/README.zh.md) | 5.12 | 必需特性 | 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 实战教程:用双红黑树索引构建 Top-Flow 监控器](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 之前分析内存回收](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.7 | 必需特性 | x86_64 | 需要 | CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_XDP_SOCKETS=y | 网络接口 | 需要 | CI 构建测试 | +| [eBPF 实战教程:用 AF_XDP 高性能接收 UDP 报文](60-afxdp-dump/README.zh.md) | 5.7 | 必需特性 | 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 构建测试 | From f2f3ab94fac3713e2afa33cded2585d270beb35d Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Sun, 26 Jul 2026 09:30:12 -0700 Subject: [PATCH 13/13] docs: clarify validation boundaries --- .agents/skills/write-bpf-production-tutorial/SKILL.md | 4 ++-- src/57-oom-watch/README.md | 2 +- src/57-oom-watch/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index cd00fb2a..072ac56d 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -1,6 +1,6 @@ --- name: write-bpf-production-tutorial -description: Write or rewrite bilingual bpf-developer-tutorial lessons by assigning each tutorial's English and Chinese README pair to its own pinned Claude Opus 4.5 process. Use when creating or revising README.md and README.zh.md while requiring Claude to edit one tutorial paragraph by paragraph from the reader's perspective, allowing Codex to make local word and punctuation edits only and forbidding an additional prose review or rewrite request. +description: Write or rewrite bilingual bpf-developer-tutorial lessons by assigning each tutorial's English and Chinese README pair to its own pinned Claude Opus 4.5 process. Use when creating or revising README.md and README.zh.md while requiring Claude to edit one tutorial paragraph by paragraph from the reader's perspective, allowing Codex only local word and punctuation edits plus the smallest evidence-backed factual corrections, and forbidding an additional prose review or rewrite request. --- # Write a BPF Tutorial @@ -19,7 +19,7 @@ Keep the prompt to that request. Do not add a paragraph plan, fact inventory, st ## 2. Preserve single-writer ownership -Treat Claude as the sole author of sentences, headings, paragraph order, explanations, and translations. After Claude returns, Codex must make a local word-choice and punctuation pass. Keep those edits within the existing sentence meaning and paragraph structure. Codex must not add or remove information, rewrite sentences, reorder paragraphs, change headings, shorten or expand explanations, or alter translations substantively. +Treat Claude as the sole author of sentences, headings, paragraph order, explanations, and translations. After Claude returns, Codex must make a local word-choice and punctuation pass. Except for the smallest evidence-backed factual corrections allowed by section 3, keep those edits within the existing sentence meaning and paragraph structure. Codex must not add or remove information, rewrite sentences, reorder paragraphs, change headings, shorten or expand explanations, or alter translations substantively. Reader-facing prose must not use em dashes, doubled Chinese em dashes, or en dashes as sentence punctuation. After every Claude writing pass, run `rg -n '[—–]'` on the target README pair and inspect every match outside source code. Replace prose matches with commas, semicolons, colons, parentheses, or separate sentences. Hyphens that belong to code identifiers, command options, URLs, or established technical names remain unchanged. diff --git a/src/57-oom-watch/README.md b/src/57-oom-watch/README.md index daee1693..17904a6c 100644 --- a/src/57-oom-watch/README.md +++ b/src/57-oom-watch/README.md @@ -430,7 +430,7 @@ The program uses several BPF maps to maintain state. The `active_reclaims` map j At reclaim begin, we extract the target cgroup ID from the `mem_cgroup` parameter, not from the current task's cgroup. This distinction matters for cross-cgroup reclaim. If someone calls `memory.reclaim` on a cgroup from outside, we correctly attribute the work to the target. The `cross_cgroup_reclaims` counter tracks how often this happens. -Stack sampling uses the `--sample-every` setting. With the default value of 1, we capture every interval. Higher values reduce overhead while still counting all intervals in the histogram. The `BPF_F_FAST_STACK_CMP` flag speeds up stack deduplication, and the `2` in the flags skips two tracing frames to get cleaner stacks. +Stack sampling uses the `--sample-every` setting. With the default value of 1, we attempt to capture a stack for each recorded begin event. Higher values reduce overhead, while every successfully matched interval still contributes to the histogram. The `BPF_F_FAST_STACK_CMP` flag speeds up stack deduplication, and the `2` in the flags skips two tracing frames to get cleaner stacks. The OOM handler has to resolve process identity carefully. The tracepoint gives us a thread ID, but we also need the thread group ID (PID from userspace perspective) and the cgroup. We use `bpf_task_from_pid()` to look up the task, read what we need, then release the reference. Victim state is keyed by TID because `sched_process_exit` fires in that thread's context. diff --git a/src/57-oom-watch/README.zh.md b/src/57-oom-watch/README.zh.md index 6dbc2397..e35a26b5 100644 --- a/src/57-oom-watch/README.zh.md +++ b/src/57-oom-watch/README.zh.md @@ -430,7 +430,7 @@ int capture_victim_exit(void *ctx) 在回收开始时,我们从 `mem_cgroup` 参数而不是当前 task 的 cgroup 中提取目标 cgroup ID。这个区别对于跨 cgroup 回收很重要。如果有人从外部对某个 cgroup 调用 `memory.reclaim`,我们可以正确地将工作归因到目标。`cross_cgroup_reclaims` 计数器跟踪这种情况发生的频率。 -调用栈采样使用 `--sample-every` 设置。默认值为 1 时,我们捕获每个间隔。更大的值可以减少开销,同时仍然在直方图中计算所有间隔。`BPF_F_FAST_STACK_CMP` 标志加速调用栈去重,标志中的 `2` 跳过两个 tracing 帧以获得更清晰的调用栈。 +调用栈采样使用 `--sample-every` 设置。默认值为 1 时,我们会为每个记录到的 begin 事件尝试捕获调用栈。更大的值可以减少开销,而每个成功匹配的间隔仍会计入直方图。`BPF_F_FAST_STACK_CMP` 标志加速调用栈去重,标志中的 `2` 跳过两个 tracing 帧以获得更清晰的调用栈。 OOM 处理程序必须仔细解析进程身份。tracepoint 给我们一个线程 ID,但我们还需要线程组 ID(用户空间视角的 PID)和 cgroup。我们使用 `bpf_task_from_pid()` 查找 task,读取所需信息,然后释放引用。victim 状态以 TID 为 key,因为 `sched_process_exit` 在该线程的上下文中触发。