kube-proxy: Biến ClusterIP Thành Đích Thật

K
Kai··8 min read

Bài 11 đưa hai worker vào cluster: kubelet chạy, node đăng ký, nhưng còn NotReady vì thiếu mạng pod. Trước khi chạm tới mạng pod (Bài 13–14), còn một thành phần nữa thuộc về mỗi worker và giải thích được trọn vẹn ngay bây giờ, không cần CNI: kube-proxy.

kube-proxy là thứ làm cho Service của Kubernetes hoạt động. Mà để thấy nó làm gì, phải hiểu trước Service thực ra là cái gì.

Service là một IP không thuộc về ai

Khi bạn tạo một Service kiểu ClusterIP, Kubernetes cấp cho nó một IP trong dải Service (cluster của ta: 10.32.0.0/24, đặt ở Bài 7). IP đó — gọi là ClusterIP — không gắn với card mạng nào cả. Không máy nào nhận nó, không interface nào trả lời ARP cho nó. Nó là một địa chỉ ảo, một con trỏ.

Phía sau Service là một tập pod thật, mỗi pod có IP riêng và thay đổi liên tục (pod chết, pod mới sinh, IP khác). Vấn đề: client muốn gọi một địa chỉ ổn định, còn đích thật thì trôi nổi. Service giải bài đó bằng cách cho client một ClusterIP cố định; nhiệm vụ dịch ClusterIP cố định ấy thành một pod thật, đang sống, là của kube-proxy.

kube-proxy chạy trên mỗi node, theo dõi hai thứ từ api-server: danh sách Service (ClusterIP nào) và danh sách Endpoint (pod nào đang đứng sau mỗi Service). Mỗi khi có thay đổi, nó viết lại luật chuyển tiếp của kernel để: gói nào gửi tới ClusterIP:port thì bị DNAT sang podIP:port của một endpoint còn sống, chọn ngẫu nhiên để chia tải.

   client trong cluster
        │  gửi tới 10.32.0.1:443   (ClusterIP — ảo)
        ▼
   ┌─────────────── kernel netfilter trên node ───────────────┐
   │  kube-proxy đã cài sẵn rule iptables:                     │
   │     10.32.0.1:443  ──DNAT──►  một trong các endpoint      │
   │                               (chia ngẫu nhiên)           │
   └───────────────────────────────────────────────────────────┘
        │
        ▼
   endpoint thật:  10.0.1.11:6443 | 10.0.1.12:6443 | 10.0.1.13:6443

Điểm quan trọng: bản thân kube-proxy không nằm trên đường đi của gói tin. Nó chỉ cài luật rồi đứng ngoài; việc DNAT do kernel làm. Nên kube-proxy chết một lúc cũng không cắt lưu lượng đang chạy; chỉ là luật không được cập nhật cho tới khi nó sống lại.

Có một Service sẵn để thử ngay

Ta chưa tạo Service nào, nhưng cluster đã tự có một cái từ lúc api-server khởi động: Service kubernetes trong namespace default.

kubectl get svc -A
kubectl get endpoints -n default kubernetes
NAMESPACE   NAME         TYPE        CLUSTER-IP   PORT(S)   AGE
default     kubernetes   ClusterIP   10.32.0.1    443/TCP   31m

NAME         ENDPOINTS                                      AGE
kubernetes   10.0.1.11:6443,10.0.1.12:6443,10.0.1.13:6443   31m

Đây là Service may mắn cho việc kiểm chứng: ClusterIP 10.32.0.1, ba endpoint là chính ba api-server của ta, và quan trọng là endpoint của nó là IP host (10.0.1.11/12/13), không phải IP pod. Nghĩa là ta có thể chứng minh kube-proxy hoạt động end-to-end mà chưa cần mạng pod: DNAT từ ClusterIP sang một IP host vốn đã định tuyến được trong VPC.

Bước 1 — Phân phối kubeconfig cho kube-proxy

kube-proxy cũng là một client của api-server, có danh tính riêng: cert kube-proxy với CN=system:kube-proxy, O=system:node-proxier (Bài 4). Nhóm system:node-proxier được binding sẵn quyền đọc Service/Endpoint qua ClusterRole system:node-proxier có sẵn trong cluster. kubeconfig của nó (Bài 5) đã trỏ tới load balancer nội bộ 10.0.1.10:6443:

# từ thư mục pki, lặp cho cả hai worker
for W in worker-0 worker-1; do
  scp kube-proxy.kubeconfig $W:/tmp/
  ssh $W 'sudo mkdir -p /var/lib/kube-proxy
    sudo mv /tmp/kube-proxy.kubeconfig /var/lib/kube-proxy/kubeconfig
    sudo chmod 600 /var/lib/kube-proxy/kubeconfig'
done

