golang服务的优雅关闭和重启

服务的优雅关闭(Graceful Shutdown)是指,当服务需要停止时,它不是被“一刀切”地瞬间杀死,而是会先完成当前正在处理的任务、释放占用的资源后,再自行有序地退出。

优雅重启(Graceceful Restart)本质上是“优雅关闭”后紧跟着一个“优雅启动”的过程,常用于服务更新或配置重载,目标是在整个过程中不中断或尽可能少地影响对外服务。

优雅关闭和重启解决了三个核心问题:

  1. 数据完整性 (Data Integrity):避免因突然中断导致数据只写了一半,造成数据库或文件损坏。比如,一个用户转账操作,钱扣了,但还没加到对方账户上,服务就停了。
  2. 用户体验 (User Experience):对于正在与系统交互的用户来说,他们的请求会被正常处理完毕,而不是突然收到一个“Connection Reset by Peer”的错误。这对于 API 服务尤其重要。
  3. 系统稳定性与资源管理 (System Stability & Resource Management):确保服务在退出前能正确释放如数据库连接、文件句柄、网络端口等关键资源,防止资源泄露,影响整个操作系统的稳定性。

1. 优雅关闭

以一个典型的 Go Web 服务为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[开始] --> [服务正常运行]
|
[接收到退出信号 (如 SIGINT, SIGTERM)]
|
v
[步骤1: 停止接收新请求] (例如: 调用 http.Server.Shutdown())
|
v
[步骤2: 等待已有任务处理完成] (例如: 使用 sync.WaitGroup 等待所有处理中的 goroutine 结束)
|
v
[步骤3: 清理并释放资源] (例如: 关闭数据库连接、日志文件等)
|
v
[步骤4: 服务进程完全退出] (例如: main 函数返回,或调用 os.Exit(0))

1.1 Golang 优雅关闭 Http Server

参考:https://github.com/gin-gonic/examples/tree/master/graceful-shutdown

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
28
29
30
31
func main() {
// 1. 设置服务 (HTTP Server, DB Connections, etc.)
server := &http.Server{Addr: ":8080"}
db := connectToDatabase()
defer db.Close() // 确保最后能关闭

// 2. 监听系统信号
quitChan := make(chan os.Signal, 1)
signal.Notify(quitChan, syscall.SIGINT, syscall.SIGTERM)

// 3. 启动服务 (在另一个 goroutine 中)
go func() {
server.ListenAndServe()
}()

// 4. 阻塞,直到接收到关闭信号
<-quitChan
log.Println("收到关闭信号,开始优雅关闭...")

// 5. 创建一个有超时的 context,用于通知 server 在规定时间内关闭
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// 6. 调用 server.Shutdown(),它会停止接收新请求并等待现有请求完成
// Shutdown 是一个阻塞方法,直到所有连接关闭或超时
if err := server.Shutdown(ctx); err != nil {
log.Fatal("服务关闭失败:", err)
}

log.Println("服务已成功关闭。")
}

1.2 Http Server 的 shutdown 方法

go 在1.8后增加了http server 的shutdown方法,内部其实会调用注册的shutdown函数钩子。

1
2
3
4
5
6
7
8
9
10
func (srv *Server) Shutdown(ctx context.Context) error {
srv.inShutdown.Store(true)

srv.mu.Lock()
lnerr := srv.closeListenersLocked()
for _, f := range srv.onShutdown {
go f()
}
srv.mu.Unlock()
}

1.3 其他依靠 WithCancel(ctx)

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
package main

import (
"context"
"fmt"
"math/rand"
"os"
"os/signal"
"sync"
"syscall"
"time"
)

const (
TimeTemplate = "15:04:05.999999999"
)

type Service interface {
GetName() string
Serve(ctx context.Context)
Shutdown() error
}

type BusinessService struct {
}

func (b *BusinessService) GetName() string {
return "BusinessService"
}

func (b *BusinessService) Serve(ctx context.Context) {
for {
fmt.Printf("BusinessService serve run at %s\n", time.Now().Format(TimeTemplate))
select {
case <-ctx.Done():
fmt.Printf("111111111111cancel %s\n", time.Now().Format(TimeTemplate))
return
default:
}
time.Sleep(time.Second)
}
return
}

func (b *BusinessService) Shutdown() error {
fmt.Printf("BusinessService shutdown begin... at %s\n", time.Now().Format(TimeTemplate))
defer func() {
fmt.Printf("BusinessService shutdown end... at %s\n", time.Now().Format(TimeTemplate))
}()
return nil
}

type LogService struct {
buffer []string
}

