优雅重启与连接迁移:不断线发布

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 不会替你迁移。

与基础设施的时间对齐

组件参数建议值作用
K8spreStopsleep 10等 endpoints 摘除生效,再让进程收到 TERM
K8sterminationGracePeriodSeconds90大于"摘流 + 排空"总时长,否则 SIGKILL(exit 137)
K8s滚动策略maxUnavailable=0、按比例 maxSurge保证在线容量不塌
systemdTimeoutStopSec / KillSignal90 / SIGTERM停机等待,代码要处理 SIGTERM
nginxworker_shutdown_timeout30sreload/退出时强制关闭剩余连接的兜底
SLB连接排空(draining)开启,且时长 < 进程 grace让 SLB 先停止分配新连接

客户端侧配合(消息不丢的两件事)

  1. 收到 1012/1001 立即重连(不要走长退避),并带 last_msg_id / seq 补拉,服务端把未确认消息落库或转投,保证不丢不重;
  2. 重连加 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 137grace 小于排空总时长,被 SIGKILL调大 terminationGracePeriodSeconds 与 preStop
排空做不干净、老节点一直有连接新老进程同时 listen(SO_REUSEPORT)先停老进程监听,再让新进程接流量
新节点刚起就过载十万客户端零抖动同时重连客户端抖动 + 会话复用 + 灰度放量

小结:长连接发布的正确姿势是"摘流 → 发 Close(1012) → 等排空 → 退出",http.Server.Shutdown 管不了已 Hijack 的连接,必须自己在 Hub 里追踪并排空;时间线上 preStop 10s + drainWait 15s + grace 30s 要小于 terminationGracePeriodSeconds 90s,客户端收到 1012 立即带抖动重连、带 seq 补拉,才能做到发布期间零感知、消息不丢不重。

笔记加载中…