0%

Gong服务实现平滑重启分析

原文档出处:https://www.kancloud.cn/chase688/orange_framework/1448035

平滑重启是指能让我们的程序在重启的过程不中断服务,新老进程无缝衔接,实现零停机时间(Zero-Downtime)部署;平滑重启是建立在优雅退出的基础之上的,目前实现平滑重启的主要策略有两种:

方案一:我们的服务如果是多机器部署,可以通过网关程序,将即将重启服务的机器从网关下线,重启完成后再重新上线,该方案适合多机器部署的企业级应用;

方案二:让我们的程序实现自启动,重启子进程来实现平滑重启,核心策略是通过拷贝文件描述符实现子进程和父进程切换,适合单机器部署应用;

今天我们就主要介绍方案二,让我们的程序拥有平滑重启的功能,相关实现参考一个开源库:

https://github.com/fvbock/endless

对原文的总结

1
os.NewFile(fd uintptr, name string) *File

godoc对它的介绍:https://golang.org/pkg/os/#NewFile

根据给定的文件描述符和文件名称返回一个文件对象。如果传入无效文件描述符,将返回nil。在Unix系统中,如果文件描述符是非阻塞模式,该函数将尝试返回pollable file。

https://golang.org/pkg/os/#pkg-variables

1
2
3
4
5
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

那么原文中fd=3怎么来的?且看"os/exec”中对Cmd的定义:

1
2
3
4
5
6
7
8
9
10
11
// Cmd represents an external command being prepared or run.
//
// A Cmd cannot be reused after calling its Run, Output or CombinedOutput
// methods.
type Cmd struct {
// ExtraFiles specifies additional open files to be inherited by the
// new process. It does not include standard input, standard output, or
// standard error. If non-nil, entry i becomes file descriptor 3+i.
//
// ExtraFiles is not supported on Windows.
ExtraFiles []*os.File

ExtraFiles 不支持 Windows,如果这个属性非nil,它表示的文件描述符从下标3开始递增。原文中传入的是一个描述符,所以,fd=3

相似的例子https://gist.github.com/jedy/3037240

s.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"net"
"os"
"bufio"
)

func main() {
file := os.NewFile(uintptr(3), "tcp") // 0,1,2是标准输入,输出,错误输出。传进来的fd从3开始
c, err := net.FileConn(file)
if err != nil {
fmt.Println(err)
}
file.Close()
writer := bufio.NewWriter(c)
writer.WriteString("hello world\n")
writer.Flush()
c.Close()
}

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

import (
"fmt"
"net"
"syscall"
"os"
)

func listen() {
ln, err := net.Listen("tcp", ":9999")
if err != nil {
panic(err)
}
tcpln := ln.(*net.TCPListener)
for {
c, err := tcpln.AcceptTCP()
if err != nil {
fmt.Println(err)
break
}
if err != nil {
fmt.Println(err)
break
}
go handleConn(c)
}
}

func handleConn(c *net.TCPConn) {
file, _ := c.File()
defer file.Close()
defer c.Close()
syscall.CloseOnExec(int(file.Fd())) // 设置关闭文件只对当前进程有效
allFiles := []*os.File{os.Stdin, os.Stdout, os.Stderr, file, nil}
wd, _ := os.Getwd()
_, err := os.StartProcess("s", nil, &os.ProcAttr{
Dir: wd,
Files: allFiles,
})
if err != nil {
fmt.Println(err)
}
}

func main() {
listen()
}

实现原理介绍

http 连接机制

我们知道,http 服务也是基于 tcp 连接,我们通过 golang http 包源码也能看到底层是通过监听 tcp 连接实现的;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

复用 socket

当程序开启 tcp 连接监听时会创建一个 socket 并返回一个文件描述符 handler 给我们的程序;

通过拷贝文件描述符文件可以使 socket 不关闭继续使用原有的端口,自然 http 连接也不会断开,启动一个相同的进程也不会出现端口被占用的问题;

通过如下代码进行测试:

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

import (
"fmt"
"net/http"
"context"
"time"
"os"
"os/signal"
"syscall"
"net"
"flag"
"os/exec"
)

var (
graceful = flag.Bool("grace", false, "graceful restart flag")
procType = ""
)

func main() {
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

fmt.Fprintln(w, fmt.Sprintf("Hello world! ===> %s", procType))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,

}

