Skip to content

GKE Service LoadBalancer Integration — Khi một dòng YAML tạo ra một load balancer

Why this matters in production

Khi bạn viết type: LoadBalancer trên một Kubernetes Service trong GKE, một load balancer thật trên GCP được tạo ra phía sau. Nhưng loại LB nào, với cơ chế gì, phụ thuộc vào một tập annotation, field, và cả phiên bản GKE — những thứ phần lớn kỹ sư copy-paste mà không hiểu. Hậu quả của việc không hiểu là những sự cố production rất cụ thể:

  • Mất client IP. Mặc định externalTrafficPolicy: Cluster SNAT source IP thành node IP — app log thấy IP node, không thấy client. IP allowlisting/audit/geo-logging dựa vào client IP đều hỏng âm thầm.
  • Phân phối lệch. Local (giải pháp giữ client IP) chỉ gửi traffic tới node có Pod, và mỗi node nhận tải xấp xỉ bằng nhau bất kể số Pod trên node → node 1 Pod và node 5 Pod nhận traffic ngang nhau → Pod trên node ít-Pod bị dội tải.
  • 502/mất traffic khi node drain / rolling update. Health check của LoadBalancer Service (trên port đặc biệt 10256, do kube-proxy phục vụ) quyết định node nào nhận traffic — hiểu sai gây mất traffic khi node thay đổi.

Điểm nền tảng cần khắc sâu: GKE LoadBalancer Service mặc định tạo ra một passthrough NLB (file 04) — không phải một LB "thông minh" L7. Vì vậy toàn bộ cơ chế DSR, consistent hashing, connection tracking, session affinity 5/3/2-tuple ở file 04 áp dụng bên dưới mọi LoadBalancer Service. File này tập trung vào lớp Kubernetes ↔ GCP LB nằm trên đó.

Internal model — Service kiểu LoadBalancer ánh xạ thành gì

Mặc định: passthrough NLB (external hoặc internal)

Theo tài liệu GKE LoadBalancer Service:

  • External LoadBalancer Service (mặc định) → External Passthrough NLB (Maglev, DSR): expose ra internet.
  • Internal LoadBalancer Service (annotation networking.gke.io/load-balancer-type: "Internal") → Internal Passthrough NLB (Andromeda): chỉ truy cập trong VPC/mạng kết nối.
yaml
# Internal passthrough NLB
metadata:
  annotations:
    networking.gke.io/load-balancer-type: "Internal"
    # tùy chọn: ghim subnet cấp VIP
    networking.gke.io/internal-load-balancer-subnet: "my-internal-subnet"
spec:
  type: LoadBalancer

Cả hai là L4 passthrough — không URL routing, không TLS termination tại LB, client IP có thể tới socket (tùy externalTrafficPolicy). Muốn L7 (host/path routing, TLS termination, Cloud Armor WAF) cho GKE, bạn không dùng type: LoadBalancer mà dùng Ingress hoặc Gateway (Chương 21), vốn tạo Application LB proxy + NEG container-native. Đây là ranh giới kiến trúc quan trọng: type: LoadBalancer = L4; Ingress/Gateway = L7.

Backend: NEG-based (subsetting) vs legacy instance-group/target-pool

Cách Service ánh xạ tới backend của NLB đã tiến hóa qua nhiều thế hệ, và sự tiến hóa này giải quyết các giới hạn scale thật:

Thế hệ            Backend của NLB                      Vấn đề / đặc điểm
──────────────────────────────────────────────────────────────────────────────────
Legacy            Target pool (external) /             MỌI node là backend; health
                  instance group (mọi node)            check ở node level; LB phải
                                                        quản lý cả cluster → không
                                                        scale tốt khi cluster lớn

Hiện đại          GCE_VM_IP NEG + GKE Subsetting        Chỉ một SUBSET node được nhóm
(backend-service- (per-Service node grouping)          vào NEG mỗi Service; scale tốt;
based)                                                  bật bằng cloud.google.com/
                                                        l4-rbs: "enabled" (external)

Vấn đề legacy: một NLB backend-service chỉ chịu được số node giới hạn; cluster vài trăm node mà mọi node thành backend là không khả thi — mỗi thay đổi Service phải cập nhật một danh sách backend khổng lồ. GKE Subsetting giải quyết bằng cách nhóm một tập con node vào NEG cho mỗi Service thay vì mọi node. Theo tài liệu, subsetting "được bật tự động nếu cluster chạy GKE 1.36 trở lên". Giới hạn cụ thể: với Local, subsetting hỗ trợ tới 250 node có Pod phục vụ; với Cluster, không giới hạn số node nhưng tối đa 25 node endpoint mỗi NEG.

Annotation cloud.google.com/l4-rbs: "enabled" chọn backend-service-based external NLB (thay vì target pool legacy) trên phiên bản hỗ trợ. Đây là hướng khuyến nghị; target pool là legacy.

