blogbyAndrew

kube-proxy Intercept LoadBalancer IP: Root Cause, Debug, và Fix cho Proxy Protocol trên K8s

April 4, 2026

Blue network patch cables fanning out from a datacenter switch panel with status LEDs

Photo by Scott Rodgerson on Unsplash

1. Bối cảnh: Hệ thống trên VKS với mô hình NLB -> Nginx Ingress

Hệ thống của chúng tôi chạy trên VNG Kubernetes Service (VKS) — managed Kubernetes của GreenNode.ai. Mô hình expose traffic ra bên ngoài khá phổ biến với nhiều team:

text
Internet

    v
┌───────────────────────────────────┐
│   vLB Network Load Balancer       │  <-- L4, TCP passthrough, Public IP
│   (auto-provisioned by VKS)       │
└─────────────────┬─────────────────┘

                  v
┌───────────────────────────────────┐
│   Nginx Ingress Controller        │  <-- L7, TLS termination
│   (inside cluster)                │      route by host / path
└─────────┬──────────────┬──────────┘
          │              │
          v              v
     Service A      Service B
     (api.*)        (admin.*)

Mô hình này phổ biến vì:

Khi tạo Service kiểu LoadBalancer cho Nginx Ingress, VKS tự động provision một NLB và trả về Public IP trong status.loadBalancer.ingress:

bash
kubectl get svc -n ingress-nginx nginx-ingress-controller
# NAME                       TYPE           CLUSTER-IP    EXTERNAL-IP      PORT(S)
# nginx-ingress-controller   LoadBalancer   10.96.45.12   103.245.252.75   80:31080/TCP,443:31443/TCP

103.245.252.75 là IP public của NLB — đây là địa chỉ mà api.company.com trỏ tới.


2. Nhu cầu: Muốn thấy Client IP thật ở backend

Một ngày team yêu cầu backend service phải log được real IP của client — phục vụ audit, rate limiting, và geo-blocking. Vấn đề là khi traffic qua NLB rồi qua Nginx, backend chỉ thấy IP của Nginx pod, không phải IP thật của client.

Giải pháp là bật Proxy Protocol theo hướng dẫn của GreenNode.ai — NLB sẽ inject một header đặc biệt vào đầu mỗi TCP stream trước khi forward sang Nginx, chứa thông tin IP gốc của client.

Proxy Protocol: Là một network protocol do HAProxy phát triển, hoạt động ở tầng TCP. LB thêm một dòng header đặc biệt vào đầu mỗi TCP stream trước khi forward đến backend, chứa thông tin về IP gốc của client.

text
PROXY TCP4 <client-ip> <nlb-ip> <client-port> <nlb-port>\r\n

Nginx đọc header này và biết được real IP, rồi forward vào X-Forwarded-For cho backend. Sau khi apply theo doc, test từ bên ngoài — client IP thật đã xuất hiện trong log:

bash
kubectl logs -n ingress-nginx nginx-ingress-controller-xxx | tail -5
# 203.0.113.42 - - [15/Jan/2024:10:23:41 +0000] "GET /api/health HTTP/1.1" 200 14 "-" "curl/7.68.0" 443 0.001
# 203.0.113.42 - - [15/Jan/2024:10:23:42 +0000] "GET /v1/products HTTP/1.1" 200 512 "-" "curl/7.68.0" 443 0.003

203.0.113.42 là IP thật của client — không còn thấy IP của NLB hay Nginx pod nữa. Mọi thứ có vẻ ổn.


3. Sự cố: Log bất thường và smoke test bắt đầu fail

Sau khi bật Proxy Protocol, log từ Nginx Ingress bắt đầu xuất hiện những error kỳ lạ:

bash
kubectl logs -n ingress-nginx nginx-ingress-controller-xxx | grep "api.company.com"
# 2024/01/15 10:23:41 [error] 123#123: *456 SSL_do_handshake() failed
#   (SSL: error:14094418:SSL routines:ssl3_read_bytes:tlsv1 alert unknown ca)
#   while SSL handshaking, client: 10.244.2.15, server: 0.0.0.0:443

