水平扩展:Kubernetes、Sticky Sessions 和 Redis
Source: Dev.to
介绍
对无状态 HTTP 应用进行扩展是一个成熟的方案:启动更多 pod,在前面放置负载均衡器,并使用轮询算法。如果某个 pod 失效,后续请求会直接路由到其他健康实例。然而,使用 WebSockets 的实时应用——尤其是基于 Flask‑SocketIO 构建的应用——从根本上打破了这一范式。
WebSockets 依赖于长连接、状态化的 TCP 连接。一旦客户端连接到服务器进程,该进程就会持有该套接字的文件描述符以及该用户的内存上下文(房间、会话数据)。如果你仅仅把 Flask‑SocketIO 容器复制到 Kubernetes 中的十个 pod,系统将在部署后立刻失效。

这种失败的根本原因在于标准的水平扩展模型没有考虑到 Socket.IO 协议的双重需求:
- 握手期间的连接持久性
- 连接建立后的分布式事件传播
要有效地扩展 Flask‑SocketIO,必须超越单服务器的思维,采用分布式架构——利用 Kubernetes Ingress 实现会话亲和(session affinity),并使用 Redis 作为发布/订阅消息总线。
有状态问题:为什么轮询(Round‑Robin)会失效
要理解标准负载均衡为何会失效,我们必须查看 Socket.IO 协议的协商过程。与原始 WebSocket 不同,Socket.IO 并不会立即建立 WebSocket 连接。它通常先使用 HTTP 长轮询(long‑polling),以确保在受限的代理环境中仍能兼容并保持稳健的连接。
握手序列如下:
握手请求(Handshake Request): POST /socket.io/?EIO=4&transport=polling
服务器会返回会话 ID(sid)以及连接间隔等信息。
轮询请求(Poll Request): GET /socket.io/?EIO=4&transport=polling&sid=...
升级请求(Upgrade Request): 客户端发送 Upgrade: websocket 头部以切换协议。
在没有会话亲和性(session affinity)的轮询(Round‑Robin)Kubernetes 环境中,握手请求 可能会被路由到 Pod A,该 Pod 生成会话 ID(例如 abc-123)并将其存储在本地内存中。随后 轮询请求 可能会被 Service 路由到 Pod B。Pod B 的内存中没有 abc-123 的记录,于是它会以 400 Bad Request 或 {"code":1,"message":"Session ID unknown"} 错误拒绝该请求。
即使连接成功升级为 WebSocket(这会将 TCP 连接锁定到单个 Pod),系统在广播(broadcast)方面仍然是破碎的。如果用户 A 连接到 Pod A,用户 B 连接到 Pod B,并且他们都在同一个聊天房间 room_1 中,用户 A 发送的消息只会存在于 Pod A 的内存中。Pod B 永远不会知道需要将该消息转发给用户 B。
粘性会话:配置 Ingress‑Nginx
解决握手失败的方法是 会话亲和性,通常称为“粘性会话”。这可以确保一旦客户端与特定 Pod 发起握手,随后该客户端的所有请求都会被路由到同一个 Pod。

在 Kubernetes 中,这通常在 Ingress 控制器层面处理,而不是 Service 层(Service 提供 sessionAffinity: ClientIP,但在 NAT 环境下常常不可靠)。对于 ingress‑nginx——许多集群使用的标准控制器——粘性是通过 基于 Cookie 的亲和性 实现的。
通过注解进行配置
在你的 Ingress 资源中添加以下注解,以注入路由 Cookie:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: socketio-ingress
annotations:
# 启用基于 Cookie 的亲和性
nginx.ingress.kubernetes.io/affinity: "cookie"
# 发送给客户端的 Cookie 名称
nginx.ingress.kubernetes.io/session-cookie-name: "route"
# 关键:使用 “persistent” 模式防止活动会话被重新平衡
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
# 哈希算法(sha1、md5 或 index)
nginx.ingress.kubernetes.io/session-cookie-hash: "sha1"
# 有效期(应与你的 socket.io ping 超时逻辑保持一致)
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
spec:
rules:
- host: socket.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: socketio-service
port:
number: 5000
“持久” 与 “平衡” 陷阱
一个常见的配置错误是忽略了 affinity-mode。默认情况下,或者当其设置为 balanced 时,Nginx 可能会在 Pod 数量上下扩容时重新分配会话以平衡负载。对于无状态应用这没有问题,但对于 WebSocket 来说会导致连接中断。设置
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
可确保 Nginx 即使在 Pod 分布变化时也遵循 cookie,从而在可能出现负载不均的代价下,保持 WebSocket 连接的稳定性。
Redis后端总线:分布式事件传播
Sticky sessions 解决 handshake 问题,但它们并未解决 跨 pod 事件广播 的需求。要将事件(例如聊天消息、通知)传播到所有已连接的客户端,无论它们附属于哪个 pod,都需要一个共享的消息总线。Redis 作为 pub/sub 后端总线,满足此角色:
- 每个 Flask‑SocketIO 实例向 Redis 频道发布事件。
- 所有实例订阅同一频道,接收来自同伴的事件。
- Redis 服务器可以部署为 StatefulSet,拥有稳定的 DNS 名称(例如
redis-master.default.svc.cluster.local),并可选地通过密码或 TLS 进行保护。
通过将 sticky sessions(用于连接持久性)与 Redis 后端总线(用于分布式事件传播)相结合,您可以在 Kubernetes 上实现真正可扩展的 Flask‑SocketIO 部署。
连接问题,但它们产生了新的隔离问题
用户现在被分散在不同的服务器上。要让 User A(在 Pod 1)向 User B(在 Pod 2)发送消息,我们需要一个 backplane——一种桥接 Flask 进程之间隔离内存空间的机制。

