优雅重启与连接迁移:不断线发布
HTTP 服务发布时"先摘流量、再停进程"就够了,长连接不行:进程一退出,十万条连接同时断,客户端一拥而上重连,新节点刚起就被握手洪峰打死。优雅发布的本质不是"让连接不断",而是分阶段驱赶连接——让客户端在可控节奏下重连到新节点,并且一条消息不丢。
目标与顺序
要看住的三个指标:发布期间的断线可感知率(客户端是否收到 1006 异常关闭)、重连风暴幅度(新节点握手峰值)、消息不丢不重(靠 seq 补拉兜底)。
正确的动作顺序只有一种:先摘流量 → 再通知客户端(发 Close 帧)→ 最后关进程;任何"先关进程再摘流量"的变体,都会产生 502 或异常断线。
原理:为什么 http.Server.Shutdown 管不了
http.Server.Shutdown 会停止接受新连接、等待活跃请求结束、关闭空闲连接。但 WebSocket 在升级时已经调用 Hijack() 把底层连接从 http.Server 手里接管了,这条连接在 http.Server 眼里既不是空闲连接也不是活跃请求——Shutdown 不会碰它,只能等你自己的代码去关。
所以长连接服务必须自己维护连接注册表(第 05 章的 Hub),并在退出时主动发 Close 帧(close code 1012 Service Restart,或 1001 Going Away)。客户端收到 1012 就知道是服务端主动甩锅,应当立刻重连;收到 1006 则是连接被异常撕断,业务上通常要告警。
三阶段时间轴
T+0 健康检查返回 503 / readiness 探针失败,注册中心下线 → LB 开始摘流
T+0~15s 等待摘流生效(K8s endpoints 同步 + kube-proxy 规则下发 + SLB 收敛)
T+15s 停止监听(ln.Close),向存量连接发 Close(1012)
T+15~45s 排空窗口:客户端重连到新节点,未确认消息落库/转投;到点强关残余连接并退出
建议值:drainWait=15s、排空窗口 grace=30s(覆盖客户端最长 30s 的一轮重连退避)、systemd TimeoutStopSec=90s、K8s terminationGracePeriodSeconds=90s。
Go 实现
type Hub struct {
mu sync.RWMutex
conns map[uint64]*Conn
closing atomic.Bool
}
// 关闭中不再接受新连接,直接回 503,让 LB 把流量给新节点
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
if h.closing.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
// ... Upgrader.Upgrade 之后把连接注册进 conns
}
// 排空:先发 Close 帧,再等客户端回帧或超时,最后强关
func (h *Hub) Shutdown(ctx context.Context) {
h.closing.Store(true)
var wg sync.WaitGroup
for _, c := range h.snapshot() { // 在锁内拷贝快照,发帧时不要持锁
wg.Add(1)
go func(c *Conn) {
defer wg.Done()
// 必须发 Close 帧(不是直接关 TCP),客户端才能拿到 1012 走"立即重连"分支
_ = c.ws.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseServiceRestart, "restart"),
time.Now().Add(3*time.Second))
select {
case <-c.closed: // 读循环收到对端 close 帧后关闭该 channel
case <-ctx.Done(): // 超时强关
}
c.Close()
}(c)
}
wg.Wait()
}
主流程把顺序固定下来:
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
deregister() // 1. 从注册中心/健康检查下线,让 LB 摘流
time.Sleep(15 * time.Second) // 2. 等摘流生效(K8s 里用 preStop 完成这一步)
ln.Close() // 3. 停止监听,不再接新连接
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = srv.Shutdown(ctx) // 4. 关普通 HTTP 连接(已 Hijack 的 WS 不受它管理)
hub.Shutdown(ctx) // 5. 排空十万条长连接:发 1012 → 等回帧 → 超时强关
连接迁移的两条路线
| 路线 | 做法 | 适用与代价 |
|---|---|---|
| 排空重连(drain & reconnect) | 摘流 → 发 1012 → 客户端带 seq 重连到新节点 | 简单可靠,几乎零风险;要求客户端有重连与补拉(第 07 章) |
| 连接迁移(handover) | 老进程保留 TCP 连接,把帧转发给新进程;或传递 listen fd 平滑交接 | 连接不断但实现复杂,仅在"不允许暂停"的场景用 |
链路要有两条硬知识:SO_REUSEPORT 不能让新旧进程"无缝"分担新连接——只要老进程还在 listen,内核就会把一部分新连接按哈希分给它,必须先停老进程的监听,否则排空永远做不干净;systemd 的 reload/SIGHUP 只适合无状态服务,长连接属于存活中的会话,reload 不会替你迁移。
与基础设施的时间对齐
| 组件 | 参数 | 建议值 | 作用 |
|---|---|---|---|
| K8s | preStop | sleep 10 | 等 endpoints 摘除生效,再让进程收到 TERM |
| K8s | terminationGracePeriodSeconds | 90 | 大于"摘流 + 排空"总时长,否则 SIGKILL(exit 137) |
| K8s | 滚动策略 | maxUnavailable=0、按比例 maxSurge | 保证在线容量不塌 |
| systemd | TimeoutStopSec / KillSignal | 90 / SIGTERM | 停机等待,代码要处理 SIGTERM |
| nginx | worker_shutdown_timeout | 30s | reload/退出时强制关闭剩余连接的兜底 |
| SLB | 连接排空(draining) | 开启,且时长 < 进程 grace | 让 SLB 先停止分配新连接 |
客户端侧配合(消息不丢的两件事)
- 收到 1012/1001 立即重连(不要走长退避),并带
last_msg_id/seq补拉,服务端把未确认消息落库或转投,保证不丢不重; - 重连加 0~3s 随机抖动,把十万个客户端的"同时"摊成斜坡,配合第 14 章的会话复用,握手洪峰才不会打垮新节点。
验证
systemctl stop im-server; time systemctl start im-server # 明显快于 TimeoutStopSec 才算做对
journalctl -u im-server -n 50 --no-pager | grep -iE 'drain|close' # 排空日志与 1012 数量
ss -tn state established '( sport = :8081 )' | wc -l # 发布期间连接数应呈斜坡下降
客户端侧的验收更直接:用压测脚本持续连接并在断线时打印 close code,发布过程中不应出现 1006,只应出现 1012,且重连成功率 100%。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 客户端普遍报 1006 | 只关了监听/进程,没发 Close 帧 | 排空前先发 1012,再等回帧或超时 |
| 发布瞬间大量 502 | 先停进程、后摘流量 | 顺序固定:摘流 → 通知 → 退出 |
| K8s 里 exit 137 | grace 小于排空总时长,被 SIGKILL | 调大 terminationGracePeriodSeconds 与 preStop |
| 排空做不干净、老节点一直有连接 | 新老进程同时 listen(SO_REUSEPORT) | 先停老进程监听,再让新进程接流量 |
| 新节点刚起就过载 | 十万客户端零抖动同时重连 | 客户端抖动 + 会话复用 + 灰度放量 |
小结:长连接发布的正确姿势是"摘流 → 发 Close(1012) → 等排空 → 退出",http.Server.Shutdown 管不了已 Hijack 的连接,必须自己在 Hub 里追踪并排空;时间线上 preStop 10s + drainWait 15s + grace 30s 要小于 terminationGracePeriodSeconds 90s,客户端收到 1012 立即带抖动重连、带 seq 补拉,才能做到发布期间零感知、消息不丢不重。