Câu chuyện về haproxy và websocket qua buổi phỏng vấn DevOps
Một ít kiến thức tưởng là bình thường nhưng lại hay bị hiểu nhầm trong thực tế.
Người ta thường nói: Đi một ngày đàng, học một sàng khôn. Hồi còn trẻ (trâu) mình cũng hay đi học nhiều sàng khôn miễn phí bằng cách tích cực đi phỏng vấn ở nhiều công ty khác nhau. Đi nhiều đến cái độ mà hồi ý còn kiểu thấy thích đi phỏng vấn nữa vì được gặp người này người kia, nói chuyện đông chuyện tây và quan trọng nhất là thấy cứ mỗi lần phỏng vấn về là sẽ thấy mình học được điều gì đó, hoặc có kiến thức nào đó mình chưa biết rõ để về đọc hiểu rõ hơn.
Giờ thì vẫn trẩu thôi nhưng mà CV hay bị các công ty từ chối nên là không được đi phỏng vấn mấy nhưng lại có cơ hội ngồi dự phỏng vấn để kiếm đồng nghiệp vào cùng bán mình cho tư bản. Thế nên mình vẫn học được nhiều sàng khôn qua những cuộc thảo luận có tính chuyên môn cao với các bạn ứng viên. Và đây là một câu chuyện trong đó.
First things first
Ờ trước khi nghe chuyện thì cứ nhắc lại tên cái cho các bạn khỏi quên. Mình là Minh Monmen, người đã mất hút cả năm trời để bận theo đuổi người yêu kiếp trước, phải tranh thủ từng tí lúc nàng ta ngủ để kể lại câu chuyện kiến thức vô cùng nóng hổi vừa thổi vừa nghe này.
Một tý context:
- Minh Monmen đang kiếm người làm cùng
- Minh Monmen ngồi phỏng vấn một bạn ứng viên
- Câu chuyện đang tới chương: tại sao chỗ này lại dùng HAProxy, chỗ kia lại dùng Nginx, có lý do gì để phải dùng HAProxy ở đó không?
- Chiến sự đang tới hồi: ứng viên phải sử dụng HAproxy để load balance cho các backend dùng Websocket vì phải config load balance bằng ip_hash.
Tới chỗ này, mình có hỏi thêm một câu:
Khi dùng haproxy để load balance nhiều server chạy websocket thì việc sử dụng ip_hash hay sticky session có phải là bắt buộc để websocket hoạt động đúng hay không?
Và đây chính là câu hỏi sẽ được trả lời trong bài viết này. Ok chưa? Let's start!
Một số từ khóa cơ bản
Trước khi có thể ngụp lặn sâu hơn để hiểu những gì được nói trong bài viết này (và xa hơn là tự đánh giá đúng sai, chọi đá vào mặt mình,...) thì mình nghĩ các bạn nên có một số kiến thức về những keyword sau:
- Reverses proxy và Load Balancer là gì, tập trung vào cách hoạt động của HAProxy (hoặc Nginx), các thuật toán được dùng để Load Balance.
- Layer 4 proxy và Layer 7 proxy là gì
- Websocket là gì, hoạt động ra sao, thường dùng khi nào, khác gì HTTP
- Socket.io, thật ra là 1 thư viện phổ biến có sử dụng websocket. Cái này ai làm dev sẽ quen thuộc hơn, biết thì tốt mà không biết thì thôi cũng không sao.
Từ câu trả lời của ứng viên
Quay trở lại câu hỏi chính của mình:
Khi dùng haproxy để load balance nhiều server chạy websocket thì việc sử dụng ip_hash hay sticky session có phải là bắt buộc để websocket hoạt động đúng hay không?
Mình nhận được câu trả lời (nguyên văn thì không nhớ nhưng ý nghĩa thì đúng nhé):
Vì websocket là kết nối dạng stateful nên cần phải config HAproxy sử dụng ip_hash hoặc sticky session để đảm bảo tất cả request từ 1 client phải được route tới cùng 1 backend thì websocket mới hoạt động được.
Nghe cũng hợp lý ha, nhưng mà mình vẫn có cảm giác nó cấn cấn cái gì đó. Thử đi dạo 1 vòng quanh google về câu chuyện cấu hình HAProxy cho websocket, thì lại có điều kỳ lạ hơn nữa là rất nhiều bài blog, tut,... trên mạng đều hướng dẫn kiểu:
Để config HAproxy cho websocket, ngoài việc set timeout tunnel ra thì còn phải sử dụng cơ chế load balancing bằng ip_hash hoặc bật sticky session qua việc set 1 cái cookie.
Đọc tut chỗ này làm mình có một cái cảm giác rất mạnh mẽ là việc phải sử dụng ip_hash hay sticky session nó là điều kiện bắt buộc để có thể load balance được websocket. Và rằng mặc định khi người ta gặp websocket là phải nghĩ ngay tới việc cấu hình 1 trong 2 cái trên.
Nhưng có thực vậy không?
Short answer
TLDR; là:
Đọc tới đây các bạn có thể dừng được rồi. Đây là câu chốt của bài viết dài như sớ này.
Nhưng nếu bạn quan tâm đến việc tại sao lại tồn tại những hiểu lầm như vậy, thì hãy đọc tiếp.
Long answer
Okay, trước hết hãy cùng rà lại một số kiến thức nền tảng cùng nhau:
Websocket hoạt động thế nào?
Đây là kiến thức quyết định cho việc trả lời câu hỏi trên. Hãy xem qua sơ đồ sau:
Ở đây chúng ta có thể thấy 3 giai đoạn chính của một kết nối sử dụng websocket gồm có:
- Connecting: Mở kết nối TCP, sau đó sử dụng HTTP request / response bình thường để handshake giữa client và server, đồng ý việc giữ lại kết nối TCP hiện tại không đóng để truyền dữ liệu.
- Open: Truyền data trực tiếp (các data frame) trên kết nối đang được mở giữa client và server, format data ở đây không còn là bản tin HTTP nữa, nhưng data vẫn được truyền trên kết nối TCP đang mở giữa client và server.
- Closing: 1 trong 2 bên dùng 1 frame data đặc biệt là "close frame" để báo cho bên còn lại về việc ngắt kết nối. Bên còn lại sẽ chấp nhận bằng việc cũng gửi lại 1 "close frame" để cùng đóng kết nối. Sau đó kết nối TCP bên dưới cũng được đóng.
Nhìn vào sơ đồ này thì điểm cần lưu ý ở đây chính là:
- Chỉ có 1 TCP connection (vùng màu đỏ) được mở ra giữa Client và Server để truyền dữ liệu trong suốt vòng đời của 1 websocket connection. Điều này QUAN TRỌNG nhé.
- Vùng màu xanh là phần client và server truyền bản tin dạng HTTP
- Vùng màu vàng là phần client và server truyền bản tin dạng websocket
HAProxy khi chạy websocket sẽ hoạt động thế nào?
Hãy sửa đổi 1 chút sơ đồ trên bằng việc thêm lớp HAProxy ở giữa:
Ở đây thì điều chúng ta cần chú ý là:
- Logic load balance (lựa chọn backend server) là phần màu tím được thực hiện ở HAProxy trong giai đoạn handshake bằng bản tin HTTP. Lúc này HAProxy sẽ dựa trên thông tin có trong HTTP Upgrade request để quyết định lựa chọn kết nối tới backend server nào. Đây là chỗ mà config sticky session hay ip_hash phát huy tác dụng.
NHƯNG trong vòng đời 1 connection websocket thì quá trình này chỉ xảy ra 1 lần thôi. Tức là với vòng đời của 1 websocket connection (khá dài) thì bạn không cần sử dụng sticky session hay ip_hash để HAProxy hoạt động đúng.
- Hết giai đoạn handshake bằng HTTP, HAProxy khi nhận được response 101 từ server sẽ tự động chuyển sang chế độ tunnel, không xử lý data nữa mà chỉ giữ TCP connection với cả 2 phía và passthrough data giữa 2 connection đó. Nếu 1 trong 2 connection bị ngắt, toàn bộ connection sẽ bị ngắt và sẽ cần client bắt đầu thiết lập lại từ đầu.
Tới đây là đã đủ để trả lời câu hỏi ở đầu bài viết rồi đấy. Websocket KHÔNG yêu cầu chúng ta phải có sticky session hay load balance dạng ip_hash trên HAProxy để có thể hoạt động.
Những nguyên nhân gây ra nhầm lẫn
Nhưng tại sao người ta lại hay thêm config sticky session và ip_hash khi cấu hình HAProxy cho websocket thế? Hãy cùng tới với một số giải thích mà mình đoán là lý do khiến chúng ta nhầm lẫn.
Backend không thật sự dùng websocket
Nghe thì khôi hài, tại sao đề bài đã bảo là backend dùng websocket rồi mà còn có lý do là không dùng websocket ở đây nữa?
Đơn giản thôi, khi nhắc tới connection realtime, có một phần không nhỏ developer trong stack JS đang sử dụng một thư viện cực kỳ nổi tiếng và lâu đời là Socket.IO. Thông thường thì dev chỉ quan tâm tới cái kết quả, tức là phía server gửi được dữ liệu chủ động cho client (là cái mà thư viện Socket.IO này đang làm) thôi, và gọi nó là websocket backend.
Thế nhưng websocket chỉ là 1 trong các phương thức được Socket.IO sử dụng cho việc truyền tải dữ liệu realtime mà thôi. Ngoài websocket ra, thì Socket.IO còn sử dụng 2 phương thức khác là HTTP long-polling
và WebTransport
(đọc kỹ hơn trong docs). 2 thằng này nó là cái gì thì các bạn google nhé.
Chính thằng HTTP long-polling
mới là thứ yêu cầu cấu hình phải có sticky session hoặc ip_hash, vì để mô phỏng lại 1 connection websocket (stateful) thì HTTP long-polling phải dùng rất nhiều request HTTP riêng lẻ (stateless). Với việc không giữ 1 connection cố định tới 1 backend mà mở nhiều connection, tạo nhiều request khác nhau,... sẽ dẫn tới việc HAProxy sẽ thực hiện logic load balance (lựa chọn backend) nhiều lần, mà thông tin về handshake của client được lưu trên memory trong 1 instance socket.io, do đó routing tới nhiều backend sẽ dẫn đến lỗi:
Ngoài ra, mặc định Socket.IO sẽ sử dụng HTTP long-polling để tạo session và có thể upgrade lên Websocket cho session đó nếu cần. Việc sử dụng cùng lúc 2 transport cho 1 session này cũng yêu cầu HAProxy phải routing tới cùng 1 backend.
Các request trong hình sẽ gồm có:
- handshake (chứa session ID -
zBjrh...AAAK
dùng cho các request phía sau) - send data (HTTP long-polling)
- receive data (HTTP long-polling)
- upgrade (WebSocket)
- receive data (HTTP long-polling, sẽ được tự kết thúc khi connection số 4 - websocket được kết nối)
Để Socket.IO có thể hoạt động đúng khi chạy qua HAProxy thì các bạn sẽ cần:
- Cấu hình HAProxy với sticky session hoặc ip_hash để với 1 client thì luôn routing nó tới cùng 1 backend. Từ đó tất cả phương thức transport của socket.io sẽ đều hoạt động. Đây là thứ mà anh em thường xuyên thực hiện khi phải cấu hình socket.io, nhiều đến mức coi nó thành tiêu chuẩn luôn.
- HOẶC chỉ sử dụng websocket transport trong cấu hình client và server khi kết nối. Như đã nói ở trên, websocket thật sự sẽ không yêu cầu cần phải có sticky session hay ip_hash
Stateful scope
Đây có thể là một trong những lý do tạo ra sự hiểu lầm ở đầu bài viết: Vì người ta nhắc đến websocket là nhắc đến stateful, mà nghe đến stateful thì khi scale theo chiều ngang (tăng số instance) sẽ thường đi kèm với sticky session.
NHƯNG, người ta chỉ nói đến tính chất stateful mà quên mất cái phạm vi của nó. Đây mới là thứ quan trọng nhưng lại thường xuyên bị quên lãng.
Stateful hay stateless chỉ có ý nghĩa khi đi cùng với một phạm vi cụ thể
Ví dụ:
- Khi nói HTTP là 1 stateless protocol, phạm vi ở đây là giữa các bản tin HTTP khác nhau có sự tách biệt rõ ràng và server không cần phải lưu trạng thái từ request phía trước để có thể xử lý được request HTTP tiếp theo
- Nhưng HTTP lại dựa trên TCP - 1 stateful protocol, vì phạm vi ở đây là trong 1 connection TCP thì các gói tin phía sau có liên quan tới gói phía trước và server phải có đủ thông tin của các gói phía trước để xử lý.
- Websocket là 1 stateful protocol, phạm vi ở đây là trong 1 connection thì các frame data phía sau sẽ có liên quan tới các frame trước đó và cả trạng thái connection đang mở
- Socket.IO khi dùng HTTP long-polling thì sẽ là sử dụng 1 stateful transport, khi phạm vi ở đây là trong 1 phiên Socket.IO thì request HTTP phía sau sẽ phụ thuộc vào request phía trước (thông qua session ID)
- 1 instance backend sẽ là stateless instance (kể cả khi sử dụng websocket) nếu ở phạm vi instance nó không chứa state giữa các connection (ví dụ khi chạy Socket.IO với websocket transport và dùng Redis để chứa state). Lúc này vai trò của các instance sẽ như nhau và giữa 2 connection websocket trước và sau sẽ không phụ thuộc gì với nhau để phải cần sticky session hay ip_hash
Điểm mấu chốt cần rõ ở đây với trường hợp là câu hỏi đầu bài là:
Scope nào ảnh hưởng đến vai trò của các instance và quá trình routing của HAProxy thì mới cần xử lý bằng sticky session?
Ví dụ với thằng Socket.IO ở trên:
Tổng kết
Có rất nhiều thứ tưởng chừng như rất đơn giản, hay gặp, chúng ta thường làm như một thói quen mà không thật sự đi tìm hiểu rằng tại sao nó lại phải như thế, hay nó hoạt động như thế nào. Mặc dù phần lớn thời gian làm DevOps có khi chỉ là đi dùng và cấu hình 1 sản phẩm khác chứ cũng không trực tiếp code, nhưng hiểu được bên dưới chúng hoạt động thế nào sẽ là thứ tạo ra sự khác biệt khi hệ thống xảy ra vấn đề và bạn là người troubleshoot. Chính vì thế những câu hỏi mình thường hay hỏi nhất trong phòng phỏng vấn thường sẽ bắt đầu bằng tại sao và kết thúc bằng như thế nào.
Hy vọng chút kiến thức lượm lặt này sẽ hữu ích với các bạn, hẹn gặp lại các bạn trong lần tái xuất tiếp theo của Minh Monmen nhé.
All rights reserved