go平滑升级必须用sigusr2触发监听器移交,仅调http.server.shutdown()会导致服务中断;新进程需通过fd继承复用端口,否则net.listen()报address already in use。

Go平滑升级必须用SIGUSR2触发监听器移交
不发SIGUSR2,只调http.Server.Shutdown(),服务必然中断。因为新进程根本没拿到监听器,端口被老进程占着,新进程net.Listen()会报address already in use。Linux 下唯一可靠的方式是老进程收到SIGUSR2后主动交出*os.File,新进程靠它重建net.Listener。
别用SIGHUP:systemd 默认拦截它;也别在容器里硬写exec.Command(os.Args[0], ...)——子进程大概率拿不到 FD,net.FileListener()直接返回invalid argument。
-
SIGUSR2必须由外部显式发送(如kill -USR2 $PID),不能靠信号自动注册后内部触发 - 老进程收到信号后,要立刻调用
listener.(*net.TCPListener).File(),不是os.Stdin.Fd()或环境变量读取 - 新进程启动时,FD 编号固定为
3(因0/1/2是 stdin/stdout/stderr),必须检查os.NewFile(3, "")是否成功 - Unix socket 场景下,还要手动
os.Chmod("/tmp/app.sock", 0666),否则新进程可能无权限 accept
ExtraFiles传递FD比写临时文件更安全
用cmd.ExtraFiles = []*os.File{file}传 FD 是标准库原生支持的跨平台方案;写临时文件(如/tmp/myapp-fd)看似简单,实则埋雷:权限错乱、清理遗漏、竞态冲突、容器内挂载点不可写——任一问题都会导致新进程启动失败。
关键细节在于listener.File()返回的是一个**复制的***os.File,它和原始 listener 共享底层 socket,但有自己的文件描述符生命周期。所以父进程必须在 exec 后显式Close()这个副本,否则 FD 泄漏,系统句柄耗尽。
- 父进程中:
defer listenerFile.Close()必须放在cmd.Start()之后,不能提前关 - 子进程拿到
os.NewFile(3, "")后,**不要**再Close()它——那是监听器的生命线 - 若监听多个端口(如
:80和:443),需分别调File()并按顺序塞进ExtraFiles切片,子进程按索引3、4…依次恢复 - Windows 不支持此机制,
ExtraFiles会被忽略,生产环境务必限定为 Linux
新进程必须验证FileListener.Addr()是否匹配原地址
常见错误是新进程调完net.FileListener(os.NewFile(3, ""))后,直接拿去http.Serve(),结果发现监听的是随机端口(比如127.0.0.1:34567),流量全丢。这是因为net.FileListener不保证复原后的地址与原始listener.Addr()一致——尤其当原始监听用net.Listen("tcp", ":8080")(未指定IP)时,FD 复原后可能绑定到localhost而非0.0.0.0。
必须在新进程启动后立即校验:
- 获取原始地址字符串(如
":8080"或"0.0.0.0:8080"),存入启动参数或环境变量传给子进程 - 子进程调
fileLn := net.FileListener(...)后,立刻执行if fileLn.Addr().String() != expectedAddr { os.Exit(1) } - 若不匹配,说明 FD 继承异常或内核行为变更,宁可启动失败,也不能让服务静默降级
Kubernetes中别自己fork,用Deployment滚动更新替代
在 K8s 里硬编码exec.Command启动子进程,99%会失败:容器 runtime(如 containerd)通常把应用进程包裹在sh -c里,SIGUSR2根本收不到;且容器命名空间对 FD 传递有额外限制,net.FileListener常返回invalid argument。
正确路径是放弃“单 Pod 内热替换”,改用 K8s 原生滚动更新能力:
-
readinessProbe必须真实探测 HTTP 监听器就绪(如 GET/healthz返回 200),不能只检查进程存在 -
preStophook 中发kill -TERM $PID即可,让老 Pod 走Shutdown()流程,不用SIGUSR2 - 新 Pod 启动时,**不要**尝试继承任何 FD,直接
net.Listen()——K8s 会确保新旧 Pod 网络隔离,端口不冲突 - 真正难处理的不是监听器,而是 DB 连接池、Redis client、gRPC 长连接这些资源:新 Pod 必须主动初始化,不能指望复用老进程 FD
最容易被跳过的不是代码逻辑,而是子进程对日志文件句柄的误继承——如果新进程没重开log.SetOutput(),它写的日志会往老进程打开的文件里追加,而那个文件可能已被 rotate 或删除,最终写入失败却无报错。











