目录

Go-26 优雅重启与信号处理

1. 从一个问题说起

服务要发新版本,最粗暴的做法是 kill 掉旧进程再启动新的。但这中间存在一个空窗期:

  1. 进程被杀,正在处理的请求被硬生生中断,客户端收到连接重置;
  2. 新进程尚未 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 终止 用户自定义,overseerSIGUSR2 触发重启

关键点:SIGKILLSIGSTOP 无法被捕获,所以 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/httpServer 自带 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。于是流程是:

  1. 父进程收到重启信号(约定用 SIGHUPSIGUSR2);
  2. 父进程 fork 出子进程(用相同的启动命令),通过 ExtraFiles 把监听 socket 的 fd 传给子进程;
  3. 子进程用继承来的 fd 直接 Listen——此时父子进程同时在同一端口收请求
  4. 子进程启动就绪后给父进程发 SIGTERM,父进程停止接收新连接、等旧连接处理完(即优雅关闭)后退出;
  5. 升级完成,端口全程可用。

传递 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 触发 forkSIGINT/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 是其封装。
  • 云原生下,滚动更新 + 优雅关闭已取代单机平滑重启成为主流;平滑重启主要留给非编排的单实例部署。