Posted in

Golang HTTP服务如何平滑重启

一个线上系统要经常迭代,有时是功能调整,有时是bug修复,无论是哪一种,都需要编译服务然后重新启动,在服务重启的过程中就回遇到这样的问题:

  1. 旧请求还没处理完服务就停止了,导致请求异常
  2. 新请求过来了,但是新服务还没启动,导致请求丢失

那么,如何才能更新服务且使请求无异常,在服务升级的过程中让用户无感呢?

从现有库说起

遇到这种问题的我们肯定不是第一个,这时候就想到了在GITHUB,在GITHUB上搜索平滑重启(graceful),star最多的是facebook的grace,根据官方提供的示例:

// Command gracedemo implements a demo server showing how to gracefully
// terminate an HTTP server using grace.
package main

import (
    "flag"
    "fmt"
    "net/http"
    "os"
    "time"

    "github.com/facebookgo/grace/gracehttp"
)

var (
    address0 = flag.String("a0", ":48567", "Zero address to bind to.")
    now      = time.Now()
)

func main() {
    flag.Parse()
    gracehttp.Serve(
        &http.Server{Addr: *address0, Handler: newHandler("Zero  ")},
    )
}

func newHandler(name string) http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("/sleep/", func(w http.ResponseWriter, r *http.Request) {
        duration, err := time.ParseDuration(r.FormValue("duration"))
        if err != nil {
            http.Error(w, err.Error(), 400)
            return
        }
        time.Sleep(duration)
        fmt.Fprintf(
            w,
            "%s started at %s slept for %d nanoseconds from pid %d.\n",
            name,
            now,
            duration.Nanoseconds(),
            os.Getpid(),
        )
    })
    return mux
}

运行程序,会启动一个端口为<code>48567 </code>的服务,终端输出:

 2025/09/28 19:07:33 Serving [::]:48567 with pid 14642.

发起一个耗时30s的请求(如代码所示,duration代表请求耗时):

 curl http://localhost:48567/sleep/?duration=30s

向程序发送平滑重启信号

 kill -USR2 14642

发起一个耗时1s的请求(重启信号和短请求必须要在第一个请求结束之前)

 curl http://localhost:48567/sleep/?duration=1s

这时候的现象因该是两次请求打印出来的pid不一样,程序实现了平滑重启:

在新服务启动之前,由旧服务处理请求,当新服务启动后,新的请求由新服务处理,而未处理完的请求由旧服务继续处理,当旧请求全部处理完成之后,旧服务退出

原理

grace为我们实现了这个功能,那么它是如何实现的呢?让我们从源码开始解析:

优雅重启的核心思路

当需要重启服务器时,新进程会继承旧进程的监听端口(文件描述符),继续处理新请求;而旧进程会等待现有连接处理完毕后再退出,从而实现 “无缝切换”。

关键技术点

(1)文件描述符继承
监听端口复用:服务器启动时,会创建监听端口的文件描述符(socket)。重启时,旧进程通过环境变量(LISTEN_FDS)将这些文件描述符传递给新进程。
继承机制:新进程启动时读取 LISTEN_FDS 环境变量,获取继承的文件描述符,并将其转换为可用的 net.Listener,从而复用旧进程的监听端口,避免端口冲突。
相关代码(gracenet/net.go):

// 新进程继承旧进程的文件描述符
func (n *Net) inherit() error {
    countStr := os.Getenv(envCountKey) // 从环境变量获取继承的文件描述符数量
    if countStr == "" {
        return nil
    }
    // 解析数量并转换为可用的Listener
    count, _ := strconv.Atoi(countStr)
    for i := fdStart; i < fdStart+count; i++ {
        file := os.NewFile(uintptr(i), "listener")
        l, _ := net.FileListener(file) // 将文件描述符转换为Listener
        n.inherited = append(n.inherited, l)
    }
}

(2)信号处理
SIGUSR2 触发重启:向旧进程发送 SIGUSR2 信号时,旧进程会启动新进程(通过 os.StartProcess),并将监听文件描述符传递给新进程。
SIGTERM 触发终止:收到 SIGTERM 信号后,旧进程会停止接受新连接,等待现有连接处理完毕后退出。
相关代码(gracehttp/http.go):

func (a *app) signalHandler(wg *sync.WaitGroup) {
    ch := make(chan os.Signal, 10)
    signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR2)
    for {
        sig := <-ch
        switch sig {
        case syscall.SIGUSR2:
            // 启动新进程并传递文件描述符
            a.net.StartProcess() 
        case syscall.SIGTERM:
            // 停止旧进程,等待连接处理完毕
            a.term(wg) 
            return
        }
    }
}

(3)新旧进程协作
新进程启动后:会通过 LISTEN_FDS 继承旧进程的监听端口,开始接受新请求。
旧进程退出前:会等待所有现有连接处理完毕(通过 httpdown.Server.Wait()),确保不中断正在处理的请求。
示例流程(来自 readme.md):
旧进程处理慢请求(如 20s 延迟)。
发送 SIGUSR2 触发重启,新进程启动并继承端口。
新进程处理后续的短请求,旧进程继续处理未完成的慢请求。
旧进程处理完所有请求后退出,新进程成为主进程。

与 systemd 兼容

通过模拟 systemd 的 socket 激活 机制,支持服务器在首次启动或重启时通过外部管理的 socket 接收连接,进一步增强与系统服务管理的兼容性。