golang程序优雅关闭与重启

何谓优雅

当线上代码有更新时,我们要首先关闭服务,然后再启动服务,如果访问量比较大,当关闭服务的时候,当前服务器很有可能有很多 连接,那么如果此时直接关闭服务,这些连接将全部断掉,影响用户体验,绝对称不上优雅

所以我们要想出一种可以平滑关闭或者重启程序的方式

是谓优雅。

思路服务端启动时多开启一个协程用来监听关闭信号

当协程接收到关闭信号时,将拒绝接收新的连接,并处理好当前所有连接后断开

启动一个新的服务端进程来接管新的连接

关闭当前进程

实现关于这个框架的系列文章:

我使用了tim1020/godaemon这个包来实现平滑重启的功能(对于大部分项目来说,直接使用可以满足大部分需求,无需改造)

期望效果:

在控制台输入 bingo run daemon [start|restart|stop] 可以令服务器 启动|重启|停止先看如何开启一个服务器 (bingo run dev)

因为是开发环境嘛,大体的思路就是吧 bingo run命令转换成令 go run start.go 这种 shell命令

所以 bingo run dev就等于 go run start.go dev

//处理http.Server,使支持graceful stop/restartfunc 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

}

这样就可以启动一个服务器,并且在连接状态变化的时候可以监听到以守护进程启动服务器

当使用 bingo run daemon或者 bingo run daemon start的时候,会触发 DaemonInit()函数,内容如下:

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() 函数启动程序,这个函数是整个功能的核心

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判定程序是否正在运行,如果正在运行,才会继续向下执行

函数体也比较简单,只有两行

syscall.Kill(pid, syscall.SIGHUP) //kill -HUP, daemon only时,会直接退出forkDaemon()

第一行杀死这个进程 第二行开启一个新进程case stop:

这里就一行代码,就是杀死这个进程

额外的想法

在开发过程中,每当有一丁点变动(比如更改来一丁点控制器),就需要再次执行一次 bingo run daemon restart 命令,让新的改动生效,十分麻烦

所以我又开发了 bingo run watch 命令,监听改动,自动重启server服务器

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 :=

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 :=

fmt.Println("error:", err)

}

}

}()

// 准备重启守护进程 go restartDaemonServer()

}

首先按照 fsnotify的文档,创建一个 watcher,然后添加监听目录(这里只是监听目录下的文件,不能监听子目录)

然后开启两个协程:监听文件变化,如果有文件变化,把变化的个数写入一个 slice 里,这是一个阻塞的 for循环

每隔1s中查看一次记录文件变化的 slice, 如果有的话,就重启服务器,并重新设置监听目录,然后清空 slice ,否则跳过

递归遍历子目录,达到监听整个工程目录的效果:

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 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 ,欢迎star,欢迎PR,欢迎提意见