Lần theo thì phát hiện smoke test — bộ Kubernetes Job chạy sau mỗi lần deploy để verify toàn bộ production path, gọi thẳng domain api.company.com thay vì ClusterIP nội bộ — bắt đầu fail hoàn toàn:

bash
kubectl logs smoke-test-job-a1b2c3
# [FAIL] GET https://api.company.com/healthz
# curl: (56) Recv failure: Connection reset by peer
#
# [FAIL] GET https://api.company.com/v1/products
# curl: (52) Empty reply from server

Trong khi đó, test từ laptop bên ngoài cluster vẫn pass hoàn toàn. Cùng domain, cùng endpoint — bên ngoài OK, bên trong fail.


4. Debug: Tìm hiểu traffic đi đâu

Log từ smoke test pod

Connection bị reset ngay lập tức, không phải timeout — đây là dấu hiệu server chủ động đóng connection, không phải network issue. Tại sao Nginx lại thấy IP nội bộ của pod thay vì IP của NLB?

tcpdump trên node

bash
# Capture on the node running the smoke test pod
tcpdump -i any host 103.245.252.75 -n
 
# Result: NO packets to/from 103.245.252.75 !

Traffic hướng đến 103.245.252.75 (IP của NLB) nhưng không có gói tin nào thật sự rời khỏi node.

Nhờ team GreenNode.ai support tcpdump trên NLB

Chúng tôi liên hệ team support của GreenNode.ai, nhờ họ capture traffic trên NLB trong thời gian smoke test chạy.

Kết quả từ phía NLB: không có connection nào từ IP của cluster đến NLB trong khoảng thời gian đó. NLB không hề nhìn thấy request của smoke test.

Kết luận: Traffic từ smoke test pod đến 103.245.252.75 đã bị "nuốt" mất ở đâu đó trong node — không ra ngoài internet, không đến NLB.

Kiểm tra iptables

bash
# SSH into node, check iptables
iptables -t nat -L KUBE-SERVICES -n | grep 103.245.252.75
 
# Output:
# KUBE-SVC-XXXXXXXXXXXXXX  tcp  --  0.0.0.0/0  103.245.252.75  tcp dpt:443
# KUBE-SVC-YYYYYYYYYYYYYY  tcp  --  0.0.0.0/0  103.245.252.75  tcp dpt:80

Tìm ra thủ phạm. kube-proxy đã tạo iptables rules cho IP 103.245.252.75 — bất kỳ traffic nào từ trong cluster đến IP này đều bị intercept và DNAT thẳng đến Nginx pod, không đi qua NLB thật.

bash
iptables -t nat -L KUBE-SVC-XXXXXXXXXXXXXX -n
# KUBE-SEP-ZZZZZZ  tcp -- DNAT to 10.244.1.8:443  (Nginx pod IP)

5. Root Cause: kube-proxy intercept LB IP — Thiết kế có chủ đích

Đây không phải bug. Kubernetes blog chính thức giải thích đây là behavior có chủ đích với hai lý do:

Lý do 1 — Tối ưu traffic path

Không có interception, traffic từ pod đến LB sẽ phải đi vòng:

text
Pod -> exit node -> NLB -> back to cluster -> Nginx pod

Với interception, kube-proxy đi tắt:

text
Pod -> kube-proxy DNAT -> Nginx pod  (no external hop)

Tiết kiệm latency và bandwidth — hoàn toàn hợp lý khi LB chỉ đơn thuần forward traffic.

Lý do 2 — Xử lý health-check packets từ LB

Một số LB gửi packet với destination IP là LB IP (không phải pod IP). Không có rules thì những packet này không biết đi đâu. kube-proxy tạo rules để route chúng đúng đến backend.

Khi nào interception trở thành vấn đề?

Khi LB có thêm logic xử lý mà packet phải đi qua:

text
Normal (LB only forwards):
  Pod -> [kube-proxy DNAT] -> Nginx  OK
  (LB does nothing special -> bypass is fine)

With Proxy Protocol:
  Pod -> [kube-proxy DNAT] -> Nginx  FAIL
  (NLB never sees the packet -> no PP header injected)
  (Nginx expects PP header -> rejects connection)

