Skip to content

Passthrough Network LB — Cân bằng tải mà không có load balancer trên đường đi

Why this matters in production

Passthrough Network Load Balancer là loại load balancer phản trực giác nhất trên GCP, vì nó vi phạm hình dung thông thường về "load balancer là một con proxy ở giữa". Ở đây không có con nào ở giữa cả. Không có proxy terminate connection. Packet đi từ client tới backend với IP đích nguyên vẹn, và backend trả lời trực tiếp về client mà không quay lại LB. Vậy "load balancing" xảy ra ở đâu, và "connection" được nhớ ở đâu, khi không có thực thể trung tâm nào giữ state?

Hiểu đúng cơ chế này quan trọng vì passthrough NLB là lựa chọn bắt buộc cho cả một lớp use case:

  • Cần client IP nguyên bản ở tầng socket (không qua X-Forwarded-For): IP allowlisting, geo-logging, mTLS dựa trên IP.
  • Cần UDP, ESP, GRE, ICMP, SCTP — các giao thức non-TCP mà proxy LB không xử lý.
  • Cần throughput cực cao, latency thêm gần như 0 — vì đường về (response, thường lớn hơn request nhiều) không đi qua LB.
  • GKE Service type: LoadBalancer mặc định tạo ra passthrough NLB — nên mọi kỹ sư GKE đều đang dùng nó dù có biết hay không (xem file 07).

Và đặc biệt: vì "connection" trong passthrough được nhớ trong một connection tracking table phân tán chứ không phải trong một proxy, hành vi failover và session affinity của nó hoàn toàn khác proxy LB. Hiểu sai dẫn tới những sự cố "connection bị reset khi scale backend" rất khó debug.

Internal model — load balancing phân tán, không proxy

Direct Server Return: vì sao không có "đường về" qua LB

Điểm khởi đầu để hiểu passthrough là Direct Server Return (DSR). Với một proxy LB, luồng là đối xứng: client → proxy → backend → proxy → client (cả đi và về đều qua proxy). Với passthrough, luồng là bất đối xứng:

   PROXY LB (đối xứng):                PASSTHROUGH LB (DSR, bất đối xứng):

   Client ──req──► Proxy ──req──► Be    Client ──req──► [LB chọn Be]──req──► Be
   Client ◄─resp── Proxy ◄─resp── Be    Client ◄────────resp (DSR)──────────── Be
                                         (response KHÔNG qua LB)

Cơ chế DSR: khi packet từ client tới VIP, hệ thống LB (Maglev cho external, Andromeda cho internal) không thay đổi IP đích — nó vẫn là VIP — mà chỉ "lái" (encapsulate/steer) packet tới backend đã chọn. Backend được cấu hình để nhận packet gửi tới VIP (qua một local route tới VIP, hoặc cơ chế tương đương) và xử lý nó. Quan trọng: vì source IP của packet vẫn là client IP thật, backend trả lời thẳng về client, không cần và không nên đi ngược qua LB.

Hệ quả cơ chế lớn:

  1. Backend thấy client IP thật ở socket. Không header, không proxy protocol — ứng dụng accept() một connection và thấy đúng IP client. Đây là tính chất không LB proxy nào cho được.
  2. Throughput không bị giới hạn bởi LB. Vì response (thường là phần lớn băng thông, ví dụ tải file/video) không qua LB, "LB" không phải bottleneck băng thông. Đây là lý do passthrough cho throughput gần line-rate.
  3. Không có gì để "terminate". LB không bắt tay TCP, không terminate TLS. Một TCP connection là end-to-end giữa client và backend; LB chỉ tham gia ở chiều client→backend để chọn đích.

Maglev (external) vs Andromeda (internal)

Hai loại passthrough dùng hai engine, nhưng cùng nguyên lý:

  • External Passthrough NLB dùng Maglev — hệ thống cân bằng tải L3/4 phần mềm của Google, chạy ở biên region. Maglev nhận packet gửi tới VIP external và chọn backend bằng consistent hashing.
  • Internal Passthrough NLB dùng Andromeda — chính host vSwitch phân tán bạn đã gặp ở Chương 19. Theo tài liệu internal NLB, nó "được xây trên Andromeda network virtualization stack". Không có thiết bị LB nào; logic chọn backend được program vào datapath Andromeda tại từng host — giống hệt cách route được program ở Chương 19. Đây là "software-defined load balancing": LB là một tính chất của datapath phân tán, không phải một hộp.

Cả hai đều regional — không có passthrough global. Lý do cơ chế: consistent hashing và connection tracking cần một phạm vi nhất quán; trải global sẽ phá vỡ tính nhất quán của việc "cùng connection → cùng backend".

Consistent hashing: chọn backend mà không cần lưu state tập trung

