go服务热重启核心是新旧进程交接监听socket:旧进程用http.server.shutdown()等待请求结束,新进程通过net.listener.file()提取并传递fd复用端口,配合sigusr2信号与extrafiles机制实现零中断切换。

Go 服务热重启不是“不重启”,而是让新旧进程交接监听 socket,旧进程等请求自然结束、新进程立刻接管新连接。核心就三件事:http.Server.Shutdown() 控制退出时机、net.Listener.File() 提取文件描述符、os.StartProcess(或 exec.Command 配 ExtraFiles)传递 fd 并拉起新进程。直接 os.Exit(0) 或 kill -9 必然触发 RST,客户端看到 connection reset by peer。
如何用标准库手写热重启逻辑
别依赖已归档的 endless 或 graceful,它们不兼容 Go 1.22+ 的 syscall 变更,且信号默认用 SIGHUP,而 Kubernetes 和 systemd 默认发 SIGTERM,收不到就卡死。
- 监听
syscall.SIGUSR2(Linux/macOS 通用),不是SIGHUP;SIGTERM留给正常关闭用 - 收到信号后,先调
ln.(*net.TCPListener).File()拿到*os.File,记下它的Fd()(通常是 3) - 用
exec.Command(os.Args[0], os.Args[1:]...)启动子进程,并把 fd 加进cmd.ExtraFiles切片里:比如[]*os.File{file} - 父进程立即调
server.Shutdown(),传入带超时的context.WithTimeout(context.Background(), 45*time.Second)—— 注意别用defer cancel(),否则 ctx 提前取消
子进程怎么复用父进程的监听端口
子进程启动后第一件事不是 net.Listen,而是从环境变量或 ExtraFiles 拿 fd,再还原成 net.Listener。否则会报 address already in use,因为父进程的 listener 还没完全释放(处于 TIME_WAIT 或 shutdown 中)。
- 如果用
ExtraFiles传 fd,子进程直接读os.Stdin.Fd()等是错的;要按顺序取:第 0 个 extra file 对应 fd=3,所以os.NewFile(3, "")→net.FileListener(file) - 如果用环境变量(如
LISTEN_FD=3),子进程启动时先fd, _ := strconv.ParseUint(os.Getenv("LISTEN_FD"), 10, 64),再os.NewFile(uintptr(fd), "") -
http.Server.Addr必须为空字符串(""),否则server.Serve(ln)会尝试重新 bind,报错
为什么新进程启动后仍可能丢请求
常见现象是 curl 偶尔失败、监控里 5xx 突增、lsof -i :8080 只看到一个 PID。根本原因不是代码没写对,而是系统级配置或 handler 内部没配合好。
- 监听 socket 必须启用
SO_REUSEPORT(Linux)或SO_REUSEADDR(macOS/BSD),否则内核不允许父子进程同时 bind 同一地址:创建 listener 后加一句ln.(*net.TCPListener).SetReusePort(true) -
http.Server.Shutdown()超时设太短(如 5 秒),大文件上传或慢查询会被强制中断;设太长(如 5 分钟),发布节奏拖慢,且旧 goroutine 占着内存 - handler 里用了
time.Sleep、db.Query、http.Do等阻塞操作,但没传入ctx(比如没用db.QueryContext(r.Context(), ...)),会导致 Shutdown 等满超时才返回
真正容易被忽略的是:子进程拿到 listener 后,不能立刻 server.Serve(ln),得等父进程完成 ln.Close()(它在 Shutdown() 内部自动触发);而 Shutdown() 返回只代表“不再 accept 新连接”,不代表 listener 已关——所以新进程 Serve 是安全的,但你得确保 handler 全链路尊重 r.Context(),否则 shutdown 流程永远卡在某个 goroutine 里。











