eBPF Từ Số Không: Chạy Chương Trình Trong Nhân Linux
This is the first part
Ở series "Kubernetes Từ Số Không", ta dùng Cilium — nó thay kube-proxy, định tuyến Service, áp NetworkPolicy — và nhiều lần nhắc "Cilium làm việc đó bằng eBPF". Nhưng eBPF là gì, và một chương trình eBPF thật sự chạy trong nhân ra sao, thì để ngỏ. Series này lấp chỗ đó: học eBPF từ nền tảng tới tự viết chương trình, dùng chính cụm Kubernetes đã dựng (kernel 6.17, Cilium 1.19) làm phòng lab.
Khởi đầu bằng một sự thật cụ thể. Trên worker-0 của cụm, ngay lúc này:
ssh worker-0 'sudo bpftool prog show | grep -c "^[0-9]*:"'
ssh worker-0 'sudo bpftool map show | grep -c "^[0-9]*:"'
140 # 140 chương trình eBPF đang nạp trong nhân
56 # 56 BPF map đang giữ trạng thái
140 chương trình do người khác viết (Cilium) đang chạy bên trong nhân Linux trên máy đó — xử lý mọi gói tin ra vào pod, kiểm soát thiết bị cgroup, gom metric. Chúng được nạp vào nhân lúc chạy mà không ai biên dịch lại nhân, không nạp module. Đó là eBPF.
eBPF là gì
eBPF cho phép chạy chương trình sandbox bên trong hệ điều hành — cụ thể là trong nhân Linux — "mà không cần sửa mã nguồn nhân hay nạp kernel module" (ebpf.io). Người ta hay ví nó với vai trò của JavaScript trên web: trình duyệt là một runtime, JavaScript cho ta nhúng hành vi vào đó mà không phải viết lại trình duyệt; eBPF là runtime trong nhân, cho ta nhúng hành vi vào nhân mà không phải viết lại nhân.
Vì sao điều này quan trọng, nhìn rõ nhất qua hai cách cũ để mở rộng nhân:
- Sửa mã nguồn nhân: phải thuyết phục cộng đồng Linux nhận thay đổi, rồi chờ nhiều năm để bản nhân đó phổ biến.
- Viết kernel module: nạp được ngay, nhưng module chạy với toàn quyền trong nhân — một con trỏ sai là kernel panic, sập cả máy; và module dễ vỡ qua mỗi bản nhân.
eBPF tách tốc độ đổi mới khỏi chu kỳ phát hành nhân: nạp chương trình lúc chạy, nhưng an toàn (nhân kiểm tra trước khi cho chạy) và nhanh (dịch sang mã máy gốc). Một chương trình eBPF lỗi bị từ chối nạp, không làm sập nhân — khác hẳn module.
Một chương trình eBPF đi từ code tới khi chạy ra sao
Đây là phần cốt lõi, và là thứ phân biệt eBPF với "script chạy trong nhân" mơ hồ. Vòng đời:
code C ──clang/LLVM──► bytecode eBPF
│ bpf() syscall (nạp)
▼
┌──────────────┐ từ chối nếu không an toàn
│ VERIFIER │ ──────────────────────────►
└──────────────┘ (kết thúc? đọc bộ nhớ hợp lệ? bounded?)
│ qua
▼
┌──────────────┐
│ JIT │ bytecode → mã máy gốc (x86/arm64)
└──────────────┘
│
▼ attach vào HOOK
XDP · tc · kprobe · tracepoint · LSM · socket ...
│ sự kiện xảy ra (gói tới, syscall gọi...)
▼ chương trình chạy
┌──────────────┐ đọc/ghi
│ MAPS │ ◄────────► tiến trình userspace
└──────────────┘ (qua bpf() syscall)
Bốn mảnh cần nắm:
-
Verifier — bộ kiểm tra an toàn trước khi chương trình được phép chạy. Nó đảm bảo "chương trình luôn chạy tới khi kết thúc" (không vòng lặp vô hạn), "không dùng biến chưa khởi tạo hay đọc bộ nhớ ngoài giới hạn", và "độ phức tạp hữu hạn". Verifier là công cụ an toàn (chương trình có sập nhân được không), không phải công cụ bảo mật (chương trình có ý đồ xấu không) — phân biệt này quan trọng. Đây là lý do eBPF nạp được vào nhân mà không như module.
-
JIT — sau khi qua verifier, bytecode chung được "dịch sang tập lệnh máy cụ thể" (x86-64, arm64). Nên eBPF không phải thông dịch chậm; nó chạy ở tốc độ mã gốc.
-
Maps — cấu trúc dữ liệu (hash, array, LRU, ring buffer...) để chương trình giữ trạng thái giữa các lần chạy, và để userspace đọc/ghi qua
bpf()syscall. Maps là cách một công cụ eBPF báo cáo kết quả ra ngoài, và cách userspace cấu hình chương trình. -
Helper — chương trình eBPF "không gọi được hàm nhân tùy ý"; nó chỉ gọi một bộ helper mà nhân cung cấp (lấy timestamp, thao tác map, sửa gói tin...) — một API ổn định. Đây cũng là một ràng buộc an toàn: chương trình không thể thò tay vào bất cứ đâu trong nhân.
Và hook là nơi chương trình gắn vào để chạy: khi một gói tin tới card mạng (XDP, tc), khi một syscall được gọi hay một hàm nhân chạy (kprobe, tracepoint), khi một thao tác bảo mật diễn ra (LSM), khi một socket gửi/nhận. Mỗi loại hook ứng với một program type, quyết định chương trình được làm gì và thấy được dữ liệu nào.
Không phải lý thuyết: mổ 140 chương trình đang chạy
bpftool (đã cài sẵn trên node) cho soi thẳng các chương trình eBPF trong nhân. Đếm theo loại:
sudo bpftool prog show | grep -oE '^[0-9]+: [a-z_]+' | awk '{print $2}' | sort | uniq -c | sort -rn
74 sched_cls # tc — datapath mạng của Cilium (xử lý gói pod)
47 cgroup_device # kiểm soát quyền truy cập thiết bị theo cgroup
8 cgroup_sock_addr
6 cgroup_skb
3 cgroup_sock
2 tracing
74 chương trình sched_cls là các chương trình gắn ở hook tc — chính datapath mạng mà Cilium dùng thay kube-proxy. Soi một cái:
sudo bpftool prog show id 2871
2871: sched_cls name tail_no_service_ipv4 tag fe7bcb57c001d434 gpl
loaded_at 2026-05-23T23:04:17+0000 uid 0
xlated 4920B jited 2778B memlock 8192B map_ids 171,631
btf_id 758
Mỗi trường ở đây là một khái niệm vừa nói, hiện ra thật:
xlated 4920B— bytecode eBPF sau khi qua verifier (4920 byte). Có con số này nghĩa là chương trình đã được verifier chấp nhận.jited 2778B— mã máy gốc sau JIT (2778 byte). Có con số này nghĩa là nó đang chạy ở tốc độ native, không thông dịch.map_ids 171,631— chương trình này dùng hai map (id 171 làcilium_metrics). Đó là cách nó giữ/báo cáo trạng thái.btf_id 758— có BTF (kiểu dữ liệu nhân), nền cho CO-RE mà ta sẽ dùng khi tự viết chương trình.gpl— khai giấy phép GPL (cần để gọi một số helper).
JIT bật toàn hệ:
cat /proc/sys/net/core/bpf_jit_enable
1
Và 56 map đang giữ trạng thái cho đám chương trình đó:
sudo bpftool map show | grep -iE 'cilium'
169: perf_event_array name cilium_events
171: percpu_hash name cilium_metrics
172: hash name cilium_ratelimi
Toàn bộ giải phẫu eBPF — bytecode đã verify, mã JIT, maps, BTF, hook — không phải sơ đồ trên giấy mà là thứ đang chạy và soi được trên máy này. Cả series sẽ quay lại mổ chính những chương trình này, và tự viết những cái tương tự.
Phòng lab và lộ trình
Cụm Kubernetes ở series trước là môi trường eBPF lý tưởng: kernel 6.17 (đủ mọi tính năng eBPF hiện đại), BTF bật (/sys/kernel/btf/vmlinux), bpftool + bpftrace cài sẵn, và một hệ eBPF production (Cilium, 140 chương trình) để mổ làm case-study — thứ một lab tự dựng hiếm có.
Lộ trình series:
- Part I — Nền tảng (đang ở đây): máy ảo eBPF, verifier, JIT, maps, program types, các hook, BTF + CO-RE.
- Part II — Tracing: quan sát nhân bằng
bpftrace. - Part III — Tự viết chương trình: libbpf + CO-RE bằng C, rồi nạp từ Go (cilium/ebpf).
- Part IV — Networking: XDP, tc, mổ datapath Cilium, tự viết firewall XDP.
- Part V — Security: LSM BPF, seccomp-bpf, cưỡng chế lúc chạy.
- Part VI — Observability: profiling, histogram độ trễ, nội tại Hubble.
- Part VII — Case-study Cilium đầu-cuối và tổng kết.
🧹 Dọn dẹp
Bài này chỉ đọc trạng thái nhân bằng bpftool (không nạp/sửa gì), nên không có gì để dọn. Mã nguồn và lệnh của series ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 00-intro.
Tổng kết
eBPF chạy chương trình sandbox bên trong nhân Linux mà không sửa mã nguồn nhân hay nạp module — an toàn (verifier kiểm trước khi chạy) và nhanh (JIT sang mã gốc), tách đổi mới khỏi chu kỳ nhân. Một chương trình đi qua vòng đời: viết C → clang dịch ra bytecode → bpf() syscall nạp → verifier kiểm an toàn (kết thúc, bộ nhớ hợp lệ, độ phức tạp hữu hạn) → JIT sang mã máy → gắn vào hook (XDP/tc/kprobe/tracepoint/LSM/socket) → chạy khi sự kiện xảy ra, dùng maps để giữ trạng thái và nói chuyện với userspace, chỉ gọi được helper chứ không phải hàm nhân tùy ý. Ta không học chay: worker-0 đang chạy 140 chương trình Cilium, và bpftool cho thấy từng khái niệm là thật — xlated (đã verify), jited (mã gốc), map_ids, btf_id trên một chương trình tc thật tên tail_no_service_ipv4.
Bài 1 đi vào trong: chính xác thì "máy ảo eBPF" là gì — tập thanh ghi, tập lệnh, và vì sao verifier chứng minh được một chương trình an toàn trước khi nó chạy.
This is the first part