Một phân biệt dễ nhầm: backend của L4 LoadBalancer Service là GCE_VM_IP NEG (node IP, không port) — LB cân bằng tới node, rồi node chuyển tới Pod. Điều này khác với GCE_VM_IP_PORT NEG (Pod IP + port) dùng cho container-native L7 ở file 05, nơi LB trỏ thẳng Pod IP. Chính vì L4 cân bằng tới node (không tới Pod) mà externalTrafficPolicy trở nên quyết định — nó kiểm soát bước "node → Pod".

externalTrafficPolicy: Local vs Cluster — field định mệnh

Đây là phần đắt giá nhất của file. externalTrafficPolicy kiểm soát điều gì xảy ra sau khi NLB gửi packet tới một node: node đó xử lý cục bộ hay chuyển tiếp sang node khác, và source IP có bị đổi không.

externalTrafficPolicy: Cluster (mặc định)
   NLB ──► Node A ──(kube-proxy có thể chuyển)──► Pod ở Node B

                     └─ SNAT: source IP đổi thành IP của Node A
   • MỌI node "pass" health check (kể cả node không có Pod)
   • Cân bằng đều giữa MỌI node → tải Pod đều hơn
   • MẤT client IP (bị SNAT) — app thấy node IP
   • Có thể thêm 1 hop (node A → node B) → thêm latency

externalTrafficPolicy: Local
   NLB ──► Node CÓ Pod ──► Pod cục bộ (KHÔNG chuyển sang node khác)

                          └─ KHÔNG SNAT: giữ nguyên client IP
   • CHỈ node có Pod ready, non-terminating "pass" health check → chỉ chúng nhận traffic
   • GIỮ client IP (app thấy IP thật ở socket)
   • KHÔNG hop thừa (chỉ tới Pod cục bộ)
   • RỦI RO phân phối lệch: mỗi node nhận tải ~bằng nhau, bất kể số Pod

Theo tài liệu, với Local: packet "chỉ được gửi tới node có ít nhất một Pod serving, ready, non-terminating", và "node nhận packet load-balanced route chúng exclusively tới Pod cục bộ, và node không đổi source IP". Với Cluster: "mỗi node nhận packet load-balanced luôn đổi source IP" (trong dataplane legacy; có điều kiện hơn trong Dataplane V2).

Cơ chế phân phối lệch của Local — điểm hay bị bỏ qua nhất: NLB cân bằng tới node, không tới Pod. Với Local, mỗi node-có-Pod nhận xấp xỉ lượng traffic bằng nhau. Nhưng nếu node X có 1 Pod và node Y có 5 Pod, cả hai vẫn nhận traffic ngang nhau → Pod trên node X nhận gấp 5 lần tải Pod trên node Y:

Node A: 5 Pod  ← nhận ~50% traffic → mỗi Pod ~10%
Node B: 1 Pod  ← nhận ~50% traffic → Pod duy nhất ~50%   (overload 5×)

Đây là phân phối lệch do kiến trúc, không phải bug. Giảm thiểu: phân bố Pod đều qua node bằng topologySpreadConstraints/pod anti-affinity, hoặc dùng weighted load balancing (tài liệu nêu Local là điều kiện để bật weighted LB — LB cân nhắc số Pod trên mỗi node để chia tải theo trọng số thay vì đều theo node).

Health check trên port 10256 — cơ chế then chốt: externalTrafficPolicy quyết định node nào pass health check của NLB, qua một endpoint do kube-proxy phục vụ trên TCP port 10256 (/healthz). Với Cluster, mọi node pass (kể cả node không Pod) → NLB gửi tới mọi node, rồi kube-proxy DNAT/SNAT chuyển tới Pod ở bất kỳ đâu. Với Local, chỉ node có Pod ready, non-terminating mới pass → NLB chỉ gửi tới chúng. Đây là cách một NLB (vốn health-check node, không Pod) lại biết được "node này có Pod để phục vụ không" — nó không hỏi Pod, nó hỏi kube-proxy qua 10256. Hiểu điều này giải thích vì sao Local tự nhiên tránh được "gửi traffic tới node không có Pod" (node đó fail 10256), và vì sao khi Pod cuối cùng trên một node bị xóa, node đó nên tự rớt khỏi pool — nếu việc cập nhật health check kịp thời.

Bảng quyết định Local vs Cluster

Tiêu chíCluster (mặc định)Local
Giữ client IPKhông (SNAT)Có (socket)
Hop thừa node→nodeCó thểKhông
Cân bằng tảiĐều theo node → Pod đều hơnLệch nếu Pod phân bố không đều
Node nào nhận trafficMọi nodeChỉ node có Pod ready
Health check (port 10256)Mọi node passChỉ node có Pod pass
Weighted LBKhôngĐiều kiện để bật
Phù hợp khiKhông cần client IP; Pod phân bố không đềuCần client IP; Pod phân bố đều

sessionAffinity: ClientIP

Service hỗ trợ sessionAffinity: ClientIP — affinity mức Kubernetes, ánh xạ tới session affinity của NLB (2-tuple CLIENT_IP, xem file 04):

yaml
spec:
  type: LoadBalancer
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800   # 3 giờ

