kubernetes 下實現socket.io 的叢集模式
socket.io
單節點模式是很容易部署的,但是往往在生產環境一個節點不能滿足業務需求,況且還要保證節點掛掉的情況仍能正常提供服務,所以多節點模式就成為了生成環境的一種必須的部署模式。
本文將介紹如何在kubernetes 叢集上部署多節點的socket.io
服務。
文章中涉及到的程式碼可以前往https://github.com/cnych/k8s-socketio-cluster-demo檢視。
問題
現在正在準備將線上環境一步步遷移到kubernetes 叢集上,這樣我們可以根據實際情況部署多個POD 來提供服務,但是socket.io
服務並不是單純的無狀態應用,只需要將POD 部署成多個就可以正常提供服務了,因為其底層需要建立很多連線來保持長連線,但是這樣的話上一個請求可能會被路由到一個POD,下一個請求則很有可能會被路由到另外一個POD 中去了,這樣就會出現錯誤了,如下圖:
socket-io errors
從上面的錯誤中我們可以看出是有的請求找不到對應的Session ID,也證明了上面提到的引起錯誤的原因。
解決方法
我們從socket.io 官方文件中可以看到對於多節點的介紹,其中通過Nginx
的ip_hash 配置用得比較多,同一個ip 訪問的請求通過hash 計算過後會被路由到相同的後端程式去,這樣就不會出現上面的問題了。我們這裡是部署在kubernetes
叢集上面的,通過traefik ingress
來連線外部和叢集內部間的請求的,所以這裡中間就省略了Nginx
這一層,當然你也可以多加上這一層,但是這樣顯然從架構上就冗餘了,而且還有更好的解決方案的:sessionAffinity
什麼是sessionAffinity? sessionAffinity是一個功能,將來自同一個客戶端的請求總是被路由回伺服器叢集中的同一臺伺服器的能力。
在kubernetes
中啟用sessionAffinity
很簡單,只需要簡單的Service
中配置即可:
service.spec.sessionAffinity = "ClientIP"
預設情況下sessionAffinity=None,會隨機選擇一個後端進行路由轉發的,設定成ClientIP
後就和上面的ip_hash
功能一樣了,由於我們使用的是traefik ingress
,這裡還需要在Service
traefik
的annotation
:
apiVersion: v1
kind: Service
metadata:
name: socket-demo
namespace: kube-apps
annotations:
traefik.backend.loadbalancer.stickiness: "true"
traefik.backend.loadbalancer.stickiness.cookieName: "socket"
labels:
k8s-app: socket-demo
spec:
sessionAffinity: "ClientIP"
ports:
- name: socketio
port: 80
protocol: TCP
targetPort: 3000
selector:
k8s-app: socket-demo
注意上面的annotations和sessionAffinity兩項配置,然後我們再來看看我們的socket.io
服務吧
已經正常了吧,注意看上面打印出來的hostname
都是一樣的,因為我們這裡去訪問的都是來自同一個IP,多重新整理幾次是不是還是這樣,證明上面的sessionAffinity
配置生效了。
如果是另外的地方去訪問,會路由到不一樣的後端去嗎?我們這裡啟用一個代理來測試下:從上圖中打印出來的hostname
可以看出兩個請求被路由到了不同的POD 中,但是現在又有一個新的問題了:繪製的圖形並沒有被廣播出去,這是為什麼呢?其實在上面提到的socket.io 官方文件中已經提到過了
:
Now that you have multiple Socket.IO nodes accepting connections, if you want to broadcast events to everyone (or even everyone in a certain room) you’ll need some way of passing messages between processes or computers.
The interface in charge of routing messages is what we call the Adapter. You can implement your own on top of the socket.io-adapter (by inheriting from it) or you can use the one we provide on top of Redis: socket.io-redis:
var io = require('socket.io')(3000);
var redis = require('socket.io-redis');
io.adapter(redis({ host: 'localhost', port: 6379 }));
總結起來就是你如果想在程序間或者節點之前傳送資訊,那麼就需要實現自己實現一個socket.io-adapter
或者利用官方提供的socket.io-redis
。
我們這裡利用socket.io-redis
這個adapter 來實現訊息的廣播,最終的服務端程式碼如下:
const express = require('express');
const socketRedis = require('socket.io-redis');
const os = require('os');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const port = process.env.PORT || 3000;
app.use(express.static(__dirname + '/static'));
app.engine('html', require('ejs').renderFile);
app.set('view engine', 'html');
app.get('/', function (req, res) {
res.render('index', {
'os': os.hostname()
});
});
function onConnection(socket){
socket.on('drawing', (data) => socket.broadcast.emit('drawing', data));
}
io.adapter(socketRedis({host: 'redis', port: 6379}));
io.on('connection', onConnection);
http.listen(port, () => console.log('listening on port ' + port));
部署在kubernetes
叢集上的yaml
檔案如下:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: socket-demo
namespace: kube-apps
labels:
k8s-app: socket-demo
spec:
replicas: 3
template:
metadata:
labels:
k8s-app: socket-demo
spec:
containers:
- image: cnych/socketdemo:k8s
imagePullPolicy: Always
name: socketdemo
ports:
- containerPort: 3000
protocol: TCP
resources:
limits:
cpu: 100m
memory: 100Mi
requests:
cpu: 50m
memory: 50Mi
---
apiVersion: v1
kind: Service
metadata:
name: socket-demo
namespace: kube-apps
annotations:
traefik.backend.loadbalancer.stickiness: "true"
traefik.backend.loadbalancer.stickiness.cookieName: "socket"
labels:
k8s-app: socket-demo
spec:
sessionAffinity: None
ports:
- name: socketio
port: 80
protocol: TCP
targetPort: 3000
selector:
k8s-app: socket-demo
現在看看最終的效果吧:
不同節點間也可以傳遞資料了,到這裡我們就實現了在kubernetes
叢集下部署socket.io
多節點。
上面的根據
traefik.backend.loadbalancer.stickiness.cookieName
來進行路由的規則在測試環境生效了,在線上沒生效,可能這個地方有什麼問題?上面沒有生效是因為客戶端連線
socket.io
的協議的時候沒有使用polling造成的,客戶端連線socket.io
要按照標準的方式指定trasports=[‘polling’, ‘websocket’]
sessionAffinity
與traefik
設定cookieName的方式貌似不能同時存在,如果遇到不生效的,將sessionAffinity
設定為None ,只保留traefik
的annotaions。在使用
socket.io-redis
的時候一定要注意,在join
和leave
房間的時候一定要使用adapter
提供的remoteJoin
和remoteLeave
方法,不然多個節點間的資料同步有問題,這個被坑了好久