Hình dung bạn muốn biết một cửa hàng bán được bao nhiêu mỗi ngày, nhưng không được vào sổ sách của họ. Bạn có hai lựa chọn. Một: đặt một quầy thu ngân của mình chắn ngay cửa, mọi khách phải đi qua — bạn thấy hết từng món, từng giá, nhưng bạn làm chậm cả dòng người. Hai: gắn một cái đếm ở khung cửa, ghi mỗi lần có người ra vào — gần như vô hình, nhưng bạn chỉ biết "có người qua", không biết họ mua gì. Quan sát một dịch vụ không sửa được mã đúng là chọn giữa hai cái đó, và bài này tôi đo cái giá thật của mỗi bên.

Ba mươi sáu phần trước đều giả định một điều: bạn sửa được mã nguồn. Thêm thư viện chỉ số, gắn SDK trace, đổi định dạng log — tất cả bắt đầu bằng việc mở tệp nguồn ra. Phần này bỏ giả định đó. Dịch vụ cần quan sát là một binary bạn không có mã, không build lại được, và không được phép khởi động lại theo ý mình. Còn đúng hai đường: đặt một proxy chắn trước nó, hoặc gắn eBPF vào nhân hệ điều hành. Cả hai đều "không đụng vào ứng dụng", nhưng giá thì lệch nhau một bậc. (Đo bằng nginx thật trong container, đo độ trễ syscall qua eBPF — không phải perf counter phần cứng.)

Bảng số liệu

Hệ đo: một nginx phát chuỗi tĩnh, một nginx thứ hai làm proxy đứng trước, một client đo độ trễ từng request, tuần tự, trên kết nối giữ sẵn, đơn vị micro-giây. Mỗi thành phần ghim vào một lõi CPU riêng. 8 000 request mỗi lượt, ba lần.

Cách quan sát p50 So với nền Sự kiện eBPF ghi nhận
Không quan sát gì 49,3 µs — —
eBPF đo thời gian (2 probe + map + histogram) 50,0 µs +0,7 µs (+1,4%) 8 201
eBPF đếm (3 probe) 50,5 µs +1,4 µs (+2,9%) 8 201 / 8 200 / 9
Proxy sidecar 82,2 µs +33,1 µs (+67%) —
Proxy và eBPF cùng lúc 81,9 µs +32,6 µs 24 601

Proxy đắt hơn eBPF khoảng 24 lần. Hai biến thể eBPF chênh nhau 0,7 µs — nằm trong nhiễu, nên nên đọc là "cả hai đều tốn cỡ một micro-giây". Cột cuối là cột quan trọng nhất trong bảng, và tôi sẽ giải thích ở cuối bài tại sao.

Đánh đổi thật không phải "nhanh hay chậm"

Proxy không đắt vì nó chậm, mà vì nó là một chặng đầy đủ. 33,1 µs là thời gian để gói tin đi qua một tiến trình nữa: nhận kết nối, đọc, phân tích HTTP, mở kết nối sau, ghi, rồi làm ngược lại. Con số ấy cộng vào mọi request, kể cả những request bạn không quan tâm. Đó là cái quầy thu ngân chắn cửa.

eBPF gần như miễn phí vì nó không đứng trên đường đi. Probe chạy trong nhân, ngay tại chỗ syscall vốn đã xảy ra. Biểu đồ phân bố mà chính probe ấy thu được cho thấy recvfrom mất 256–1024 ns — tức bản thân syscall được đo chỉ chiếm khoảng 1,6% thời gian một request 49,3 µs. Gắn thêm vài chục nano giây vào một chỗ vốn chỉ tốn vài trăm nano giây thì không đủ làm lệch con số tổng. Đó là cái đếm ở khung cửa.

Nhưng eBPF thấy ít hơn hẳn. Ba probe của tôi đếm được 8 201 lần recvfrom và 8 200 lần writev. Chúng không nói được request đó là GET hay POST, đường dẫn nào, mã trả về bao nhiêu. Muốn có những thứ đó phải tự phân tích byte trong bộ đệm — và nếu ứng dụng dùng TLS thì byte ấy đã mã hoá, phải chuyển sang gắn uprobe vào SSL_read/SSL_write của thư viện, tức quay lại phụ thuộc chi tiết bên trong ứng dụng. Proxy thì biết ngay mã trả về, vì nó là một máy chủ HTTP. Đó mới là đánh đổi thật: 33,1 µs là giá của ngữ nghĩa HTTP, không phải giá của việc quan sát.

Dòng cuối bảng: đặt cả hai không tốn thêm gì. 81,9 µs so với 82,2 µs của proxy đơn thuần — chênh lệch nằm trong nhiễu. Đã trả tiền cho proxy rồi thì thêm eBPF gần như không mất gì.

Vì sao con số eBPF qua proxy là 24 601

Với proxy, cùng một bộ 8 200 request lại cho 24 601 lần recvfrom — đúng gấp 3,00 lần. Không phải sai số: bộ lọc của tôi bắt theo tên tiến trình nginx, mà trong cấu hình có hai nginx, và mỗi request đi qua ba lượt đọc:

  1. proxy đọc request từ client,
  2. app đọc request từ proxy,
  3. proxy đọc phản hồi từ app.

Đây là điều đáng nhớ với ai đang chạy service mesh: thêm một sidecar là nhân ba số sự kiện mà mọi công cụ eBPF trên máy đó phải xử lý. Chi phí eBPF vốn nhỏ, nhưng nó nhân lên theo số chặng, không theo số request.

Ba lần đo cho eBPF tốn đúng 0%

Đây là phần đáng giá nhất của bài này, và nó suýt không được viết ra.

