NetworkPolicy: Tường Lửa Theo Nhãn

K
Kai··8 min read

Ở Bài 13–14 ta dựng mô hình mạng phẳng: mọi pod ping được mọi pod, mọi cổng. Đó là yêu cầu nền của Kubernetes — nhưng cũng có nghĩa một pod bị chiếm có thể quét cả cluster. Bài 46 đã thay datapath bằng Cilium eBPF, mà Cilium thì hiện thực NetworkPolicy. Bài này dùng nó để chặn lưu lượng ở tầng pod theo nhãn, và xem Hubble in ra từng gói bị chặn.

Doc kubernetes.io ghi rõ: NetworkPolicy chỉ có tác dụng nếu CNI hiện thực nó. Trên một cụm dùng CNI không hỗ trợ policy (như bridge thuần của Bài 14 trước khi migrate), các object NetworkPolicy vẫn tạo được nhưng không chặn gì cả. Cụm ta giờ chạy Cilium nên policy có hiệu lực thật — đó là lý do bài này nằm sau Bài 46 chứ không phải trong Part I.

Mô hình: pod "isolated" hay không

NetworkPolicy là object namespaced, chọn pod đích qua podSelector. Quy tắc nền nằm ở hai chữ isolated:

        Pod KHÔNG bị policy nào chọn          Pod BỊ policy chọn (Ingress)
        ───────────────────────────          ──────────────────────────────
   ┌────────────────────────────┐        ┌────────────────────────────────┐
   │  non-isolated               │        │  isolated cho Ingress           │
   │  → nhận MỌI kết nối vào      │        │  → CHẶN hết, trừ thứ được       │
   │  → gửi đi MỌI kết nối        │        │     liệt kê trong ingress.from  │
   └────────────────────────────┘        └────────────────────────────────┘
        (mặc định của cluster)              (bật ngay khi 1 policy chọn nó)

Mặc định, mọi pod non-isolated — nhận và gửi thoải mái (đúng cụm ta tới giờ). Một pod trở nên isolated theo một chiều ngay khi có một NetworkPolicy chọn nó liệt kê chiều đó trong policyTypes. Khi đã isolated cho Ingress, chỉ những nguồn khớp ingress.from mới vào được; mọi nguồn khác bị chặn. Chiều egress hoạt động tương tự. Lưu lượng trả lời (reply) của một kết nối đã được phép thì luôn được phép, vì policy xét ở mức kết nối.

Hai hệ quả hay quên: policy cộng dồn (union) — nhiều policy cùng chọn một pod thì pod được phép nếu bất kỳ policy nào cho phép; và để A nói chuyện với B thì egress của A lẫn ingress của B đều phải cho qua (nếu cả hai cùng bị siết).

Để test, ta dùng một pod web, hai client frontend (sẽ được phép) và other (sẽ bị chặn).

Bước 1 — chưa có policy: mọi pod tới được web

Dựng namespace riêng cho gọn và một server HTTP đơn giản (agnhost netexec như Bài 20):

kubectl create namespace np-demo
kubectl -n np-demo run web --image=registry.k8s.io/e2e-test-images/agnhost:2.45 \
  --labels="app=web" --port=8080 -- netexec --http-port=8080
kubectl -n np-demo run frontend --image=busybox:1.36 --labels="role=frontend" -- sleep 100000
kubectl -n np-demo run other    --image=busybox:1.36 --labels="role=other"    -- sleep 100000
kubectl -n np-demo get pods -o wide
NAME       READY   STATUS    IP             NODE
frontend   1/1     Running   10.200.1.107   worker-1
other      1/1     Running   10.200.0.160   worker-0
web        1/1     Running   10.200.0.141   worker-0

Chưa có policy nào → web non-isolated → cả hai client tới được (/hostname trả về tên pod là web):

kubectl -n np-demo exec frontend -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
kubectl -n np-demo exec other    -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
web      # frontend tới được
web      # other tới được

