Sáu tháng trước, hệ thống Prometheus node-exporter của chúng tôi đã chạm ngưỡng giới hạn. Trên toàn bộ cụm Kubernetes gồm 2.500 node, các agent observability chạy ở user-space tiêu tốn tới gần 15% tổng CPU chỉ để parse /proc/net/tcp và lấy mẫu socket buffer ở mức tải đỉnh 1,2 triệu req/giây. Chúng tôi đã loại bỏ hoàn toàn các poller này và triển khai các probe eBPF tùy chỉnh do Go quản lý bằng cilium/ebpf. Mức sử dụng CPU dành cho observability lập tức giảm xuống dưới 0,8%.
Tracing ở cấp độ kernel mang lại các metric socket với tốc độ dây (wire-speed) mà không cần chỉnh sửa kernel hay dùng các kernel module out-of-tree kém ổn định. Dưới đây là bản thiết kế chi tiết (blueprint) chuẩn production để biên dịch, nạp và stream dữ liệu đo kiểm mạng eBPF trong Go.
Bắt đầu nhanh (5 phút)
Hãy quên việc nhúng các toolchain nặng nề như LLVM hay Clang vào container production đi. Tiện ích bpf2go của Cilium có thể biên dịch mã nguồn C sang eBPF bytecode trước (ahead of time) ngay trong bước go generate, đồng thời tạo ra các Go binding gọn gàng và type-safe.
1. Cài đặt các công cụ cần thiết
Bạn cần kernel 5.15+ để hỗ trợ ring buffer tiêu chuẩn, cùng với các công cụ build cốt lõi:
sudo apt-get update && sudo apt-get install -y clang llvm libbpf-dev linux-headers-$(uname -r)
go install github.com/cilium/ebpf/cmd/bpf2go@latest
2. Viết mã nguồn C eBPF cho Kernel
Lưu file này dưới tên kprobe_tcp.c. Chúng ta sẽ gắn vào tcp_v4_connect, hàm này nhận IP và port đích bên trong tham số struct sockaddr trước khi socket thực hiện kết nối:
//go:build ignore
#include <linux/bpf.h>
#include <linux/in.h>
#include <linux/ptrace.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
struct event {
__u32 saddr;
__u32 daddr;
__u16 dport;
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 16 * 1024 * 1024); // Ring buffer kích thước 16 MB
} events SEC(".maps");
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk, struct sockaddr *uaddr) {
struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
struct event *e;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) {
return 0; // Buffer đã đầy; drop gói tin để bảo vệ thông lượng của kernel
}
BPF_CORE_READ_INTO(&e->daddr, usin, sin_addr.s_addr);
BPF_CORE_READ_INTO(&e->dport, usin, sin_port);
BPF_CORE_READ_INTO(&e->saddr, sk, __sk_common.skc_rcv_saddr);
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "Dual BSD/GPL";
3. Tạo binding và stream các sự kiện
Tạo file main.go. Dòng //go:generate sẽ biên dịch mã nguồn C của chúng ta cho kiến trúc little-endian thành một object file được nhúng trực tiếp:
package main
//go:generate bpf2go -target bpfel kprobeTCP kprobe_tcp.c
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
)
type tcpEvent struct {
Saddr uint32
Daddr uint32
Dport uint16
}
func main() {
// Kernel <5.11 yêu cầu nâng giới hạn locked memory
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Gỡ bỏ giới hạn memlock thất bại: %v", err)
}
objs := kprobeTCPObjects{}
if err := loadKprobeTCPObjects(&objs, nil); err != nil {
log.Fatalf("Nạp các eBPF object thất bại: %v", err)
}
defer objs.Close()
kp, err := link.Kprobe("tcp_v4_connect", objs.TcpV4Connect, nil)
if err != nil {
log.Fatalf("Gắn kprobe thất bại: %v", err)
}
defer kp.Close()
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("Mở ringbuf thất bại: %v", err)
}
defer rd.Close()
log.Println("Đã gắn probe. Bắt đầu stream các kết nối TCP ra ngoài...")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
var event tcpEvent
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
return
}
log.Printf("Lỗi đọc ringbuf: %v", err)
continue
}
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
continue
}
dstIP := make(net.IP, 4)
binary.LittleEndian.PutUint32(dstIP, event.Daddr)
// Chuyển đổi port từ network byte order
port := (event.Dport>>8) | (event.Dport<<8)
fmt.Printf("Kết nối -> %s:%d\n", dstIP, port)
}
}()
<-stop
log.Println("Đang tắt collector...")
}
Chạy go generate && sudo go run .. Mở một terminal khác, thực hiện một lệnh bất kỳ như curl https://example.com và quan sát sự kiện xuất hiện ngay lập tức.
Kiến trúc & Cơ chế hoạt động nội bộ
Tại sao lại thay thế BCC? Điểm khác biệt mấu chốt nằm ở mức tiêu hao tài nguyên khi chạy (runtime footprint) và tính tiền định của pipeline (pipeline determinism).
Pure Go so với BCC
BCC biên dịch mã nguồn probe trực tiếp khi chạy (on-the-fly) bằng trình biên dịch LLVM nhúng trên từng máy đích. Trong các môi trường mật độ cao, điều này gây ra những bài toán vận hành nghiêm trọng:
- Các worker node bắt buộc phải cài đặt các gói kernel development header và LLVM nặng nề. Điều này khiến base container image bị phình to thêm từ 380MB đến 500MB.
- Quá trình biên dịch Just-in-time (JIT) đẩy CPU lên 100% trên một core trong vài giây mỗi khi pod khởi động lại hoặc xoay vòng.
- Những sai khác nhỏ giữa các phiên bản thư viện glibc hoặc Clang sẽ gây ra lỗi biên dịch trên các base image khác nhau.
Biên dịch tĩnh (static compilation) giải quyết triệt để vấn đề này. bpf2go xây dựng ELF bytecode ngay trong bước CI tiêu chuẩn của bạn. Khi khởi động, Go runtime chỉ cần đọc binary ELF, parse các bảng BTF (BPF Type Format) và nạp chương trình trực tiếp qua syscall bpf(). Không cần CGO. Không cần thư viện hệ thống chia sẻ. Không có độ trễ biên dịch khi khởi động.
Ring Buffer từ Kernel-space sang User-space
Các probe thực thi bên trong một máy ảo cô lập ngay trong kernel. Chúng không thể ghi trực tiếp vào bộ nhớ heap của tiến trình hay gọi các system call tùy ý. Thay vào đó, dữ liệu được truyền qua ranh giới này thông qua BPF Map.
Dưới đây là pipeline giao tiếp cho mỗi nỗ lực kết nối:
- Kích hoạt Probe: Kernel thực thi
tcp_v4_connectvà chuyển hướng sang bytecode đã nạp trong khoảng 15 đến 30 nano giây. - Cấp phát Zero-Copy: Hàm
bpf_ringbuf_reservegiữ trước vùng nhớ trực tiếp trong một ring buffer dùng chung. Nếu buffer bị đầy, bản ghi sẽ bị loại bỏ thay vì làm nghẽn tiến trình xử lý gói tin mạng. - Kiến trúc bộ nhớ hợp nhất: Cấu trúc cũ
PERF_EVENT_ARRAYcấp phát các buffer riêng biệt theo từng CPU (per-CPU). Nếu core 0 rảnh rỗi trong khi core 15 phải xử lý ngắt mạng liên tục, buffer của core 0 sẽ trống rỗng trong khi core 15 lại làm rơi rớt gói tin. Ngược lại, cấu trúc hiện đạiBPF_MAP_TYPE_RINGBUFchia sẻ một buffer đa ghi (multi-producer) duy nhất trên toàn bộ các core, giúp cắt giảm mức tiêu thụ RAM từ 64MB xuống chỉ còn 16MB cho mỗi daemon. - Đánh thức qua Epoll: Reader ringbuf của Go duy trì trạng thái sleep trên epoll event descriptor cho đến khi kernel báo có dữ liệu mới, giữ mức sử dụng CPU ở user-space ở mức 0% khi rảnh rỗi.
Kỹ thuật nâng cao: Tổng hợp độ trễ ngay trong Kernel
Việc stream từng sự kiện của gói tin lên user-space có thể dễ dàng làm tràn Go channel khi lưu lượng truy cập tăng đột biến. Nếu node của bạn xử lý tới 80.000 request mỗi giây, hãy tổng hợp độ trễ trực tiếp trong các kernel map trước.
Theo dõi độ trễ bắt tay bằng Hash Map
Để đo lường thời gian kết nối, hãy kết hợp một kprobe với một kretprobe. Lưu timestamp bắt đầu với key là thread ID duy nhất (bpf_get_current_pid_tgid()), sau đó tính tổng thời gian đã trôi qua khi hàm trả về kết quả:
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 32768);
__type(key, __u64);
__type(value, __u64);
} start_times SEC(".maps");
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(trace_connect_entry) {
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start_times, &pid_tgid, &ts, BPF_ANY);
return 0;
}
SEC("kretprobe/tcp_v4_connect")
int BPF_KRETPROBE(trace_connect_exit, int ret) {
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u64 *start_ts = bpf_map_lookup_elem(&start_times, &pid_tgid);
if (start_ts) {
if (ret == 0) {
__u64 latency_ns = bpf_ktime_get_ns() - *start_ts;
// Đưa độ trễ vào BPF_MAP_TYPE_HISTOGRAM hoặc counter trong kernel
(void)latency_ns;
}
bpf_map_delete_elem(&start_times, &pid_tgid);
}
return 0;
}
Luôn đặt key cho các probe từ lúc bắt đầu đến khi trả về bằng bpf_get_current_pid_tgid() thay vì phụ thuộc vào các tham số đầu vào trong một kretprobe. Các return probe không thể kiểm tra tham số đầu vào một cách đáng tin cậy vì các thanh ghi CPU đã được tái sử dụng trong suốt quá trình thực thi hàm.
Đảm bảo tính tương thích đa nền tảng với CO-RE
Cấu trúc struct của kernel thay đổi qua các bản phát hành Linux. Offset của các trường trong struct sock ở kernel 5.4 không hề trùng khớp với kernel 6.2. Nếu không có cơ chế tái định vị (relocation), probe của bạn sẽ trích xuất sai vùng nhớ hoặc bị verifier từ chối ngay lập tức.
CO-RE (Compile Once – Run Everywhere) loại bỏ hoàn toàn nguy cơ này. Hãy tạo một file header toàn diện vmlinux.h từ bất kỳ host nào:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
Các macro như BPF_CORE_READ() ghi lại thông tin tái định vị trường vào các section BTF. Khi cilium/ebpf nạp chương trình, nó sẽ kiểm tra /sys/kernel/btf/vmlinux của host hiện tại và viết lại các byte offset ngay trong lúc chạy. Một binary Go tĩnh duy nhất có thể chạy trên cả Ubuntu 20.04, Debian 12 và Amazon Linux 2023 mà không cần build lại.
Những cạm bẫy cần tránh trong môi trường Production
Việc triển khai eBPF trên hàng nghìn máy sẽ nhanh chóng bộc lộ các giới hạn cấp thấp mà những bài hướng dẫn thông thường thường bỏ qua.
1. Lưu ý giới hạn 512 byte của Stack
Kernel verifier phân bổ một vùng stack nghiêm ngặt chỉ 512 byte cho mỗi probe. Việc khai báo một biến char path[256] cùng với struct event sẽ khiến quá trình build thất bại ngay với lỗi look like stack overflow. Khi cần ghép các payload lớn hơn 256 byte, hãy cấp phát chúng bên trong một scratch array map theo từng CPU (BPF_MAP_TYPE_PERCPU_ARRAY) hoặc cấp phát bộ nhớ trực tiếp trong ring buffer.
2. Căn chỉnh Struct (Alignment) và Deserialization trong Go
Trình biên dịch C tự động chèn các byte đệm (padding) ẩn để thỏa mãn yêu cầu căn chỉnh từ 4 byte và 8 byte (word alignment). Nếu struct C không khớp với cấu trúc giải mã binary trong Go, các metric sẽ bị lệch sang sai trường dữ liệu. Hãy sắp xếp các thành viên của struct theo thứ tự kích thước giảm dần một cách chặt chẽ, và kiểm tra cấu trúc bộ nhớ bằng pahole:
// Cấu trúc tối ưu: 24 byte, 0 khoảng trống padding nội bộ
struct event {
__u64 timestamp_ns; // 8 byte (offset 0)
__u32 saddr; // 4 byte (offset 8)
__u32 daddr; // 4 byte (offset 12)
__u16 dport; // 2 byte (offset 16)
__u8 protocol; // 1 byte (offset 18)
__u8 pad; // 1 byte (offset 19) để hoàn thành căn chỉnh 4 byte
__u32 reserved; // 4 byte (offset 20) đệm struct lên tổng 24 byte
};
3. Vận hành không cần quyền root bằng Linux Capabilities
Đừng chạy các daemon giám sát Go với toàn quyền root. Kể từ Linux 5.8, bạn có thể cấp các capability chi tiết cho binary của mình bằng lệnh setcap:
sudo setcap cap_bpf,cap_perfmon,cap_net_admin+ep ./network-collector
Hãy nhớ rằng Linux 5.11 đã chuyển cơ chế hạch toán map sang memory cgroup. Trên các kernel cũ hơn như 5.4 hoặc 5.8, bạn vẫn phải gọi rlimit.RemoveMemlock() trước khi nạp map. Mức giới hạn trần locked-memory mặc định chỉ 64KB sẽ gây lỗi ngay lập tức khi khởi tạo bất kỳ map thực tế nào.
Sự kết hợp giữa cilium/ebpf và mô hình đồng thời (concurrency) siêu nhẹ của Go mang lại độ chính xác ở cấp độ kernel với mức tiêu tốn CPU cực kỳ nhỏ. Bạn có được khả năng quan sát mạng chuyên sâu trong khi vẫn dành trọn năng lực tính toán của host cho mục đích chính: phục vụ workload của người dùng.

