golang优雅关闭与重启

silsuer · · 3453 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

# golang程序优雅关闭与重启 # 何谓优雅 当线上代码有更新时,我们要首先关闭服务,然后再启动服务,如果访问量比较大,当关闭服务的时候,当前服务器很有可能有很多 连接,那么如果此时直接关闭服务,这些连接将全部断掉,影响用户体验,绝对称不上优雅 所以我们要想出一种可以平滑关闭或者重启程序的方式 是谓优雅。 ## 思路 1. 服务端启动时多开启一个协程用来监听关闭信号 2. 当协程接收到关闭信号时,将拒绝接收新的连接,并处理好当前所有连接后断开 3. 启动一个新的服务端进程来接管新的连接 4. 关闭当前进程 ## 实现 以 [siluser/bingo](https://github.com/silsuer/bingo)框架为例 > 关于这个框架的系列文章: - [使用Go写一个简易的MVC的Web框架](https://studygolang.com/articles/12818) - [使用Go封装一个便捷的ORM](https://studygolang.com/articles/12825) - [改造httprouter使其支持中间件](改造httprouter使其支持中间件) - [仿照laravel-artisan实现简易go开发脚手架](https://studygolang.com/articles/14148) 我使用了[tim1020/godaemon](https://github.com/tim1020/godaemon)这个包来实现平滑重启的功能(对于大部分项目来说,直接使用可以满足大部分需求,无需改造) 期望效果: 在控制台输入 `bingo run daemon [start|restart|stop]` 可以令服务器 `启动|重启|停止` 1. 先看如何开启一个服务器 (`bingo run dev`) 关于 `bingo` 命令的实现可以看我以前的博客: [仿照laravel-artisan实现简易go开发脚手架](https://studygolang.com/articles/14148) 因为是开发环境嘛,大体的思路就是吧 `bingo run`命令转换成令 `go run start.go` 这种 `shell`命令 所以 `bingo run dev`就等于 `go run start.go dev` ```go //处理http.Server,使支持graceful stop/restart func Graceful(s http.Server) error { // 设置一个环境变量 os.Setenv("__GRACEFUL", "true") // 创建一个自定义的server srv = &server{ cm: newConnectionManager(), Server: s, } // 设置server的状态 srv.ConnState = func(conn net.Conn, state http.ConnState) { switch state { case http.StateNew: srv.cm.add(1) case http.StateActive: srv.cm.rmIdleConns(conn.LocalAddr().String()) case http.StateIdle: srv.cm.addIdleConns(conn.LocalAddr().String(), conn) case http.StateHijacked, http.StateClosed: srv.cm.done() } } l, err := srv.getListener() if err == nil { err = srv.Server.Serve(l) } else { fmt.Println(err) } return err } ``` 这样就可以启动一个服务器,并且在连接状态变化的时候可以监听到 2. 以守护进程启动服务器 当使用 `bingo run daemon`或者 `bingo run daemon start`的时候,会触发 `DaemonInit()`函数,内容如下: ```go func DaemonInit() { // 得到存放pid文件的路径 dir, _ := os.Getwd() pidFile = dir + "/" + Env.Get("PID_FILE") if os.Getenv("__Daemon") != "true" { //master cmd := "start" //缺省为start if l := len(os.Args); l > 2 { cmd = os.Args[l-1] } switch cmd { case "start": if isRunning() { fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo is running", 0x1B) } else { //fork daemon进程 if err := forkDaemon(); err != nil { fmt.Println(err) } } case "restart": //重启: if !isRunning() { fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B) restart(pidVal) } else { fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo restart now", 0x1B) restart(pidVal) } case "stop": //停止 if !isRunning() { fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B) } else { syscall.Kill(pidVal, syscall.SIGTERM) //kill } case "-h": fmt.Println("Usage: " + appName + " start|restart|stop") default: //其它不识别的参数 return //返回至调用方 } //主进程退出 os.Exit(0) } go handleSignals() } ``` 首先要获取`pidFile` 这个文件主要是存储令程序运行时候的进程`pid`,为什么要持久化`pid`呢?是为了让多次程序运行过程中,判定是否有相同程序启动等操作 之后要获取对应的操作 (start|restart|stop),一个一个说 > case `start`: 首先使用 `isRunning()`方法判断当前程序是否在运行,如何判断?就是从上面提到的 `pidFile` 中取出进程号 然后判断当前系统是否运行令这个进程,如果有,证明正在运行,返回 `true`,反之返回 `false` 如果没有运行的话,调用 `forkDaemon()` 函数启动程序,这个函数是整个功能的核心 ```go func forkDaemon() error { args := os.Args os.Setenv("__Daemon", "true") procAttr := &syscall.ProcAttr{ Env: os.Environ(), Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()}, } pid, err := syscall.ForkExec(args[0], []string{args[0], "dev"}, procAttr) if err != nil { panic(err) } savePid(pid) fmt.Printf("\n %c[0;48;32m%s%c[0m", 0x1B, "["+strconv.Itoa(pid)+"] Bingo running...", 0x1B) fmt.Println() return nil } ``` `syscall`包不支持win系统,也就意味着如果想在 `windows`上做开发的话,只能使用虚拟机或者 `docker`啦 这里的主要功能就是,使用 `syscall.ForkExec()`,`fork` 一个进程出来 运行这个进程所执行的命令就是这里的参数(因为我们的原始命令是 `go run start.go dev`,所以这里的`args[0]`实际上是 `start.go`编译之后的二进制文件) 然后再把 `fork`出来的进程号保存在 `pidFile`里 所以最终运行的效果就是我们第一步时候说到的 `bingo run dev` 达到的效果 > case `restart`: 这个比较简单,通过 `pidFile`判定程序是否正在运行,如果正在运行,才会继续向下执行 函数体也比较简单,只有两行 ```go syscall.Kill(pid, syscall.SIGHUP) //kill -HUP, daemon only时,会直接退出 forkDaemon() ``` 第一行杀死这个进程 第二行开启一个新进程 > case `stop`: 这里就一行代码,就是杀死这个进程 ## 额外的想法 在开发过程中,每当有一丁点变动(比如更改来一丁点控制器),就需要再次执行一次 `bingo run daemon restart` 命令,让新的改动生效,十分麻烦 所以我又开发了 `bingo run watch` 命令,监听改动,自动重启server服务器 我使用了[github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify)包来实现监听 ```go func startWatchServer(port string, handler http.Handler) { // 监听目录变化,如果有变化,重启服务 // 守护进程开启服务,主进程阻塞不断扫描当前目录,有任何更新,向守护进程传递信号,守护进程重启服务 // 开启一个协程运行服务 // 监听目录变化,有变化运行 bingo run daemon restart f, err := fsnotify.NewWatcher() if err != nil { panic(err) } defer f.Close() dir, _ := os.Getwd() wdDir = dir fileWatcher = f f.Add(dir) done := make(chan bool) go func() { procAttr := &syscall.ProcAttr{ Env: os.Environ(), Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()}, } _, err := syscall.ForkExec(os.Args[0], []string{os.Args[0], "daemon", "start"}, procAttr) if err != nil { fmt.Println(err) } }() go func() { for { select { case ev := <-f.Events: if ev.Op&fsnotify.Create == fsnotify.Create { fmt.Printf("\n %c[0;48;33m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]created file:"+ev.Name, 0x1B) } if ev.Op&fsnotify.Remove == fsnotify.Remove { fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]deleted file:"+ev.Name, 0x1B) } if ev.Op&fsnotify.Rename == fsnotify.Rename { fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]renamed file:"+ev.Name, 0x1B) } else { fmt.Printf("\n %c[0;48;32m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]modified file:"+ev.Name, 0x1B) } // 有变化,放入重启数组中 restartSlice = append(restartSlice, 1) case err := <-f.Errors: fmt.Println("error:", err) } } }() // 准备重启守护进程 go restartDaemonServer() <-done } ``` 首先按照 `fsnotify`的文档,创建一个 `watcher`,然后添加监听目录(这里只是监听目录下的文件,不能监听子目录) 然后开启两个协程: 1. 监听文件变化,如果有文件变化,把变化的个数写入一个 `slice` 里,这是一个阻塞的 `for`循环 2. 每隔1s中查看一次记录文件变化的 `slice`, 如果有的话,就重启服务器,并重新设置监听目录,然后清空 `slice` ,否则跳过 递归遍历子目录,达到监听整个工程目录的效果: ```go func listeningWatcherDir(dir string) { filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { dir, _ := os.Getwd() pidFile = dir + "/" + Env.Get("PID_FILE") fileWatcher.Add(path) // 这里不能监听 pidFile,否则每次重启都会导致pidFile有更新,会不断的触发重启功能 fileWatcher.Remove(pidFile) return nil }) } ``` 这里这个 `slice` 的作用也就是为了避免当一次保存更新了多个文件的时候,也重启了多次服务器 下面看看重启服务器的代码: ```go go func() { // 执行重启命令 cmd := exec.Command("bingo", "run", "daemon", "restart") stdout, err := cmd.StdoutPipe() if err != nil { fmt.Println(err) } defer stdout.Close() if err := cmd.Start(); err != nil { panic(err) } reader := bufio.NewReader(stdout) //实时循环读取输出流中的一行内容 for { line, err2 := reader.ReadString('\n') if err2 != nil || io.EOF == err2 { break } fmt.Print(line) } if err := cmd.Wait(); err != nil { fmt.Println(err) } opBytes, _ := ioutil.ReadAll(stdout) fmt.Print(string(opBytes)) }() ``` 使用 `exec.Command()` 方法得到一个 `cmd` 调用 `cmd.Stdoutput()` 得到一个输出管道,命令打印出来的数据都会从这个管道流出来 然后使用 `reader := bufio.NewReader(stdout)` 从管道中读出数据 用一个阻塞的`for`循环,不断的从管道中读出数据,以 `\n` 为一行,一行一行的读 并打印在控制台里,达到输出的效果,如果这几行不写的话,在新的进程里的 `fmt.Println()`方法打印出来的数据将无法显示在控制台上. 就酱,最后贴下项目链接 [silsuer/bingo](https://github.com/silsuer/bingo) ,欢迎star,欢迎PR,欢迎提意见

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077

3453 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传