使用多個節點
在部署多個 Socket.IO 伺服器時,有兩件事需要注意
連線維持負載平衡
如果您計畫在不同的程序或機器之間分配連線負載,您必須確保與特定會話 ID 相關的所有請求都能到達產生它們的程序。
為什麼需要連線維持
這是因為 HTTP 長輪詢傳輸在 Socket.IO 會話的存續期間會傳送多個 HTTP 請求。
事實上,Socket.IO 在技術上可以在沒有連線維持的情況下工作,並具有以下同步(以虛線表示)


雖然顯然可以實作,我們認為 Socket.IO 伺服器之間的這個同步程序會導致您的應用程式效能大幅下降。
備註
- 如果不啟用黏性會話,您會因為「Session ID 未知」而遇到 HTTP 400 錯誤
- WebSocket 傳輸沒有這個限制,因為它在整個會話期間依賴單一 TCP 連線。這表示如果您停用 HTTP 長輪詢傳輸(在 2021 年是一個完全有效的選擇),您就不需要黏性會話
const socket = io("https://io.yourhost.com", {
// WARNING: in that case, there is no fallback to long-polling
transports: [ "websocket" ] // or [ "websocket", "polling" ] (the order matters)
});
文件:transports
啟用黏性會話
要達成黏性會話,有兩個主要的解決方案
- 根據 Cookie 路由客戶端(建議的解決方案)
- 根據來源位址路由客戶端
您會在下方找到一些常見負載平衡解決方案的範例
- nginx(基於 IP)
- nginx Ingress (Kubernetes)(基於 IP)
- Apache HTTPD(基於 Cookie)
- HAProxy(基於 Cookie)
- Traefik(基於 Cookie)
- Node.js
cluster
模組
對於其他平台,請參閱相關文件
- Kubernetes:https://kubernetes.github.io/ingress-nginx/examples/affinity/cookie/
- AWS(應用程式負載平衡器):https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html
- GCP:https://cloud.google.com/load-balancing/docs/backend-service#session_affinity
- Heroku:https://devcenter.heroku.com/articles/session-affinity
重要注意事項:如果您處於 CORS 情況(前端網域與伺服器網域不同),而且會話關聯性是透過 Cookie 達成的,您需要允許憑證
伺服器
const io = require("socket.io")(httpServer, {
cors: {
origin: "https://front-domain.com",
methods: ["GET", "POST"],
credentials: true
}
});
用戶端
const io = require("socket.io-client");
const socket = io("https://server-domain.com", {
withCredentials: true
});
如果不允許,瀏覽器不會傳送 Cookie,而且您會遇到 HTTP 400「Session ID 未知」回應。更多資訊 在此。
nginx 設定
在 nginx.conf
檔案的 http { }
區段中,您可以宣告一個 upstream
區段,其中包含要平衡負載的 Socket.IO 程序清單
http {
server {
listen 3000;
server_name io.yourhost.com;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://nodes;
# enable WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
upstream nodes {
# enable sticky session with either "hash" (uses the complete IP address)
hash $remote_addr consistent;
# or "ip_hash" (uses the first three octets of the client IPv4 address, or the entire IPv6 address)
# ip_hash;
# or "sticky" (needs commercial subscription)
# sticky cookie srv_id expires=1h domain=.example.com path=/;
server app01:3000;
server app02:3000;
server app03:3000;
}
}
請注意 hash
指令,它表示連線會是固定的。
請務必也在最上層設定 worker_processes
,以表示 nginx 應該使用多少個工作程序。您可能也想要調整 events { }
區塊中的 worker_connections
設定。
連結
nginx 的 proxy_read_timeout
(預設為 60 秒)值必須大於 Socket.IO 的 pingInterval + pingTimeout
(預設為 45 秒),否則如果在給定的延遲後沒有傳送資料,nginx 將強制關閉連線,而用戶端會收到「傳輸關閉」錯誤。
nginx Ingress (Kubernetes)
在 Ingress 設定的 annotations
區段中,您可以宣告一個基於用戶端 IP 位址的 upstream hashing,以便 Ingress 控制器始終將來自特定 IP 位址的請求指定給同一個 pod
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: your-ingress
namespace: your-namespace
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
set $forwarded_client_ip "";
if ($http_x_forwarded_for ~ "^([^,]+)") {
set $forwarded_client_ip $1;
}
set $client_ip $remote_addr;
if ($forwarded_client_ip != "") {
set $client_ip $forwarded_client_ip;
}
nginx.ingress.kubernetes.io/upstream-hash-by: "$client_ip"
spec:
ingressClassName: nginx
rules:
- host: io.yourhost.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: your-service
port:
number: 80
備註
nginx.ingress.kubernetes.io/upstream-hash-by: "$client_ip"
此註解指示 NGINX Ingress 控制器使用用戶端 IP 位址將傳入流量路由到 Kubernetes 群集中的特定 Pod。這對於維持固定會話至關重要。
nginx.ingress.kubernetes.io/configuration-snippet
此自訂 NGINX 設定片段具有雙重用途
如果請求通過附加
X-Forwarded-For
標頭的上游反向代理或 API 閘道,此片段會從該標頭中擷取第一個 IP 位址,並使用它來更新 $client_ip。在沒有此類代理或閘道的情況下,此片段只會使用 remote_addr,它是直接連接到 ingress 的用戶端 IP 位址。
這可確保使用正確的用戶端 IP 進行固定會話邏輯,並由 nginx.ingress.kubernetes.io/upstream-hash-by: "$client_ip"
註解啟用。當您的架構包含反向代理或 API 閘道等上游網路元件時,此片段特別重要。
連結
Apache HTTPD 組態
Header add Set-Cookie "SERVERID=sticky.%{BALANCER_WORKER_ROUTE}e; path=/" env=BALANCER_ROUTE_CHANGED
<Proxy "balancer://nodes_polling">
BalancerMember "http://app01:3000" route=app01
BalancerMember "http://app02:3000" route=app02
BalancerMember "http://app03:3000" route=app03
ProxySet stickysession=SERVERID
</Proxy>
<Proxy "balancer://nodes_ws">
BalancerMember "ws://app01:3000" route=app01
BalancerMember "ws://app02:3000" route=app02
BalancerMember "ws://app03:3000" route=app03
ProxySet stickysession=SERVERID
</Proxy>
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) balancer://nodes_ws/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) balancer://nodes_polling/$1 [P,L]
# must be bigger than pingInterval (25s by default) + pingTimeout (20s by default)
ProxyTimeout 60
連結
HAProxy 組態
# Reference: http://blog.haproxy.com/2012/11/07/websockets-load-balancing-with-haproxy/
listen chat
bind *:80
default_backend nodes
backend nodes
option httpchk HEAD /health
http-check expect status 200
cookie io prefix indirect nocache # using the `io` cookie set upon handshake
server app01 app01:3000 check cookie app01
server app02 app02:3000 check cookie app02
server app03 app03:3000 check cookie app03
連結
Traefik
使用容器標籤
# docker-compose.yml
services:
traefik:
image: traefik:2.4
volumes:
- /var/run/docker.sock:/var/run/docker.sock
links:
- server
server:
image: my-image:latest
labels:
- "traefik.http.routers.my-service.rule=PathPrefix(`/`)"
- traefik.http.services.my-service.loadBalancer.sticky.cookie.name=server_id
- traefik.http.services.my-service.loadBalancer.sticky.cookie.httpOnly=true
使用 檔案提供者
## Dynamic configuration
http:
services:
my-service:
rule: "PathPrefix(`/`)"
loadBalancer:
sticky:
cookie:
name: server_id
httpOnly: true
連結
使用 Node.js 叢集
就像 nginx,Node.js 透過 cluster
模組內建叢集支援。
有幾個解決方案,視你的使用案例而定
NPM 套件 | 運作方式 |
---|---|
@socket.io/sticky | 路由基於 sid 查詢參數 |
sticky-session | 路由基於 connection.remoteAddress |
socketio-sticky-session | 路由基於 x-forwarded-for 標頭) |
使用 @socket.io/sticky
的範例
const cluster = require("cluster");
const http = require("http");
const { Server } = require("socket.io");
const numCPUs = require("os").cpus().length;
const { setupMaster, setupWorker } = require("@socket.io/sticky");
const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter");
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
const httpServer = http.createServer();
// setup sticky sessions
setupMaster(httpServer, {
loadBalancingMethod: "least-connection",
});
// setup connections between the workers
setupPrimary();
// needed for packets containing buffers (you can ignore it if you only send plaintext objects)
// Node.js < 16.0.0
cluster.setupMaster({
serialization: "advanced",
});
// Node.js > 16.0.0
// cluster.setupPrimary({
// serialization: "advanced",
// });
httpServer.listen(3000);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork();
});
} else {
console.log(`Worker ${process.pid} started`);
const httpServer = http.createServer();
const io = new Server(httpServer);
// use the cluster adapter
io.adapter(createAdapter());
// setup connection with the primary process
setupWorker(io);
io.on("connection", (socket) => {
/* ... */
});
}
在節點之間傳遞事件
現在你有許多 Socket.IO 節點接受連線,如果你想廣播事件給所有用戶端(或特定 房間 中的用戶端),你需要一些方法在程序或電腦之間傳遞訊息。
負責路由訊息的介面就是我們所說的 適配器。