平滑重启是指能让我们的程序在重启的过程不中断服务, 新老进程无缝衔接, 实现零停机时间 (Zero-Downtime) 部署;
平滑重启是建立在优雅退出的基础之上的, 之前一篇文章介绍了相关实现: Golang 中使用 Shutdown 特性对 http 服务进行优雅退出使用总结
目前实现平滑重启的主要策略有两种:
方案一: 我们的服务如果是多机器部署, 可以通过网关程序, 将即将重启服务的机器从网关下线, 重启完成后再重新上线, 该方案适合多机器部署的企业级应用;
方案二: 让我们的程序实现自启动, 重启子进程来实现平滑重启, 核心策略是通过拷贝文件描述符实现子进程和父进程切换, 适合单机器部署应用;
今天我们就主要介绍方案二, 让我们的程序拥有平滑重启的功能, 相关实现参考一个开源库: https://github.com/fvbock/endless
实现原理介绍
http 连接介绍:
我们知道, http 服务也是基于 tcp 连接, 我们通过 golang http 包源码也能看到底层是通过监听 tcp 连接实现的;
- 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 连接也不会断开, 启动一个相同的进程也不会出现端口被占用的问题;
通过如下代码进行测试:
- 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 命令查看如图可以看到有两个进程同时共存:
然后我们可以通过浏览器访问 http://127.0.0.1/ 可以看到会随机显示主进程或子进程的输出;
写一个测试代码进行循环请求:
- 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)
- }
能看到返回的数据也是有些是主进程有些是子进程.
切换过程:
在开启新的进程和老进程退出的瞬间, 会有一个短暂的瞬间是同时有两个进程使用同一个文件描述符, 此时这种状态, 通过 http 请求访问, 会随机请求到新进程或老进程上, 这样也没有问题, 因为请求不是在新进程上就是在老进程上; 当老进程结束后请求就会全部到新进程上进行处理, 通过这种方式即可实现平滑重启;
综上, 我们可以将核心的实现总结如下:
1. 监听退出信号;
2. 监听到信号后 fork 子进程, 使用相同的命令启动程序, 将文件描述符传递给子进程;
3. 子进程启动后, 父进程停止服务并处理正在执行的任务 (或超时) 退出;
4. 此时只有一个新的进程在运行, 实现平滑重启.
一个完整的 demo 代码, 通过发送 USR1 信号, 程序会自动创建子进程并关闭主进程, 实现平滑重启:
- 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 信号, 可以看到压测结果, 没有失败的请求, 由此可知, 该方案实现平滑重启是木有问题的.
最后给大家安利一个 web 开发框架, 该框架已经将平滑重启进行的封装, 开箱即用, 快速构建一个带平滑重启的 Web 服务.
框架源码: https://gitee.com/zhucheer/orange
文档:
来源: https://www.cnblogs.com/zhucheer/p/12368301.html