Câu hỏi cốt lõi: nếu không có proxy trung tâm, làm sao mọi packet của cùng một connection tới cùng một backend? Câu trả lời là consistent hashing.

Ý tưởng: thay vì lưu một bảng "connection X → backend Y" ở đâu đó tập trung, hệ thống tính backend từ các trường của packet bằng một hàm hash nhất quán. Cùng một input (cùng 5-tuple) → cùng một output (cùng backend), ở bất kỳ host nào, không cần tra cứu state chung. Theo tài liệu, consistent hashing "đảm bảo LB phân phối connection mới giữa các backend đủ điều kiện một cách công bằng nhất có thể" và "luôn chọn cùng một backend đủ điều kiện cho một connection".

Tính chất "consistent" (nhất quán) là điểm tinh tế và quan trọng nhất: khi tập backend thay đổi (thêm/bớt/unhealthy), một hàm hash thường (ví dụ hash(5-tuple) mod N) sẽ làm gần như mọi connection ánh xạ lại sang backend khác — thảm họa. Consistent hashing được thiết kế để khi N thay đổi, "minimize disruption": hầu hết connection vẫn ánh xạ tới đúng backend cũ, chỉ một phần nhỏ (tỷ lệ với thay đổi) bị ánh xạ lại. Đây là toàn bộ lý do Maglev tồn tại — phân phối tải ổn định mapping khi backend set biến động.

Session affinity: hash trên bao nhiêu tuple

Session affinity của passthrough = chọn các trường nào của packet đưa vào hàm hash. Nguồn: traffic distribution external NLB.

Affinity                 Hash trên (tuple)                          "Cùng backend" khi...
─────────────────────────────────────────────────────────────────────────────────────
NONE /                   5-tuple: srcIP, srcPort, proto,            cùng connection
CLIENT_IP_PORT_PROTO     dstIP, dstPort                             (chi tiết nhất)
CLIENT_IP_PROTO          3-tuple: srcIP, dstIP, proto               cùng client IP + proto
CLIENT_IP                2-tuple: srcIP, dstIP                       cùng client IP
                                                                     (thô nhất; mọi conn từ
                                                                      1 client → 1 backend)

Trade-off cơ chế: 5-tuple (NONE) phân phối mịn nhất — mỗi connection độc lập, tải trải đều — nhưng hai connection khác nhau từ cùng client có thể tới hai backend khác nhau. 2-tuple (CLIENT_IP) đảm bảo mọi traffic từ một client IP tới cùng một backend (cần cho app stateful theo client), nhưng dễ gây hotspot khi nhiều client sau NAT chung một IP. Chọn affinity là chọn điểm cân bằng giữa "mịn nhưng không sticky" và "sticky nhưng dễ lệch".

Lưu ý: với fragmented UDP, hệ thống lùi về 3-tuple (vì packet phân mảnh không có port). Đây là chi tiết biên nhưng giải thích một số hành vi UDP "lạ".

Connection tracking: vì sao connection sống sót dù hash thay đổi

Consistent hashing chọn backend cho packet mới. Nhưng để một connection đang chạy ổn định dù backend set thay đổi, passthrough còn dùng một connection tracking table. Theo tài liệu, khi một backend được chọn cho một connection, "lựa chọn này được ghi vào connection tracking table". Packet sau của connection đó được tra trong table và đi tới đúng backend đã ghi — kể cả khi consistent hashing bây giờ sẽ chọn backend khác (do backend set đã đổi). Connection tracking "ghi đè" hash cho các connection đã established.

Hai connection tracking mode (nguồn cùng tài liệu):

  • PER_CONNECTION (mặc định): luôn dùng 5-tuple để theo dõi connection (3-tuple cho non-TCP/fragmented). Một packet TCP có cờ SYN luôn được coi là connection mới, bất kể session affinity. Mode này theo dõi từng connection riêng biệt — chính xác nhất.
  • PER_SESSION: dùng hash khớp với session affinity đã đặt. Hệ quả: "tùy session affinity, PER_SESSION có thể coi nhiều connection riêng biệt như một connection duy nhất cho mục đích tracking". Nghĩa là nhiều connection từ cùng client (nếu affinity là CLIENT_IP) được gom lại theo dõi như một — giảm tần suất một connection bị coi là "mới" khi backend set đổi.

Mental model: consistent hashing = quy tắc cho connection MỚI; connection tracking = bộ nhớ cho connection ĐANG CHẠY. Hai cơ chế phối hợp: hash trải tải connection mới đều và ổn định; tracking giữ connection cũ dính backend cũ. Đây là cách passthrough đạt được cả phân phối đều tính sticky mà không cần một proxy trung tâm giữ state.

Hành vi khi backend unhealthy: connection persistence