Flask‑SocketIO 通过 Message Queue 解决了这个问题,Redis 是性能最佳且最常用的选择。这实现了 Pub/Sub(发布/订阅) 模式。
内部工作原理
当你使用 Redis 消息队列配置 Flask‑SocketIO 时:
| 步骤 | 描述 |
|---|---|
| 订阅 | 每个 Flask‑SocketIO 工作进程都会建立到 Redis 的连接,并订阅一个特定的频道(通常是 flask-socketio)。 |
| 发射 | 当 Pod A 中的代码执行 emit('chat', msg, room='lobby') 时,它 不会 遍历自己的客户端列表,而是向 Redis 发布一条消息,内容为 “将 chat 发送到 lobby”。 |
| 分发 | Redis 将此消息推送给所有其他已订阅的 Flask 工作进程(Pod B、Pod C,……)。 |
| 扇出 | 每个 pod 接收到 Redis 消息后,会在本地内存中查找 lobby 中的客户端,并通过它们打开的 WebSocket 连接转发该消息。 |
这种架构将事件的产生与事件的传递解耦。
安装:设置 Redis 和 Flask‑SocketIO
实现此功能需要安装 Redis 服务器(通常通过 Kubernetes 中的 Helm Chart,例如 bitnami/redis),并将 Python 应用程序配置为使用它。
依赖
pip install flask-socketio redis
注意: 如果你使用
eventlet或gevent作为异步工作者,请确保 Redis 客户端兼容 monkey‑patch,或使用支持该特性的驱动程序。标准的redis-py在正确打补丁后可良好配合近期版本的eventlet。
应用配置
将连接字符串传递给 SocketIO 构造函数。这是将单节点内存存储切换为分布式 Redis 存储所需的唯一代码更改。
from flask import Flask
from flask_socketio import SocketIO
app = Flask(__name__)
# `message_queue` 参数启用 Redis 后端。
# 在 Kubernetes 中,`redis-master` 通常是服务的 DNS 名称。
socketio = SocketIO(
app,
message_queue='redis://redis-master:6379/0',
cors_allowed_origins="*"
)
@socketio.on('message')
def handle_message(data):
# 现在此 emit 通过 Redis 广播到所有 pod
socketio.emit('response', data)
常见错误: 除非你在自定义底层 Engine.IO 实现,否则 不要 手动使用 client_manager 参数。message_queue 参数是高级包装器,会自动配置 RedisManager。
Source: …
权衡:延迟与瓶颈
虽然此架构支持水平扩展,但它会带来必须监控的特定工程权衡。
延迟引入
在单节点部署中,emit 是一次直接的内存操作。 在分布式部署中,每一次广播都要经过一次到 Redis 的网络往返。
Client → Pod A → Redis → Pod B → Client
- 这会在每一跳增加个位数毫秒的延迟(通常 1–5 ms)。
- 网络拥塞或 Redis 过载会导致延迟显著上升。
生产检查清单
| 层级 | 要求 |
|---|---|
| 层 1(Ingress) | 必须 启用粘性会话(cookie 亲和)以确保握手完成。 |
| 层 2(App) | 必须 配置 Redis 消息队列,以桥接相互隔离的工作进程。 |
- Ingress 配置亲和性:
cookie和affinity-mode: persistent。 - 部署 Redis(若仅用于 Pub/Sub 性能,建议禁用持久化;若还需要存储功能,则启用持久化)。
- 使用
message_queue='redis://...'初始化 Flask‑SocketIO。 - 配置监控,关注 Redis CPU、网络延迟以及 WebSocket 连接健康状况。
- 在应用入口点的最顶部应用 Gevent/Eventlet 的 monkey‑patch。
通过实现此架构,你可以将 Flask‑SocketIO 从开发玩具转变为能够处理数万并发连接的稳健、可扩展的实时平台。