Tableflip + Systemd 实现 Go 应用热重启

对于一个常驻、高访问量的网络服务来说,在进行代码变更需要对进程进行升级/重启时,如何避免对正在通信的用户以及即将访问的用户造成影响便是一个难以忽视的问题,尤其是在用户量达到一定规模的时候。

本文将使用 tableflip并结合 systemd 实现 Go 应用优雅的热重启。

tableflip 简介

tableflipcloudflare 实现 Go 进程优雅重启的一个开源库,采用 继承监听套接字 方案,整体设计开放性足够,目前看起来是最好的一个实现。

tableflip 的设计宗旨就是实现类似 nginx 的优雅热更新能力,包括:

  • 新进程启动成功后,老进程不会有资源残留
  • 优雅的新进程初始化(新进程启动和初始化的过程中服务不会中断)
  • 容忍新进程初始化的失败(如果新进程初始化失败,老进程会继续工作而不是退出)
  • 同一时间只能有一个更新动作执行

tableflip 中的核心类型是 Upgrader,调用 Upgrader.Upgrade 会产生一个继承必要的 net.Listeners
的新进程,并等待新进程发出表明其已成功完成初始化、退出或超时的信号。如果当前已有升级的任务在执行,则直接返回相应的错误。

当新进程启动成功后,调用 Upgrader.Ready 会清除无效的 fd 并向父进程发出初始化成功完成的信号,然后父进程就可以安心退出。至此,我们就完成了一次优雅的进程重启。

tableflip upgrade

代码示例

源码地址:https://github.com/ryan961/go-exp/blob/main/reload/main.go

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
func main() {
upg, err := tableflip.New(tableflip.Options{PIDFile: "./reload.pid"})
if err != nil {
panic(err)
}
defer upg.Stop()

// 为了演示方便,为程序启动强行加入 1s 的延时,并在日志中附上进程 pid
time.Sleep(time.Second)
log.SetPrefix(fmt.Sprintf("[PID: %d] ", os.Getpid()))

// 监听系统的 SIGHUP 信号,以此信号触发进程重启
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGUSR2, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
for si := range sig {
switch si {
case syscall.SIGHUP, syscall.SIGUSR2:
// 核心的 Upgrade 调用
if err := upg.Upgrade(); err != nil {
log.Println("server upgrade failed:", err)
}
default:
log.Println("shutdown server start:", si)
upg.Stop()
}
}
}()

// 注意必须使用 upg.Listen 对端口进行监听
ln, err := upg.Listen("tcp", fmt.Sprintf(":%d", endPoint))
if err != nil {
log.Fatalln("Can't listen:", err)
}

// 创建一个简单的 http server,/version 返回当前的程序版本
mux := http.NewServeMux()
mux.HandleFunc("/version", func(rw http.ResponseWriter, r *http.Request) {
log.Println(Version)
rw.Write([]byte(Version + "\n"))
})
server := http.Server{
Handler: mux,
}

// 照常启动 http server
go func() {
err := server.Serve(ln)
if err != http.ErrServerClosed {
log.Println("HTTP server:", err)
}
}()

if err := upg.Ready(); err != nil {
panic(err)
}

log.Printf("http server listening %d", endPoint)

<-upg.Exit()

// 给老进程的退出设置一个 30s 的超时时间,保证老进程的退出
time.AfterFunc(30*time.Second, func() {
log.Println("Graceful shutdown timed out")
os.Exit(1)
})

// 等待 http server 的优雅退出上面的代码实现了一个返回当前 version 的 http server,我们在启动过程中延迟 1s 以便观察升级过程中服务是否依旧可用。
server.Shutdown(context.Background())
}

编译运行:

1
2
go build -ldflags "-X main.Version=0.0.1" -o reload
./reload

使用 curl 模拟一些客户端请求(10 qps):

1
while true; do curl http://localhost:8080/version; sleep 0.1; done
1
2
3
4
5
[PID: 3399216] 2022/03/23 22:21:31 http server listening 8080
[PID: 3399216] 2022/03/23 22:23:48 0.0.1
[PID: 3399216] 2022/03/23 22:23:48 0.0.1
[PID: 3399216] 2022/03/23 22:23:48 0.0.1
...

