334 lines
7.7 KiB
Markdown
334 lines
7.7 KiB
Markdown
# Horizontal Scaling Reference
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
┌─────────────┐
|
|
│Load Balancer│ (nginx/HAProxy with sticky sessions)
|
|
└──────┬──────┘
|
|
│
|
|
┌───┴───┐
|
|
│ │
|
|
┌──▼──┐ ┌──▼──┐
|
|
│WS #1│ │WS #2│ ... (Socket.IO servers)
|
|
└──┬──┘ └──┬──┘
|
|
│ │
|
|
└───┬───┘
|
|
│
|
|
┌───▼───┐
|
|
│ Redis │ (Pub/Sub adapter)
|
|
└───────┘
|
|
```
|
|
|
|
## Redis Adapter Configuration
|
|
|
|
### Socket.IO with Redis
|
|
|
|
```javascript
|
|
const { createServer } = require('http');
|
|
const { Server } = require('socket.io');
|
|
const { createAdapter } = require('@socket.io/redis-adapter');
|
|
const { createClient } = require('redis');
|
|
|
|
const httpServer = createServer();
|
|
const io = new Server(httpServer, {
|
|
cors: { origin: '*' }
|
|
});
|
|
|
|
// Redis pub/sub client setup
|
|
const pubClient = createClient({
|
|
host: 'localhost',
|
|
port: 6379
|
|
});
|
|
const subClient = pubClient.duplicate();
|
|
|
|
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
|
io.adapter(createAdapter(pubClient, subClient));
|
|
console.log('Redis adapter connected');
|
|
});
|
|
|
|
// Now broadcasts work across all servers
|
|
io.emit('news', { hello: 'world' });
|
|
|
|
httpServer.listen(3000);
|
|
```
|
|
|
|
### Redis Streams for Reliable Delivery
|
|
|
|
```javascript
|
|
const { createAdapter } = require('@socket.io/redis-streams-adapter');
|
|
|
|
const redisClient = createClient({ url: 'redis://localhost:6379' });
|
|
|
|
redisClient.connect().then(() => {
|
|
io.adapter(createAdapter(redisClient, {
|
|
streamName: 'socket.io-stream',
|
|
maxLen: 10000, // Keep last 10k messages
|
|
readCount: 100 // Process 100 messages at a time
|
|
}));
|
|
});
|
|
```
|
|
|
|
## Sticky Sessions
|
|
|
|
### Nginx Configuration
|
|
|
|
```nginx
|
|
upstream websocket_backend {
|
|
ip_hash; # Sticky sessions based on IP
|
|
server ws1.example.com:3000;
|
|
server ws2.example.com:3000;
|
|
server ws3.example.com:3000;
|
|
}
|
|
|
|
server {
|
|
listen 80;
|
|
server_name example.com;
|
|
|
|
location /socket.io/ {
|
|
proxy_pass http://websocket_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
# Timeouts
|
|
proxy_connect_timeout 7d;
|
|
proxy_send_timeout 7d;
|
|
proxy_read_timeout 7d;
|
|
}
|
|
}
|
|
```
|
|
|
|
### HAProxy Configuration
|
|
|
|
```haproxyconf
|
|
frontend websocket_frontend
|
|
bind *:80
|
|
mode http
|
|
option httplog
|
|
use_backend websocket_backend
|
|
|
|
backend websocket_backend
|
|
mode http
|
|
balance source # Sticky sessions by source IP
|
|
hash-type consistent # Consistent hashing
|
|
|
|
# Health checks
|
|
option httpchk GET /health
|
|
http-check expect status 200
|
|
|
|
server ws1 10.0.1.1:3000 check
|
|
server ws2 10.0.1.2:3000 check
|
|
server ws3 10.0.1.3:3000 check
|
|
```
|
|
|
|
### Cookie-based Sticky Sessions
|
|
|
|
```javascript
|
|
// Server-side: Set affinity cookie
|
|
io.engine.on('connection', (rawSocket) => {
|
|
const serverID = process.env.SERVER_ID || 'server1';
|
|
rawSocket.request.res.setHeader(
|
|
'Set-Cookie',
|
|
`io=${serverID}; Path=/; HttpOnly; SameSite=Lax`
|
|
);
|
|
});
|
|
```
|
|
|
|
```nginx
|
|
# Nginx: Use cookie for routing
|
|
upstream websocket_backend {
|
|
server ws1.example.com:3000;
|
|
server ws2.example.com:3000;
|
|
}
|
|
|
|
map $cookie_io $backend_server {
|
|
"server1" ws1.example.com:3000;
|
|
"server2" ws2.example.com:3000;
|
|
default websocket_backend;
|
|
}
|
|
|
|
location /socket.io/ {
|
|
proxy_pass http://$backend_server;
|
|
# ... other proxy settings
|
|
}
|
|
```
|
|
|
|
## State Management
|
|
|
|
### Shared State in Redis
|
|
|
|
```javascript
|
|
const Redis = require('ioredis');
|
|
const redis = new Redis();
|
|
|
|
// Store user connection info
|
|
io.on('connection', async (socket) => {
|
|
const userId = socket.handshake.auth.userId;
|
|
|
|
// Track which server has this user
|
|
await redis.hset('user:connections', userId, process.env.SERVER_ID);
|
|
|
|
// Store user presence
|
|
await redis.hset(`user:${userId}`, {
|
|
socketId: socket.id,
|
|
serverId: process.env.SERVER_ID,
|
|
connectedAt: Date.now(),
|
|
status: 'online'
|
|
});
|
|
|
|
socket.on('disconnect', async () => {
|
|
await redis.hdel('user:connections', userId);
|
|
await redis.del(`user:${userId}`);
|
|
});
|
|
});
|
|
|
|
// Send message to specific user across cluster
|
|
async function sendToUser(userId, event, data) {
|
|
const serverId = await redis.hget('user:connections', userId);
|
|
|
|
if (serverId === process.env.SERVER_ID) {
|
|
// User is on this server
|
|
const sockets = await io.in(`user:${userId}`).fetchSockets();
|
|
sockets.forEach(socket => socket.emit(event, data));
|
|
} else {
|
|
// User is on another server - use Redis to route
|
|
io.to(`user:${userId}`).emit(event, data);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Connection Limits
|
|
|
|
### Per-Server Limits
|
|
|
|
```javascript
|
|
const MAX_CONNECTIONS = 50000;
|
|
|
|
io.engine.on('connection', (socket) => {
|
|
const currentConnections = io.engine.clientsCount;
|
|
|
|
if (currentConnections > MAX_CONNECTIONS) {
|
|
socket.close(1008, 'Server at capacity');
|
|
return;
|
|
}
|
|
});
|
|
```
|
|
|
|
### Kubernetes Horizontal Pod Autoscaling
|
|
|
|
```yaml
|
|
apiVersion: autoscaling/v2
|
|
kind: HorizontalPodAutoscaler
|
|
metadata:
|
|
name: websocket-server-hpa
|
|
spec:
|
|
scaleTargetRef:
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
name: websocket-server
|
|
minReplicas: 3
|
|
maxReplicas: 20
|
|
metrics:
|
|
- type: Resource
|
|
resource:
|
|
name: cpu
|
|
target:
|
|
type: Utilization
|
|
averageUtilization: 70
|
|
- type: Pods
|
|
pods:
|
|
metric:
|
|
name: websocket_connections
|
|
target:
|
|
type: AverageValue
|
|
averageValue: "40000" # Scale when avg > 40k connections/pod
|
|
```
|
|
|
|
## Graceful Shutdown
|
|
|
|
```javascript
|
|
const gracefulShutdown = () => {
|
|
console.log('Shutting down gracefully...');
|
|
|
|
// Stop accepting new connections
|
|
io.close(() => {
|
|
console.log('All connections closed');
|
|
process.exit(0);
|
|
});
|
|
|
|
// Force close after 30 seconds
|
|
setTimeout(() => {
|
|
console.error('Forcing shutdown after timeout');
|
|
process.exit(1);
|
|
}, 30000);
|
|
};
|
|
|
|
process.on('SIGTERM', gracefulShutdown);
|
|
process.on('SIGINT', gracefulShutdown);
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Node.js Clustering
|
|
|
|
```javascript
|
|
const cluster = require('cluster');
|
|
const os = require('os');
|
|
|
|
if (cluster.isMaster) {
|
|
const numWorkers = os.cpus().length;
|
|
|
|
console.log(`Master ${process.pid} starting ${numWorkers} workers`);
|
|
|
|
for (let i = 0; i < numWorkers; i++) {
|
|
cluster.fork();
|
|
}
|
|
|
|
cluster.on('exit', (worker) => {
|
|
console.log(`Worker ${worker.process.pid} died, spawning new`);
|
|
cluster.fork();
|
|
});
|
|
} else {
|
|
// Worker process runs Socket.IO server
|
|
const io = require('./socket-server');
|
|
io.listen(3000);
|
|
console.log(`Worker ${process.pid} started`);
|
|
}
|
|
```
|
|
|
|
### uWebSockets.js for Maximum Performance
|
|
|
|
```javascript
|
|
const uWS = require('uWebSockets.js');
|
|
|
|
const app = uWS.App()
|
|
.ws('/*', {
|
|
compression: uWS.SHARED_COMPRESSOR,
|
|
maxPayloadLength: 16 * 1024,
|
|
idleTimeout: 60,
|
|
|
|
open: (ws) => {
|
|
console.log('Client connected');
|
|
},
|
|
|
|
message: (ws, message, isBinary) => {
|
|
// Echo message
|
|
ws.send(message, isBinary);
|
|
},
|
|
|
|
close: (ws, code, message) => {
|
|
console.log('Client disconnected');
|
|
}
|
|
})
|
|
.listen(9001, (token) => {
|
|
if (token) {
|
|
console.log('Listening on port 9001');
|
|
}
|
|
});
|
|
```
|