Cơ chế: hash source IP → backend, mọi connection từ một client IP tới cùng một backend. Hạn chế cố hữu: client sau NAT chung một IP → tất cả dồn về một backend → hotspot (giống cảnh báo ở file 04). LoadBalancer Service mặc định tạo NLB với affinity NONE (phân phối mịn); chỉ bật ClientIP khi app thực sự cần stickiness, và chỉ hỗ trợ ClientIPkhông có cookie-based affinity cho L4 (cookie là khái niệm L7, chỉ có ở Ingress/Gateway).

Constraints, trade-offs & failure modes

Failure mode: bật Local để giữ client IP rồi gặp phân phối lệch

Chuỗi điển hình: app cần client IP (logging/geo/allowlist) → đổi sang externalTrafficPolicy: Local → client IP xuất hiện đúng → vài tuần sau phát hiện vài Pod quá tải còn Pod khác nhàn rỗi. Nguyên nhân: Pod phân bố không đều qua node (HPA tạo Pod dồn vào vài node mới scale), và Local cân bằng theo node nên Pod trên node ít-Pod bị dội. Sửa: kết hợp Local với topologySpreadConstraints để Pod trải đều, hoặc dùng weighted LB. Đây là trade-off cố hữu của L4 passthrough: giữ client IP (Local) và cân bằng hoàn hảo (Cluster) mâu thuẫn nhau.

Failure mode: 502/mất traffic khi node drain/upgrade

Khi một node bị cordon/drain (node upgrade), Pod trên nó vào trạng thái terminating. Với Local, node đó nên fail health check trên 10256 ngay khi không còn Pod ready, để NLB ngừng gửi tới nó. Nếu có độ trễ giữa "Pod terminating" → "node fail 10256" → "NLB rút node", một cửa sổ ngắn có thể có traffic tới node đang mất Pod → reset/502. Giảm thiểu: connection draining (file 08), terminationGracePeriodSeconds đủ dài, và preStop hook để Pod tiếp tục phục vụ connection cũ trong lúc được rút khỏi pool.

Constraint: L4 không làm được việc của L7

LoadBalancer Service là L4 passthrough: không terminate TLS, không route theo host/path, không chạy Cloud Armor WAF L7. Khi cần các thứ đó, dùng Ingress/Gateway (Application LB proxy + NEG container-native + managed cert + Armor). Ép type: LoadBalancer rồi nhét nginx/envoy trong cluster để làm L7 là anti-pattern phổ biến — bạn đang dựng lại Ingress thủ công, mất tích hợp managed cert/Armor/CDN và phải tự vận hành reverse proxy.

Real-world scenario: API cần client IP allowlisting

Một API nội bộ-công ty cần: chỉ cho phép một dải IP văn phòng, và log IP client thật cho audit. Phân tích cơ chế từng quyết định:

  • Cần client IP ở socket → không thể Cluster (SNAT mất IP). Dùng externalTrafficPolicy: Local.
  • Để tránh phân phối lệch của Local, đặt topologySpreadConstraints cho Pod trải đều qua node.
  • Allowlisting: vì đây là L4 và cần chặn sớm, đặt ở firewall VPC (dải office) cho VIP của LB. Nếu cần allowlist linh hoạt hơn (theo path/header), đặt một Ingress + Cloud Armor phía trước — đánh đổi: Armor đọc client IP qua X-Forwarded-For, không phải socket.
  • Health check: đảm bảo firewall allow dải prober passthrough (35.191.0.0/16, 209.85.x, xem file 06) và node pass health check trên 10256.

Lựa chọn cốt lõi Local đến trực tiếp từ ràng buộc "cần client IP ở socket" — một quyết định cơ chế, không phải sở thích.

Common mistakes / anti-patterns

Anti-pattern 1 — Đổi sang Local mà không xử lý phân bố Pod. Hiểu lầm: nghĩ Local chỉ "giữ client IP" mà không ảnh hưởng cân bằng. Hậu quả: phân phối lệch theo node, hotspot. Sửa: kết hợp Local với topologySpreadConstraints / weighted LB.

Anti-pattern 2 — Giữ Cluster rồi cố đọc client IP từ socket. Hiểu lầm: không biết Cluster SNAT source IP. Hậu quả: app log toàn node IP, allowlist theo IP fail âm thầm. Sửa: dùng Local (giữ IP ở socket), hoặc chuyển sang L7 Ingress đọc X-Forwarded-For.

Anti-pattern 3 — Dùng type: LoadBalancer cho web cần TLS/host routing. Hiểu lầm: tưởng LoadBalancer Service làm được L7. Hậu quả: tự dựng reverse proxy trong cluster, mất managed cert/Armor/CDN, vận hành phức tạp. Sửa: dùng Ingress/Gateway cho L7.

Anti-pattern 4 — Một Internal LoadBalancer Service cho mỗi microservice nội bộ. Hiểu lầm: dùng internal LB như router L7. Hậu quả: mỗi Service một VIP riêng, không path routing, tốn IP. Sửa: dùng Internal Ingress (gce-internal) hoặc Internal Gateway (gke-l7-rilb) — một VIP, route theo path/host.

References