go长连接网关在k8s缩容时必须实现六层协同的优雅下线:prestop触发注册中心注销与endpoint同步,监听器关闭后遍历主动发websocket关闭帧并等待goroutine退出,shutdown超时需大于客户端重连间隔,且就绪探针须分离ready/shutting-down状态。

Go长连接网关在K8s缩容时,不主动管理连接生命周期,必然丢消息、断连接、触发客户端疯狂重连——这不是配置调优问题,是架构层缺失的关键闭环。
为什么 readinessProbe 不能代替优雅下线
readinessProbe 只控制流量是否继续打进来,完全不干预 Pod 终止流程。Kubelet 在 terminationGracePeriodSeconds 超时(默认30秒)后,直接发 SIGTERM,接着是 SIGKILL,不管你的 *websocket.Conn 还在不在收包。
常见错误现象:
- 客户端突然收到
1006错误或心跳超时断连 - 服务端日志里出现大量
use of closed network connection - 消息重复推送(旧连接未关完,新请求又打进来)
根本原因:net.Conn.Close() 被粗暴中断,FIN/ACK 握手没完成。必须在收到 SIGTERM 后立即停止 accept,并主动发关闭帧。
http.Server.Shutdown 单独调用为什么不够
srv.Shutdown(ctx) 只管 Go 服务端的监听器和连接池,不通知上游负载均衡或注册中心下线。Kubernetes Endpoint 更新有延迟(通常 1–3 秒),这期间新请求仍会被转发到正在关闭的 Pod。
实操建议:
- PreStop 阶段先调用
curl http://localhost:8080/healthz/shutting-down,再sleep 2s,确保 Endpoint 同步完成 - 若使用 Consul/Nacos,必须显式调用 deregister 接口,且等它返回成功后再执行
Shutdown - WebSocket/SSE 场景下,
Shutdown的 ctx 超时时间(如 30s)必须大于客户端最长重连间隔,否则会“刚关完又连上”,实则已丢包
连接级优雅关闭必须手动处理 WebSocket 协议细节
Go 的 http.Server 对 HTTP/1.1 长连接支持较好,但对 websocket.Conn 和 SSE 流式响应,Shutdown 会直接关闭底层 net.Conn,触发 RST,而不是按协议发送关闭帧。
正确做法是分三步逐层退出:
-
监听器层:调用
listener.Close(),让后续Accept()立即返回 error -
连接层:遍历所有活跃
*websocket.Conn,先conn.WriteMessage(websocket.CloseMessage, nil),再conn.Close() -
协程层:用
sync.WaitGroup或context.WithCancel管理所有读写 goroutine,确保它们收到信号后自行退出
示例关键逻辑片段:
func (s *Server) Shutdown(ctx context.Context) error {
s.listener.Close() // 停止接收新连接
close(s.shutdownCh) // 通知所有连接 goroutine 开始退出
return s.wg.Wait() // 等待所有连接处理完毕
}
就绪探针必须区分「可接入」和「可下线」两个状态
只用一个 readinessProbe 指向同一路径,滚动更新时容易出现新 Pod 还没注册好、老 Pod 就被切走流量的情况。
建议暴露两个独立 HTTP 端点:
-
/healthz/ready:判断是否完成注册、路由加载、后端连通,供readinessProbe使用 -
/healthz/shutting-down:由 PreStop 触发,返回503表示拒绝新连接,但保持旧连接继续服务
注意:livenessProbe 和 readinessProbe 绝对不能指向同一路径,否则高内存压力下健康检查误判会引发重启循环。
真正难的不是写 shutdown 逻辑,而是把监听器关闭、连接通知、协程退出、注册中心注销、Endpoint 同步、客户端重连节奏这六件事的时间窗口对齐——差 200ms,就可能丢一条心跳或一个 prompt。










