跳至主要内容
版本:4.x

使用多个节点

在部署多个 Socket.IO 服务器时,需要处理两件事

  • 如果启用了 HTTP 长轮询(默认情况下),则启用粘性会话:请参阅 下方
  • 使用兼容的适配器,请参阅 此处

粘性负载均衡

如果您计划在不同的进程或机器之间分配连接的负载,则必须确保与特定会话 ID 相关的所有请求都到达创建它们的进程。

为什么需要粘性会话

这是因为 HTTP 长轮询传输在 Socket.IO 会话的整个生命周期内发送多个 HTTP 请求。

实际上,Socket.IO 可以在没有粘性会话的情况下工作,但需要以下同步(虚线表示):

Using multiple nodes without sticky sessionsUsing multiple nodes without sticky sessions

虽然显然可以实现,但我们认为 Socket.IO 服务器之间的这种同步过程会导致应用程序的性能大幅下降。

备注

  • 如果不启用粘性会话,您将遇到 HTTP 400 错误,原因是“会话 ID 未知”。
  • 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 的路由客户端(推荐解决方案)
  • 基于其源地址的路由客户端

您将在下面找到一些常见的负载均衡解决方案示例

对于其他平台,请参阅相关文档

重要说明:如果您处于 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“会话 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 地址的上游哈希,以便 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 配置片段具有双重用途

  1. 如果请求通过附加了 X-Forwarded-For 标头的上游反向代理或 API 网关,则此片段将从该标头中提取第一个 IP 地址,并使用它来更新 $client_ip。

  2. 如果没有此类代理或网关,则该片段将简单地使用 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 Cluster

与 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 节点接受连接,如果您想将事件广播到所有客户端(或广播到特定 房间 中的客户端),您需要某种方法在进程或计算机之间传递消息。

负责路由消息的接口称为 适配器