原文档出处: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 type Cmd struct { 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 mainimport ( "fmt" "net" "os" "bufio" ) func main () { file := os.NewFile(uintptr (3 ), "tcp" ) 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 mainimport ( "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 mainimport ( "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" 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 cmd.ExtraFiles = []*os.File{f} return cmd.Start() }
该程序启动后,等待 5s 会自动 fork 子进程,通过 ps 命令查看如图可以看到有两个进程同时共存:
然后我们可以通过浏览器访问 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 mainimport ( "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) }
能看到返回的数据也是有些是主进程有些是子进程。
切换过程 :
在开启新的进程和老进程退出的瞬间,会有一个短暂的瞬间是同时有两个进程使用同一个文件描述符,此时这种状态,通过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 mainimport ( "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 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 信号,可以看到压测结果,没有失败的请求,由此可知,该方案实现平滑重启是木有问题的。
最后给大家安利一个 Web 开发框架,该框架已经将平滑重启进行的封装,开箱即用,快速构建一个带平滑重启的 Web 服务。
框架源码:https://gitee.com/zhucheer/orange