var err error
var listener net.Listener
if *graceful {
f := os.NewFile(3, "")
listener, err = net.FileListener(f)
procType = "fork process"
} else {
listener, _ = net.Listen("tcp", server.Addr)
procType = "main process"

//主程序开启5s 后 fork 子进程
go func() {
time.Sleep(5*time.Second)
forkSocket(listener.(*net.TCPListener))
}()

}

err=server.Serve(listener.(*net.TCPListener))

fmt.Println(fmt.Sprintf("proc exit %v", err))
}


func forkSocket(tcpListener *net.TCPListener) error {
f, err := tcpListener.File()
if err != nil {
return err
}

args := []string{"-grace"}
fmt.Println(os.Args[0], args)
cmd := exec.Command(os.Args[0], args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// put socket FD at the first entry
cmd.ExtraFiles = []*os.File{f}
return cmd.Start()
}

该程序启动后,等待 5s 会自动 fork 子进程,通过 ps 命令查看如图可以看到有两个进程同时共存:

img

然后我们可以通过浏览器访问 http://127.0.0.1/ 可以看到会随机显示主进程或子进程的输出;

请求测试

写一个测试代码进行循环请求:

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 (
"net/http"
"io/ioutil"
"fmt"
"sync"
)

func main(){

wg:=sync.WaitGroup{}
wg.Add(100)
for i:=0; i<100; i++ {
go func(index int) {
result:=getUrl(fmt.Sprintf("http://127.0.0.1:8080?%d", i))
fmt.Println(fmt.Sprintf("loop:%d %s", index, result))
wg.Done()
}(i)
}
wg.Wait()
}

func getUrl(url string) string{
resp, _ := http.Get(url)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
return string(body)
}

img

能看到返回的数据也是有些是主进程有些是子进程。

切换过程

在开启新的进程和老进程退出的瞬间,会有一个短暂的瞬间是同时有两个进程使用同一个文件描述符,此时这种状态,通过http请求访问,会随机请求到新进程或老进程上,这样也没有问题,因为请求不是在新进程上就是在老进程上;当老进程结束后请求就会全部到新进程上进行处理,通过这种方式即可实现平滑重启;

总结

综上,我们可以将核心的实现总结如下:

1.监听退出信号;

2.监听到信号后 fork 子进程,使用相同的命令启动程序,将文件描述符传递给子进程;

3.子进程启动后,父进程停止服务并处理正在执行的任务(或超时)退出;

4.此时只有一个新的进程在运行,实现平滑重启。

一个完整的 demo 代码,通过发送 USR1 信号,程序会自动创建子进程并关闭主进程,实现平滑重启:

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

import (
"fmt"
"net/http"
"context"
"os"
"os/signal"
"syscall"
"net"
"flag"
"os/exec"
)

var (
graceful = flag.Bool("grace", false, "graceful restart flag")
)

func main() {
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

fmt.Fprintln(w, "Hello world!")
})
server := &http.Server{
Addr: ":8080",
Handler: mux,

}

var err error
var listener net.Listener
if *graceful {
f := os.NewFile(3, "")
listener, err = net.FileListener(f)
} else {
listener, err = net.Listen("tcp", server.Addr)
}
if err != nil{
fmt.Println(fmt.Sprintf("listener error %v", err))
return
}

go listenSignal(context.Background(), server, listener)

err=server.Serve(listener.(*net.TCPListener))
fmt.Println(fmt.Sprintf("proc exit %v", err))
}


func forkSocket(tcpListener *net.TCPListener) error {
f, err := tcpListener.File()
if err != nil {
return err
}

args := []string{"-grace"}
fmt.Println(os.Args[0], args)
cmd := exec.Command(os.Args[0], args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// put socket FD at the first entry
cmd.ExtraFiles = []*os.File{f}
return cmd.Start()
}

func listenSignal(ctx context.Context, httpSrv *http.Server, listener net.Listener) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.USR1)

select {
case <-sigs:
forkSocket(listener.(*net.TCPListener))
httpSrv.Shutdown(ctx)
fmt.Println("http shutdown")
}
}

使用 apache 的 ab 压测工具进行验证一下,执行 ab -c 50 -t 20 http://127.0.0.1:8080/ 持续 50 的并发 20s,在压测的期间向程序运行的pid发送 USR1 信号,可以看到压测结果,没有失败的请求,由此可知,该方案实现平滑重启是木有问题的。

img

最后给大家安利一个 Web 开发框架,该框架已经将平滑重启进行的封装,开箱即用,快速构建一个带平滑重启的 Web 服务。

框架源码:https://gitee.com/zhucheer/orange