tc/sched_cls và Mổ Datapath Cilium Đang Chạy
Bài 11, XDP chặn gói ở driver, trước khi nhân biết tới gói. Nhưng phần lớn việc mạng của Kubernetes — định tuyến giữa pod, cân bằng tải Service, áp NetworkPolicy — diễn ra ở tầng kế tiếp: tc. Đây là nơi Cilium đặt gần như cả datapath. Thay vì viết một chương trình tc đồ chơi, bài này mổ thẳng 74 chương trình sched_cls của Cilium đang chạy thật trên một node để thấy một gói pod thực sự được xử lý ra sao.
tc/sched_cls: hook sau khi có sk_buff
tc (traffic control) là hệ thống định hình lưu lượng có sẵn lâu đời của nhân. eBPF cắm vào đó qua loại chương trình BPF_PROG_TYPE_SCHED_CLS — bpftool hiển thị là sched_cls. Khác XDP ở mấy điểm cốt lõi:
| XDP | tc/sched_cls | |
|---|---|---|
| Chạy khi | trước sk_buff |
sau khi có sk_buff |
| Thấy được | chỉ ingress | cả ingress và egress |
| Dữ liệu | gói thô | __sk_buff (đã có metadata: mark, ifindex, cgroup...) |
| Vị trí | chỉ ở card vật lý | mọi interface, kể cả veth của pod |
Đổi lại sự "muộn" hơn XDP, tc thấy được egress và làm việc với sk_buff đầy đủ metadata — đúng thứ cần để định tuyến giữa các pod. Kernel 6.6 trở lên còn có tcx, một cơ chế gắn tc kiểu mới dựa trên BPF link: nhiều chương trình xếp chuỗi trên cùng một hook, sở hữu an toàn, tự gỡ khi đóng, thứ tự định bằng cờ BPF_F_BEFORE/BPF_F_AFTER — và đó chính là cái cụm này (kernel 6.17) đang dùng (bpftool net show ghi rõ tcx/ingress, tcx/egress).
74 chương trình, gắn ở đâu
Trên node, đếm chương trình eBPF theo loại:
sudo bpftool prog show | grep -c sched_cls
74
74 trên tổng 140 chương trình eBPF của node là sched_cls — gần như tất cả là của Cilium. Chúng gắn vào đâu? bpftool net show:
tc:
ens5(2) tcx/ingress cil_from_netdev prog_id 2969
ens5(2) tcx/egress cil_to_netdev prog_id 2960
cilium_host(74) tcx/ingress cil_to_host prog_id 2944
cilium_host(74) tcx/egress cil_from_host prog_id 2943
lxc9020cf5e63ba(100) tcx/ingress cil_from_container prog_id 2889
lxc_health(124) tcx/ingress cil_from_container prog_id 2934
lxcd74767545ed6(160) tcx/ingress cil_from_container prog_id 3388
lxcbafc4faaa189(162) tcx/ingress cil_from_container prog_id 3437
Đọc ra cả kiến trúc datapath:
cil_from_netdev/cil_to_netdevgắn trênens5(card vật lý) — gói vào/ra node đi qua đây. Đó là tầng ngay sau XDP firewall ở Bài 11.cil_from_containergắn ở ingress của từnglxc...— mỗi interface đó là một đầu veth của một pod. Mỗi pod thêm vào node là thêm một chương trìnhsched_clsgắn vào veth của nó. Đó là lý do con số 74: nó tỉ lệ với số pod + interface trên node.cil_host/cil_netxử lý lưu lượng đi vào host stack.
Pod A ──veth(lxc..)──► cil_from_container ──┐
│ (BPF, trong nhân)
▼
tra map: LB? policy? CT?
│
┌───────────────────┴────────────┐
▼ ▼
Pod B trên cùng node cil_to_netdev ──► ens5 ──► node khác
(chuyển thẳng veth→veth)
Tail call: datapath chẻ thành nhiều chương trình
Vì sao tới 74 chương trình chứ không phải một? Hai lý do. Một, mỗi pod có chương trình riêng. Hai, Cilium chẻ datapath thành nhiều chương trình gọi nhau qua tail call — vì một chương trình eBPF bị giới hạn 1 triệu lệnh (Bài 2), datapath đầy đủ (LB + CT + NAT + policy + encap) không nhét vừa một chương trình. Nhìn tên các sched_cls là thấy mạch xử lý:
cil_from_container <- điểm vào từ pod
tail_handle_ipv4 <- xử lý IPv4
tail_handle_ipv4_cont <- (tiếp)
tail_ipv4_ct_ingress <- tra/cập nhật conntrack
tail_nodeport_rev_dnat_ipv4 <- NAT ngược cho NodePort
tail_no_service_ipv4 <- không trúng Service
cil_lxc_policy <- áp NetworkPolicy
tail_ipv4_to_endpoint <- giao tới pod đích
Cơ chế là tail call (Bài 4): một chương trình bpf_tail_call sang chương trình kế qua một prog_array map — và đúng map đó có trong danh sách map của node:
174: prog_array name cilium_call_pol <- bảng tail call của datapath
175: prog_array name cilium_egressca
Mỗi entry trong cilium_call_policy trỏ tới một tail_* ở trên. Datapath vì thế là một chuỗi chương trình nhỏ nối nhau bằng tail call, mỗi chương trình lo một việc và nằm gọn trong giới hạn verifier.
Load balancing là một lần tra map
Đây là chỗ "kube-proxy-less" lộ rõ. Cụm này không chạy kube-proxy; Cilium làm cân bằng tải Service hoàn toàn trong eBPF, và "bảng Service" chỉ là một BPF map — cilium_lb4_services (map id 181 trong danh sách). Đọc nó ra dạng người hiểu được:
cilium bpf lb list # đọc từ map cilium_lb4_services
SERVICE ADDRESS BACKEND ADDRESS (REVNAT_ID) (SLOT)
10.32.0.1:443/TCP (2) 10.0.1.12:6443/TCP (1) (2) <- kube-apiserver, backend 1
10.32.0.1:443/TCP (3) 10.0.1.13:6443/TCP (1) (3) <- kube-apiserver, backend 2
10.32.0.10:53/UDP (2) 10.200.0.44:53/UDP (6) (2) <- CoreDNS
10.32.0.10:9153/TCP (2) 10.200.0.44:9153/TCP (7) (2) <- CoreDNS metrics
Đọc được trọn cơ chế: Service ClusterIP 10.32.0.1:443 (kube-apiserver) có hai slot, mỗi slot trỏ tới một backend thật (10.0.1.12:6443, 10.0.1.13:6443). Khi một pod gửi gói tới 10.32.0.1:443, cil_from_container tra map này, chọn một slot, rồi DNAT địa chỉ đích thành IP:port của backend — toàn bộ trong nhân, ngay tại veth của pod, không qua iptables, không qua kube-proxy, không rời nhân. Thêm/bớt một pod backend chỉ là cập nhật map này. Đây đúng nghĩa là điều khiến Cilium nhanh: cân bằng tải = một lần tra hash map + DNAT.
Conntrack và policy cũng là map
Sau DNAT, gói cần được nhớ để chiều về NAT ngược cho đúng — đó là conntrack, lại là một map (cilium_ct4_global, id 192):
cilium bpf ct list global
TCP IN 10.200.0.64:56046 -> 10.200.0.44:8080 expires=49141 ... [ RxClosing TxClosing SeenNonSyn ] RevNAT=0
TCP IN 10.200.0.64:45086 -> 10.200.0.44:8181 ... SourceSecurityID=1
Mỗi dòng là một kết nối đang được theo dõi: chiều, IP:port hai đầu, cờ TCP, và SourceSecurityID — danh tính bảo mật của pod nguồn. Chính SecurityID này là cách Cilium áp NetworkPolicy: thay vì khớp theo IP (đổi liên tục trong k8s), Cilium gán mỗi pod một identity theo nhãn, lưu ánh xạ pod→identity trong cilium_lxc (id 198) rồi quyết định cho/cấm trong cil_lxc_policy dựa trên identity. cilium endpoint list cho thấy ánh xạ đó:
ENDPOINT IDENTITY LABELS IPv4
403 18203 k8s:k8s-app=kube-dns ... 10.200.0.33
849 18203 k8s:io.kubernetes.pod.namespace=kube-system 10.200.0.44
Pod CoreDNS mang identity 18203 (suy từ nhãn k8s). NetworkPolicy biên dịch thành các entry trong map policy (cilium_policy_v*, lpm_trie) khóa theo identity — và cil_lxc_policy chỉ việc tra map đó để cho qua hay vứt. Policy, cũng như LB và CT, quy về một lần tra BPF map.
🧹 Dọn dẹp
Bài này chỉ đọc trạng thái datapath đang chạy — không gắn hay sửa gì, không có gì để dọn. Node vẫn 140 chương trình như đầu bài. Mọi lệnh quan sát (bpftool net show, bpftool prog show, cilium bpf lb/ct list, cilium endpoint list) ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 12-tc-cilium-datapath.
Tổng kết
tc/sched_cls là hook eBPF chạy sau khi có sk_buff, thấy cả ingress lẫn egress, làm việc với __sk_buff đầy metadata — đúng thứ cần để định tuyến mạng pod, nên Cilium đặt gần như cả datapath ở đây (74/140 chương trình của node là sched_cls). Chúng gắn ở ens5 (cil_from_netdev, ngay sau XDP), ở host (cil_to_host), và ở ingress của từng veth pod (cil_from_container) — số chương trình tỉ lệ số pod. Datapath chẻ thành nhiều chương trình nhỏ nối nhau bằng tail call qua prog_array cilium_call_policy (vì giới hạn 1 triệu lệnh), thấy rõ qua tên tail_handle_ipv4 → tail_ipv4_ct_ingress → cil_lxc_policy. Và ba trụ cột của datapath đều quy về tra BPF map: cân bằng tải Service = tra cilium_lb4_services rồi DNAT (kube-proxy-less, đọc thấy kube-apiserver 10.32.0.1:443 → hai backend :6443); conntrack = cilium_ct4_global; NetworkPolicy = tra map policy theo identity của pod (không theo IP). Toàn bộ trong nhân, không rời sang userspace.
Part IV còn một bài nữa: ta đã đọc datapath của Cilium; Bài 13 tự viết một chương trình tc nhỏ — đếm và phân loại lưu lượng egress của chính node — để nắm __sk_buff từ trong ra, rồi đối chiếu với cách Cilium dùng đúng hook đó.