Kubernetes blog tổng kết hai nhóm vấn đề:

"Mất tính năng ở tầng load balancer: Một số cloud provider cung cấp các tính năng như TLS termination, proxy protocol, v.v. ở tầng load balancer. Khi bypass load balancer, các tính năng này bị mất khi packet đến service, dẫn đến lỗi giao thức."

Và:

"Source IP: Một số cloud provider dùng IP của load balancer làm source IP khi truyền packet đến node. Trong mode ipvs của kube-proxy, health check từ load balancer sẽ không bao giờ nhận được response."

Đây chính xác là vấn đề chúng tôi gặp: Nginx được cấu hình use-proxy-protocol: "True" — nghĩa là mọi connection đến đều phải có PP header. Connection từ pod không có header -> Nginx reject ngay lập tức.

text
================================================================
BEFORE: Proxy Protocol OFF
================================================================

External client                  Smoke test pod (inside cluster)
      │                                       │
      v                                       │ calls 103.245.252.75
┌─────────────┐                               │
│  vLB NLB    │                               │ kube-proxy DNAT
│  (forward)  │                               │ (shortcut to Nginx)
└──────┬──────┘                               │
       v                                      v
┌─────────────────┐                  ┌─────────────────┐
│  Nginx Ingress  │                  │  Nginx Ingress  │
│  (no PP config) │                  │  (no PP config) │
└────────┬────────┘                  └────────┬────────┘
         v                                    v
   Backend OK                           Backend OK


================================================================
AFTER: Proxy Protocol ON (per GreenNode.ai docs)
================================================================

External client                  Smoke test pod (inside cluster)
      │                                       │
      v                                       │ calls 103.245.252.75
┌─────────────┐                               │
│  vLB NLB    │                               │ kube-proxy DNAT
│  +PP header │                               │ (same shortcut!)
└──────┬──────┘                               │
       │ TCP + PP header                      │ (no PP header)
       v                                      v
┌──────────────────────┐          ┌──────────────────────┐
│    Nginx Ingress     │          │    Nginx Ingress     │
│ use-proxy-protocol:  │          │ use-proxy-protocol:  │
│        true          │          │        true          │
│  reads PP header OK  │          │  NO PP header !!     │
└──────────┬───────────┘          └──────────────────────┘
           v                                  v
     Backend OK                    Connection reset !!

6. Tìm giải pháp: Làm việc cùng team GreenNode.ai

Sau khi xác định root cause, chúng tôi làm việc với team support GreenNode.ai để tìm phương án. Có ba hướng được đặt ra:

Phương án 1: Dùng hostname thay vì IP cho LB

kube-proxy chỉ tạo iptables rules dựa trên IP lấy từ status.loadBalancer.ingress[].ip. Nếu thay bằng hostname, kube-proxy không tạo được rules -> traffic buộc phải đi theo routing thật -> qua NLB -> PP header được inject.

Trên AWS, NLB luôn trả về hostname thay vì IP trong status.loadBalancer.ingress — nên vấn đề này không xảy ra ở đó.

Vậy nếu cloud provider trả về IP thì sao?

Đơn giản là dùng một wildcard DNS service như nip.io để tạo hostname trỏ về IP đó — không cần cấu hình DNS phức tạp, không cần domain thật.

nip.io: Là một wildcard DNS service miễn phí — 103.245.252.75.nip.io resolve thành 103.245.252.75. Không cần cấu hình gì thêm, không cần DNS server riêng. Đây là giải pháp nhanh khi chưa có domain thật.

GreenNode.ai update controller để set hostname thay vì IP:

yaml
# Before
status:
  loadBalancer:
    ingress:
    - ip: "103.245.252.75"        # kube-proxy creates rules for this IP
 
# After (workaround)
status:
  loadBalancer:
    ingress:
    - hostname: "103.245.252.75.nip.io"  # kube-proxy cannot process hostname
text
Smoke test pod calls 103.245.252.75.nip.io

    v  DNS resolve -> 103.245.252.75

    │  (no iptables rule matches!)
    v