Điều gì xảy ra với connection đang chạy khi backend của nó trở nên unhealthy? Đây là nguồn của nhiều sự cố tinh vi. Theo tài liệu, hành vi phụ thuộc setting connectionPersistenceOnUnhealthyBackends. Với mặc định, "TCP: connection persist trên backend unhealthy (mọi session affinity)" — nghĩa là connection đang chạy vẫn được gửi tới backend unhealthy, với giả định nó có thể đang xử lý dở. Chỉ khi connection persistence không áp dụng, "packet tiếp theo của connection được xử lý như thuộc về connection mới" (và được hash lại sang backend khỏe).

Hệ quả vận hành: khi bạn rút một backend khỏi service (ví dụ deploy mới), connection cũ có thể cố tình được giữ lại trên backend đang tắt một thời gian — đây là nơi connection draining (file 08) phối hợp với connection tracking. Hiểu lầm "backend unhealthy thì connection chuyển ngay" là sai; mặc định nó persist.

Internal Passthrough NLB như một next-hop

Một năng lực đặc biệt chỉ internal passthrough NLB có: nó có thể làm next-hop của một custom route. Bạn tạo một route 0.0.0.0/0 → next-hop internal-NLB, và mọi traffic khớp route đó được cân bằng tải qua tập backend của NLB. Đây là cơ chế nền tảng cho NVA (Network Virtual Appliance) HA: đặt một fleet firewall/NAT VM sau một internal NLB, rồi dùng NLB làm next-hop — traffic được phân phối qua các NVA và tự failover khi một NVA chết. Không proxy LB nào làm được điều này, vì làm next-hop đòi hỏi giữ nguyên packet (passthrough), không terminate. Đây là một ví dụ đắt giá về việc tính chất passthrough mở ra năng lực kiến trúc mà proxy không có.

Constraints, trade-offs & failure modes

Failure mode: "connection reset khi scale backend"

Triệu chứng: mỗi lần MIG scale out/in, một số connection đang chạy bị reset. Cơ chế: khi backend set thay đổi, consistent hashing ánh xạ lại một phần nhỏ connection; nếu connection tracking không giữ được chúng (ví dụ mode hoặc affinity khiến chúng bị coi là mới), packet của chúng đi tới backend mới — backend mới không biết connection này → gửi RST. Đây là lý do consistent hashing "minimize" disruption nhưng không loại bỏ hoàn toàn. Giảm thiểu: bật connection draining, dùng PER_SESSION tracking phù hợp affinity, và thiết kế client biết retry. Đây không phải bug — đó là bản chất của LB phân tán không proxy.

Trade-off: không TLS termination, không L7, không WAF L7

Vì passthrough không terminate gì và không đọc HTTP, nó không thể: terminate TLS tại LB, route theo host/path, chạy Cloud Armor WAF L7. Nếu cần các thứ này, hoặc đặt một proxy LB phía trước, hoặc để backend tự terminate TLS. Passthrough chỉ được bảo vệ bởi Network Edge Security của Cloud Armor (lọc L3/4), không phải WAF L7. Đây là đánh đổi cố hữu của việc giữ client IP + DSR + throughput cao.

Constraint: backend phải bind đúng

Vì DSR giữ IP đích là VIP, backend phải được cấu hình để chấp nhận traffic gửi tới VIP. Với health check passthrough, tài liệu lưu ý "ứng dụng phải bind vào IP của LB" trong một số cấu hình. GKE/MIG xử lý việc này tự động, nhưng khi tự dựng trên VM thuần, cấu hình sai (backend không nhận packet gửi tới VIP) gây "health check fail không rõ lý do".

Common mistakes / anti-patterns

Anti-pattern 1 — Dùng CLIENT_IP affinity rồi ngạc nhiên vì phân phối lệch. Hiểu lầm: nghĩ affinity theo client IP trải tải đều. Hậu quả: nhiều client sau một NAT chung một IP → cùng một backend → hotspot. Sửa: chỉ dùng CLIENT_IP khi thực sự cần stickiness theo client; mặc định NONE (5-tuple) cho phân phối mịn.

Anti-pattern 2 — Giả định backend unhealthy thì connection chuyển ngay. Hiểu lầm: nghĩ health check fail = traffic lập tức rời backend. Hậu quả: trong rolling update, connection cũ vẫn tới pod đang tắt (do connection persistence mặc định), gây lỗi nếu app tắt phũ. Sửa: hiểu connection persistence + dùng connection draining + graceful shutdown trong app.

Anti-pattern 3 — Mong đợi X-Forwarded-For từ passthrough NLB. Hiểu lầm: nghĩ mọi LB thêm header client IP. Hậu quả: app đọc X-Forwarded-For không thấy gì (passthrough đưa client IP vào socket, không thêm header). Sửa: với passthrough, đọc client IP từ getpeername()/socket, không phải header.

References