func (l *LogService) Serve(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Printf("2222222222cancel %s\n", time.Now().Format(TimeTemplate))
return
default:
// 0.5s append one log
time.Sleep(500 * time.Millisecond)
l.buffer = append(l.buffer, fmt.Sprintf("Time: %d", time.Now().Unix()))
}
}
}

func (b *LogService) GetName() string {
return "LogService"
}

func (l *LogService) Shutdown() (err error) {
fmt.Printf("LogService shutdown begin... at %s\n", time.Now().Format(TimeTemplate))
defer fmt.Printf("LogService shutdown end... at %s\n", time.Now().Format(TimeTemplate))
if len(l.buffer) == 0 {
return
}
fmt.Printf("cache [%d] wait to send \n", len(l.buffer))
for _, log := range l.buffer {
fmt.Printf("send Log [%s]\n", log)
}
return
}

type ServiceGroup struct {
ctx context.Context
cancel func()
services []Service //service list
}

func NewServiceGroup(ctx context.Context) *ServiceGroup {
g := ServiceGroup{}
g.ctx, g.cancel = context.WithCancel(ctx) // 🔥注意这里
return &g
}

func (s *ServiceGroup) Add(service Service) {
s.services = append(s.services, service)
}

func (s *ServiceGroup) run(service Service) (err error) {
defer func() {
if r := recover(); r != nil {
err = r.(error)
fmt.Printf("receive panic msg: %s\n", err.Error())
}
}()
//with cancel ctx to child context
service.Serve(s.ctx)
return
}

func (s *ServiceGroup) watchDog() {
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case signalData := <-signalChan:
switch signalData {
case syscall.SIGINT:
fmt.Println("receive signal sigint")
case syscall.SIGTERM:
fmt.Println("receive signal sigerm")
default:
fmt.Println("receive singal unknown")
}
// do cancel notify all services cancel
s.cancel()
goto CLOSE
case <-s.ctx.Done():
goto CLOSE
}
}
CLOSE:
for _, service := range s.services {
if err := service.Shutdown(); err != nil {
fmt.Printf("shutdown failed err: %s", err)
}
}
}