Mạng phẳng đúng như Bài 14. Bây giờ khoá lại bằng policy đầu tiên.

Bước 2 — default-deny: chặn hết ingress vào web

Policy đầu tiên chỉ chọn app=web và khai policyTypes: [Ingress], không liệt kê from nào. Doc gọi đây là mẫu default-deny-ingress: pod được chọn trở thành isolated cho Ingress, và vì không có nguồn nào được liệt kê nên không nguồn nào vào được:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: web-deny-ingress
  namespace: np-demo
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes:
  - Ingress

Không có khối ingress: nghĩa là danh sách nguồn được phép rỗng → web isolated, không nguồn nào lọt. Áp vào rồi thử lại:

kubectl -n np-demo apply -f web-deny-ingress.yaml
kubectl -n np-demo exec frontend -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
kubectl -n np-demo exec other    -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
wget: download timed out      # frontend bị chặn
wget: download timed out      # other bị chặn

Cả hai timeout. Một policy chọn đúng một pod đích là đủ để cắt mọi đường vào web từ trong namespace. Để ý kết quả là timeout chứ không phải connection refused: Cilium drop gói SYN im lặng nên client ngồi chờ tới hết hạn, khác với trường hợp server còn sống và chủ động từ chối.

Bước 3 — mở đúng một cửa cho frontend

Thêm policy thứ hai cho phép ingress, nhưng chỉ từ pod mang nhãn role=frontend, chỉ cổng 8080:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: web-allow-frontend
  namespace: np-demo
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          role: frontend
    ports:
    - protocol: TCP
      port: 8080
kubectl -n np-demo apply -f web-allow-frontend.yaml
kubectl -n np-demo exec frontend -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
kubectl -n np-demo exec other    -- wget -qO- --timeout=4 http://10.200.0.141:8080/hostname
kubectl -n np-demo get netpol
web                           # frontend lại tới được
wget: download timed out      # other vẫn bị chặn
NAME                 POD-SELECTOR   AGE
web-allow-frontend   app=web        8s
web-deny-ingress     app=web        29s

Hai policy cùng chọn app=web, và chúng cộng dồn: web-deny-ingress không mở nguồn nào, web-allow-frontend mở cho role=frontend, hợp lại thành "chỉ frontend được vào". other không khớp nguồn nào nên vẫn rớt. Trong thực tế ta thường chỉ viết policy allow — chỉ cần một policy chọn pod là pod đã isolated, nên policy allow tự nó cũng deny phần còn lại; mẫu web-deny-ingress riêng ở đây là để thấy rõ hai bước.

Về ngữ nghĩa ghép selector, doc phân biệt rạch ròi và rất dễ sai:

ingress:
- from:
  - podSelector: {role: frontend}      ┐  hai mục TRONG cùng from[]
  - namespaceSelector: {team: a}       ┘  = OR  (frontend  HOẶC  pod ở ns team=a)

- from:
  - namespaceSelector: {team: a}       ┐  hai selector TRONG cùng một mục
    podSelector: {role: frontend}      ┘  = AND (pod role=frontend  VÀ  ở ns team=a)

Khác nhau ở một dấu gạch đầu dòng: hai - rời là OR, gộp dưới một - là AND (giao).

Verdict của Hubble

Vì cụm chạy Cilium eBPF (Bài 46), không cần đoán tại sao timeout — Hubble ghi lại verdict từng flow. Sinh lại traffic rồi soi flow tới web ngay trên cilium-agent của worker-0 (nơi web chạy):

CIL=$(kubectl -n kube-system get pod -l k8s-app=cilium \
  --field-selector spec.nodeName=worker-0 -o jsonpath='{.items[0].metadata.name}')
