一个线上系统要经常迭代,有时是功能调整,有时是bug修复,无论是哪一种,都需要编译服务然后重新启动,在服务重启的过程中就回遇到这样的问题:
- 旧请求还没处理完服务就停止了,导致请求异常
- 新请求过来了,但是新服务还没启动,导致请求丢失
那么,如何才能更新服务且使请求无异常,在服务升级的过程中让用户无感呢?
从现有库说起
遇到这种问题的我们肯定不是第一个,这时候就想到了在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 接收连接,进一步增强与系统服务管理的兼容性。