Giới thiệu
Khi triển khai ứng dụng gRPC trong Kubernetes, bạn có thể gặp phải vấn đề rằng Kubernetes Service không thể thực hiện cân bằng tải hiệu quả cho các endpoint gRPC. Ta nhận thấy rằng một kết nối lâu dài (long-lived connection) từ client chỉ định tuyến đến một pod duy nhất. Điều này khiến hiệu quả cân bằng tải bị suy giảm, đặc biệt trong các hệ thống có lưu lượng cao, có thể khiến một replica bị quá tải, trong khi nhiều replicas còn lại ở trạng thái Idle, hay có thể nói một các dân dã là "thằng nhiều hộp sữa thằng không hộp nào"
Để dễ hình dung, mình đã làm 1 thí nghiệm như sau:
Chạy 1 app gồm 2 services A và B, client gửi request đến A qua HTTP, request sau đó được forward tới B bằng gRPC qua k8s service của B.
Cách test:
- Chạy K6 script để bắn 1 loạt requests đồng thời vào service A
- Trong quá trình bắn tải, xem logs của các pod service B
Kết quả như sau:
Nhìn vào hình trên có thể thấy rõ, chỉ có duy nhất 1 pod nhận requests trong suốt quá trình bắn tải, toàn bộ các pods còn lại không nhận requests.
Tại sao điều này xảy ra với gRPC ?
Sơ lược về HTTP/1 và HTTP/2
HTTP/1.1
- Mỗi request sử dụng một kết nối TCP riêng biệt.
- Với nhiều request đồng thời (ví dụ: styles.css, script.js, image.jpg), sẽ cần mở nhiều TCP connection.
HTTP/2
- Tất cả các request và response đều sử dụng một kết nối TCP duy nhất.
- Dữ liệu được truyền qua các stream độc lập và sử dụng HTTP/2 framing layer.
- Các stream có thể truyền song song trong cùng một kết nối TCP, giúp loại bỏ tình trạng head-of-line blocking và tối ưu hóa băng thông.
- Multiplexing: Dữ liệu từ nhiều request và response được phân mảnh và gửi đồng thời trên một kết nối duy nhất.
GRPC hoạt động dựa trên HTTP/2
Như đã đề cập, gRPC dựa trên HTTP/2, tức là các instance gRPC giao tiếp qua một kết nối TCP duy nhất. Trong Kubernetes, khi Service A mở một kết nối TCP tới Service B, kết nối này sẽ được duy trì cho đến khi hết hạn (hoặc bị đóng). Toàn bộ các request sau đó đều được gửi qua kết nối TCP này mà không cần mở thêm kết nối mới.
Tại sao Kubernetes service không Load balancing trong trường hợp này ?
Kubernetes Service hoạt động chủ yếu dựa vào Kube-proxy, và cơ chế cân bằng tải của Kubernetes Service chủ yếu hoạt động ở Layer 4. Kube-proxy thực hiện chuyển tiếp các kết nối TCP/UDP đến một pod endpoint cụ thể, nhưng khi gRPC client mở một kết nối TCP duy nhất tới gRPC Service (ở đây là Kubernetes Service của Service B), toàn bộ request sau đó sẽ được giữ trên kết nối này.
Dù Kube-proxy có sử dụng cơ chế phân tải (round-robin...), nó chỉ áp dụng trong quá trình thiết lập kết nối ban đầu. Sau khi kết nối được thiết lập, tất cả lưu lượng sẽ được gửi đến một pod cụ thể mà không được phân phối lại.
Vẫn chưa hiểu tại sao K8s service không thể phân tải trong trường hợp này ?
Cốt lõi vấn đề nằm ở sự khác biệt giữa cách hoạt động của HTTP/1 và HTTP/2:
-
Kết Nối Duy Nhất: gRPC (sử dụng HTTP/2) chỉ mở một kết nối TCP duy nhất. Khi gRPC client kết nối với một Kubernetes Service, Kube-proxy chỉ thực hiện chuyển tiếp kết nối đó đến một pod duy nhất tại thời điểm thiết lập ban đầu.
-
Hoạt Động Tại Lớp 4 (Layer 4): Kube-proxy hoạt động ở Layer 4, chỉ xử lý kết nối TCP/UDP mà không quan tâm đến các luồng bên trong (streams) của HTTP/2. Vì vậy, nó không thể cân bằng tải giữa các pod cho từng request trong cùng một kết nối.
Tại sao nó không ảnh hưởng tới HTTP/1.1 ?
Dựa vào hình trên, có thể thấy rõ:
- Đối với HTTP/1.1, mỗi request sẽ tương ứng với một kết nối TCP mới.
- Điều này có nghĩa là các instance không tái sử dụng kết nối TCP cũ mà sẽ tạo một kết nối mới cho mỗi request.
- Khi sử dụng Kubernetes Service, Kube-proxy sẽ phân phối từng kết nối đến các pod khác nhau, dựa trên cơ chế cân bằng tải mặc định (ECMP, round-robin).
- Nhờ vậy, các request trong giao tiếp HTTP/1.1 sẽ được phân tải giữa các pod, đảm bảo cân bằng tải hiệu quả hơn. Ngược lại, HTTP/2 và gRPC duy trì một kết nối TCP duy nhất, dẫn đến hiện tượng không tận dụng được cơ chế cân bằng tải.
Giải pháp cân bằng tải cho gRPC ?
Sử dụng Client-side loadbalancing
Client-side load balancing là một kỹ thuật trong đó client (ứng dụng) tự đảm nhận việc phân phối các request đến các endpoint khác nhau của backend service. Điều này được thực hiện thông qua danh sách endpoint (pod IP) mà client quản lý và sử dụng các thuật toán cân bằng tải
Service Discovery
Client cần có khả năng tự động cập nhật danh sách endpoint khi pod được thêm, xóa hoặc thay đổi. Kubernetes DNS hỗ trợ điều này thông qua các bản ghi SRV hoặc A.
Logic cân bằng tải tại client
Client phải được tích hợp thêm logic để thực hiện cân bằng tải dựa trên danh sách endpoint (ví dụ: sử dụng thư viện gRPC client hỗ trợ load balancing như grpc-go, grpc-java hoặc grpc-python).
Sử dụng Service Mesh
Service Mesh cung cấp khả năng cân bằng tải (load balancing) như một tính năng tích hợp sẵn. Các công cụ phổ biến như Linkerd và Istio hỗ trợ Layer 7 load balancing, đặc biệt phù hợp với gRPC vì gRPC dựa trên HTTP/2. Các service mesh tool phổ biến có thể kể đến như:
- Istio
- Linkerd
Sơ lược về cách hoạt động
Sidecar Proxy:
- Các sidecar proxy (như Envoy trong Istio hoặc proxy riêng của Linkerd) được triển khai cùng với mỗi pod.
- Proxy này sẽ chịu trách nhiệm chuyển tiếp các request, cân bằng tải, và thực hiện các tính năng khác như mTLS, retry, hoặc circuit breaking.
Routing và Load Balancing:
- Service Mesh sẽ theo dõi toàn bộ các pod trong mesh, từ đó thực hiện cân bằng tải thông minh dựa trên các thuật toán như round-robin, least-request, hoặc random.
Các giải pháp cân bằng tải sẽ được trình bày chi tiết hơn ở phần sau.
Kết bài
Giao tiếp gRPC trong Kubernetes có thể gặp khó khăn khi tích hợp với cơ chế load balancing mặc định. Tuy nhiên, với các giải pháp như Client-Side Load Balancing hoặc Service Mesh (Linkerd, Istio), chúng ta có thể đảm bảo hiệu quả phân tải và tính ổn định cho ứng dụng. Việc chọn giải pháp phù hợp phụ thuộc vào yêu cầu cụ thể về hiệu năng, quản lý, và chi phí của hệ thống. Hãy luôn kiểm tra và tối ưu hóa kiến trúc để đảm bảo hệ thống vận hành hiệu quả và tin cậy. Hi vọng bài viết sẽ giúp mọi người hiểu hơn về Kubernetes networking cũng các vấn đề với load balancing.