Go-26 优雅重启与信号处理
1. 从一个问题说起
服务要发新版本,最粗暴的做法是 kill 掉旧进程再启动新的。但这中间存在一个空窗期:
- 进程被杀,正在处理的请求被硬生生中断,客户端收到连接重置;
- 新进程尚未
Listen成功,这段时间内到达的请求全部失败。
我们希望做到两件事:优雅关闭(graceful shutdown)——退出前把手头的请求处理完;以及优雅重启(graceful restart / 平滑重启)——升级过程中服务不中断、连接不丢。二者都建立在「进程信号」这块基石之上。
2. 进程信号基础
信号(signal)是操作系统通知进程「发生了某件事」的软中断机制。进程收到信号后,会中断正常流程,转去执行对应的信号处理函数,处理完再回到中断点。与服务治理相关的常用信号:
| 信号 | 编号 | 默认行为 | 典型用途 |
|---|---|---|---|
SIGINT |
2 | 终止 | 终端 Ctrl+C |
SIGTERM |
15 | 终止 | kill、K8s 停止容器的默认信号,优雅关闭的入口 |
SIGKILL |
9 | 强制终止 | kill -9,不可捕获、不可忽略 |
SIGHUP |
1 | 终止 | 终端挂断;常被约定为「重载配置/平滑重启」 |
SIGUSR1/SIGUSR2 |
10/12 | 终止 | 用户自定义,overseer 用 SIGUSR2 触发重启 |
关键点:SIGKILL 和 SIGSTOP 无法被捕获,所以 kill -9 一定会让进程立即消失——优雅逻辑对它无效。这也是 K8s 先发 SIGTERM、宽限期(terminationGracePeriodSeconds)过后才补 SIGKILL 的原因。
3. Go 中的 signal.Notify
Go 通过在 channel 上投递 os.Signal 来传递信号。signal.Notify 把指定信号「订阅」到一个 channel:
func main() {
sigs := make(chan os.Signal, 1) // 带缓冲,避免信号丢失
done := make(chan bool, 1)
// 监听 SIGINT 和 SIGTERM
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs // 阻塞等待信号
fmt.Println("\n收到信号:", sig)
done <- true
}()
fmt.Println("awaiting signal")
<-done
fmt.Println("exiting")
}
当操作系统把信号投递给进程,Go runtime 会把对应的 os.Signal 值写入 sigs channel。channel 一定要带缓冲(容量至少 1),否则在没有接收者就绪的瞬间信号可能被丢弃。现代写法更推荐 signal.NotifyContext,它把信号直接转成 context 取消,与下面的 Shutdown 天然契合。
4. 优雅关闭:http.Server.Shutdown
Go 1.8 起,net/http 的 Server 自带 Shutdown 方法,实现优雅关闭:它停止接收新连接,但等待已建立的连接把当前请求处理完(在给定的超时内),再返回。
标准范式是「主 goroutine 起服务,等信号后调 Shutdown」:
func main() {
srv := &http.Server{Addr: ":8080", Handler: newHandler()}
// 用 context 承接信号,收到 SIGINT/SIGTERM 即取消
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 在独立 goroutine 中启动服务
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
log.Println("server started on :8080")
<-ctx.Done() // 阻塞直到收到信号
log.Println("shutting down...")
// 给正在处理的请求最多 10s 收尾
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("forced shutdown: %v", err)
}
log.Println("server exited")
}
注意 ListenAndServe 在被 Shutdown 后会返回 http.ErrServerClosed,这是正常退出,需要显式排除,不能当错误处理。至此我们解决了「退出前不丢在途请求」,但升级期间仍有服务空窗——这需要优雅重启。
5. 优雅重启原理:fork 子进程 + 继承 listener fd
优雅重启的核心诉求是:升级过程中监听端口一刻都不中断。做不到「先关端口再重开」,因为中间会拒绝连接。答案是让新旧两个进程在一段时间内共用同一个监听 socket。
Linux 下,子进程可以继承父进程打开的文件描述符(fd),而监听 socket 本质就是一个 fd。于是流程是:
- 父进程收到重启信号(约定用
SIGHUP或SIGUSR2); - 父进程
fork出子进程(用相同的启动命令),通过ExtraFiles把监听 socket 的 fd 传给子进程; - 子进程用继承来的 fd 直接
Listen——此时父子进程同时在同一端口收请求; - 子进程启动就绪后给父进程发
SIGTERM,父进程停止接收新连接、等旧连接处理完(即优雅关闭)后退出; - 升级完成,端口全程可用。
传递 fd 的关键代码:
// netListener 是当前的 *net.TCPListener
file, _ := netListener.File() // 返回该 socket 的一份 dup 副本
cmd := exec.Command(os.Args[0], "-graceful") // 相同启动命令
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{file} // 子进程可通过约定的 fd 号访问它
if err := cmd.Start(); err != nil {
log.Fatalf("重启失败: %v", err)
}
ExtraFiles 里的文件会依次排在标准输入/输出/错误(fd 0/1/2)之后,也就是从 fd 3 开始,子进程用 os.NewFile(3, "") 即可拿回这个监听 socket。
netListener.File() 底层通过 dup 系统调用复制出一份 fd:
func Dup(oldfd int) (fd int, err error) {
r0, _, e1 := Syscall(SYS_DUP, uintptr(oldfd), 0, 0)
fd = int(r0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}
6. endless 源码剖析
github.com/fvbock/endless 把上面这套流程封装成了 gin/http 的替代入口。用法几乎零成本——把 http.ListenAndServe 换成 endless.ListenAndServe 即可:
func main() {
engine := gin.Default()
engine.GET("/hello", func(c *gin.Context) {
time.Sleep(40 * time.Second) // 模拟长耗时请求
c.JSON(http.StatusOK, "v1")
})
if err := endless.ListenAndServe("localhost:8080", engine); err != nil {
log.Println(err)
}
log.Println("Server on 8080 stopped")
}
验证平滑重启:请求进入后(长耗时未返回时)改代码重新 go build,再 kill -1 <pid>(发 SIGHUP),旧请求仍返回旧结果 v1,之后的新请求由新进程返回 v2,端口全程不中断。
入口逻辑:ListenAndServe 里异步起信号处理,拿到监听后判断自己是不是子进程——是则立刻通知父进程可以退了:
func (srv *endlessServer) ListenAndServe() (err error) {
go srv.handleSignals() // 异步处理信号
l, err := srv.getListener(srv.Addr) // 子进程会从继承的 fd 取 listener
if err != nil {
return err
}
srv.EndlessListener = newEndlessListener(l, srv)
if srv.isChild {
// 子进程就绪,通知父进程优雅退出
syscall.Kill(syscall.Getppid(), syscall.SIGTERM)
}
srv.BeforeBegin(srv.Addr)
return srv.Serve()
}
信号分发:handleSignals 是一个 for 循环,按信号类型分派——SIGHUP 触发 fork,SIGINT/SIGTERM 触发 shutdown,处理前后还留了 hook 扩展点:
func (srv *endlessServer) handleSignals() {
signal.Notify(srv.sigChan, hookableSignals...)
pid := syscall.Getpid()
for {
sig := <-srv.sigChan
srv.signalHooks(PRE_SIGNAL, sig)
switch sig {
case syscall.SIGHUP: // 平滑重启
log.Println(pid, "Received SIGHUP. forking.")
if err := srv.fork(); err != nil {
log.Println("Fork err:", err)
}
case syscall.SIGINT, syscall.SIGTERM: // 优雅停机
log.Println(pid, "Received", sig)
srv.shutdown()
}
srv.signalHooks(POST_SIGNAL, sig)
}
}
shutdown 内部正是调用了 http.Server 的优雅关闭并等待在途连接,与第 4 节一脉相承。
7. overseer:更进一步
github.com/jpillora/overseer 思路类似但更完善:它引入一个常驻的 master 进程专门管理 fork/exec,业务代码跑在被它监督的 slave 进程里;重启用 SIGUSR2 触发,还支持从远端拉取新二进制做自升级、失败自动回滚。相比 endless「进程自己 fork 自己」,overseer 的 master/slave 模型让升级与回滚更可控。
8. 云原生时代:平滑重启还需要吗?
必须客观说明:fork+继承 fd 这套平滑重启,是「单机、进程原地升级」时代的产物,它解决的是「同一台机器上换二进制不断服」。而在 K8s / 云原生下,主流做法已经变了:
- 滚动更新(Rolling Update):K8s 不在原 Pod 内换二进制,而是新建新版本 Pod、就绪后再销毁旧 Pod,配合 Service/负载均衡把流量平滑迁移。多副本天然保证升级期间总有实例在服务,根本不需要单进程内的 fork 重启。
- 优雅关闭仍然关键:滚动更新时,旧 Pod 会收到
SIGTERM,此时第 4 节的Shutdown逻辑至关重要——它保证旧 Pod 在terminationGracePeriodSeconds宽限期内把在途请求处理完再退出,配合preStop钩子和就绪探针摘流量,实现零丢包升级。
结论:在容器编排环境里,优雅关闭是标配、平滑重启已非必需——负载均衡 + 多副本滚动更新在集群层面提供了同等甚至更强的无损升级能力。endless/overseer 这类方案的适用场景,如今更多收敛到裸机/虚拟机单实例部署、不便引入编排的存量系统。理解其原理仍有价值(fd 继承、信号协作是操作系统级的通用知识),但技术选型时要认清它所处的时代背景。
9. 小结
- 信号是进程级软中断;
SIGTERM是优雅关闭入口,SIGKILL不可捕获。 - Go 用
signal.Notify(带缓冲 channel)或signal.NotifyContext接收信号。 - 优雅关闭:
http.Server.Shutdown停收新连接、等在途请求完成,ErrServerClosed是正常退出。 - 优雅重启:
fork子进程并通过ExtraFiles继承监听 socket 的 fd,父子共用端口交接,endless/overseer是其封装。 - 云原生下,滚动更新 + 优雅关闭已取代单机平滑重启成为主流;平滑重启主要留给非编排的单实例部署。
xingliuhua