vLB NLB  ->  inject PP header  ->  Nginx  ->  Backend OK

Kết quả: Smoke test pass. Client IP thật hiện trong log.

Nhược điểm:

Phương án 2: ipMode: Proxy — Fix chính thức từ K8s 1.29+

Đây là giải pháp sạch nhất và chính thức. Kubernetes blog chính thức thừa nhận workaround hostname chỉ là "makeshift solution" và giới thiệu field ipMode từ K8s 1.29:

yaml
status:
  loadBalancer:
    ingress:
    - ip: "103.245.252.75"
      ipMode: "Proxy"    # kube-proxy will NOT bind this IP on node

Khi ipMode: "Proxy" được set, kube-proxy không tạo iptables rules cho IP đó — dù IP vẫn được hiển thị bình thường. Traffic từ trong cluster sẽ đi theo routing thật và qua NLB.

K8s VersionTrạng thái
1.29Alpha
1.30Beta
1.32Stable / GA

Phương án được chọn: Hostname workaround cho các cluster đang chạy K8s < 1.29, migrate sang ipMode: Proxy khi cluster lên 1.29+.


7. Với Cilium thay thế kube-proxy — Có bị dính không?

Một câu hỏi tự nhiên: nếu cluster dùng Cilium với kube-proxy replacement thì sao? Cilium dùng eBPF thay iptables — liệu vấn đề còn tồn tại không?

Câu trả lời: Vẫn bị, thậm chí sớm hơn.

Cilium kube-proxy replacement: Cilium dùng socket-level load balancing — intercept ở tầng connect() syscall, trước cả khi packet vào network stack. Tuy nhiên cơ chế cũng tương tự: Cilium đọc status.loadBalancer.ingress[].ip và tạo eBPF maps dựa trên IP đó.

text
kube-proxy (iptables):
  Packet -> iptables DNAT -> Backend pod
  (intercept at L3/L4, after packet is created)

Cilium socket-LB (eBPF):
  connect(LB_IP) -> eBPF hook -> redirect -> Backend pod
  (intercept at socket layer, before packet is created!)

Cilium thậm chí intercept sớm hơn kube-proxy — nhưng cùng nguồn gốc: đọc IP từ ingress[].ip và tạo rules.

Điều quan trọng: Cả kube-proxy lẫn Cilium đều không xử lý ingress[].hostname. Do đó:

Đây là lý do workaround hostname là universal fix — không phụ thuộc CNI plugin đang dùng.


8. Bài học

1. Khi debug networking K8s: đừng tin log application, trace packet thật

bash
# Check iptables rules
iptables -t nat -L KUBE-SERVICES -n | grep <lb-ip>
 
# Trace traffic
tcpdump -i any host <lb-ip> -n -v

2. kube-proxy intercept LB IP là thiết kế có chủ đích — tối ưu traffic path. Nhưng khi LB có thêm logic (TLS, Proxy Protocol, WAF...), bypass LB sẽ gây mất tính năng.

3. Workaround hostname là universal — hoạt động với kube-proxy (iptables/IPVS) và Cilium (eBPF), vì cả hai đều chỉ xử lý ingress[].ip.

4. Migrate sang ipMode: Proxy khi có thể — giải pháp sạch, transparent, không cần DNS workaround.


References

  1. GreenNode.ai Docs: Preserve Source IP when using NLB and Nginx Ingress Controller
  2. Kubernetes Blog: Load Balancer IP Mode for Services (K8s 1.29 alpha)
  3. KEP-1860: Service LoadBalancer IP Mode
  4. Deckhouse Issue #14657: No more domain workaround needed for Proxy Protocol on K8s 1.32+
  5. LearnKube: Kubernetes networking — service, kube-proxy, load balancing
  6. Cilium Docs: Kubernetes Without kube-proxy
  7. nip.io — Wildcard DNS for any IP Address

Bài viết dựa trên trải nghiệm thực tế khi vận hành hệ thống trên VNG Kubernetes Service. Cảm ơn team support GreenNode.ai đã hỗ trợ debug cùng trong quá trình này.

Submitted to: Cloud Native Vietnam Writing Contest 2026