Khác với kubelet, kube-proxy không có danh tính riêng theo từng node; cả hai worker dùng chung một cert system:kube-proxy. Hợp lý: kube-proxy chỉ cần đọc Service/Endpoint toàn cluster, việc giống hệt nhau trên mọi node, không có thao tác nào ràng buộc vào danh tính của riêng một node như kubelet.

Bước 2 — Cài binary

# trên worker-0
cd /tmp
curl -fsSL -o kube-proxy https://dl.k8s.io/v1.36.1/bin/linux/amd64/kube-proxy
ls -la kube-proxy
sudo install -m 755 kube-proxy /usr/local/bin/kube-proxy
kube-proxy --version
-rw-rw-r-- 1 ubuntu ubuntu 44200098 kube-proxy
Kubernetes v1.36.1

Bước 3 — KubeProxyConfiguration và systemd unit

Cấu hình tối thiểu: trỏ kubeconfig, chọn chế độ iptables, và khai báo clusterCIDR, dải mạng pod tổng (10.200.0.0/16, supernet của hai dải /24 mỗi worker).

# trên worker-0
sudo tee /var/lib/kube-proxy/kube-proxy-config.yaml >/dev/null <<'EOF'
kind: KubeProxyConfiguration
apiVersion: kubeproxy.config.k8s.io/v1alpha1
clientConnection:
  kubeconfig: "/var/lib/kube-proxy/kubeconfig"
mode: "iptables"
clusterCIDR: "10.200.0.0/16"
EOF

clusterCIDR để kube-proxy phân biệt lưu lượng từ trong dải pod với lưu lượng từ ngoài: gói tới ClusterIP mà nguồn nằm ngoài dải pod sẽ được đánh dấu để masquerade (SNAT) trên đường ra, tránh tình huống pod nhận gói với địa chỉ nguồn nó không định tuyến về được. Ta sẽ thấy đúng cái dấu này trong chain NAT ở bước sau.

Vì sao iptables chứ không phải ipvs hay nftables? kube-proxy có nhiều chế độ. iptables là chế độ lâu đời nhất, rõ ràng để đọc và đủ tốt cho cụm nhỏ, nên phù hợp để học. Với cụm hàng nghìn Service thì ipvs hoặc chế độ nftables (đã ổn định ở các bản gần đây) co giãn tốt hơn vì không tuyến tính theo số rule. Ta dùng iptables ở đây, và đằng nào tới cuối series cũng bỏ kube-proxy hẳn khi chuyển sang Cilium eBPF (Bài 18–19).

Unit systemd:

sudo tee /etc/systemd/system/kube-proxy.service >/dev/null <<'EOF'
[Unit]
Description=Kubernetes Kube Proxy
Documentation=https://github.com/kubernetes/kubernetes
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/kube-proxy \
  --config=/var/lib/kube-proxy/kube-proxy-config.yaml \
  --v=2
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now kube-proxy
sleep 4
systemctl is-active kube-proxy
active

Xem nó làm gì lúc khởi động:

sudo journalctl -u kube-proxy --no-pager | grep -E 'Reloading|SyncProxyRules complete' | tail -2
proxier.go:1389] "Reloading service iptables data" ipFamily="IPv4" numServices=1 numEndpoints=3 ...
proxier.go:662] "SyncProxyRules complete" ipFamily="IPv4" elapsed="107.898293ms"

numServices=1 numEndpoints=3: kube-proxy đã thấy đúng Service kubernetes với ba endpoint api-server, và đã viết xong rule.

Bước 4 — Đọc các chain iptables kube-proxy sinh ra

Đây là phần đáng xem nhất. kube-proxy tổ chức rule thành nhiều chain lồng nhau. Bắt đầu từ KUBE-SERVICES, cửa vào chứa một dòng cho mỗi ClusterIP:

sudo iptables -t nat -L KUBE-SERVICES -n | grep -E 'Chain|10.32.0.1'
Chain KUBE-SERVICES (2 references)
KUBE-SVC-NPX46M4PTMTKRN6Y  6  --  0.0.0.0/0  10.32.0.1  /* default/kubernetes:https cluster IP */ tcp dpt:443

Gói tới 10.32.0.1:443 được đẩy sang chain KUBE-SVC-... (tên là hash của Service). Mở chain đó ra:

sudo iptables -t nat -L KUBE-SVC-NPX46M4PTMTKRN6Y -n
target           prot  source           destination     /* ... */
KUBE-MARK-MASQ   6     !10.200.0.0/16    10.32.0.1       /* ... cluster IP */ tcp dpt:443
KUBE-SEP-L63N... 0     0.0.0.0/0         0.0.0.0/0       /* ... -> 10.0.1.11:6443 */ statistic mode random probability 0.33333333349
KUBE-SEP-DFEZ... 0     0.0.0.0/0         0.0.0.0/0       /* ... -> 10.0.1.12:6443 */ statistic mode random probability 0.50000000000
KUBE-SEP-UYNO... 0     0.0.0.0/0         0.0.0.0/0       /* ... -> 10.0.1.13:6443 */