Lần một: proxy nhanh hơn đường thẳng. Bộ đo đầu tiên dùng ab đo thông lượng, và cho proxy 49 349 req/s so với 48 452 req/s khi đi thẳng. Thêm một chặng mà nhanh hơn là chuyện không thể. CPU tố cáo lý do: app tốn 0,13 giây qua proxy nhưng 0,23 giây khi đi thẳng — vì ab mở kết nối mới cho mỗi request, còn proxy giữ sẵn 64 kết nối tới app. Tôi đang đo lợi ích của việc tái dùng kết nối chứ không phải chi phí của proxy. Cùng lúc đó, thông lượng nền dao động 42 544–49 349 req/s giữa các lần chạy (±13%), và ab chỉ báo độ trễ tròn tới mili-giây — quá thô để thấy một chặng cỡ vài chục micro-giây. Chữa bằng bốn thay đổi: đo độ trễ tuần tự thay vì thông lượng, kết nối giữ sẵn cho cả hai đường, ghim mỗi thành phần vào một lõi CPU riêng, và đồng hồ micro-giây.

Lần hai: probe gắn vào syscall mà ứng dụng không bao giờ gọi. Tôi đặt probe lên read. Kết quả: @doc: 0, và độ trễ trùng khít với nền. Nginx đọc socket bằng recvfrom, không bằng read. Đếm toàn bộ syscall cho thấy read, readv, sendto đều bằng 0, còn recvfrom 2 201 và writev 2 200 cho 2 200 request.

Lần ba: chương trình bpftrace chết ngay lúc khởi động. Cú pháp /A/ /B/ — hai khối điều kiện rời — không hợp lệ ở bpftrace 0.17; phải viết /A && B/. Tiến trình thoát tức khắc, tệp kết quả 0 byte, thông báo lỗi đi ra stderr mà tôi không đọc. Độ trễ đo được: 50,2 µs, so với nền 49,3 µs.

Cả ba lần đều cho ra một câu chuyện hoàn toàn thuyết phục: "eBPF gần như miễn phí". Kết luận ấy tình cờ đúng, và đó chính là chỗ nguy hiểm — tôi suýt đăng một con số đúng được rút ra từ một phép đo không hề chạy. Thứ duy nhất bắt được cả ba là cột "sự kiện eBPF ghi nhận": một con số biết trước để đối chiếu. 8 200 request thì probe phải thấy 8 200 sự kiện. Thấy 0, hoặc thấy trống, là hỏng — bất kể độ trễ trông hợp lý đến đâu.

Cùng một lựa chọn quan sát, đổi ngăn xếp

Chuyện "quan sát cái mình không sửa được mã, và chọn giữa chặn-trên-đường-đi với quan-sát-bên-cạnh" không riêng gì nginx — nó lặp lại ở mọi tầng, và cùng đánh đổi "ngữ nghĩa đầy đủ nhưng đắt" đối lại "rẻ nhưng ít bối cảnh".

Trong thế giới Java, đúng lựa chọn này hiện ra giữa một Java agent (-javaagent với bytecode instrumentation) và JFR/async-profiler dựa trên sự kiện JVM. Agent chèn được ngữ nghĩa giàu — tên method, tham số, span — nhưng phải sửa cách chạy và thêm overhead; JFR gần như miễn phí (sampling ở tầng runtime) nhưng cho bức tranh thô hơn. Cùng cái trục "quầy thu ngân đầy đủ" đối lại "cái đếm ở khung cửa".

Trong Go, không có JVM để hook, nên hệ sinh thái nghiêng hẳn về eBPF cho quan sát không-sửa-mã (Pixie, Parca, các profiler eBPF liên tục) — chính vì Go biên dịch ra binary tĩnh khó chèn agent. Điểm đáng nhớ giống hệt bài này: eBPF thấy syscall và stack, nhưng để lấy ngữ nghĩa HTTP/gRPC của một binary Go thường phải gắn uprobe vào hàm cụ thể, tức lại phụ thuộc chi tiết bên trong.

Còn ở cơ sở dữ liệu, cùng lựa chọn là proxy-quan-sát (pgbouncer/ProxySQL đứng trước, thấy từng câu query, nhưng thêm một chặng và độ trễ) đối lại đọc thống kê tại chỗ (pg_stat_statements, slow query log — gần như miễn phí nhưng đã gộp, mất dấu từng request cụ thể). DBA chọn bên nào tuỳ cần "từng câu query của phiên này" hay "bức tranh tổng, rẻ".

Mẫu số chung: quan sát cái bạn không kiểm soát luôn là đánh đổi giữa đứng trên đường đi để thấy đủ ngữ nghĩa và quan sát bên cạnh để rẻ nhưng mất bối cảnh — và cái giá bạn trả gần như luôn là giá của ngữ nghĩa, không phải giá của việc đo. Nhưng có một điều đúng cho mọi cách đo trong danh sách này, và nó là bài học đắt nhất của bài: một công cụ quan sát phải tự chứng minh nó đang chạy. Con số "chi phí gần bằng không" và con số "không đo gì cả" trông y hệt nhau nếu bạn không có một mốc biết trước để đối chiếu. Nên câu hỏi đầu tiên cho bất kỳ hệ quan sát nào không phải "nó nhẹ không" mà là "làm sao tôi biết chắc nó thật sự thấy cái nó đáng thấy".

Muốn tự kiểm, nếu bạn đang chạy một công cụ quan sát dựa trên eBPF, hãy hỏi nó một câu duy nhất: trong một phút vừa rồi nó ghi nhận bao nhiêu sự kiện? Rồi so với số request mà bạn biết chắc hệ thống đã phục vụ trong phút ấy. Hai con số phải khớp. Nếu công cụ không trả lời được câu hỏi đó, bạn không có cách nào phân biệt "chi phí gần bằng không" với "không chạy".