golang服务的优雅关闭和重启
服务的优雅关闭(Graceful Shutdown)是指,当服务需要停止时,它不是被“一刀切”地瞬间杀死,而是会先完成当前正在处理的任务、释放占用的资源后,再自行有序地退出。
优雅重启(Graceceful Restart)本质上是“优雅关闭”后紧跟着一个“优雅启动”的过程,常用于服务更新或配置重载,目标是在整个过程中不中断或尽可能少地影响对外服务。
优雅关闭和重启解决了三个核心问题:
- 数据完整性 (Data Integrity):避免因突然中断导致数据只写了一半,造成数据库或文件损坏。比如,一个用户转账操作,钱扣了,但还没加到对方账户上,服务就停了。
- 用户体验 (User Experience):对于正在与系统交互的用户来说,他们的请求会被正常处理完毕,而不是突然收到一个“Connection Reset by Peer”的错误。这对于 API 服务尤其重要。
- 系统稳定性与资源管理 (System Stability & Resource Management):确保服务在退出前能正确释放如数据库连接、文件句柄、网络端口等关键资源,防止资源泄露,影响整个操作系统的稳定性。
1. 优雅关闭
以一个典型的 Go Web 服务为例:
1 | [开始] --> [服务正常运行] |
1.1 Golang 优雅关闭 Http Server
参考:https://github.com/gin-gonic/examples/tree/master/graceful-shutdown
1 | func main() { |
1.2 Http Server 的 shutdown 方法
go 在1.8后增加了http server 的shutdown方法,内部其实会调用注册的shutdown函数钩子。
1 | func (srv *Server) Shutdown(ctx context.Context) error { |
1.3 其他依靠 WithCancel(ctx)
1 | package main |
1.4 使用注意
务必设置超时 (Timeout):优雅关闭不能无限期地等待。如果某个请求因为 bug 而卡住,整个服务就永远无法退出。
context.WithTimeout
是你的好朋友,它为优雅关闭设定了一个“最后的底线”。信号传播 (Signal Propagation):一个应用不仅有 HTTP 服务,可能还有消息队列消费者、定时任务等多个长期运行的 goroutine。你需要一个统一的机制(context.Context 是最佳选择)将关闭信号传递给所有这些组件,并使用 sync.WaitGroup 来等待它们全部完成。
正确处理信号:主要关注 SIGINT (中断信号, Ctrl+C) 和 SIGTERM (终止信号, kill 命令默认发送的信号,也是 Kubernetes 等编排工具的首选)。
SIGKILL (kill -9) 是无法被程序捕获的,它会直接由操作系统内核强制杀死进程,让你的所有优雅关闭逻辑都失效。
2. 优雅重启
优雅重启是实现零停机部署的关键一环,可以研究蓝绿部署、金丝雀发布等策略。优雅重启的目标是:在更新服务程序(代码或配置)时,服务能力不中断,即实现零停机部署。
它本质上是 旧进程的优雅关闭 + 新进程的无缝接替。
最大的挑战在于:端口是独占资源。在一个标准的网络模型中,一个端口在同一时间只能被一个进程监听。如果你先停掉旧进程,再启动新进程,中间就会有一段“空窗期”,导致请求失败。如果你先启动新进程,它会因为端口已被旧进程占用(address already in use
)而启动失败。
注意:优雅重启方式端口用的是同一个,但是 PID 肯定是不一样的。优雅重启的“魔法”在于无缝地交接“监听端口”这个职责,而不是“复用进程”。进程本身是一次性的,总是在被替换。
2.1 经典 fork
& exec
方式
这是最传统、最经典的 Unix/Linux 方式。它的核心思想是利用进程的父子关系和文件描述符继承的特性。
“一句话”类比:
想象一场接力赛。旧进程(运动员A)不是把接力棒扔在地上让新进程(运动员B)去捡,而是在奔跑中直接将接力棒(监听套接字的文件描述符)递到了B的手中,B拿到棒后立刻开始奔跑,整个队伍的速度(服务能力)没有丝毫停顿。
工作流程详解:
- 准备阶段:旧进程 P1 正常启动,创建监听套接字(
Listener
),并拿到一个文件描述符(File Descriptor,比如说是 FD 3)。P1 在这个 FD 上Accept()
新连接。 - 触发重启:我们向 P1 发送一个特定信号,通常是
SIGHUP
(挂起信号,常用于通知守护进程重载配置)。 - 魔法开始:Fork:
- 当 P1 收到
SIGHUP
信号,它做的第一件事不是退出,而是执行fork()
系统调用。 fork()
会创建一个与 P1 几乎一模一样的子进程 P2。最关键的一点是:P2 继承了 P1 所有打开的文件描述符。这意味着,P2 也拥有了那个指向监听套接字的 FD 3。现在,父子两个进程都手握着同一个监听套接字。
- 当 P1 收到
- 传递信息:P1 需要告诉 P2 哪个 FD 是需要接管的。通常的做法是,在
fork
之前,将 FD 的编号(”3”)设置到一个约定的环境变量中。 - 魔法继续:Exec:
- 子进程 P2 紧接着执行
exec()
系统调用。 exec()
会用全新的程序代码(你的新版本服务)替换掉 P2 当前的进程内存空间。- 关键点:
exec()
在替换进程代码时,默认不会关闭已打开的文件描述符!所以,即使 P2 的代码被换成了新版本,它依然持有那个从 P1 继承来的 FD 3。
- 子进程 P2 紧接着执行
- 新进程接管:新程序(P2)启动,它会检查那个约定的环境变量,拿到 FD 编号 “3”,然后直接基于这个现成的文件描述符创建
Listener
,并开始Accept()
新的连接。它跳过了bind()
和listen()
的步骤,因为它接管的是一个已经就绪的套接字。 - 旧进程退场:父进程 P1 在
fork
成功后,就知道自己的使命即将结束。它会立即调用我们之前学过的优雅关闭流程:停止Accept()
新连接(此时新连接都由 P2 去处理了),等待现有连接处理完毕,然后清理资源,最终退出。
这样一来,从外部看,监听端口上始终有进程在服务,实现了无缝切换。
2.2 现代 SO_REUSEPORT
方式
SO_REUSEPORT
是一个更现代、更灵活的内核特性(Linux kernel 3.9+ 开始支持)。它允许多个独立的进程 bind
到完全相同的 IP 和端口上。
“一句话”类比:
想象一个多窗口的银行柜台。原来只有一个柜员(旧进程)在服务。现在,新来一个柜员(新进程)直接在旁边开设了一个新窗口,两个窗口都能接待客户。然后你告诉老柜员可以下班了,他服务完手头的客户就离开,新柜员则继续工作。客户流完全没有中断。
工作流程详解:
- 启动旧进程:旧进程 P1 启动时,在创建套接字后、
bind
之前,需要设置SO_REUSEPORT
这个套接字选项。然后bind
到:8080
并开始监听。 - 启动新进程:现在,你直接启动一个全新的进程 P2(新版代码)。P2 也设置
SO_REUSEPORT
选项,然后也去bind
到:8080
。因为设置了SO_REUSEPORT
,bind
调用会成功,而不会报address already in use
错误。 - 内核负载均衡:此时,P1 和 P2 都在监听同一个端口。当一个新的 TCP 连接请求到达时,操作系统内核会自动进行负载均衡,决定将这个连接交给 P1 还是 P2 来处理。
- 旧进程退场:你向旧进程 P1 发送
SIGTERM
信号。P1 开始执行优雅关闭流程:停止Accept
,处理完现有连接后退出。 - 完成切换:当 P1 退出后,所有新连接就自然而然地只会被内核分发给 P2 了。整个重启过程完成。
2.3 golang 实现
我们可以使用 fvbock/endless 来替换默认的 ListenAndServe
启动服务来实现。
1 | package main |
- 测试
1 | # 1. 启动服务 |
需要注意的是,此时程序的PID变化了,因为endless
是通过fork
子进程处理新请求,待原进程处理完当前请求后再退出的方式实现优雅重启的。
2.4 golang 版本区别
Go 1.8 之前:第三方库的“战国时代”
在 Go 1.8 之前,Go 的标准库 net/http 没有提供 server.Shutdown() 方法。
这些库几乎清一色地使用了我们前面讲的 fork
& exec
模型。
它们会自己实现一套复杂的逻辑:
- 监听
SIGHUP
等重启信号。 - 执行
fork/exec
来创建和启动子进程,并通过环境变量传递监听器的文件描述符(FD)。 - 在新进程中,通过传递来的 FD 恢复监听。
- 最复杂的部分:它们需要自己实现一个
net.Listener
的包装,手动追踪每一个活跃的连接。当旧进程需要退出时,它会停止接受新连接,并等待自己追踪的连接计数器归零,然后才真正退出。这实质上是自己实现了一遍Shutdown
的逻辑。
- 监听
著名代表库:
facebookgo/grace
endless
gracehttp
Go 1.8 及之后:标准库“一统天下”
Go 1.8 最大的改变就是引入了 http.Server.Shutdown(ctx context.Context) 方法。
核心优势:官方将最复杂、最核心的“追踪活跃连接并等待其完成”的逻辑内置了。开发者不再需要为了这个功能引入一个庞大的第三方库。
实现优雅重启的方式:
- 优雅关闭部分:直接使用官方的
server.Shutdown()
即可,简单、可靠。 - 监听交接部分:开发者仍然需要自己处理文件描述符的传递。但现在可以组合使用更底层的标准库功能来完成,而无需依赖“黑魔法”式的第三方库。
net.FileListener(f *os.File)
:这个函数允许你从一个*os.File
(代表一个文件描述符)创建一个net.Listener
。(*net.TCPListener).File()
:这个方法可以让你从一个现有的TCPListener
中获取其底层的*os.File
。
组合使用流程:
- 旧进程收到重启信号。
- 调用
(*net.TCPListener).File()
获取监听器的文件描述符。 - 将这个文件描述符作为“额外文件”(
ExtraFiles
)传递给os.StartProcess
(fork/exec
的 Go 封装)。 - 新进程启动后,从
os.ExtraFiles
中取回这个文件描述符。 - 调用
net.FileListener()
基于这个文件描述符重建监听器。 - 旧进程在新进程成功启动后,调用
server.Shutdown()
开始优雅退出。
SO_REUSEPORT
的兴起与此同时,随着 Linux 内核
SO_REUSEPORT
功能的普及和 Go 1.9 提供了net.ListenConfig.Control
回调函数,使用SO_REUSEPORT
方式实现优雅重启变得越来越简单和流行,因为它避免了fork/exec
的复杂进程关系和文件描述符传递。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28package main
import (
"context"
"net"
"syscall"
"golang.org/x/sys/unix"
)
func createListener(addr string) (net.Listener, error) {
lc := &net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var err error
c.Control(func(fd uintptr) {
// 设置 SO_REUSEADDR
err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
if err != nil {
return
}
// 设置 SO_REUSEPORT
err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
})
return err
},
}
return lc.Listen(context.Background(), "tcp", addr)
}
3. 提问问题
Kill -9 信号可以被程序捕获不处理吗?
不可以。kill -9
发送的是 SIGKILL 信号,这个信号不能被捕获、阻塞或忽略。
这是操作系统的设计决定,主要原因包括:
- 系统安全性:确保系统管理员始终有办法终止失控的进程
- 防止恶意程序:避免程序通过捕获所有信号来拒绝被终止
- 系统稳定性:提供一个最终的 “ 紧急停止 “ 机制
在 Linux/Unix 系统中,有两个信号不能被捕获:
- SIGKILL (9):强制终止进程
- SIGSTOP (19):强制暂停进程
SIGTERM, SIGINT 什么区别
SIGTERM (15)
- 通常由系统或其他进程发送
kill
命令的默认信号- systemd/init 系统关闭服务时发送
SIGINT (2)
- 通常由用户交互产生
- 按下
Ctrl+C
时发送给前台进程组 - 表示”用户想要中断程序”
1 | # SIGTERM - 系统管理场景 |
两者的默认行为都是终止进程,但:
- SIGTERM:干净地终止,不产生 core dump
- SIGINT:可能产生 core dump(取决于系统配置)
WithCancel(ctx) ,执行 cancel 函数会触发 ctx.Done 吗?
是的,WithCancel(ctx)
返回的 cancel 函数被调用时会触发子 context 的 Done()
channel。
1 | package main |
- 多个 go 监听一个
1 | func multipleListeners() { |