kubectl -n kube-system exec $CIL -c cilium-agent -- hubble observe --to-pod np-demo/web --last 12 -o compact
np-demo/other:58020 (ID:46753) <> np-demo/web:8080 (ID:60437) Policy denied DROPPED (TCP Flags: SYN)
np-demo/frontend:54530 (ID:26159) -> np-demo/web:8080 (ID:60437) to-endpoint FORWARDED (TCP Flags: SYN)
np-demo/frontend:54530 (ID:26159) -> np-demo/web:8080 (ID:60437) to-endpoint FORWARDED (TCP Flags: ACK)
np-demo/frontend:54530 (ID:26159) -> np-demo/web:8080 (ID:60437) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
np-demo/other:55938 (ID:46753) <> np-demo/web:8080 (ID:60437) Policy denied DROPPED (TCP Flags: SYN)

Đọc trực tiếp: gói SYN của frontend được FORWARDED, gói SYN của other bị Policy denied DROPPED. Không cần tcpdump, không cần đọc rule iptables (vốn đã không còn từ Bài 46) — verdict policy hiện ngay ở tầng quan sát. Để gỡ rối một policy không hoạt động như ý, đây là công cụ đầu tiên nên mở.

Policy dịch ra identity từ nhãn

Để ý mấy con số ID:xxxxx trong log Hubble. Đó là Cilium identity, đúng cơ chế đã nói ở Bài 45. Cilium không viết rule theo IP pod, vì IP đổi mỗi lần pod tái tạo; thay vào đó nó gán cho mỗi tập nhãn một identity số, và policy được dịch thành "identity nào nói được với identity nào". Soi thử:

kubectl -n kube-system exec $CIL -c cilium-agent -- cilium-dbg identity get 26159 | grep role
kubectl -n kube-system exec $CIL -c cilium-agent -- cilium-dbg identity get 46753 | grep role
k8s:role=frontend      # identity 26159  ← chính là nhãn role=frontend
k8s:role=other         # identity 46753  ← chính là nhãn role=other

Identity 26159 role=frontend. Khi frontend bị xoá và tạo lại với IP khác nhưng cùng nhãn, nó vẫn mang identity 26159 và policy vẫn áp đúng — không có khoảng trống lúc IP chưa kịp cập nhật vào rule. Đây là khác biệt nền tảng so với firewall theo IP: ta khai báo ai được nói với ai bằng nhãn, phần dịch sang datapath để Cilium lo.

🧹 Dọn dẹp

kubectl delete namespace np-demo

Xoá namespace cuốn theo cả 3 pod lẫn 2 NetworkPolicy. Cụm trở về trạng thái nền: chỉ kube-system chạy, mọi pod Running, không còn workload thử. Giữ nguyên Cilium + Hubble. Manifest ở github.com/nghiadaulau/kubernetes-from-scratch, thư mục 47-networkpolicy.

Tổng kết

NetworkPolicy biến mạng phẳng của Bài 14 thành mạng phân đoạn theo nhãn. Quy tắc gốc: pod non-isolated nhận hết, nhưng hễ một policy chọn nó và khai policyTypes thì pod isolated cho chiều đó — chỉ nguồn khớp ingress.from/egress.to lọt qua, phần còn lại bị chặn; nhiều policy cộng dồn. Ta đã test thật cả ba trạng thái: allow-all (chưa policy) → deny-all (chọn app=web, không mở cửa nào → cả hai client timeout) → allow chọn lọc (mở cho role=frontend → đúng frontend tới, other vẫn rớt). Hai thứ Cilium thêm vào: Hubble in verdict FORWARDED/Policy denied DROPPED cho từng gói, và cilium-dbg identity cho thấy policy bám vào identity sinh từ nhãn (26159 = role=frontend) chứ không phải IP, đúng cơ chế "bảo mật theo identity" ở Bài 45. Một điều cần nhớ: trên CNI không hỗ trợ policy, các YAML này không có tác dụng gì.

NetworkPolicy kiểm soát lưu lượng pod với pod bên trong cluster. Bài 48 ra mép cluster với Ingress và Ingress controller, đưa lưu lượng HTTP từ ngoài vào Service và định tuyến theo host/path.