func (s *ServiceGroup) ServeAll() {
var wg sync.WaitGroup
for idx := range s.services {
service := s.services[idx]
wg.Add(1)
go func() {
defer wg.Done()
if err := s.run(service); err != nil {
fmt.Printf("receive service [%s] has error: 【%s】, do cancel\n", service.GetName(), err.Error())
s.cancel()
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
s.watchDog()
}()
wg.Wait()
}

func main() {
rand.Seed(time.Now().Unix())
ctx := context.Background()

g := NewServiceGroup(ctx)
g.Add(&LogService{})
g.Add(&BusinessService{})
g.ServeAll()
}

/*
BusinessService serve run at 18:59:07.839774
BusinessService serve run at 18:59:08.841111
BusinessService serve run at 18:59:09.841922

^Creceive signal sigint # 在这里关闭

LogService shutdown begin... at 18:59:09.868933
cache [4] wait to send
send Log [Time: 1719399548]
send Log [Time: 1719399548]
send Log [Time: 1719399549]
send Log [Time: 1719399549]
LogService shutdown end... at 18:59:09.869027

BusinessService shutdown begin... at 18:59:09.869044
BusinessService shutdown end... at 18:59:09.869047

2222222222cancel 18:59:10.343492

BusinessService serve run at 18:59:10.843066
111111111111cancel 18:59:10.843116
*/

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拿到棒后立刻开始奔跑,整个队伍的速度(服务能力)没有丝毫停顿。

工作流程详解:

  1. 准备阶段:旧进程 P1 正常启动,创建监听套接字(Listener),并拿到一个文件描述符(File Descriptor,比如说是 FD 3)。P1 在这个 FD 上 Accept() 新连接。
  2. 触发重启:我们向 P1 发送一个特定信号,通常是 SIGHUP (挂起信号,常用于通知守护进程重载配置)。
  3. 魔法开始:Fork:
    • 当 P1 收到 SIGHUP 信号,它做的第一件事不是退出,而是执行 fork() 系统调用。
    • fork() 会创建一个与 P1 几乎一模一样的子进程 P2。最关键的一点是:P2 继承了 P1 所有打开的文件描述符。这意味着,P2 也拥有了那个指向监听套接字的 FD 3。现在,父子两个进程都手握着同一个监听套接字。
  4. 传递信息:P1 需要告诉 P2 哪个 FD 是需要接管的。通常的做法是,在 fork 之前,将 FD 的编号(”3”)设置到一个约定的环境变量中。
  5. 魔法继续:Exec:
    • 子进程 P2 紧接着执行 exec() 系统调用。
    • exec() 会用全新的程序代码(你的新版本服务)替换掉 P2 当前的进程内存空间。
    • 关键点:exec() 在替换进程代码时,默认不会关闭已打开的文件描述符!所以,即使 P2 的代码被换成了新版本,它依然持有那个从 P1 继承来的 FD 3。
  6. 新进程接管:新程序(P2)启动,它会检查那个约定的环境变量,拿到 FD 编号 “3”,然后直接基于这个现成的文件描述符创建 Listener,并开始 Accept() 新的连接。它跳过了 bind()listen() 的步骤,因为它接管的是一个已经就绪的套接字。
  7. 旧进程退场:父进程 P1 在 fork 成功后,就知道自己的使命即将结束。它会立即调用我们之前学过的优雅关闭流程:停止 Accept() 新连接(此时新连接都由 P2 去处理了),等待现有连接处理完毕,然后清理资源,最终退出。

这样一来,从外部看,监听端口上始终有进程在服务,实现了无缝切换。

2.2 现代 SO_REUSEPORT 方式

SO_REUSEPORT 是一个更现代、更灵活的内核特性(Linux kernel 3.9+ 开始支持)。它允许多个独立的进程 bind 到完全相同的 IP 和端口上。

“一句话”类比:
想象一个多窗口的银行柜台。原来只有一个柜员(旧进程)在服务。现在,新来一个柜员(新进程)直接在旁边开设了一个新窗口,两个窗口都能接待客户。然后你告诉老柜员可以下班了,他服务完手头的客户就离开,新柜员则继续工作。客户流完全没有中断。

工作流程详解:

  1. 启动旧进程:旧进程 P1 启动时,在创建套接字后、bind 之前,需要设置 SO_REUSEPORT 这个套接字选项。然后 bind:8080 并开始监听。
  2. 启动新进程:现在,你直接启动一个全新的进程 P2(新版代码)。P2 也设置 SO_REUSEPORT 选项,然后也去 bind:8080。因为设置了 SO_REUSEPORTbind 调用会成功,而不会报 address already in use 错误。
  3. 内核负载均衡:此时,P1 和 P2 都在监听同一个端口。当一个新的 TCP 连接请求到达时,操作系统内核会自动进行负载均衡,决定将这个连接交给 P1 还是 P2 来处理。
  4. 旧进程退场:你向旧进程 P1 发送 SIGTERM 信号。P1 开始执行优雅关闭流程:停止 Accept,处理完现有连接后退出。
  5. 完成切换:当 P1 退出后,所有新连接就自然而然地只会被内核分发给 P2 了。整个重启过程完成。

2.3 golang 实现

我们可以使用 fvbock/endless 来替换默认的 ListenAndServe启动服务来实现。

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
28
29
package main

import (
"log"
"net/http"
"time"

"github.com/fvbock/endless"
"github.com/gin-gonic/gin"
)

func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "hello gin!")
})
// 默认endless服务器会监听下列信号:
// syscall.SIGHUP,syscall.SIGUSR1,syscall.SIGUSR2,syscall.SIGINT,syscall.SIGTERM和syscall.SIGTSTP
// 接收到 SIGHUP 信号将触发`fork/restart` 实现优雅重启(kill -1 pid会发送SIGHUP信号)
// 接收到 syscall.SIGINT或syscall.SIGTERM 信号将触发优雅关机
// 接收到 SIGUSR2 信号将触发HammerTime
// SIGUSR1 和 SIGTSTP 被用来触发一些用户自定义的hook函数
if err := endless.ListenAndServe(":8080", router); err != nil {
log.Fatalf("listen: %s\n", err)
}

log.Println("Server exiting")
}
  • 测试
1
2
3
4
5
6
7
# 1. 启动服务
go run main.go

# 2. 另外一个终端请求
curl http://localhost:8080

# 3. 可以测试 kill -1 信号,当前进程挂掉,开了一个新进程。 退出和-9会优雅关机。

需要注意的是,此时程序的PID变化了,因为endless 是通过fork子进程处理新请求,待原进程处理完当前请求后再退出的方式实现优雅重启的。

2.4 golang 版本区别

Go 1.8 之前:第三方库的“战国时代”

在 Go 1.8 之前,Go 的标准库 net/http 没有提供 server.Shutdown() 方法。

这些库几乎清一色地使用了我们前面讲的 fork & exec 模型。

  • 它们会自己实现一套复杂的逻辑:

    1. 监听 SIGHUP 等重启信号。
    2. 执行 fork/exec 来创建和启动子进程,并通过环境变量传递监听器的文件描述符(FD)。
    3. 在新进程中,通过传递来的 FD 恢复监听。
    4. 最复杂的部分:它们需要自己实现一个 net.Listener 的包装,手动追踪每一个活跃的连接。当旧进程需要退出时,它会停止接受新连接,并等待自己追踪的连接计数器归零,然后才真正退出。这实质上是自己实现了一遍 Shutdown 的逻辑。
  • 著名代表库:

    • facebookgo/grace
    • endless
    • gracehttp

