Bài toán scalable trên WebSocket servers

Websocket là một giao thức phổ biến rộng rãi cho phép giao tiếp song công hoàn toàn qua TCP. Có một số thư viện triển khai giao thức đó, một trong những thư viện mạnh mẽ và nổi tiếng nhất là Socket.io ở phía Javascript, cho phép nhanh chóng tạo các mẫu giao tiếp theo thời gian thực.
Việc xây dựng một WebSocket server đơn lẻ với một server duy nhất là một công việc đơn giản. Tuy nhiên, khi lượng user tăng vọt với số lượng request khổng lồ, dường như việc xây dựng một hạ tầng server WebSockets có khả năng mở rộng là nhiệm vụ cấp thiết và không hề đơn giản.
Trong bài viết này, chúng ta sẽ cùng phân tích những thách thức trong việc mở rộng hạ tầng WebSocket và những giải pháp cần nắm để giải quyết những thách thức đó nhằm mang lại một hệ thống cụm nhiều servers WebSocket đáp ứng nhu cầu thực tiễn.

Thách thức trong mở rộng WebSocket

Mở rộng HTTP

Để biết lý do tại sao việc mở rộng quy mô WebSockets có vẻ khó khăn, hãy so sánh nó với HTTP, vì hầu hết mọi người đều hiểu rõ về nó.
Với HTTP, bạn có mẫu yêu cầu/trả lời một lần, bạn không mong đợi yêu cầu tiếp theo từ máy khách sẽ quay trở lại cùng một máy chủ. Ít nhất thì bạn không nên làm như vậy, vì điều đó có nghĩa là bạn gặp vấn đề về phiên dính và bạn không thể dễ dàng mở rộng quy mô để đạt được hiệu suất hoặc sự dư thừa.
Với HTTP, bạn có thể chạy số lượng phiên bản máy chủ web gần như không giới hạn sau bộ cân bằng tải. Bộ cân bằng tải chuyển yêu cầu đến một phiên bản máy chủ web hoạt động tốt khi nó xuất hiện và phản hồi sẽ được chuyển trở lại máy khách sau khi máy chủ web đã tính toán xong. Các kết nối HTTP thường tồn tại rất ngắn, chúng chỉ tồn tại cho đến khi có phản hồi.
notion image

Mở rộng WebSocket

Mặt khác, WebSockets khác với các yêu cầu HTTP ở chỗ chúng liên tục. Máy khách WebSocket mở một kết nối đến máy chủ và sử dụng lại nó. Trên kết nối dài hạn này, cả máy chủ và máy khách đều có thể xuất bản và phản hồi các sự kiện. Khái niệm này được gọi là kết nối song công (duplex connection). Một kết nối có thể được mở thông qua bộ cân bằng tải, nhưng khi kết nối được mở, kết nối đó vẫn ở cùng một máy chủ cho đến khi bị đóng hoặc bị gián đoạn.
Điều này có nghĩa là sự tương tác có trạng thái; rằng cuối cùng bạn sẽ lưu trữ ít nhất một số dữ liệu trong bộ nhớ trên máy chủ WebSocket cho mỗi kết nối máy khách đang mở. Ví dụ: bạn có thể biết người dùng nào ở phía máy khách của socket và loại dữ liệu mà người dùng quan tâm.
Thực tế là các kết nối WebSocket ổn định là điều khiến nó trở nên mạnh mẽ đối với các ứng dụng thời gian thực, nhưng đó cũng là điều khiến việc mở rộng quy mô trở nên khó khăn hơn.

Phân tích lý thuyết

Mục tiêu

Xây dựng cụm WebSocket có thể mở rộng để duy trì kết nối giữa nhiều máy khách và nhiều máy chủ.

Vấn đề

Có 2 vấn đề chính cần giải quyết:
  • Nhiều máy chủ websocket có liên quan nên phối hợp với nhau. Khi máy chủ nhận được tin nhắn từ máy khách, nó phải đảm bảo rằng mọi máy khách được kết nối với bất kỳ máy chủ nào đều nhận được tin nhắn này.
  • Khi máy khách bắt tay và thiết lập kết nối với máy chủ, tất cả các tin nhắn trong tương lai của nó sẽ chuyển qua cùng một máy chủ, nếu không máy chủ khác sẽ từ chối các tin nhắn tiếp theo khi nhận được chúng.