Bốn dòng này gói trọn cách Service hoạt động:

  • Dòng đầu — KUBE-MARK-MASQ với điều kiện nguồn !10.200.0.0/16 (NGOÀI dải pod) — chính là cái dấu masquerade mà clusterCIDR sinh ra: lưu lượng tới Service từ ngoài dải pod sẽ được SNAT trên đường ra.
  • Ba dòng KUBE-SEP-... là ba endpoint. Để ý cột probability: dòng đầu khớp với xác suất 1/3 ≈ 0.333; nếu trượt, dòng hai khớp với 0.5 (tức một nửa của 2/3 còn lại); nếu lại trượt, dòng ba nhận hết. Ba ngưỡng đó cộng lại chia đều ⅓–⅓–⅓ cho ba endpoint. Đó là toàn bộ "thuật toán cân bằng tải" của iptables mode: xác suất tĩnh, không có khái niệm endpoint nào đang bận hay rảnh.

Mỗi chain KUBE-SEP-... (Service EndPoint) chứa lệnh DNAT thật sự, đổi đích gói thành podIP:port của endpoint tương ứng. Ở đây "pod" tình cờ là api-server trên IP host, nên ta kiểm chứng được ngay.

Bước 5 — curl thẳng vào ClusterIP

Từ chính worker-0, gọi https://10.32.0.1:443, một IP ảo không thuộc về máy nào:

# trên worker-0
curl -s --cacert /var/lib/kubernetes/ca.pem https://10.32.0.1:443/healthz; echo
ok

Gói rời tiến trình curl với đích 10.32.0.1:443; netfilter khớp chain KUBE-SERVICESKUBE-SVC → một KUBE-SEP, DNAT đích thành (ví dụ) 10.0.1.11:6443; api-server trả lời ok. ClusterIP ảo vừa trở thành một đích thật, đúng việc kube-proxy sinh ra để làm, và toàn bộ diễn ra mà chưa có một dòng cấu hình CNI nào, vì endpoint ở đây là IP host.

Bước 6 — Lặp lại trên worker-1

worker-1 dùng chung kubeconfig kube-proxy (đã copy ở Bước 1). Tải binary, viết đúng kube-proxy-config.yamlkube-proxy.service như trên (cả hai file giống hệt giữa hai node), rồi khởi động và kiểm chứng:

# trên worker-1
sudo systemctl daemon-reload
sudo systemctl enable --now kube-proxy
systemctl is-active kube-proxy
curl -s --cacert /var/lib/kubernetes/ca.pem https://10.32.0.1:443/healthz; echo
active
ok

Lưu ý: cài kube-proxy không làm node chuyển sang Ready; kubectl get nodes vẫn báo NotReady. kube-proxy lo Service, còn điều kiện Ready của node phụ thuộc mạng pod (CNI). Hai mối quan tâm tách biệt, và mảnh còn thiếu vẫn là CNI.

🧹 Dọn dẹp

kube-proxy là dịch vụ thường trú, để nguyên (đã enable). Dọn binary tải về:

# trên mỗi worker
rm -f /tmp/kube-proxy

Các rule iptables do kube-proxy quản lý; đừng sửa tay. Nếu cần xóa sạch để chẩn đoán, kube-proxy --cleanup gỡ hết rule của nó, nhưng ở cụm đang chạy thì hiếm khi cần. Khi stop/start cụm EC2, kube-proxy tự lên lại và dựng lại toàn bộ rule từ đầu.

Script đầy đủ ở github.com/nghiadaulau/kubernetes-from-scratch, thư mục 12-kube-proxy.

Tổng kết

Worker giờ đã đủ bộ ba: containerd chạy container, kubelet điều phối node, kube-proxy hiện thực Service. Ta đã thấy tận mắt ClusterIP chỉ là vài chain iptables do kube-proxy viết, dịch một IP ảo sang một endpoint thật bằng DNAT, chia tải bằng xác suất tĩnh. Nắm được chuỗi KUBE-SERVICES → KUBE-SVC → KUBE-SEP thì sau này gặp Service không thông, bạn biết chính xác phải iptables -t nat -L ở đâu.

Vẫn còn lỗ hổng đã đào ba bài nay: pod chưa có mạng, node còn NotReady. Bài 13 lùi lại một bước để dựng nền lý thuyết: mô hình mạng phẳng của Kubernetes, bốn yêu cầu nó đặt ra, và CNI khớp vào đâu, trước khi Bài 14 thực sự nối mạng pod và xem hai node cuối cùng chuyển sang Ready.

Related Posts