Capstone: Tự Viết connmon — Monitor Kết Nối TCP Toàn Node
You've reached the end
Bài cuối. Không khái niệm mới — chỉ ghép những mảnh của cả series thành một công cụ quan sát thật: connmon, in mỗi kết nối TCP mới của toàn node ngay khi nó xảy ra. Nó dùng kprobe (Bài 6), ring buffer (Bài 9), CO-RE (Bài 5), và loader Go cilium/ebpf (Bài 10) — đúng tinh thần "từ số không" tới một thứ chạy được.
Ý tưởng: bắt mọi tcp_connect
Mỗi kết nối TCP đi ra đều gọi hàm nhân tcp_connect(). Gắn một kprobe vào đó, ta bắt được mọi kết nối của mọi tiến trình trên node — không cần sửa hay biết trước ứng dụng nào. Từ tham số struct sock *sk, đọc địa chỉ/cổng đích rồi đẩy lên userspace.
Phía nhân: kprobe + ring buffer
struct event { // chia sẻ layout với phía Go
__u32 pid;
__u32 saddr, daddr; // __be32 (network order)
__u16 dport; // host order
__u16 af;
char comm[16];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
SEC("kprobe/tcp_connect")
int BPF_KPROBE(trace_tcp_connect, struct sock *sk)
{
if (BPF_CORE_READ(sk, __sk_common.skc_family) != AF_INET)
return 0; // IPv4 cho gọn
struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
e->saddr = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr); // CO-RE (Bài 5)
e->daddr = BPF_CORE_READ(sk, __sk_common.skc_daddr);
e->dport = bpf_ntohs(BPF_CORE_READ(sk, __sk_common.skc_dport));
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0); // đẩy lên userspace (Bài 9)
return 0;
}
BPF_KPROBE (macro libbpf) khai triển chữ ký kprobe để lấy struct sock *sk từ pt_regs. Các trường socket đọc bằng BPF_CORE_READ — relocate theo BTF của kernel này (Bài 5), nên cùng binary chạy được trên kernel khác layout. Sự kiện đẩy qua ring buffer y như execsnoop (Bài 9).
Phía userspace: loader Go
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang connmon connmon.bpf.c -- -I. -D__TARGET_ARCH_x86
func main() {
rlimit.RemoveMemlock()
objs := connmonObjects{}
loadConnmonObjects(&objs, nil) // nạp + verifier + CO-RE + JIT
defer objs.Close()
kp, _ := link.Kprobe("tcp_connect", objs.TraceTcpConnect, nil) // gắn kprobe
defer kp.Close()
rd, _ := ringbuf.NewReader(objs.Events) // đọc ring buffer
defer rd.Close()
for {
rec, err := rd.Read()
if errors.Is(err, ringbuf.ErrClosed) { return }
var e event
binary.Read(bytes.NewReader(rec.RawSample), binary.LittleEndian, &e)
fmt.Printf("%-12s %-16s %-7d %-15s -> %s:%d\n",
time.Now().Format("15:04:05.000"), cstr(e.Comm[:]), e.Pid,
ipv4(e.Saddr), ipv4(e.Daddr), e.Dport)
}
}
Đúng khung của Bài 10: bpf2go nhúng object + sinh connmonObjects/loadConnmonObjects; link.Kprobe thay link.Tracepoint; ringbuf.Reader đọc sự kiện. ipv4() đổi __be32 sang chuỗi a.b.c.d (byte thấp = octet đầu).
Một cái bẫy build thật: kprobe cần biết kiến trúc
Lần go generate đầu báo lỗi lạ:
GCC error "The eBPF is using target specific macros, please provide -target
that is not bpf, bpfel or bpfeb"
Nguyên nhân: BPF_KPROBE lấy tham số từ pt_regs, mà layout pt_regs khác nhau theo kiến trúc CPU — bpf_tracing.h cần biết kiến trúc đích qua macro __TARGET_ARCH_x86. Bài 9-10 dùng tracepoint (không đụng pt_regs) nên không gặp; kprobe thì cần. Sửa: thêm -D__TARGET_ARCH_x86 vào cờ biên dịch của bpf2go (thấy trong directive go:generate ở trên). Một cái bẫy rất thật khi chuyển từ tracepoint sang kprobe.
Chạy: thấy cả node đang kết nối đi đâu
go generate && go build -o connmon .
sudo ./connmon
TIME COMM PID SADDR -> DADDR:PORT
03:39:43.301 coredns 2555 127.0.0.1 -> 127.0.0.1:8080
03:39:44.082 kubelet 817 10.200.0.64 -> 10.200.0.180:9808
03:39:44.093 curl 7812 10.0.1.20 -> 10.0.1.10:6443
03:39:45.868 kubelet 817 10.200.0.64 -> 10.200.0.30:8181
03:39:48.803 kubelet 817 10.0.1.20 -> 10.0.1.20:9808
Một binary tĩnh ~5.4MB, chạy là thấy ngay đời sống mạng của node: coredns self-check cổng 8080, kubelet thăm dò sức khỏe các pod (:9808, :8181), và curl của ta đi tới kube-apiserver qua LB (10.0.1.10:6443). Mỗi dòng là một tcp_connect thật, bắt ở nhân, đẩy qua ring buffer, in realtime — không cài gì vào các tiến trình đó. Thoát (Ctrl+C) là kprobe tự gỡ, node về 140 chương trình. Đây là một tcpconnect thu nhỏ — đúng kiểu công cụ mà các bộ quan sát sản xuất (Cilium, Pixie, Falco) xây ở quy mô lớn.
🧹 Dọn dẹp
connmon tự gỡ kprobe + ring buffer khi thoát; node về 140 chương trình. Mã nguồn đầy đủ (connmon.bpf.c, main.go, go.mod/go.sum) ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 21-capstone-connmon; chạy go generate && go build.
Nhìn lại: hành trình eBPF từ số không
Hai mươi mốt bài, một mạch:
- Part I — Nền tảng: máy ảo eBPF (thanh ghi, lệnh, JIT), verifier chứng minh an toàn, maps chia sẻ trạng thái, program type & hook, BTF + CO-RE để một binary chạy đa kernel. Mỗi khái niệm neo vào 140 chương trình thật của Cilium trên node.
- Part II — Tracing:
bpftracetừ one-liner tới histogram tới soi pod từ host — quan sát không cần viết C. - Part III — Viết công cụ thật:
execsnoopbằng C (libbpf + skeleton + ring buffer) rồi bằng Go (cilium/ebpf + bpf2go). - Part IV — Networking: XDP (firewall, hook sớm nhất), tc/sched_cls, mổ datapath Cilium thật, tự viết tc.
- Part V — Security: LSM BPF (chặn trước, cần bật bpf LSM), seccomp (cBPF, mọi container), kiểu Tetragon (kprobe +
bpf_send_signal). - Part VI — Observability: CPU profiling (perf_event), off-CPU/độ trễ scheduler, Hubble internals.
- Part VII — Ghép lại: hành trình một gói qua datapath, và
connmonbài này.
Sợi chỉ xuyên suốt: eBPF là một công nghệ — nạp một chương trình nhỏ vào nhân, verifier chứng minh nó an toàn, JIT biên dịch, nó gắn vào một hook và chia sẻ trạng thái qua map. Từ nền đó mọc ra tracing, networking, security, observability — và cả một dự án như Cilium. Bắt đầu từ số không, giờ bạn đọc được 140 chương trình trên một node thật, viết được công cụ của riêng mình bằng C lẫn Go, và hiểu vì sao hạ tầng cloud-native hiện đại dựng trên eBPF.
Cảm ơn đã theo tới cuối. Toàn bộ mã nguồn hands-on ở github.com/nghiadaulau/ebpf-from-scratch — clone về, chạy thử, sửa, và viết chương trình eBPF của riêng bạn.
You've reached the end