Giải pháp

Vấn đề đầu tiên có thể được giải quyết nguyên bản bằng cách sử dụng Adapter. Với Socket.io adapter vốn nằm trong bộ nhớ, cho phép truyền tin nhắn giữa các tiến trình (máy chủ) và phát các sự kiện tới tất cả máy khách. Bộ adapter phù hợp nhất cho kịch bản nhiều máy chủ là socket.io-redis, tận dụng mô hình pub/sub của Redis. Như dự đoán, cấu hình rất đơn giản và mượt mà, chỉ cần một đoạn mã nhỏ.
const redisAdapter = require('socket.io-redis'); const redisHost = process.env.REDIS_HOST || 'localhost'; io.adapter(redisAdapter({ host: redisHost, port: 6379 }));
Vấn đề thứ hai về việc giữ phiên khởi tạo bởi một máy khách có cùng máy chủ gốc có thể được giải quyết mà không gặp khó khăn gì. Bí quyết nằm ở việc tạo các kết nối cố định để khi máy khách kết nối với một máy chủ cụ thể, nó sẽ bắt đầu một phiên được liên kết hiệu quả với cùng một máy chủ.
Điều này không thể đạt được một cách trực tiếp nhưng chúng ta nên đặt thứ gì đó phía trước ứng dụng máy chủ NodeJS. Đây thường có thể là Proxy ngược (reverse porxy), như NGINX, HAProxy, Apache Httpd, Treafik,….

Tóm tắt

Để tóm tắt những gì bạn mong đợi với cấu hình này:
  • Mỗi máy khách sẽ thiết lập kết nối với một phiên bản máy chủ ứng dụng websocket cụ thể.
  • Bất kỳ tin nhắn nào được gửi từ máy khách luôn đi qua cùng một máy chủ mà phiên được khởi tạo.
  • Khi nhận được tin nhắn, máy chủ có thể phát nó. Bộ điều hợp chịu trách nhiệm quảng bá (broadcast) tất cả các máy chủ khác sẽ chuyển tiếp tin nhắn đến tất cả các máy khách đã thiết lập kết nối với chúng.
notion image

Tutorials

Bước 1: Khởi tạo WebSocket server với Redis adapter

const os = require('os'); const ifaces = os.networkInterfaces(); const privateIp = (() => { return Object.values(ifaces).flat().find(val => { return (val.family == 'IPv4' && val.internal == false); }).address; })(); const randomOffset = Math.floor(Math.random() * 10); const intervalOffset = (30+randomOffset) * Math.pow(10,3); // WebSocket Server const socketPort = 5000; const socketServer = require('http').createServer(); const io = require('socket.io')(socketServer, { path: '/' }); // Redis Adapter const redisAdapter = require('socket.io-redis'); const redisHost = process.env.REDIS_HOST || 'localhost'; // 'redis-master.default.svc'; io.adapter(redisAdapter({ host: redisHost, port: 6379 })); // Handlers io.on('connection', client => { console.log('New incoming Connection from', client.id); client.on('test000', function(message) { console.log('Message from the client:',client.id,'->',message); }) }); setInterval(() => { let log0 = `I am the host: ${privateIp}. I am healty.`; console.log(log0); io.emit("okok", log0); }, intervalOffset); // Web Socket listen socketServer.listen(socketPort);

Bước 2: Build image cho WebSocket server

# The instructions for the first stage FROM node:13-alpine as node-compiled ARG NODE_ENV=prod ENV NODE_ENV=${NODE_ENV} # dependecies for npm compiling RUN apk --no-cache add python make g++ COPY package*.json ./ RUN npm install # The instructions for second stage FROM node:13-alpine WORKDIR /usr/src/app COPY . . COPY --from=node-compiled node_modules node_modules # EXPOSE 5000 CMD [ "npm", "run", "start" ]
docker build . -t ws_local

Bước 3: Tạo file docker-compose.yaml