然后,我们对应用进行了一些升级,将版本号修改为 0.0.2,并重新编译程序:

1
go build -ldflags "-X main.Version=0.0.2" -o reload

最后,尝试优雅的热重启:

1
kill -HUP 3399216

由此可见,客户端没有受到服务器端 reload 的影响。至此,进程优雅的热更新已完成。

1
2
3
4
5
6
7
8
...
[PID: 3399216] 2022/03/23 22:23:48 0.0.1
[PID: 3399216] 2022/03/23 22:23:48 0.0.1
[PID: 3401398] 2022/03/23 22:23:48 http server listening 8080
~/reload# [PID: 3401398] 2022/03/23 22:23:48 0.0.2
[PID: 3401398] 2022/03/23 22:23:48 0.0.2
[PID: 3401398] 2022/03/23 22:23:48 0.0.2
...

systemd 管理进程

reload_go.service:

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Service using tableflip

[Service]
WorkingDirectory=/root/reload
ExecStart=/root/reload/reload
ExecStop=/bin/kill -TERM $MAINPID
ExecReload=/bin/kill -HUP $MAINPID
PIDFile=/root/reload/reload.pid
Type=simple

使用 systemd 管理进程:

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
root@:# systemctl start reload_go.service
root@:# systemctl status reload_go.service
● reload_go.service - Service using tableflip
Loaded: loaded (/etc/systemd/system/reload_go.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-03-24 21:04:07 CST; 6s ago
Main PID: 3929535 (reload)
Tasks: 7 (limit: 4619)
Memory: 1.4M
CGroup: /system.slice/reload_go.service
└─3929535 /root/reload/reload

Mar 24 21:04:07 systemd[1]: Started Service using tableflip.
Mar 24 21:04:08 reload[3929535]: [PID: 3929535] 2022-03-24 21:04:08 http server listening 8080
root@:# systemctl reload reload_go.service
root@:# systemctl status reload_go.service
● reload_go.service - Service using tableflip
Loaded: loaded (/etc/systemd/system/reload_go.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-03-24 21:04:07 CST; 17s ago
Process: 3929585 ExecReload=/bin/kill -HUP $MAINPID (code=exited, status=0/SUCCESS)
Main PID: 3929588 (reload)
Tasks: 10 (limit: 4619)
Memory: 1.8M
CGroup: /system.slice/reload_go.service
└─3929588 /root/reload/reload

Mar 24 21:04:07 systemd[1]: Started Service using tableflip.
Mar 24 21:04:08 reload[3929535]: [PID: 3929535] 2022/03/24 21:04:08 http server listening 8080
Mar 24 21:04:23 systemd[1]: Reloading Service using tableflip.
Mar 24 21:04:23 systemd[1]: Reloaded Service using tableflip.
Mar 24 21:04:24 reload[3929588]: [PID: 3929588] 2022/03/24 21:04:24 http server listening 8080
...

至此,我们已基本完成使用 systemd 管理进程。

判断 reload 是否成功

根据前面的流程,我们已基本完成 reload + systemd 的需求。但实际在发布过程中,reload 成功与否 systemd 无法感知,必须人工介入查看进程状态才能获知。而判断 reload 成功与否的标志便是进程 pid 是否发生变化,所以我们加入 reload.sh 来判断 pid 是否变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash

OLD_PID=$(cat $1)
/bin/kill -HUP $OLD_PID
# 等待进程 reload 成功
sleep 10
# 检查是否启动成功
NEW_PID=$(cat $1)

# 旧进程退出, 新进程启动
ps --pid $OLD_PID &>/dev/null
OLD_EXIST=$?
ps --pid $NEW_PID &>/dev/null
NEW_EXIST=$?

if [ $OLD_EXIST -eq 1 ] && [ $NEW_EXIST -eq 0 ]; then
exit 0
else
exit -1
fi

更新 reload_go.service :

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Service using tableflip

[Service]
WorkingDirectory=/root/reload
ExecStart=/root/reload/reload
ExecStop=/bin/kill -TERM $MAINPID
ExecReload=/root/reload/reload.sh /root/reload/reload.pid
PIDFile=/root/reload/reload.pid
Type=simple

模拟进程 reload 失败:

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
root@:# systemctl start reload_go.service
root@:# systemctl status reload_go.service
● reload_go.service - Service using tableflip
Loaded: loaded (/etc/systemd/system/reload_go.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-03-24 21:43:15 CST; 2s ago
Main PID: 3935728 (reload)
Tasks: 7 (limit: 4619)
Memory: 1.3M
CGroup: /system.slice/reload_go.service
└─3935728 /root/reload/reload

Mar 24 21:43:15 systemd[1]: Started Service using tableflip.
Mar 24 21:43:16 reload[3935728]: [PID: 3935728] 2022/03/24 21:43:16 http server listening 8080
root@:# systemctl reload reload_go.service
root@:# systemctl status reload_go.service
● reload_go.service - Service using tableflip
Loaded: loaded (/etc/systemd/system/reload_go.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-03-24 21:43:15 CST; 28s ago
Process: 3935781 ExecReload=/root/reload/reload.sh /root/reload/reload.pid (code=exited, status=0/SUCCESS)
Main PID: 3935789 (reload)
Tasks: 7 (limit: 4619)
Memory: 2.0M
CGroup: /system.slice/reload_go.service
└─3935789 /root/reload/reload

Mar 24 21:43:15 systemd[1]: Started Service using tableflip.
Mar 24 21:43:16 reload[3935728]: [PID: 3935728] 2022/03/24 21:43:16 http server listening 8080
Mar 24 21:43:30 systemd[1]: Reloading Service using tableflip.
Mar 24 21:43:31 reload[3935789]: [PID: 3935789] 2022/03/24 21:43:31 http server listening 8080
Mar 24 21:43:40 systemd[1]: Reloaded Service using tableflip.
root@:/etc/systemd/system# systemctl reload reload_go.service
Job for reload_go.service failed.
See "systemctl status reload_go.service" and "journalctl -xe" for details.
root@:# systemctl status reload_go.service
● reload_go.service - Service using tableflip
Loaded: loaded (/etc/systemd/system/reload_go.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-03-24 21:43:15 CST; 1min 43s ago
Process: 3936335 ExecReload=/root/reload/reload.sh /root/reload/reload.pid (code=exited, status=255/EXCEPTION)
Main PID: 3935789 (reload)
Tasks: 7 (limit: 4619)
Memory: 2.2M
CGroup: /system.slice/reload_go.service
└─3935789 /root/reload/reload

Mar 24 21:43:31 reload[3935789]: [PID: 3935789] 2022/03/24 21:43:31 http server listening 8080
Mar 24 21:43:40 systemd[1]: Reloaded Service using tableflip.
Mar 24 21:44:39 systemd[1]: Reloading Service using tableflip.
Mar 24 21:44:39 reload[3936338]: panic: reload.sh
Mar 24 21:44:39 reload[3936338]: goroutine 1 [running]:
Mar 24 21:44:39 reload[3936338]: main.main()
Mar 24 21:44:39 reload[3936338]: /root/reload/main.go:23 +0x3e
Mar 24 21:44:39 reload[3935789]: [PID: 3935789] 2022/03/24 21:44:39 server upgrade failed: child pid=3936338 exited: ex>
Mar 24 21:44:49 systemd[1]: reload_go.service: Control process exited, code=exited, status=255/EXCEPTION
Mar 24 21:44:49 systemd[1]: Reload failed for Service using tableflip.
...

总结

根据上述流程,我们已完成使用 systemd + tableflip 优雅重启进程的需求,并基本可以投入生产环境中使用。有需要也可以自行接入 spug 发布流程中。

源码地址:https://github.com/ryan961/go-exp/tree/main/reload

参考资料