Go服务平滑热重启核心是父子进程协作:父进程用http.Server.Shutdown()等待请求结束,通过net.Listener.File()提取fd并经ExtraFiles传给子进程,子进程用net.FileListener复用端口;必须提前注册signal.Notify、屏蔽SIGUSR2防传播、阻塞主goroutine,并设合理Shutdown超时。

Go 服务用 signal 实现平滑热重启,核心不在信号注册本身,而在父子进程间 socket fd 的传递与 Shutdown 时机控制——漏掉任意一环,就会出现连接中断、端口占用或子进程启动失败。
为什么 signal.Notify 注册了却没触发热重启
常见现象是 kill -USR2 发出去,进程毫无反应,或者只打印日志但没启动新实例。根本原因不是信号没收到,而是:
• signal.Notify 必须在 http.Server.Serve 启动前调用,否则主 goroutine 已阻塞在 Accept,信号 channel 永远收不到值
• 没加互斥保护,连续两次 USR2 可能并发进入重启逻辑,导致两个子进程同时尝试接管同一个 listener
• 子进程启动后没检查环境变量(如 LISTEN_FDS 和 LISTEN_PID),直接走新建 listener 路径,结果报 bind: address already in use
如何安全传递 listener 文件描述符给子进程
Go 标准库不提供开箱即用的 fd 传递封装,必须手动配合 os/exec 和底层 syscall:
• 父进程用 listener.(*net.TCPListener).File() 获取 fd,再通过 cmd.ExtraFiles = []*os.File{f} 传入子进程
• 子进程启动时读取 os.Getenv("LISTEN_FDS") 判断是否继承 fd;若为 "1",则用 os.NewFile(3, "") 构造 *os.File,再转成 net.Listener
• fd 编号从 3 开始(0/1/2 是 stdin/stdout/stderr),不能硬写死;如果传多个 fd,需配合 LISTEN_FD_NAMES 解析名称
• systemd 下需显式配置 FileDescriptorStoreMax=1,否则 fd 不会自动注入到子进程环境
http.Server.Shutdown() 总是立即返回或卡死的三个原因
现象是 SIGTERM 后进程秒退,正在上传的文件被截断;或一直 hang 住不退出:
• 没设 context timeout:srv.Shutdown(context.WithTimeout(ctx, 10*time.Second)) 必须显式传入,否则默认无限等待
• handler 内部没响应 context:http.Request.Context() 没传给下游 DB 查询、HTTP client 或 time.Sleep,导致 Shutdown 等不到它们结束
• 忘记关闭非 HTTP 资源:Shutdown 只管 HTTP 连接,数据库连接池、消息队列消费者等必须单独调用 db.Close() 或 consumer.Stop(),且也要设超时
立即学习“go语言免费学习笔记(深入)”;
开发与生产环境信号选择差异
本地调试常用 os.Interrupt(Ctrl+C),但线上必须响应 syscall.SIGTERM:
• Kubernetes、systemd 默认发 SIGTERM,只监听 os.Interrupt 会导致容器被强制 kill
• macOS 对 SIGUSR2 限制严格(operation not permitted),开发机建议改用 SIGHUP,上线再切回 SIGUSR2
• 必须调用 signal.Ignore(syscall.SIGUSR2) 防止子进程也收到该信号,否则新老进程互相干扰
• signal.Notify(c, syscall.SIGTERM, os.Interrupt) 可以共用 channel,但内部逻辑要分支处理:SIGTERM 走完整 Shutdown,os.Interrupt 可跳过资源清理直接 exit
真正难的不是写几行 signal.Notify,而是确保 fd 传递不出错、Shutdown 能真正等完所有 goroutine、子进程启动时环境变量和工作目录完全一致——这些细节一旦漏掉,热重启就变成“假平滑”。


