version: "3.2" services: socket-server: # build: . # image: sw360cab/wsk-base:0.1.1 image: websocket_local # restart: always deploy: mode: replicated replicas: 2 environment: - "REDIS_HOST=redis" labels: - "traefik.enable=true" - "traefik.http.routers.socket-router.rule=PathPrefix(`/wsk`)" - "traefik.http.services.service01.loadbalancer.server.port=5000" - "traefik.http.services.service01.loadbalancer.sticky.cookie=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.name=io" - "traefik.http.services.service01.loadbalancer.sticky.cookie.httponly=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.samesite=lax" # Replace prefix /wsk - "traefik.http.middlewares.socket-replaceprefix.replacepath.path=/" # Apply the middleware named `socket-replaceprefix` to the router named `scoker-router` - "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker" traefik-reverse-proxy: image: traefik:v2.2 command: - "--api.insecure=true" - "--providers.docker.exposedByDefault=false" - "--accesslog" # - "--entryPoints.web.address=:80" # - "--entryPoints.web.forwardedHeaders.insecure=true" ports: - "80:80" # The Web UI (enabled by --api.insecure=true) - "8080:8080" volumes: # allow Traefik to listen to the Docker events - /var/run/docker.sock:/var/run/docker.sock redis: image: redis:5.0

Giải thích code

traefik-reverse-proxy: image: traefik:v2.2 command: - "--api.insecure=true" - "--accesslog" ports: - "80:80" # The Web UI (enabled by --api.insecure=true) - "8080:8080" volumes: # allow Traefik to listen to the Docker events - /var/run/docker.sock:/var/run/docker.sock
Như bạn thấy phần quan trọng là ánh xạ điểm vào Docker Daemon cho API Docker vào Traefik, do đó nó sẽ có thể lắng nghe bất kỳ thay đổi cấu hình nào trong ngăn xếp.
Tất cả các phần cấu hình cần thiết sẽ ở dạng động và được giao cho nhà cung cấp Docker. Chúng sẽ được đặt trong dịch vụ ứng dụng websocket dưới dạng các mục nhãn (lable) của cấu hình dịch vụ:
socket-server: image: sw360cab/wsk-base:0.1.1 restart: always deploy: mode: replicated replicas: 2 environment: - "REDIS_HOST=redis" labels: - "traefik.http.routers.socket-router.rule=PathPrefix(`/wsk`)" - "traefik.http.services.service01.loadbalancer.server.port=5000" - "traefik.http.services.service01.loadbalancer.sticky.cookie=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.name=io" - "traefik.http.services.service01.loadbalancer.sticky.cookie.httponly=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.samesite=io" - "traefik.http.middlewares.socket-replaceprefix.replacepath.path=/" - "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker"
Lưu ý: Trong cấu hình này tôi đã thêm một bước phức tạp khác. Chúng tôi hy vọng ứng dụng websocket sẽ được hiển thị ở đường dẫn /wsk thay vì đường dẫn gốc. Điều này sẽ cho phép chúng ta thấy các thành phần phần mềm trung gian của Traefik đang hoạt động.
Những lables này xác định:
  • Một bộ định tuyến (router) và các quy tắc của nó xử lý các yêu cầu tới PathPrefix /wsk
  • Một dịch vụ (service) tham chiếu cổng (port) được hiển thị bởi dịch vụ ứng dụng websocket (5000) và giải quyết vấn đề truy xuất các phiên cố định - sticky session.
  • Một phần mềm trung gian (middleware) sẽ dịch tất cả các yêu cầu nhận được tại đường dẫn /wsk thành những gì dịch vụ websocket đang mong đợi: /.
Trong dòng này:
- "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker"
Phần trung gian (middleware) được xác định được liên kết động với bộ định tuyến (router) bằng danh sách các chuỗi string được định dạng theo quy ước <middleware_name>@<provider_name>.
Lưu ý: tên của bộ định tuyến (socket-router), dịch vụ (service01) và phần trung gian (socket-replaceprefix) không tuân theo bất kỳ quy ước nào và hoàn toàn tùy thuộc vào bạn.