Go 1.8 及之后:标准库“一统天下”

Go 1.8 最大的改变就是引入了 http.Server.Shutdown(ctx context.Context) 方法。

核心优势:官方将最复杂、最核心的“追踪活跃连接并等待其完成”的逻辑内置了。开发者不再需要为了这个功能引入一个庞大的第三方库。

实现优雅重启的方式:

  1. 优雅关闭部分:直接使用官方的 server.Shutdown() 即可,简单、可靠。
  2. 监听交接部分:开发者仍然需要自己处理文件描述符的传递。但现在可以组合使用更底层的标准库功能来完成,而无需依赖“黑魔法”式的第三方库。
    • net.FileListener(f *os.File):这个函数允许你从一个 *os.File(代表一个文件描述符)创建一个 net.Listener
    • (*net.TCPListener).File():这个方法可以让你从一个现有的 TCPListener 中获取其底层的 *os.File
  • 组合使用流程:

    1. 旧进程收到重启信号。
    2. 调用 (*net.TCPListener).File() 获取监听器的文件描述符。
    3. 将这个文件描述符作为“额外文件”(ExtraFiles)传递给 os.StartProcessfork/exec 的 Go 封装)。
    4. 新进程启动后,从 os.ExtraFiles 中取回这个文件描述符。
    5. 调用 net.FileListener() 基于这个文件描述符重建监听器。
    6. 旧进程在新进程成功启动后,调用 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
    28
    package 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 信号,这个信号不能被捕获、阻塞或忽略。

这是操作系统的设计决定,主要原因包括:

  1. 系统安全性:确保系统管理员始终有办法终止失控的进程
  2. 防止恶意程序:避免程序通过捕获所有信号来拒绝被终止
  3. 系统稳定性:提供一个最终的 “ 紧急停止 “ 机制

在 Linux/Unix 系统中,有两个信号不能被捕获:

  • SIGKILL (9):强制终止进程
  • SIGSTOP (19):强制暂停进程

SIGTERM, SIGINT 什么区别

SIGTERM (15)

  • 通常由系统或其他进程发送
  • kill 命令的默认信号
  • systemd/init 系统关闭服务时发送

SIGINT (2)

  • 通常由用户交互产生
  • 按下 Ctrl+C 时发送给前台进程组
  • 表示”用户想要中断程序”
1
2
3
4
5
6
7
8
# SIGTERM - 系统管理场景
kill <pid> # 默认发送 SIGTERM
systemctl stop service # 发送 SIGTERM
docker stop container # 先发送 SIGTERM

# SIGINT - 用户交互场景
# 在终端按 Ctrl+C # 发送 SIGINT 给前台进程组
kill -2 <pid> # 手动发送 SIGINT

两者的默认行为都是终止进程,但:

  • SIGTERM:干净地终止,不产生 core dump
  • SIGINT:可能产生 core dump(取决于系统配置)

WithCancel(ctx) ,执行 cancel 函数会触发 ctx.Done 吗?

是的,WithCancel(ctx) 返回的 cancel 函数被调用时会触发子 context 的 Done() channel。

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
package main

import (
"context"
"fmt"
"time"
)

func main() {
// 创建一个带取消功能的 context
ctx, cancel := context.WithCancel(context.Background())

// 启动一个 goroutine 监听 ctx.Done()
go func() {
<-ctx.Done() // 阻塞直到 cancel() 被调用
fmt.Println("Context cancelled:", ctx.Err())
}()

// 等待1秒后取消
time.Sleep(1 * time.Second)
cancel() // 这会触发 ctx.Done()

// 给 goroutine 一些时间来打印消息
time.Sleep(100 * time.Millisecond)
}

  • 多个 go 监听一个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func multipleListeners() {
ctx, cancel := context.WithCancel(context.Background())

// 启动多个 goroutine
for i := 0; i < 3; i++ {
go func(id int) {
select {
case <-ctx.Done():
fmt.Printf("Goroutine %d: received cancellation\n", id)
}
}(i)
}

time.Sleep(1 * time.Second)
cancel() // 所有监听 ctx.Done() 的 goroutine 都会收到信号
time.Sleep(100 * time.Millisecond)
}

/*
oroutine 1: received cancellation
Goroutine 2: received cancellation
Goroutine 0: received cancellation
*/

4. 参考资料