Bước 4: Khởi tạo ngăn xếp Docker Swarm

docker stack deploy --compose-file stack/docker-compose.yml wsk
Sau đó, hãy kiểm tra Bảng điều khiển Traefik được hiển thị theo mặc định tại http://localhost:8080 và nếu không có lỗi nào hiển thị, hãy chuẩn bị sẵn sàng kết nối ứng dụng khách websocket của bạn. Đừng quên điều chỉnh cấu hình của nó: bây giờ để tiếp cận dịch vụ websocket, khách hàng sẽ liên hệ trực tiếp với Traefik, đến lượt Traefik hiện đang hiển thị dịch vụ tại đường dẫn /wsk.

Bước 5: Khởi chạy WebSocket client

Chỉnh sửa cổng: 80 - khi sử dụng Proxy ngược Haproxy hoặc Treafik
const uuid = require('uuid'); const io = require('socket.io-client'); const client = io('http://localhost:80', { // port: 5000 - when using direct connection // port: 80 - when using Haproxy or Treafik Reverse Proxy // port: 30000 - when using plain k8s with NodePort // port: 30080, when using Traefik Ingress in k8s path: '/wsk', // when using Traefik Ingress // path: '/', // for all other scenarios reconnection: true, reconnectionDelay: 500, transports: ['websocket'] }); const clientId = uuid.v4(); let disconnectTimer; client.on('connect', function(){ console.log("Connected!", clientId); setTimeout(function() { console.log('Sending first message'); client.emit('test000', clientId); }, 500); // clear disconnection timeout clearTimeout(disconnectTimer); }); client.on('okok', function(message) { console.log('The server has a message for you:', message); }) client.on('disconnect', function(){ console.log("Disconnected!"); disconnectTimer = setTimeout(function() { console.log('Not reconnecting in 30s. Exiting...'); process.exit(0); }, 10000); }); client.on('error', function(err){ console.error(err); process.exit(1); }); setInterval(function() { console.log('Sending repeated message'); client.emit('test000', clientId); }, 5000);
node client_socket.js
Và điều kỳ diệu sẽ xảy ra! Giao tiếp giữa khách hàng và dịch vụ websocket tiếp tục hoạt động trơn tru! Source code chi tiết tutorials cũng như những cấu hình nâng cao được public tại repo websockets-scaling.
notion image

Tổng kết

Bài toán mở rộng server trên hạ tầng WebSocket là một bài toán xuất phát từ nhu cầu thực tế nhưng tương đối phức tạp và không dễ dàng để xử lý. So với các HTTP server, mỗi request là staless nên chỉ cần đặt thêm một hay nhiều máy chủ server tương tự mà không cần quá quan tâm nhiều các vấn đề còn lại.
Bài viết đã phân tích hai thách thức lớn nhất mà một cụm WebSocket mở rộng gặp phải cũng như cách giải quyết hai vấn đề dựa trên Pub/sub và Reverse Proxy. Hi vọng các bạn có được kiến thức cần thiết khi đối mặt với bài toán mở rộng WebSocket và có được hướng giải quyết phù hợp.
Đồng thời, trong bài viết này cũng cung cấp một quy trình thiết lập cụ thể một cụm WebSocket server có thể mở rộng bằng cách áp dụng Socket.io với Redis adpater và Treafik reverse proxy được gom cụm và xây dựng trong Docker.
Trong thực tế, các bạn có thể lựa chọn các Adpater mô hình Pub/sub khác, cũng như một số reverse proxy phổ biến như nginx, haproxy,… hoặc lựa chọn môi trường triển khai phù hợp như Kubernetes K8s.

Tài liệu tham khảo

    Loading Comments...

    Follow me @kevinbkdev

    Donate to me
    Bank QR

    Bank QR Code

    Buy me a coffee
    Buy Me A Coffee

    @source-blog by @thanhledev

    Thứ 7 (24-12) lúc 9 giờ sáng mình có buổi workshop nhỏ chia sẻ cách viết Smart Contract dùng Solidity, target là chỉ cần biết code là làm được.
    Nếu bạn hứng thú hãy tham gia nhé!